Repository: gedoor/legado Branch: master Commit: 0486da3c0255 Files: 1746 Total size: 11.0 MB Directory structure: gitextract_9ap85jiu/ ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── 01-bugReport.yml │ │ ├── 02-featureRequest.yml │ │ └── config.yml │ ├── dependabot.yml │ ├── scripts/ │ │ ├── cronet.sh │ │ ├── lzy_web.py │ │ └── tg_bot.py │ └── workflows/ │ ├── autoupdatefork.yml │ ├── cronet.yml │ ├── legado.jks │ ├── release.yml │ ├── stale.yml │ ├── test.yml │ └── web.yml ├── .gitignore ├── CHANGELOG.md ├── English.md ├── LICENSE ├── README.md ├── api.md ├── app/ │ ├── .gitignore │ ├── build.gradle │ ├── cronet-proguard-rules.pro │ ├── cronetlib/ │ │ ├── cronet_api.jar │ │ ├── cronet_impl_common_java.jar │ │ ├── cronet_impl_native_java.jar │ │ ├── cronet_impl_platform_java.jar │ │ └── cronet_shared_java.jar │ ├── download.gradle │ ├── google-services.json │ ├── proguard-rules.pro │ ├── schemas/ │ │ └── io.legado.app.data.AppDatabase/ │ │ ├── 1.json │ │ ├── 10.json │ │ ├── 11.json │ │ ├── 12.json │ │ ├── 13.json │ │ ├── 14.json │ │ ├── 15.json │ │ ├── 16.json │ │ ├── 17.json │ │ ├── 18.json │ │ ├── 19.json │ │ ├── 2.json │ │ ├── 20.json │ │ ├── 21.json │ │ ├── 22.json │ │ ├── 23.json │ │ ├── 24.json │ │ ├── 25.json │ │ ├── 26.json │ │ ├── 27.json │ │ ├── 28.json │ │ ├── 29.json │ │ ├── 3.json │ │ ├── 30.json │ │ ├── 31.json │ │ ├── 32.json │ │ ├── 33.json │ │ ├── 34.json │ │ ├── 35.json │ │ ├── 36.json │ │ ├── 37.json │ │ ├── 38.json │ │ ├── 39.json │ │ ├── 4.json │ │ ├── 40.json │ │ ├── 41.json │ │ ├── 42.json │ │ ├── 43.json │ │ ├── 44.json │ │ ├── 45.json │ │ ├── 46.json │ │ ├── 47.json │ │ ├── 48.json │ │ ├── 49.json │ │ ├── 5.json │ │ ├── 50.json │ │ ├── 51.json │ │ ├── 52.json │ │ ├── 53.json │ │ ├── 54.json │ │ ├── 55.json │ │ ├── 56.json │ │ ├── 57.json │ │ ├── 58.json │ │ ├── 59.json │ │ ├── 6.json │ │ ├── 60.json │ │ ├── 61.json │ │ ├── 62.json │ │ ├── 63.json │ │ ├── 64.json │ │ ├── 65.json │ │ ├── 66.json │ │ ├── 67.json │ │ ├── 68.json │ │ ├── 69.json │ │ ├── 7.json │ │ ├── 70.json │ │ ├── 71.json │ │ ├── 72.json │ │ ├── 73.json │ │ ├── 74.json │ │ ├── 75.json │ │ ├── 8.json │ │ └── 9.json │ └── src/ │ ├── androidTest/ │ │ └── java/ │ │ └── io/ │ │ └── legado/ │ │ └── app/ │ │ ├── AndroidJsTest.kt │ │ ├── ExampleInstrumentedTest.kt │ │ ├── HttpTest.kt │ │ ├── HttpTtsTest.kt │ │ ├── MigrationTest.kt │ │ └── UpdateTest.kt │ ├── debug/ │ │ └── res/ │ │ ├── values/ │ │ │ └── strings.xml │ │ └── values-zh/ │ │ └── strings.xml │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── assets/ │ │ │ ├── 18PlusList.txt │ │ │ ├── LICENSE.md │ │ │ ├── cronet.json │ │ │ ├── defaultData/ │ │ │ │ ├── bookSources.json │ │ │ │ ├── coverRule.json │ │ │ │ ├── dictRules.json │ │ │ │ ├── directLinkUpload.json │ │ │ │ ├── httpTTS.json │ │ │ │ ├── keyboardAssists.json │ │ │ │ ├── readConfig.json │ │ │ │ ├── rssSources.json │ │ │ │ ├── themeConfig.json │ │ │ │ └── txtTocRule.json │ │ │ ├── disclaimer.md │ │ │ ├── epub/ │ │ │ │ ├── chapter.html │ │ │ │ ├── cover.html │ │ │ │ ├── fonts.css │ │ │ │ ├── intro.html │ │ │ │ └── main.css │ │ │ ├── privacyPolicy.md │ │ │ ├── storageHelp.md │ │ │ ├── updateLog.md │ │ │ └── web/ │ │ │ ├── assets/ │ │ │ │ ├── css/ │ │ │ │ │ └── main.css │ │ │ │ └── js/ │ │ │ │ ├── dist.js │ │ │ │ └── md5.js │ │ │ ├── help/ │ │ │ │ ├── index.html │ │ │ │ ├── js/ │ │ │ │ │ ├── main.js │ │ │ │ │ ├── marked-highlight.umd.js │ │ │ │ │ └── require.js │ │ │ │ └── md/ │ │ │ │ ├── ExtensionContentType.md │ │ │ │ ├── SourceMBookHelp.md │ │ │ │ ├── SourceMRssHelp.md │ │ │ │ ├── appHelp.md │ │ │ │ ├── debugHelp.md │ │ │ │ ├── dictRuleHelp.md │ │ │ │ ├── httpTTSHelp.md │ │ │ │ ├── jsHelp.md │ │ │ │ ├── readMenuHelp.md │ │ │ │ ├── regexHelp.md │ │ │ │ ├── replaceRuleHelp.md │ │ │ │ ├── ruleHelp.md │ │ │ │ ├── txtTocRuleHelp.md │ │ │ │ ├── webDavBookHelp.md │ │ │ │ ├── webDavHelp.md │ │ │ │ └── xpathHelp.md │ │ │ ├── index.html │ │ │ ├── uploadBook/ │ │ │ │ ├── css/ │ │ │ │ │ └── wifi_send.css │ │ │ │ ├── index.html │ │ │ │ └── js/ │ │ │ │ ├── common.js │ │ │ │ └── html5_fun.js │ │ │ └── vue/ │ │ │ ├── assets/ │ │ │ │ ├── BookChapter-BsiFtdIw.css │ │ │ │ ├── BookChapter-Cs3stH93.js │ │ │ │ ├── BookShelf-00b2QCsd.css │ │ │ │ ├── BookShelf-DIQtBULC.js │ │ │ │ ├── index-CrxHVQK7.css │ │ │ │ ├── index-Wr40-hHf.js │ │ │ │ ├── loading-C4J6hIxs.js │ │ │ │ ├── loading-DkQYEuap.css │ │ │ │ ├── vendor-CXe1BRiH.css │ │ │ │ └── vendor-KSDcS24u.js │ │ │ └── index.html │ │ ├── java/ │ │ │ └── io/ │ │ │ └── legado/ │ │ │ └── app/ │ │ │ ├── App.kt │ │ │ ├── README.md │ │ │ ├── api/ │ │ │ │ ├── ReaderProvider.kt │ │ │ │ ├── ReturnData.kt │ │ │ │ ├── ShortCuts.kt │ │ │ │ └── controller/ │ │ │ │ ├── BookController.kt │ │ │ │ ├── BookSourceController.kt │ │ │ │ ├── ReplaceRuleController.kt │ │ │ │ └── RssSourceController.kt │ │ │ ├── base/ │ │ │ │ ├── AppContextWrapper.kt │ │ │ │ ├── BaseActivity.kt │ │ │ │ ├── BaseDialogFragment.kt │ │ │ │ ├── BaseFragment.kt │ │ │ │ ├── BasePrefDialogFragment.kt │ │ │ │ ├── BaseService.kt │ │ │ │ ├── BaseViewModel.kt │ │ │ │ ├── README.md │ │ │ │ ├── VMBaseActivity.kt │ │ │ │ ├── VMBaseFragment.kt │ │ │ │ └── adapter/ │ │ │ │ ├── DiffRecyclerAdapter.kt │ │ │ │ ├── ItemAnimation.kt │ │ │ │ ├── ItemViewHolder.kt │ │ │ │ ├── RecyclerAdapter.kt │ │ │ │ └── animations/ │ │ │ │ ├── AlphaInAnimation.kt │ │ │ │ ├── BaseAnimation.kt │ │ │ │ ├── ScaleInAnimation.kt │ │ │ │ ├── SlideInBottomAnimation.kt │ │ │ │ ├── SlideInLeftAnimation.kt │ │ │ │ └── SlideInRightAnimation.kt │ │ │ ├── constant/ │ │ │ │ ├── AppConst.kt │ │ │ │ ├── AppLog.kt │ │ │ │ ├── AppPattern.kt │ │ │ │ ├── BookSourceType.kt │ │ │ │ ├── BookType.kt │ │ │ │ ├── EventBus.kt │ │ │ │ ├── IntentAction.kt │ │ │ │ ├── NotificationId.kt │ │ │ │ ├── PageAnim.kt │ │ │ │ ├── PreferKey.kt │ │ │ │ ├── SourceType.kt │ │ │ │ ├── Status.kt │ │ │ │ └── Theme.kt │ │ │ ├── data/ │ │ │ │ ├── AppDatabase.kt │ │ │ │ ├── DatabaseMigrations.kt │ │ │ │ ├── README.md │ │ │ │ ├── dao/ │ │ │ │ │ ├── BookChapterDao.kt │ │ │ │ │ ├── BookDao.kt │ │ │ │ │ ├── BookGroupDao.kt │ │ │ │ │ ├── BookSourceDao.kt │ │ │ │ │ ├── BookmarkDao.kt │ │ │ │ │ ├── CacheDao.kt │ │ │ │ │ ├── CookieDao.kt │ │ │ │ │ ├── DictRuleDao.kt │ │ │ │ │ ├── HttpTTSDao.kt │ │ │ │ │ ├── KeyboardAssistsDao.kt │ │ │ │ │ ├── ReadRecordDao.kt │ │ │ │ │ ├── ReplaceRuleDao.kt │ │ │ │ │ ├── RssArticleDao.kt │ │ │ │ │ ├── RssReadRecordDao.kt │ │ │ │ │ ├── RssSourceDao.kt │ │ │ │ │ ├── RssStarDao.kt │ │ │ │ │ ├── RuleSubDao.kt │ │ │ │ │ ├── SearchBookDao.kt │ │ │ │ │ ├── SearchKeywordDao.kt │ │ │ │ │ ├── ServerDao.kt │ │ │ │ │ └── TxtTocRuleDao.kt │ │ │ │ └── entities/ │ │ │ │ ├── BaseBook.kt │ │ │ │ ├── BaseRssArticle.kt │ │ │ │ ├── BaseSource.kt │ │ │ │ ├── Book.kt │ │ │ │ ├── BookChapter.kt │ │ │ │ ├── BookChapterReview.kt │ │ │ │ ├── BookGroup.kt │ │ │ │ ├── BookProgress.kt │ │ │ │ ├── BookSource.kt │ │ │ │ ├── BookSourcePart.kt │ │ │ │ ├── Bookmark.kt │ │ │ │ ├── Cache.kt │ │ │ │ ├── Cookie.kt │ │ │ │ ├── DictRule.kt │ │ │ │ ├── HttpTTS.kt │ │ │ │ ├── KeyboardAssist.kt │ │ │ │ ├── ReadRecord.kt │ │ │ │ ├── ReadRecordShow.kt │ │ │ │ ├── ReplaceRule.kt │ │ │ │ ├── RssArticle.kt │ │ │ │ ├── RssReadRecord.kt │ │ │ │ ├── RssSource.kt │ │ │ │ ├── RssStar.kt │ │ │ │ ├── RuleSub.kt │ │ │ │ ├── SearchBook.kt │ │ │ │ ├── SearchKeyword.kt │ │ │ │ ├── Server.kt │ │ │ │ ├── TxtTocRule.kt │ │ │ │ └── rule/ │ │ │ │ ├── BookInfoRule.kt │ │ │ │ ├── BookListRule.kt │ │ │ │ ├── ContentRule.kt │ │ │ │ ├── ExploreKind.kt │ │ │ │ ├── ExploreRule.kt │ │ │ │ ├── FlexChildStyle.kt │ │ │ │ ├── ReviewRule.kt │ │ │ │ ├── RowUi.kt │ │ │ │ ├── SearchRule.kt │ │ │ │ └── TocRule.kt │ │ │ ├── exception/ │ │ │ │ ├── ConcurrentException.kt │ │ │ │ ├── ContentEmptyException.kt │ │ │ │ ├── EmptyFileException.kt │ │ │ │ ├── InvalidBooksDirException.kt │ │ │ │ ├── NoBooksDirException.kt │ │ │ │ ├── NoStackTraceException.kt │ │ │ │ ├── RegexTimeoutException.kt │ │ │ │ └── TocEmptyException.kt │ │ │ ├── help/ │ │ │ │ ├── AppFreezeMonitor.kt │ │ │ │ ├── AppWebDav.kt │ │ │ │ ├── CacheManager.kt │ │ │ │ ├── ConcurrentRateLimiter.kt │ │ │ │ ├── CrashHandler.kt │ │ │ │ ├── DefaultData.kt │ │ │ │ ├── DirectLinkUpload.kt │ │ │ │ ├── DispatchersMonitor.kt │ │ │ │ ├── EventMessage.kt │ │ │ │ ├── ExecutorService.kt │ │ │ │ ├── IntentData.kt │ │ │ │ ├── IntentHelp.kt │ │ │ │ ├── JsEncodeUtils.kt │ │ │ │ ├── JsExtensions.kt │ │ │ │ ├── LauncherIconHelp.kt │ │ │ │ ├── LayoutManager.kt │ │ │ │ ├── LifecycleHelp.kt │ │ │ │ ├── MediaHelp.kt │ │ │ │ ├── PaintPool.kt │ │ │ │ ├── README.md │ │ │ │ ├── ReplaceAnalyzer.kt │ │ │ │ ├── RuleBigDataHelp.kt │ │ │ │ ├── RuleComplete.kt │ │ │ │ ├── TTS.kt │ │ │ │ ├── book/ │ │ │ │ │ ├── BookContent.kt │ │ │ │ │ ├── BookExtensions.kt │ │ │ │ │ ├── BookHelp.kt │ │ │ │ │ ├── ContentHelp.kt │ │ │ │ │ └── ContentProcessor.kt │ │ │ │ ├── config/ │ │ │ │ │ ├── AppConfig.kt │ │ │ │ │ ├── LocalConfig.kt │ │ │ │ │ ├── ReadBookConfig.kt │ │ │ │ │ ├── ReadTipConfig.kt │ │ │ │ │ ├── SourceConfig.kt │ │ │ │ │ └── ThemeConfig.kt │ │ │ │ ├── coroutine/ │ │ │ │ │ ├── ActivelyCancelException.kt │ │ │ │ │ ├── CompositeCoroutine.kt │ │ │ │ │ ├── Coroutine.kt │ │ │ │ │ └── CoroutineContainer.kt │ │ │ │ ├── crypto/ │ │ │ │ │ ├── AsymmetricCrypto.kt │ │ │ │ │ ├── README.md │ │ │ │ │ ├── Sign.kt │ │ │ │ │ └── SymmetricCryptoAndroid.kt │ │ │ │ ├── exoplayer/ │ │ │ │ │ ├── ExoPlayerHelper.kt │ │ │ │ │ └── InputStreamDataSource.kt │ │ │ │ ├── glide/ │ │ │ │ │ ├── AsyncRecycleBitmapPool.kt │ │ │ │ │ ├── BlurTransformation.kt │ │ │ │ │ ├── FilePathLoader.kt │ │ │ │ │ ├── GlideHeaders.kt │ │ │ │ │ ├── ImageLoader.kt │ │ │ │ │ ├── LegadoDataUrlLoader.kt │ │ │ │ │ ├── LegadoGlideModule.kt │ │ │ │ │ ├── OkHttpModeLoaderFactory.kt │ │ │ │ │ ├── OkHttpModelLoader.kt │ │ │ │ │ ├── OkHttpStreamFetcher.kt │ │ │ │ │ └── progress/ │ │ │ │ │ ├── OnProgressListener.kt │ │ │ │ │ ├── ProgressManager.kt │ │ │ │ │ └── ProgressResponseBody.kt │ │ │ │ ├── http/ │ │ │ │ │ ├── BackstageWebView.kt │ │ │ │ │ ├── CookieManager.kt │ │ │ │ │ ├── CookieStore.kt │ │ │ │ │ ├── Cronet.kt │ │ │ │ │ ├── DecompressInterceptor.kt │ │ │ │ │ ├── HttpHelper.kt │ │ │ │ │ ├── ObsoleteUrlFactory.kt │ │ │ │ │ ├── OkHttpExceptionInterceptor.kt │ │ │ │ │ ├── OkHttpUtils.kt │ │ │ │ │ ├── OkhttpUncaughtExceptionHandler.kt │ │ │ │ │ ├── RequestMethod.kt │ │ │ │ │ ├── SSLHelper.kt │ │ │ │ │ ├── StrResponse.kt │ │ │ │ │ └── api/ │ │ │ │ │ └── CookieManagerInterface.kt │ │ │ │ ├── rhino/ │ │ │ │ │ └── NativeBaseSource.kt │ │ │ │ ├── source/ │ │ │ │ │ ├── BaseSourceExtensions.kt │ │ │ │ │ ├── BookSourceExtensions.kt │ │ │ │ │ ├── RssSourceExtensions.kt │ │ │ │ │ ├── SourceHelp.kt │ │ │ │ │ └── SourceVerificationHelp.kt │ │ │ │ ├── storage/ │ │ │ │ │ ├── Backup.kt │ │ │ │ │ ├── BackupAES.kt │ │ │ │ │ ├── BackupConfig.kt │ │ │ │ │ ├── ImportOldData.kt │ │ │ │ │ └── Restore.kt │ │ │ │ └── update/ │ │ │ │ ├── AppReleaseInfo.kt │ │ │ │ ├── AppUpdate.kt │ │ │ │ └── AppUpdateGitHub.kt │ │ │ ├── lib/ │ │ │ │ ├── README.md │ │ │ │ ├── aliyun/ │ │ │ │ │ └── ALiYun.kt │ │ │ │ ├── cronet/ │ │ │ │ │ ├── AbsCallBack.kt │ │ │ │ │ ├── BodyUploadProvider.kt │ │ │ │ │ ├── CallbackResult.kt │ │ │ │ │ ├── CallbackStep.kt │ │ │ │ │ ├── CronetCoroutineInterceptor.kt │ │ │ │ │ ├── CronetHelper.kt │ │ │ │ │ ├── CronetInterceptor.kt │ │ │ │ │ ├── CronetLoader.kt │ │ │ │ │ ├── LargeBodyUploadProvider.kt │ │ │ │ │ ├── NewCallBack.kt │ │ │ │ │ └── OldCallback.kt │ │ │ │ ├── dialogs/ │ │ │ │ │ ├── AlertBuilder.kt │ │ │ │ │ ├── AndroidAlertBuilder.kt │ │ │ │ │ ├── AndroidDialogs.kt │ │ │ │ │ ├── AndroidSelectors.kt │ │ │ │ │ └── SelectItem.kt │ │ │ │ ├── icu4j/ │ │ │ │ │ ├── CharsetDetector.java │ │ │ │ │ ├── CharsetMatch.java │ │ │ │ │ ├── CharsetRecog_2022.java │ │ │ │ │ ├── CharsetRecog_UTF8.java │ │ │ │ │ ├── CharsetRecog_Unicode.java │ │ │ │ │ ├── CharsetRecog_mbcs.java │ │ │ │ │ ├── CharsetRecog_sbcs.java │ │ │ │ │ └── CharsetRecognizer.java │ │ │ │ ├── mobi/ │ │ │ │ │ ├── KF6Book.kt │ │ │ │ │ ├── KF8Book.kt │ │ │ │ │ ├── MobiBook.kt │ │ │ │ │ ├── MobiReader.kt │ │ │ │ │ ├── PDBFile.kt │ │ │ │ │ ├── decompress/ │ │ │ │ │ │ ├── CDICData.kt │ │ │ │ │ │ ├── Decompressor.kt │ │ │ │ │ │ ├── HuffcdicDecompressor.kt │ │ │ │ │ │ ├── Lz77Decompressor.kt │ │ │ │ │ │ └── PlainDecompressor.kt │ │ │ │ │ ├── entities/ │ │ │ │ │ │ ├── ExthRecordType.kt │ │ │ │ │ │ ├── FdstHeader.kt │ │ │ │ │ │ ├── Fragment.kt │ │ │ │ │ │ ├── IndexData.kt │ │ │ │ │ │ ├── IndexEntry.kt │ │ │ │ │ │ ├── IndexTag.kt │ │ │ │ │ │ ├── IndxHeader.kt │ │ │ │ │ │ ├── KF6Section.kt │ │ │ │ │ │ ├── KF8Header.kt │ │ │ │ │ │ ├── KF8Pos.kt │ │ │ │ │ │ ├── KF8Resource.kt │ │ │ │ │ │ ├── KF8Section.kt │ │ │ │ │ │ ├── MobiEntryHeaders.kt │ │ │ │ │ │ ├── MobiHeader.kt │ │ │ │ │ │ ├── MobiMetadata.kt │ │ │ │ │ │ ├── NCX.kt │ │ │ │ │ │ ├── PalmDocHeader.kt │ │ │ │ │ │ ├── Ptagx.kt │ │ │ │ │ │ ├── Skeleton.kt │ │ │ │ │ │ ├── TOC.kt │ │ │ │ │ │ ├── TagxHeader.kt │ │ │ │ │ │ └── TagxTag.kt │ │ │ │ │ └── utils/ │ │ │ │ │ ├── BitwiseExtensions.kt │ │ │ │ │ └── ByteBufferExtensions.kt │ │ │ │ ├── permission/ │ │ │ │ │ ├── OnErrorCallback.kt │ │ │ │ │ ├── OnPermissionsDeniedCallback.kt │ │ │ │ │ ├── OnPermissionsGrantedCallback.kt │ │ │ │ │ ├── OnPermissionsResultCallback.kt │ │ │ │ │ ├── OnRequestPermissionsResultCallback.kt │ │ │ │ │ ├── PermissionActivity.kt │ │ │ │ │ ├── Permissions.kt │ │ │ │ │ ├── PermissionsCompat.kt │ │ │ │ │ ├── Request.kt │ │ │ │ │ ├── RequestManager.kt │ │ │ │ │ └── RequestPlugins.kt │ │ │ │ ├── prefs/ │ │ │ │ │ ├── ColorPreference.kt │ │ │ │ │ ├── EditTextPreference.kt │ │ │ │ │ ├── EditTextPreferenceDialog.kt │ │ │ │ │ ├── IconListPreference.kt │ │ │ │ │ ├── ListPreferenceDialog.kt │ │ │ │ │ ├── MultiSelectListPreferenceDialog.kt │ │ │ │ │ ├── NameListPreference.kt │ │ │ │ │ ├── Preference.kt │ │ │ │ │ ├── PreferenceCategory.kt │ │ │ │ │ ├── SwitchPreference.kt │ │ │ │ │ └── fragment/ │ │ │ │ │ └── PreferenceFragment.kt │ │ │ │ ├── theme/ │ │ │ │ │ ├── MaterialValueHelper.kt │ │ │ │ │ ├── Selector.kt │ │ │ │ │ ├── ThemeStore.kt │ │ │ │ │ ├── ThemeStoreInterface.kt │ │ │ │ │ ├── ThemeStorePrefKeys.kt │ │ │ │ │ ├── ThemeUtils.kt │ │ │ │ │ ├── TintHelper.kt │ │ │ │ │ ├── ViewUtils.kt │ │ │ │ │ └── view/ │ │ │ │ │ ├── ThemeBottomNavigationVIew.kt │ │ │ │ │ ├── ThemeCheckBox.kt │ │ │ │ │ ├── ThemeEditText.kt │ │ │ │ │ ├── ThemeProgressBar.kt │ │ │ │ │ ├── ThemeRadioButton.kt │ │ │ │ │ ├── ThemeRadioNoButton.kt │ │ │ │ │ ├── ThemeSeekBar.kt │ │ │ │ │ └── ThemeSwitch.kt │ │ │ │ └── webdav/ │ │ │ │ ├── Authorization.kt │ │ │ │ ├── WebDav.kt │ │ │ │ ├── WebDavException.kt │ │ │ │ └── WebDavFile.kt │ │ │ ├── model/ │ │ │ │ ├── AudioPlay.kt │ │ │ │ ├── BookCover.kt │ │ │ │ ├── CacheBook.kt │ │ │ │ ├── CheckSource.kt │ │ │ │ ├── Debug.kt │ │ │ │ ├── Download.kt │ │ │ │ ├── ImageProvider.kt │ │ │ │ ├── README.md │ │ │ │ ├── ReadAloud.kt │ │ │ │ ├── ReadBook.kt │ │ │ │ ├── ReadManga.kt │ │ │ │ ├── SharedJsScope.kt │ │ │ │ ├── analyzeRule/ │ │ │ │ │ ├── AnalyzeByJSonPath.kt │ │ │ │ │ ├── AnalyzeByJSoup.kt │ │ │ │ │ ├── AnalyzeByRegex.kt │ │ │ │ │ ├── AnalyzeByXPath.kt │ │ │ │ │ ├── AnalyzeRule.kt │ │ │ │ │ ├── AnalyzeUrl.kt │ │ │ │ │ ├── CustomUrl.kt │ │ │ │ │ ├── QueryTTF.java │ │ │ │ │ ├── RuleAnalyzer.kt │ │ │ │ │ ├── RuleData.kt │ │ │ │ │ └── RuleDataInterface.kt │ │ │ │ ├── localBook/ │ │ │ │ │ ├── BaseLocalBookParse.kt │ │ │ │ │ ├── EpubFile.kt │ │ │ │ │ ├── LocalBook.kt │ │ │ │ │ ├── MobiFile.kt │ │ │ │ │ ├── PdfFile.kt │ │ │ │ │ ├── README.md │ │ │ │ │ ├── TextFile.kt │ │ │ │ │ └── UmdFile.kt │ │ │ │ ├── remote/ │ │ │ │ │ ├── RemoteBook.kt │ │ │ │ │ ├── RemoteBookManager.kt │ │ │ │ │ └── RemoteBookWebDav.kt │ │ │ │ ├── rss/ │ │ │ │ │ ├── Rss.kt │ │ │ │ │ ├── RssParserByRule.kt │ │ │ │ │ └── RssParserDefault.kt │ │ │ │ └── webBook/ │ │ │ │ ├── BookChapterList.kt │ │ │ │ ├── BookContent.kt │ │ │ │ ├── BookInfo.kt │ │ │ │ ├── BookList.kt │ │ │ │ ├── SearchModel.kt │ │ │ │ └── WebBook.kt │ │ │ ├── receiver/ │ │ │ │ ├── MediaButtonReceiver.kt │ │ │ │ ├── NetworkChangedListener.kt │ │ │ │ ├── SharedReceiverActivity.kt │ │ │ │ └── TimeBatteryReceiver.kt │ │ │ ├── service/ │ │ │ │ ├── AudioPlayService.kt │ │ │ │ ├── BaseReadAloudService.kt │ │ │ │ ├── CacheBookService.kt │ │ │ │ ├── CheckSourceService.kt │ │ │ │ ├── DownloadService.kt │ │ │ │ ├── ExportBookService.kt │ │ │ │ ├── HttpReadAloudService.kt │ │ │ │ ├── README.md │ │ │ │ ├── TTSReadAloudService.kt │ │ │ │ ├── WebService.kt │ │ │ │ └── WebTileService.kt │ │ │ ├── ui/ │ │ │ │ ├── README.md │ │ │ │ ├── about/ │ │ │ │ │ ├── AboutActivity.kt │ │ │ │ │ ├── AboutFragment.kt │ │ │ │ │ ├── AppLogDialog.kt │ │ │ │ │ ├── CrashLogsDialog.kt │ │ │ │ │ ├── ReadRecordActivity.kt │ │ │ │ │ └── UpdateDialog.kt │ │ │ │ ├── association/ │ │ │ │ │ ├── AddToBookshelfDialog.kt │ │ │ │ │ ├── BaseAssociationViewModel.kt │ │ │ │ │ ├── FileAssociationActivity.kt │ │ │ │ │ ├── FileAssociationViewModel.kt │ │ │ │ │ ├── ImportBookSourceDialog.kt │ │ │ │ │ ├── ImportBookSourceViewModel.kt │ │ │ │ │ ├── ImportDictRuleDialog.kt │ │ │ │ │ ├── ImportDictRuleViewModel.kt │ │ │ │ │ ├── ImportHttpTtsDialog.kt │ │ │ │ │ ├── ImportHttpTtsViewModel.kt │ │ │ │ │ ├── ImportReplaceRuleDialog.kt │ │ │ │ │ ├── ImportReplaceRuleViewModel.kt │ │ │ │ │ ├── ImportRssSourceDialog.kt │ │ │ │ │ ├── ImportRssSourceViewModel.kt │ │ │ │ │ ├── ImportThemeDialog.kt │ │ │ │ │ ├── ImportThemeViewModel.kt │ │ │ │ │ ├── ImportTxtTocRuleDialog.kt │ │ │ │ │ ├── ImportTxtTocRuleViewModel.kt │ │ │ │ │ ├── OnLineImportActivity.kt │ │ │ │ │ ├── OnLineImportViewModel.kt │ │ │ │ │ ├── OpenUrlConfirmActivity.kt │ │ │ │ │ ├── OpenUrlConfirmDialog.kt │ │ │ │ │ ├── OpenUrlConfirmViewModel.kt │ │ │ │ │ ├── VerificationCodeActivity.kt │ │ │ │ │ ├── VerificationCodeDialog.kt │ │ │ │ │ └── VerificationCodeViewModel.kt │ │ │ │ ├── book/ │ │ │ │ │ ├── audio/ │ │ │ │ │ │ ├── AudioPlayActivity.kt │ │ │ │ │ │ ├── AudioPlayViewModel.kt │ │ │ │ │ │ └── TimerSliderPopup.kt │ │ │ │ │ ├── bookmark/ │ │ │ │ │ │ ├── AllBookmarkActivity.kt │ │ │ │ │ │ ├── AllBookmarkViewModel.kt │ │ │ │ │ │ ├── BookmarkAdapter.kt │ │ │ │ │ │ ├── BookmarkDecoration.kt │ │ │ │ │ │ └── BookmarkDialog.kt │ │ │ │ │ ├── cache/ │ │ │ │ │ │ ├── CacheActivity.kt │ │ │ │ │ │ ├── CacheAdapter.kt │ │ │ │ │ │ └── CacheViewModel.kt │ │ │ │ │ ├── changecover/ │ │ │ │ │ │ ├── ChangeCoverDialog.kt │ │ │ │ │ │ ├── ChangeCoverViewModel.kt │ │ │ │ │ │ └── CoverAdapter.kt │ │ │ │ │ ├── changesource/ │ │ │ │ │ │ ├── ChangeBookSourceAdapter.kt │ │ │ │ │ │ ├── ChangeBookSourceDialog.kt │ │ │ │ │ │ ├── ChangeBookSourceViewModel.kt │ │ │ │ │ │ ├── ChangeChapterSourceAdapter.kt │ │ │ │ │ │ ├── ChangeChapterSourceDialog.kt │ │ │ │ │ │ ├── ChangeChapterSourceViewModel.kt │ │ │ │ │ │ └── ChangeChapterTocAdapter.kt │ │ │ │ │ ├── explore/ │ │ │ │ │ │ ├── ExploreShowActivity.kt │ │ │ │ │ │ ├── ExploreShowAdapter.kt │ │ │ │ │ │ └── ExploreShowViewModel.kt │ │ │ │ │ ├── group/ │ │ │ │ │ │ ├── GroupEditDialog.kt │ │ │ │ │ │ ├── GroupManageDialog.kt │ │ │ │ │ │ ├── GroupSelectDialog.kt │ │ │ │ │ │ └── GroupViewModel.kt │ │ │ │ │ ├── import/ │ │ │ │ │ │ ├── BaseImportBookActivity.kt │ │ │ │ │ │ ├── local/ │ │ │ │ │ │ │ ├── ImportBook.kt │ │ │ │ │ │ │ ├── ImportBookActivity.kt │ │ │ │ │ │ │ ├── ImportBookAdapter.kt │ │ │ │ │ │ │ └── ImportBookViewModel.kt │ │ │ │ │ │ └── remote/ │ │ │ │ │ │ ├── RemoteBookActivity.kt │ │ │ │ │ │ ├── RemoteBookAdapter.kt │ │ │ │ │ │ ├── RemoteBookSort.kt │ │ │ │ │ │ ├── RemoteBookViewModel.kt │ │ │ │ │ │ ├── ServerConfigDialog.kt │ │ │ │ │ │ ├── ServerConfigViewModel.kt │ │ │ │ │ │ ├── ServersDialog.kt │ │ │ │ │ │ └── ServersViewModel.kt │ │ │ │ │ ├── info/ │ │ │ │ │ │ ├── BookInfoActivity.kt │ │ │ │ │ │ ├── BookInfoViewModel.kt │ │ │ │ │ │ └── edit/ │ │ │ │ │ │ ├── BookInfoEditActivity.kt │ │ │ │ │ │ └── BookInfoEditViewModel.kt │ │ │ │ │ ├── manage/ │ │ │ │ │ │ ├── BookAdapter.kt │ │ │ │ │ │ ├── BookshelfManageActivity.kt │ │ │ │ │ │ ├── BookshelfManageViewModel.kt │ │ │ │ │ │ └── SourcePickerDialog.kt │ │ │ │ │ ├── manga/ │ │ │ │ │ │ ├── ReadMangaActivity.kt │ │ │ │ │ │ ├── ReadMangaViewModel.kt │ │ │ │ │ │ ├── config/ │ │ │ │ │ │ │ ├── MangaColorFilterConfig.kt │ │ │ │ │ │ │ ├── MangaColorFilterDialog.kt │ │ │ │ │ │ │ ├── MangaEpaperDialog.kt │ │ │ │ │ │ │ ├── MangaFooterConfig.kt │ │ │ │ │ │ │ └── MangaFooterSettingDialog.kt │ │ │ │ │ │ ├── entities/ │ │ │ │ │ │ │ ├── BaseMangaPage.kt │ │ │ │ │ │ │ ├── EpaperTransformation.kt │ │ │ │ │ │ │ ├── GrayscaleTransformation.kt │ │ │ │ │ │ │ ├── MangaChapter.kt │ │ │ │ │ │ │ ├── MangaContent.kt │ │ │ │ │ │ │ ├── MangaPage.kt │ │ │ │ │ │ │ └── ReaderLoading.kt │ │ │ │ │ │ └── recyclerview/ │ │ │ │ │ │ ├── GestureDetectorWithLongTap.kt │ │ │ │ │ │ ├── MangaAdapter.kt │ │ │ │ │ │ ├── MangaLayoutManager.kt │ │ │ │ │ │ ├── MangaVH.kt │ │ │ │ │ │ ├── ScrollTimer.kt │ │ │ │ │ │ ├── WebtoonFrame.kt │ │ │ │ │ │ └── WebtoonRecyclerView.kt │ │ │ │ │ ├── read/ │ │ │ │ │ │ ├── BaseReadBookActivity.kt │ │ │ │ │ │ ├── ContentEditDialog.kt │ │ │ │ │ │ ├── EffectiveReplacesDialog.kt │ │ │ │ │ │ ├── MangaMenu.kt │ │ │ │ │ │ ├── ReadBookActivity.kt │ │ │ │ │ │ ├── ReadBookViewModel.kt │ │ │ │ │ │ ├── ReadMenu.kt │ │ │ │ │ │ ├── SearchMenu.kt │ │ │ │ │ │ ├── TextActionMenu.kt │ │ │ │ │ │ ├── config/ │ │ │ │ │ │ │ ├── AutoReadDialog.kt │ │ │ │ │ │ │ ├── BgAdapter.kt │ │ │ │ │ │ │ ├── BgTextConfigDialog.kt │ │ │ │ │ │ │ ├── ChineseConverter.kt │ │ │ │ │ │ │ ├── ClickActionConfigDialog.kt │ │ │ │ │ │ │ ├── HttpTtsEditDialog.kt │ │ │ │ │ │ │ ├── HttpTtsEditViewModel.kt │ │ │ │ │ │ │ ├── MoreConfigDialog.kt │ │ │ │ │ │ │ ├── PaddingConfigDialog.kt │ │ │ │ │ │ │ ├── PageKeyDialog.kt │ │ │ │ │ │ │ ├── ReadAloudConfigDialog.kt │ │ │ │ │ │ │ ├── ReadAloudDialog.kt │ │ │ │ │ │ │ ├── ReadStyleDialog.kt │ │ │ │ │ │ │ ├── SpeakEngineDialog.kt │ │ │ │ │ │ │ ├── SpeakEngineViewModel.kt │ │ │ │ │ │ │ ├── TextFontWeightConverter.kt │ │ │ │ │ │ │ └── TipConfigDialog.kt │ │ │ │ │ │ └── page/ │ │ │ │ │ │ ├── AutoPager.kt │ │ │ │ │ │ ├── ContentTextView.kt │ │ │ │ │ │ ├── PageView.kt │ │ │ │ │ │ ├── ReadView.kt │ │ │ │ │ │ ├── api/ │ │ │ │ │ │ │ ├── DataSource.kt │ │ │ │ │ │ │ └── PageFactory.kt │ │ │ │ │ │ ├── delegate/ │ │ │ │ │ │ │ ├── CoverPageDelegate.kt │ │ │ │ │ │ │ ├── HorizontalPageDelegate.kt │ │ │ │ │ │ │ ├── NoAnimPageDelegate.kt │ │ │ │ │ │ │ ├── PageDelegate.kt │ │ │ │ │ │ │ ├── ScrollPageDelegate.kt │ │ │ │ │ │ │ ├── SimulationPageDelegate.kt │ │ │ │ │ │ │ └── SlidePageDelegate.kt │ │ │ │ │ │ ├── entities/ │ │ │ │ │ │ │ ├── PageDirection.kt │ │ │ │ │ │ │ ├── TextChapter.kt │ │ │ │ │ │ │ ├── TextLine.kt │ │ │ │ │ │ │ ├── TextPage.kt │ │ │ │ │ │ │ ├── TextParagraph.kt │ │ │ │ │ │ │ ├── TextPos.kt │ │ │ │ │ │ │ └── column/ │ │ │ │ │ │ │ ├── BaseColumn.kt │ │ │ │ │ │ │ ├── ButtonColumn.kt │ │ │ │ │ │ │ ├── ImageColumn.kt │ │ │ │ │ │ │ ├── ReviewColumn.kt │ │ │ │ │ │ │ └── TextColumn.kt │ │ │ │ │ │ └── provider/ │ │ │ │ │ │ ├── ChapterProvider.kt │ │ │ │ │ │ ├── LayoutProgressListener.kt │ │ │ │ │ │ ├── TextChapterLayout.kt │ │ │ │ │ │ ├── TextMeasure.kt │ │ │ │ │ │ ├── TextPageFactory.kt │ │ │ │ │ │ └── ZhLayout.kt │ │ │ │ │ ├── search/ │ │ │ │ │ │ ├── BookAdapter.kt │ │ │ │ │ │ ├── HistoryKeyAdapter.kt │ │ │ │ │ │ ├── SearchActivity.kt │ │ │ │ │ │ ├── SearchAdapter.kt │ │ │ │ │ │ ├── SearchScope.kt │ │ │ │ │ │ ├── SearchScopeDialog.kt │ │ │ │ │ │ └── SearchViewModel.kt │ │ │ │ │ ├── searchContent/ │ │ │ │ │ │ ├── SearchContentActivity.kt │ │ │ │ │ │ ├── SearchContentAdapter.kt │ │ │ │ │ │ ├── SearchContentViewModel.kt │ │ │ │ │ │ └── SearchResult.kt │ │ │ │ │ ├── source/ │ │ │ │ │ │ ├── debug/ │ │ │ │ │ │ │ ├── BookSourceDebugActivity.kt │ │ │ │ │ │ │ ├── BookSourceDebugAdapter.kt │ │ │ │ │ │ │ └── BookSourceDebugModel.kt │ │ │ │ │ │ ├── edit/ │ │ │ │ │ │ │ ├── BookSourceEditActivity.kt │ │ │ │ │ │ │ ├── BookSourceEditAdapter.kt │ │ │ │ │ │ │ └── BookSourceEditViewModel.kt │ │ │ │ │ │ └── manage/ │ │ │ │ │ │ ├── BookSourceActivity.kt │ │ │ │ │ │ ├── BookSourceAdapter.kt │ │ │ │ │ │ ├── BookSourceSort.kt │ │ │ │ │ │ ├── BookSourceViewModel.kt │ │ │ │ │ │ └── GroupManageDialog.kt │ │ │ │ │ └── toc/ │ │ │ │ │ ├── BookmarkAdapter.kt │ │ │ │ │ ├── BookmarkFragment.kt │ │ │ │ │ ├── ChapterListAdapter.kt │ │ │ │ │ ├── ChapterListFragment.kt │ │ │ │ │ ├── TocActivity.kt │ │ │ │ │ ├── TocActivityResult.kt │ │ │ │ │ ├── TocViewModel.kt │ │ │ │ │ └── rule/ │ │ │ │ │ ├── TxtTocRuleActivity.kt │ │ │ │ │ ├── TxtTocRuleAdapter.kt │ │ │ │ │ ├── TxtTocRuleDialog.kt │ │ │ │ │ ├── TxtTocRuleEditDialog.kt │ │ │ │ │ └── TxtTocRuleViewModel.kt │ │ │ │ ├── browser/ │ │ │ │ │ ├── WebViewActivity.kt │ │ │ │ │ └── WebViewModel.kt │ │ │ │ ├── config/ │ │ │ │ │ ├── BackupConfigFragment.kt │ │ │ │ │ ├── CheckSourceConfig.kt │ │ │ │ │ ├── ConfigActivity.kt │ │ │ │ │ ├── ConfigTag.kt │ │ │ │ │ ├── ConfigViewModel.kt │ │ │ │ │ ├── CoverConfigFragment.kt │ │ │ │ │ ├── CoverRuleConfigDialog.kt │ │ │ │ │ ├── DirectLinkUploadConfig.kt │ │ │ │ │ ├── OtherConfigFragment.kt │ │ │ │ │ ├── ThemeConfigFragment.kt │ │ │ │ │ ├── ThemeListDialog.kt │ │ │ │ │ └── WelcomeConfigFragment.kt │ │ │ │ ├── dict/ │ │ │ │ │ ├── DictDialog.kt │ │ │ │ │ ├── DictViewModel.kt │ │ │ │ │ └── rule/ │ │ │ │ │ ├── DictRuleActivity.kt │ │ │ │ │ ├── DictRuleAdapter.kt │ │ │ │ │ ├── DictRuleEditDialog.kt │ │ │ │ │ └── DictRuleViewModel.kt │ │ │ │ ├── file/ │ │ │ │ │ ├── FileManageActivity.kt │ │ │ │ │ ├── FileManageViewModel.kt │ │ │ │ │ ├── FilePickerDialog.kt │ │ │ │ │ ├── FilePickerViewModel.kt │ │ │ │ │ ├── HandleFileActivity.kt │ │ │ │ │ ├── HandleFileContract.kt │ │ │ │ │ ├── HandleFileViewModel.kt │ │ │ │ │ └── utils/ │ │ │ │ │ └── FilePickerIcon.java │ │ │ │ ├── font/ │ │ │ │ │ ├── FontAdapter.kt │ │ │ │ │ └── FontSelectDialog.kt │ │ │ │ ├── login/ │ │ │ │ │ ├── SourceLoginActivity.kt │ │ │ │ │ ├── SourceLoginDialog.kt │ │ │ │ │ ├── SourceLoginViewModel.kt │ │ │ │ │ └── WebViewLoginFragment.kt │ │ │ │ ├── main/ │ │ │ │ │ ├── MainActivity.kt │ │ │ │ │ ├── MainFragmentInterface.kt │ │ │ │ │ ├── MainViewModel.kt │ │ │ │ │ ├── bookshelf/ │ │ │ │ │ │ ├── BaseBookshelfFragment.kt │ │ │ │ │ │ ├── BookshelfViewModel.kt │ │ │ │ │ │ ├── style1/ │ │ │ │ │ │ │ ├── BookshelfFragment1.kt │ │ │ │ │ │ │ └── books/ │ │ │ │ │ │ │ ├── BaseBooksAdapter.kt │ │ │ │ │ │ │ ├── BooksAdapterGrid.kt │ │ │ │ │ │ │ ├── BooksAdapterList.kt │ │ │ │ │ │ │ └── BooksFragment.kt │ │ │ │ │ │ └── style2/ │ │ │ │ │ │ ├── BaseBooksAdapter.kt │ │ │ │ │ │ ├── BooksAdapterGrid.kt │ │ │ │ │ │ ├── BooksAdapterList.kt │ │ │ │ │ │ └── BookshelfFragment2.kt │ │ │ │ │ ├── explore/ │ │ │ │ │ │ ├── ExploreAdapter.kt │ │ │ │ │ │ ├── ExploreDiffItemCallBack.kt │ │ │ │ │ │ ├── ExploreFragment.kt │ │ │ │ │ │ └── ExploreViewModel.kt │ │ │ │ │ ├── my/ │ │ │ │ │ │ └── MyFragment.kt │ │ │ │ │ └── rss/ │ │ │ │ │ ├── RssAdapter.kt │ │ │ │ │ ├── RssFragment.kt │ │ │ │ │ └── RssViewModel.kt │ │ │ │ ├── qrcode/ │ │ │ │ │ ├── QrCodeActivity.kt │ │ │ │ │ ├── QrCodeFragment.kt │ │ │ │ │ ├── QrCodeResult.kt │ │ │ │ │ └── ScanResultCallback.kt │ │ │ │ ├── replace/ │ │ │ │ │ ├── GroupManageDialog.kt │ │ │ │ │ ├── ReplaceRuleActivity.kt │ │ │ │ │ ├── ReplaceRuleAdapter.kt │ │ │ │ │ ├── ReplaceRuleViewModel.kt │ │ │ │ │ └── edit/ │ │ │ │ │ ├── ReplaceEditActivity.kt │ │ │ │ │ └── ReplaceEditViewModel.kt │ │ │ │ ├── rss/ │ │ │ │ │ ├── article/ │ │ │ │ │ │ ├── BaseRssArticlesAdapter.kt │ │ │ │ │ │ ├── ReadRecordDialog.kt │ │ │ │ │ │ ├── RssArticlesAdapter.kt │ │ │ │ │ │ ├── RssArticlesAdapter1.kt │ │ │ │ │ │ ├── RssArticlesAdapter2.kt │ │ │ │ │ │ ├── RssArticlesFragment.kt │ │ │ │ │ │ ├── RssArticlesViewModel.kt │ │ │ │ │ │ ├── RssSortActivity.kt │ │ │ │ │ │ └── RssSortViewModel.kt │ │ │ │ │ ├── favorites/ │ │ │ │ │ │ ├── RssFavoritesActivity.kt │ │ │ │ │ │ ├── RssFavoritesAdapter.kt │ │ │ │ │ │ ├── RssFavoritesDialog.kt │ │ │ │ │ │ ├── RssFavoritesFragment.kt │ │ │ │ │ │ └── RssFavoritesViewModel.kt │ │ │ │ │ ├── read/ │ │ │ │ │ │ ├── ReadRssActivity.kt │ │ │ │ │ │ ├── ReadRssViewModel.kt │ │ │ │ │ │ ├── RssJsExtensions.kt │ │ │ │ │ │ └── VisibleWebView.kt │ │ │ │ │ ├── source/ │ │ │ │ │ │ ├── debug/ │ │ │ │ │ │ │ ├── RssSourceDebugActivity.kt │ │ │ │ │ │ │ ├── RssSourceDebugAdapter.kt │ │ │ │ │ │ │ └── RssSourceDebugModel.kt │ │ │ │ │ │ ├── edit/ │ │ │ │ │ │ │ ├── RssSourceEditActivity.kt │ │ │ │ │ │ │ ├── RssSourceEditAdapter.kt │ │ │ │ │ │ │ └── RssSourceEditViewModel.kt │ │ │ │ │ │ └── manage/ │ │ │ │ │ │ ├── GroupManageDialog.kt │ │ │ │ │ │ ├── RssSourceActivity.kt │ │ │ │ │ │ ├── RssSourceAdapter.kt │ │ │ │ │ │ └── RssSourceViewModel.kt │ │ │ │ │ └── subscription/ │ │ │ │ │ ├── RuleSubActivity.kt │ │ │ │ │ └── RuleSubAdapter.kt │ │ │ │ ├── welcome/ │ │ │ │ │ └── WelcomeActivity.kt │ │ │ │ └── widget/ │ │ │ │ ├── BatteryView.kt │ │ │ │ ├── DetailSeekBar.kt │ │ │ │ ├── LabelsBar.kt │ │ │ │ ├── NoChildScrollNestedScrollView.kt │ │ │ │ ├── PopupAction.kt │ │ │ │ ├── ReaderInfoBarView.kt │ │ │ │ ├── SearchView.kt │ │ │ │ ├── SelectActionBar.kt │ │ │ │ ├── ShadowLayout.kt │ │ │ │ ├── TitleBar.kt │ │ │ │ ├── anima/ │ │ │ │ │ ├── RefreshProgressBar.kt │ │ │ │ │ ├── RotateLoading.kt │ │ │ │ │ └── explosion_field/ │ │ │ │ │ ├── ExplosionAnimator.kt │ │ │ │ │ ├── ExplosionField.kt │ │ │ │ │ ├── ExplosionView.kt │ │ │ │ │ ├── OnAnimatorListener.kt │ │ │ │ │ └── Utils.kt │ │ │ │ ├── checkbox/ │ │ │ │ │ └── SmoothCheckBox.kt │ │ │ │ ├── code/ │ │ │ │ │ ├── CodeView.kt │ │ │ │ │ ├── CodeViewExtensions.kt │ │ │ │ │ └── KeywordTokenizer.kt │ │ │ │ ├── dialog/ │ │ │ │ │ ├── CodeDialog.kt │ │ │ │ │ ├── PhotoDialog.kt │ │ │ │ │ ├── TextDialog.kt │ │ │ │ │ ├── TextListDialog.kt │ │ │ │ │ ├── UrlOptionDialog.kt │ │ │ │ │ ├── VariableDialog.kt │ │ │ │ │ └── WaitDialog.kt │ │ │ │ ├── dynamiclayout/ │ │ │ │ │ ├── DynamicFrameLayout.kt │ │ │ │ │ └── ViewSwitcher.kt │ │ │ │ ├── image/ │ │ │ │ │ ├── ArcView.kt │ │ │ │ │ ├── CircleImageView.kt │ │ │ │ │ ├── CoverImageView.kt │ │ │ │ │ ├── FilletImageView.kt │ │ │ │ │ ├── ImageButton.kt │ │ │ │ │ ├── PhotoView.kt │ │ │ │ │ └── photo/ │ │ │ │ │ ├── Info.kt │ │ │ │ │ └── RotateGestureDetector.kt │ │ │ │ ├── keyboard/ │ │ │ │ │ ├── KeyboardAssistsConfig.kt │ │ │ │ │ └── KeyboardToolPop.kt │ │ │ │ ├── number/ │ │ │ │ │ └── NumberPickerDialog.kt │ │ │ │ ├── recycler/ │ │ │ │ │ ├── DividerNoLast.kt │ │ │ │ │ ├── DragSelectTouchHelper.kt │ │ │ │ │ ├── HeaderAdapterDataObserver.kt │ │ │ │ │ ├── ItemTouchCallback.kt │ │ │ │ │ ├── LoadMoreView.kt │ │ │ │ │ ├── NoChildScrollLinearLayoutManager.kt │ │ │ │ │ ├── RecyclerViewAtPager2.kt │ │ │ │ │ ├── UpLinearLayoutManager.kt │ │ │ │ │ ├── VerticalDivider.kt │ │ │ │ │ ├── ViewPager2Container.kt │ │ │ │ │ └── scroller/ │ │ │ │ │ ├── FastScrollRecyclerView.kt │ │ │ │ │ ├── FastScrollStateChangeListener.kt │ │ │ │ │ └── FastScroller.kt │ │ │ │ ├── seekbar/ │ │ │ │ │ ├── SeekBarChangeListener.kt │ │ │ │ │ ├── VerticalSeekBar.kt │ │ │ │ │ └── VerticalSeekBarWrapper.kt │ │ │ │ └── text/ │ │ │ │ ├── AccentBgTextView.kt │ │ │ │ ├── AccentStrokeTextView.kt │ │ │ │ ├── AccentTextView.kt │ │ │ │ ├── AutoCompleteTextView.kt │ │ │ │ ├── BadgeView.kt │ │ │ │ ├── BevelLabelView.kt │ │ │ │ ├── EditEntity.kt │ │ │ │ ├── MultilineTextView.kt │ │ │ │ ├── PrimaryTextView.kt │ │ │ │ ├── ScrollMultiAutoCompleteTextView.kt │ │ │ │ ├── ScrollTextView.kt │ │ │ │ ├── SecondaryTextView.kt │ │ │ │ ├── StrokeTextView.kt │ │ │ │ └── TextInputLayout.kt │ │ │ ├── utils/ │ │ │ │ ├── ACache.kt │ │ │ │ ├── ActivityExtensions.kt │ │ │ │ ├── ActivityResult.kt │ │ │ │ ├── ActivityResultContracts.kt │ │ │ │ ├── AlphanumComparator.kt │ │ │ │ ├── AnimationExtensions.kt │ │ │ │ ├── ArchiveUtils.kt │ │ │ │ ├── AsyncFileHandler.kt │ │ │ │ ├── BitmapUtils.kt │ │ │ │ ├── BookChapterExtensions.kt │ │ │ │ ├── ByteArrayExtensions.kt │ │ │ │ ├── ChineseUtils.kt │ │ │ │ ├── CollectionExtensions.kt │ │ │ │ ├── ColorUtils.kt │ │ │ │ ├── ConfigurationExtensions.kt │ │ │ │ ├── ConflateLiveData.kt │ │ │ │ ├── ConstraintModify.kt │ │ │ │ ├── ContextExtensions.kt │ │ │ │ ├── ConvertExtensions.kt │ │ │ │ ├── CookieManagerExtensions.kt │ │ │ │ ├── CoroutineExtensions.kt │ │ │ │ ├── CustomExportUtils.kt │ │ │ │ ├── Debounce.kt │ │ │ │ ├── DebugLog.kt │ │ │ │ ├── DialogExtensions.kt │ │ │ │ ├── DocumentUtils.kt │ │ │ │ ├── DrawableUtils.kt │ │ │ │ ├── EncoderUtils.kt │ │ │ │ ├── EncodingDetect.kt │ │ │ │ ├── EventBusExtensions.kt │ │ │ │ ├── FileDocExtensions.kt │ │ │ │ ├── FileExtensions.kt │ │ │ │ ├── FileUtils.kt │ │ │ │ ├── FlowExtensions.kt │ │ │ │ ├── FragmentExtensions.kt │ │ │ │ ├── GsonExtensions.kt │ │ │ │ ├── HandlerUtils.kt │ │ │ │ ├── HtmlFormatter.kt │ │ │ │ ├── ImageUtils.kt │ │ │ │ ├── InputStreamExtensions.kt │ │ │ │ ├── IntentExtensions.kt │ │ │ │ ├── IntentType.kt │ │ │ │ ├── JsURL.kt │ │ │ │ ├── JsonExtensions.kt │ │ │ │ ├── JsoupExtensions.kt │ │ │ │ ├── LogUtils.kt │ │ │ │ ├── MD5Utils.kt │ │ │ │ ├── MapExtensions.kt │ │ │ │ ├── MenuExtensions.kt │ │ │ │ ├── MenuItemExtensions.kt │ │ │ │ ├── MutableLiveDataExtensions.kt │ │ │ │ ├── NavigationViewUtils.kt │ │ │ │ ├── NetworkUtils.kt │ │ │ │ ├── PaintExtensions.kt │ │ │ │ ├── ParcelFileDescriptorChannel.kt │ │ │ │ ├── PreferencesExtensions.kt │ │ │ │ ├── QRCodeUtils.kt │ │ │ │ ├── RandomColor.kt │ │ │ │ ├── RealPathUtil.kt │ │ │ │ ├── RecyclerViewExtensions.kt │ │ │ │ ├── RegexExtensions.kt │ │ │ │ ├── RequestManagerExtensions.kt │ │ │ │ ├── Snackbars.kt │ │ │ │ ├── StringExtensions.kt │ │ │ │ ├── StringUtils.kt │ │ │ │ ├── SvgUtils.kt │ │ │ │ ├── SyncedRenderer.kt │ │ │ │ ├── SystemUtils.kt │ │ │ │ ├── Throttle.kt │ │ │ │ ├── ThrowableExtensions.kt │ │ │ │ ├── TimeUtils.kt │ │ │ │ ├── ToastUtils.kt │ │ │ │ ├── ToolBarExtensions.kt │ │ │ │ ├── UriExtensions.kt │ │ │ │ ├── UrlUtil.kt │ │ │ │ ├── Utf8BomUtils.kt │ │ │ │ ├── ViewExtensions.kt │ │ │ │ ├── WebSettingsExtensions.kt │ │ │ │ ├── WindowInsetsExtensions.kt │ │ │ │ ├── canvasrecorder/ │ │ │ │ │ ├── BaseCanvasRecorder.kt │ │ │ │ │ ├── CanvasRecorder.kt │ │ │ │ │ ├── CanvasRecorderApi23Impl.kt │ │ │ │ │ ├── CanvasRecorderApi29Impl.kt │ │ │ │ │ ├── CanvasRecorderExtensions.kt │ │ │ │ │ ├── CanvasRecorderFactory.kt │ │ │ │ │ ├── CanvasRecorderImpl.kt │ │ │ │ │ ├── CanvasRecorderLocked.kt │ │ │ │ │ └── pools/ │ │ │ │ │ ├── CanvasPool.kt │ │ │ │ │ ├── PicturePool.kt │ │ │ │ │ └── RenderNodePool.kt │ │ │ │ ├── compress/ │ │ │ │ │ ├── LibArchiveUtils.kt │ │ │ │ │ └── ZipUtils.kt │ │ │ │ ├── objectpool/ │ │ │ │ │ ├── BaseObjectPool.kt │ │ │ │ │ ├── BaseSafeObjectPool.kt │ │ │ │ │ ├── ObjectPool.kt │ │ │ │ │ ├── ObjectPoolExtensions.kt │ │ │ │ │ └── ObjectPoolLocked.kt │ │ │ │ └── viewbindingdelegate/ │ │ │ │ ├── ActivityViewBindings.kt │ │ │ │ ├── FragmentViewBindings.kt │ │ │ │ └── ViewBindingProperty.kt │ │ │ └── web/ │ │ │ ├── HttpServer.kt │ │ │ ├── ReadMe.md │ │ │ ├── WebSocketServer.kt │ │ │ ├── socket/ │ │ │ │ ├── BookSearchWebSocket.kt │ │ │ │ ├── BookSourceDebugWebSocket.kt │ │ │ │ └── RssSourceDebugWebSocket.kt │ │ │ └── utils/ │ │ │ └── AssetsWeb.kt │ │ └── res/ │ │ ├── anim/ │ │ │ ├── anim_none.xml │ │ │ ├── anim_readbook_bottom_in.xml │ │ │ ├── anim_readbook_bottom_out.xml │ │ │ ├── anim_readbook_top_in.xml │ │ │ └── anim_readbook_top_out.xml │ │ ├── color/ │ │ │ └── selector_image.xml │ │ ├── drawable/ │ │ │ ├── bg_chapter_item_divider.xml │ │ │ ├── bg_edit.xml │ │ │ ├── bg_eink_border_bottom.xml │ │ │ ├── bg_eink_border_dialog.xml │ │ │ ├── bg_eink_border_top.xml │ │ │ ├── bg_find_book_group.xml │ │ │ ├── bg_gradient.xml │ │ │ ├── bg_img_border.xml │ │ │ ├── bg_item_focused_on_tv.xml │ │ │ ├── bg_popup_menu.xml │ │ │ ├── bg_prefs_color.xml │ │ │ ├── bg_searchview.xml │ │ │ ├── bg_shadow_bottom.xml │ │ │ ├── bg_shadow_bottom_night.xml │ │ │ ├── bg_shadow_top.xml │ │ │ ├── bg_shadow_top_night.xml │ │ │ ├── bg_textfield_search.xml │ │ │ ├── fastscroll_bubble.xml │ │ │ ├── fastscroll_handle.xml │ │ │ ├── fastscroll_track.xml │ │ │ ├── ic_add.xml │ │ │ ├── ic_add_online.xml │ │ │ ├── ic_arrange.xml │ │ │ ├── ic_arrow_back.xml │ │ │ ├── ic_arrow_down.xml │ │ │ ├── ic_arrow_drop_down.xml │ │ │ ├── ic_arrow_drop_up.xml │ │ │ ├── ic_arrow_right.xml │ │ │ ├── ic_author.xml │ │ │ ├── ic_auto_page.xml │ │ │ ├── ic_auto_page_stop.xml │ │ │ ├── ic_backup.xml │ │ │ ├── ic_baseline_close.xml │ │ │ ├── ic_baseline_sort_24.xml │ │ │ ├── ic_book_has.xml │ │ │ ├── ic_book_last.xml │ │ │ ├── ic_bookmark.xml │ │ │ ├── ic_bottom_books.xml │ │ │ ├── ic_bottom_books_e.xml │ │ │ ├── ic_bottom_books_s.xml │ │ │ ├── ic_bottom_explore.xml │ │ │ ├── ic_bottom_explore_e.xml │ │ │ ├── ic_bottom_explore_s.xml │ │ │ ├── ic_bottom_person.xml │ │ │ ├── ic_bottom_person_e.xml │ │ │ ├── ic_bottom_person_s.xml │ │ │ ├── ic_bottom_rss_feed.xml │ │ │ ├── ic_bottom_rss_feed_e.xml │ │ │ ├── ic_bottom_rss_feed_s.xml │ │ │ ├── ic_brightness.xml │ │ │ ├── ic_brightness_auto.xml │ │ │ ├── ic_bubble_chart.xml │ │ │ ├── ic_bug_report.xml │ │ │ ├── ic_cfg_about.xml │ │ │ ├── ic_cfg_backup.xml │ │ │ ├── ic_cfg_donate.xml │ │ │ ├── ic_cfg_other.xml │ │ │ ├── ic_cfg_replace.xml │ │ │ ├── ic_cfg_source.xml │ │ │ ├── ic_cfg_theme.xml │ │ │ ├── ic_cfg_web.xml │ │ │ ├── ic_chapter_list.xml │ │ │ ├── ic_check.xml │ │ │ ├── ic_check_source.xml │ │ │ ├── ic_clear_all.xml │ │ │ ├── ic_copy.xml │ │ │ ├── ic_create_folder_outline.xml │ │ │ ├── ic_cursor_left.xml │ │ │ ├── ic_cursor_right.xml │ │ │ ├── ic_daytime.xml │ │ │ ├── ic_divider.xml │ │ │ ├── ic_download.xml │ │ │ ├── ic_download_line.xml │ │ │ ├── ic_edit.xml │ │ │ ├── ic_exchange.xml │ │ │ ├── ic_exchange_order.xml │ │ │ ├── ic_exit.xml │ │ │ ├── ic_expand_less.xml │ │ │ ├── ic_expand_more.xml │ │ │ ├── ic_export.xml │ │ │ ├── ic_fast_forward.xml │ │ │ ├── ic_fast_rewind.xml │ │ │ ├── ic_find_replace.xml │ │ │ ├── ic_folder.xml │ │ │ ├── ic_folder_open.xml │ │ │ ├── ic_folder_outline.xml │ │ │ ├── ic_groups.xml │ │ │ ├── ic_help.xml │ │ │ ├── ic_history.xml │ │ │ ├── ic_image.xml │ │ │ ├── ic_import.xml │ │ │ ├── ic_interface_setting.xml │ │ │ ├── ic_launcher1.xml │ │ │ ├── ic_launcher1_b.xml │ │ │ ├── ic_launcher2.xml │ │ │ ├── ic_launcher3.xml │ │ │ ├── ic_launcher4.xml │ │ │ ├── ic_launcher4_b.xml │ │ │ ├── ic_launcher5.xml │ │ │ ├── ic_launcher5_b.xml │ │ │ ├── ic_launcher6.xml │ │ │ ├── ic_launcher7.xml │ │ │ ├── ic_launcher7_b.xml │ │ │ ├── ic_lock_outline.xml │ │ │ ├── ic_menu.xml │ │ │ ├── ic_more.xml │ │ │ ├── ic_more_vert.xml │ │ │ ├── ic_network_check.xml │ │ │ ├── ic_outline_cloud_24.xml │ │ │ ├── ic_outline_delete.xml │ │ │ ├── ic_pause_24dp.xml │ │ │ ├── ic_pause_outline_24dp.xml │ │ │ ├── ic_play_24dp.xml │ │ │ ├── ic_play_mode_list_end_stop.xml │ │ │ ├── ic_play_mode_list_loop.xml │ │ │ ├── ic_play_mode_random.xml │ │ │ ├── ic_play_mode_single_loop.xml │ │ │ ├── ic_play_outline_24dp.xml │ │ │ ├── ic_praise.xml │ │ │ ├── ic_read_aloud.xml │ │ │ ├── ic_reduce.xml │ │ │ ├── ic_refresh_black_24dp.xml │ │ │ ├── ic_refresh_white_24dp.xml │ │ │ ├── ic_restore.xml │ │ │ ├── ic_save.xml │ │ │ ├── ic_scan.xml │ │ │ ├── ic_scoring.xml │ │ │ ├── ic_screen.xml │ │ │ ├── ic_search.xml │ │ │ ├── ic_search_hint.xml │ │ │ ├── ic_settings.xml │ │ │ ├── ic_share.xml │ │ │ ├── ic_skip_next.xml │ │ │ ├── ic_skip_previous.xml │ │ │ ├── ic_sort.xml │ │ │ ├── ic_star.xml │ │ │ ├── ic_star_border.xml │ │ │ ├── ic_stop_black_24dp.xml │ │ │ ├── ic_storage_black_24dp.xml │ │ │ ├── ic_swap_horiz.xml │ │ │ ├── ic_time_add_24dp.xml │ │ │ ├── ic_timer_black_24dp.xml │ │ │ ├── ic_toc.xml │ │ │ ├── ic_translate.xml │ │ │ ├── ic_update.xml │ │ │ ├── ic_view_quilt.xml │ │ │ ├── ic_visibility_off.xml │ │ │ ├── ic_volume_up.xml │ │ │ ├── ic_web_outline.xml │ │ │ ├── ic_web_service_noti.xml │ │ │ ├── recyclerview_divider_horizontal.xml │ │ │ ├── recyclerview_divider_vertical.xml │ │ │ ├── selector_btn_accent_bg.xml │ │ │ ├── selector_circle_btn_bg.xml │ │ │ ├── selector_common_bg.xml │ │ │ ├── selector_fillet_btn_bg.xml │ │ │ ├── selector_tv_black.xml │ │ │ ├── shape_card_view.xml │ │ │ ├── shape_circle.xml │ │ │ ├── shape_fillet_btn.xml │ │ │ ├── shape_fillet_btn_press.xml │ │ │ ├── shape_pop_checkaddshelf_bg.xml │ │ │ ├── shape_radius_10dp.xml │ │ │ ├── shape_radius_1dp.xml │ │ │ ├── shape_space_divider.xml │ │ │ ├── shape_text_cursor.xml │ │ │ └── shape_translucent_card.xml │ │ ├── layout/ │ │ │ ├── activity_about.xml │ │ │ ├── activity_all_bookmark.xml │ │ │ ├── activity_arrange_book.xml │ │ │ ├── activity_audio_play.xml │ │ │ ├── activity_book_info.xml │ │ │ ├── activity_book_info_edit.xml │ │ │ ├── activity_book_read.xml │ │ │ ├── activity_book_search.xml │ │ │ ├── activity_book_source.xml │ │ │ ├── activity_book_source_edit.xml │ │ │ ├── activity_cache_book.xml │ │ │ ├── activity_chapter_list.xml │ │ │ ├── activity_config.xml │ │ │ ├── activity_dict_rule.xml │ │ │ ├── activity_donate.xml │ │ │ ├── activity_explore_show.xml │ │ │ ├── activity_file_manage.xml │ │ │ ├── activity_import_book.xml │ │ │ ├── activity_main.xml │ │ │ ├── activity_manga.xml │ │ │ ├── activity_qrcode_capture.xml │ │ │ ├── activity_read_record.xml │ │ │ ├── activity_replace_edit.xml │ │ │ ├── activity_replace_rule.xml │ │ │ ├── activity_rss_artivles.xml │ │ │ ├── activity_rss_favorites.xml │ │ │ ├── activity_rss_read.xml │ │ │ ├── activity_rss_source.xml │ │ │ ├── activity_rss_source_edit.xml │ │ │ ├── activity_rule_sub.xml │ │ │ ├── activity_search_content.xml │ │ │ ├── activity_source_debug.xml │ │ │ ├── activity_source_login.xml │ │ │ ├── activity_translucence.xml │ │ │ ├── activity_txt_toc_rule.xml │ │ │ ├── activity_web_view.xml │ │ │ ├── activity_welcome.xml │ │ │ ├── dialog_add_to_bookshelf.xml │ │ │ ├── dialog_auto_read.xml │ │ │ ├── dialog_book_change_source.xml │ │ │ ├── dialog_book_group_edit.xml │ │ │ ├── dialog_book_group_picker.xml │ │ │ ├── dialog_bookmark.xml │ │ │ ├── dialog_bookshelf_config.xml │ │ │ ├── dialog_change_cover.xml │ │ │ ├── dialog_chapter_change_source.xml │ │ │ ├── dialog_check_source_config.xml │ │ │ ├── dialog_click_action_config.xml │ │ │ ├── dialog_code_view.xml │ │ │ ├── dialog_content_edit.xml │ │ │ ├── dialog_cover_rule_config.xml │ │ │ ├── dialog_custom_group.xml │ │ │ ├── dialog_dict.xml │ │ │ ├── dialog_dict_rule_edit.xml │ │ │ ├── dialog_direct_link_upload_config.xml │ │ │ ├── dialog_download_choice.xml │ │ │ ├── dialog_edit_text.xml │ │ │ ├── dialog_file_chooser.xml │ │ │ ├── dialog_font_select.xml │ │ │ ├── dialog_http_tts_edit.xml │ │ │ ├── dialog_image_blurring.xml │ │ │ ├── dialog_login.xml │ │ │ ├── dialog_manga_color_filter.xml │ │ │ ├── dialog_manga_epaper.xml │ │ │ ├── dialog_manga_footer_setting.xml │ │ │ ├── dialog_multiple_edit_text.xml │ │ │ ├── dialog_number_picker.xml │ │ │ ├── dialog_open_url_confirm.xml │ │ │ ├── dialog_page_key.xml │ │ │ ├── dialog_photo_view.xml │ │ │ ├── dialog_progressbar_view.xml │ │ │ ├── dialog_read_aloud.xml │ │ │ ├── dialog_read_bg_text.xml │ │ │ ├── dialog_read_book_style.xml │ │ │ ├── dialog_read_padding.xml │ │ │ ├── dialog_recycler_view.xml │ │ │ ├── dialog_rss_favorite_config.xml │ │ │ ├── dialog_rule_sub_edit.xml │ │ │ ├── dialog_search_scope.xml │ │ │ ├── dialog_select_section_export.xml │ │ │ ├── dialog_simulated_reading.xml │ │ │ ├── dialog_source_picker.xml │ │ │ ├── dialog_text_view.xml │ │ │ ├── dialog_tip_config.xml │ │ │ ├── dialog_toc_regex.xml │ │ │ ├── dialog_toc_regex_edit.xml │ │ │ ├── dialog_update.xml │ │ │ ├── dialog_url_option_edit.xml │ │ │ ├── dialog_variable.xml │ │ │ ├── dialog_verification_code_view.xml │ │ │ ├── dialog_wait.xml │ │ │ ├── dialog_webdav_server.xml │ │ │ ├── fragment_bookmark.xml │ │ │ ├── fragment_books.xml │ │ │ ├── fragment_bookshelf1.xml │ │ │ ├── fragment_bookshelf2.xml │ │ │ ├── fragment_chapter_list.xml │ │ │ ├── fragment_explore.xml │ │ │ ├── fragment_my_config.xml │ │ │ ├── fragment_rss.xml │ │ │ ├── fragment_rss_articles.xml │ │ │ ├── fragment_web_view_login.xml │ │ │ ├── item_1line_text.xml │ │ │ ├── item_1line_text_and_del.xml │ │ │ ├── item_app_log.xml │ │ │ ├── item_arrange_book.xml │ │ │ ├── item_bg_image.xml │ │ │ ├── item_book_file_import.xml │ │ │ ├── item_book_group_manage.xml │ │ │ ├── item_book_manga_edge.xml │ │ │ ├── item_book_manga_page.xml │ │ │ ├── item_book_source.xml │ │ │ ├── item_bookmark.xml │ │ │ ├── item_bookshelf_grid.xml │ │ │ ├── item_bookshelf_grid_group.xml │ │ │ ├── item_bookshelf_list.xml │ │ │ ├── item_bookshelf_list_group.xml │ │ │ ├── item_change_source.xml │ │ │ ├── item_chapter_list.xml │ │ │ ├── item_check_box.xml │ │ │ ├── item_cover.xml │ │ │ ├── item_dict_rule.xml │ │ │ ├── item_download.xml │ │ │ ├── item_file.xml │ │ │ ├── item_file_picker.xml │ │ │ ├── item_fillet_text.xml │ │ │ ├── item_find_book.xml │ │ │ ├── item_font.xml │ │ │ ├── item_group_manage.xml │ │ │ ├── item_group_select.xml │ │ │ ├── item_http_tts.xml │ │ │ ├── item_icon_preference.xml │ │ │ ├── item_import_book.xml │ │ │ ├── item_log.xml │ │ │ ├── item_path_picker.xml │ │ │ ├── item_radio_button.xml │ │ │ ├── item_read_record.xml │ │ │ ├── item_read_style.xml │ │ │ ├── item_replace_rule.xml │ │ │ ├── item_rss.xml │ │ │ ├── item_rss_article.xml │ │ │ ├── item_rss_article_1.xml │ │ │ ├── item_rss_article_2.xml │ │ │ ├── item_rss_read_record.xml │ │ │ ├── item_rss_source.xml │ │ │ ├── item_rule_sub.xml │ │ │ ├── item_search.xml │ │ │ ├── item_search_list.xml │ │ │ ├── item_server_select.xml │ │ │ ├── item_source_edit.xml │ │ │ ├── item_source_edit_check_box.xml │ │ │ ├── item_source_import.xml │ │ │ ├── item_text.xml │ │ │ ├── item_theme_config.xml │ │ │ ├── item_toc_regex.xml │ │ │ ├── item_txt_toc_rule.xml │ │ │ ├── popup_action.xml │ │ │ ├── popup_action_menu.xml │ │ │ ├── popup_keyboard_tool.xml │ │ │ ├── popup_seek_bar.xml │ │ │ ├── view_action_button.xml │ │ │ ├── view_book_page.xml │ │ │ ├── view_detail_seek_bar.xml │ │ │ ├── view_dynamic.xml │ │ │ ├── view_error.xml │ │ │ ├── view_fastscroller.xml │ │ │ ├── view_icon.xml │ │ │ ├── view_load_more.xml │ │ │ ├── view_loading.xml │ │ │ ├── view_manga_menu.xml │ │ │ ├── view_navigation_badge.xml │ │ │ ├── view_preference.xml │ │ │ ├── view_preference_category.xml │ │ │ ├── view_read_menu.xml │ │ │ ├── view_refresh_recycler.xml │ │ │ ├── view_search.xml │ │ │ ├── view_search_menu.xml │ │ │ ├── view_select_action_bar.xml │ │ │ ├── view_tab_layout.xml │ │ │ ├── view_tab_layout_min.xml │ │ │ ├── view_title_bar.xml │ │ │ ├── view_title_bar_dark.xml │ │ │ └── view_toast.xml │ │ ├── layout-land/ │ │ │ └── activity_book_info.xml │ │ ├── menu/ │ │ │ ├── about.xml │ │ │ ├── app_log.xml │ │ │ ├── app_update.xml │ │ │ ├── audio_play.xml │ │ │ ├── backup_restore.xml │ │ │ ├── book_cache.xml │ │ │ ├── book_cache_download.xml │ │ │ ├── book_group_manage.xml │ │ │ ├── book_info.xml │ │ │ ├── book_info_edit.xml │ │ │ ├── book_manga.xml │ │ │ ├── book_read.xml │ │ │ ├── book_read_change_source.xml │ │ │ ├── book_read_record.xml │ │ │ ├── book_read_refresh.xml │ │ │ ├── book_read_source.xml │ │ │ ├── book_remote.xml │ │ │ ├── book_search.xml │ │ │ ├── book_search_scope.xml │ │ │ ├── book_source.xml │ │ │ ├── book_source_debug.xml │ │ │ ├── book_source_item.xml │ │ │ ├── book_source_sel.xml │ │ │ ├── book_toc.xml │ │ │ ├── bookmark.xml │ │ │ ├── bookshelf_manage.xml │ │ │ ├── bookshelf_menage_sel.xml │ │ │ ├── change_cover.xml │ │ │ ├── change_source.xml │ │ │ ├── change_source_item.xml │ │ │ ├── code_edit.xml │ │ │ ├── content_edit.xml │ │ │ ├── content_search.xml │ │ │ ├── content_select_action.xml │ │ │ ├── crash_log.xml │ │ │ ├── dialog_text.xml │ │ │ ├── dict_rule.xml │ │ │ ├── dict_rule_edit.xml │ │ │ ├── dict_rule_sel.xml │ │ │ ├── direct_link_upload_config.xml │ │ │ ├── explore_item.xml │ │ │ ├── file_chooser.xml │ │ │ ├── file_long_click.xml │ │ │ ├── font_select.xml │ │ │ ├── group_manage.xml │ │ │ ├── import_book.xml │ │ │ ├── import_book_sel.xml │ │ │ ├── import_replace.xml │ │ │ ├── import_source.xml │ │ │ ├── keyboard_assists_config.xml │ │ │ ├── main_bnv.xml │ │ │ ├── main_bookshelf.xml │ │ │ ├── main_explore.xml │ │ │ ├── main_my.xml │ │ │ ├── main_rss.xml │ │ │ ├── open_url_confirm.xml │ │ │ ├── qr_code_scan.xml │ │ │ ├── replace_edit.xml │ │ │ ├── replace_rule.xml │ │ │ ├── replace_rule_item.xml │ │ │ ├── replace_rule_sel.xml │ │ │ ├── rss_articles.xml │ │ │ ├── rss_favorites.xml │ │ │ ├── rss_main_item.xml │ │ │ ├── rss_read.xml │ │ │ ├── rss_read_record.xml │ │ │ ├── rss_source.xml │ │ │ ├── rss_source_debug.xml │ │ │ ├── rss_source_item.xml │ │ │ ├── rss_source_sel.xml │ │ │ ├── save.xml │ │ │ ├── search_view.xml │ │ │ ├── server_config.xml │ │ │ ├── servers.xml │ │ │ ├── source_edit.xml │ │ │ ├── source_login.xml │ │ │ ├── source_picker.xml │ │ │ ├── source_sub_item.xml │ │ │ ├── source_subscription.xml │ │ │ ├── source_webview_login.xml │ │ │ ├── speak_engine.xml │ │ │ ├── speak_engine_edit.xml │ │ │ ├── theme_config.xml │ │ │ ├── theme_list.xml │ │ │ ├── txt_toc_rule.xml │ │ │ ├── txt_toc_rule_edit.xml │ │ │ ├── txt_toc_rule_item.xml │ │ │ ├── txt_toc_rule_sel.xml │ │ │ ├── verification_code.xml │ │ │ └── web_view.xml │ │ ├── mipmap-anydpi-v26/ │ │ │ ├── ic_launcher.xml │ │ │ ├── launcher1.xml │ │ │ ├── launcher2.xml │ │ │ ├── launcher3.xml │ │ │ ├── launcher4.xml │ │ │ ├── launcher5.xml │ │ │ └── launcher6.xml │ │ ├── values/ │ │ │ ├── array_values.xml │ │ │ ├── arrays.xml │ │ │ ├── attrs.xml │ │ │ ├── colors.xml │ │ │ ├── colors_material_design.xml │ │ │ ├── dimens.xml │ │ │ ├── ids.xml │ │ │ ├── non_translat.xml │ │ │ ├── strings.xml │ │ │ └── styles.xml │ │ ├── values-es-rES/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-ja-rJP/ │ │ │ └── strings.xml │ │ ├── values-night/ │ │ │ ├── colors.xml │ │ │ └── styles.xml │ │ ├── values-pt-rBR/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-vi/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-zh/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-zh-rHK/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-zh-rTW/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ └── xml/ │ │ ├── about.xml │ │ ├── file_paths.xml │ │ ├── network_security_config.xml │ │ ├── pref_config_aloud.xml │ │ ├── pref_config_backup.xml │ │ ├── pref_config_cover.xml │ │ ├── pref_config_other.xml │ │ ├── pref_config_read.xml │ │ ├── pref_config_theme.xml │ │ ├── pref_config_welcome.xml │ │ ├── pref_main.xml │ │ └── spen_remote_actions.xml │ └── test/ │ └── java/ │ └── io/ │ └── legado/ │ └── app/ │ ├── ExampleUnitTest.kt │ └── JsTest.kt ├── avd.bat ├── avd.sh ├── build.gradle ├── gradle/ │ ├── libs.versions.toml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── modules/ │ ├── book/ │ │ ├── .gitignore │ │ ├── build.gradle │ │ ├── consumer-rules.pro │ │ └── src/ │ │ └── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── me/ │ │ │ └── ag2s/ │ │ │ ├── base/ │ │ │ │ ├── PfdHelper.java │ │ │ │ └── ThrowableUtils.java │ │ │ ├── epublib/ │ │ │ │ ├── Constants.java │ │ │ │ ├── browsersupport/ │ │ │ │ │ ├── NavigationEvent.java │ │ │ │ │ ├── NavigationEventListener.java │ │ │ │ │ ├── NavigationHistory.java │ │ │ │ │ ├── Navigator.java │ │ │ │ │ └── package-info.java │ │ │ │ ├── domain/ │ │ │ │ │ ├── Author.java │ │ │ │ │ ├── Date.java │ │ │ │ │ ├── EpubBook.java │ │ │ │ │ ├── EpubResourceProvider.java │ │ │ │ │ ├── FileResourceProvider.java │ │ │ │ │ ├── Guide.java │ │ │ │ │ ├── GuideReference.java │ │ │ │ │ ├── Identifier.java │ │ │ │ │ ├── LazyResource.java │ │ │ │ │ ├── LazyResourceProvider.java │ │ │ │ │ ├── ManifestItemProperties.java │ │ │ │ │ ├── ManifestItemRefProperties.java │ │ │ │ │ ├── ManifestProperties.java │ │ │ │ │ ├── MediaType.java │ │ │ │ │ ├── MediaTypes.java │ │ │ │ │ ├── Metadata.java │ │ │ │ │ ├── Relator.java │ │ │ │ │ ├── Resource.java │ │ │ │ │ ├── ResourceInputStream.java │ │ │ │ │ ├── ResourceReference.java │ │ │ │ │ ├── Resources.java │ │ │ │ │ ├── Spine.java │ │ │ │ │ ├── SpineReference.java │ │ │ │ │ ├── TOCReference.java │ │ │ │ │ ├── TableOfContents.java │ │ │ │ │ └── TitledResourceReference.java │ │ │ │ ├── epub/ │ │ │ │ │ ├── BookProcessor.java │ │ │ │ │ ├── BookProcessorPipeline.java │ │ │ │ │ ├── DOMUtil.java │ │ │ │ │ ├── EpubProcessorSupport.java │ │ │ │ │ ├── EpubReader.java │ │ │ │ │ ├── EpubWriter.java │ │ │ │ │ ├── EpubWriterProcessor.java │ │ │ │ │ ├── HtmlProcessor.java │ │ │ │ │ ├── NCXDocumentV2.java │ │ │ │ │ ├── NCXDocumentV3.java │ │ │ │ │ ├── PackageDocumentBase.java │ │ │ │ │ ├── PackageDocumentMetadataReader.java │ │ │ │ │ ├── PackageDocumentMetadataWriter.java │ │ │ │ │ ├── PackageDocumentReader.java │ │ │ │ │ ├── PackageDocumentWriter.java │ │ │ │ │ └── ResourcesLoader.java │ │ │ │ └── util/ │ │ │ │ ├── CollectionUtil.java │ │ │ │ ├── IOUtil.java │ │ │ │ ├── NoCloseOutputStream.java │ │ │ │ ├── NoCloseWriter.java │ │ │ │ ├── ResourceUtil.java │ │ │ │ ├── StringUtil.java │ │ │ │ ├── URLEncodeUtil.java │ │ │ │ ├── commons/ │ │ │ │ │ └── io/ │ │ │ │ │ ├── BOMInputStream.java │ │ │ │ │ ├── ByteOrderMark.java │ │ │ │ │ ├── IOConsumer.java │ │ │ │ │ ├── ProxyInputStream.java │ │ │ │ │ ├── XmlStreamReader.java │ │ │ │ │ └── XmlStreamReaderException.java │ │ │ │ └── zip/ │ │ │ │ ├── AndroidZipEntry.java │ │ │ │ ├── AndroidZipFile.java │ │ │ │ ├── ZipConstants.java │ │ │ │ ├── ZipEntryWrapper.java │ │ │ │ ├── ZipException.java │ │ │ │ └── ZipFileWrapper.java │ │ │ └── umdlib/ │ │ │ ├── domain/ │ │ │ │ ├── UmdBook.java │ │ │ │ ├── UmdChapters.java │ │ │ │ ├── UmdCover.java │ │ │ │ ├── UmdEnd.java │ │ │ │ └── UmdHeader.java │ │ │ ├── tool/ │ │ │ │ ├── StreamReader.java │ │ │ │ ├── UmdUtils.java │ │ │ │ └── WrapOutputStream.java │ │ │ └── umd/ │ │ │ └── UmdReader.java │ │ └── resources/ │ │ ├── dtd/ │ │ │ ├── openebook.org/ │ │ │ │ └── dtds/ │ │ │ │ └── oeb-1.2/ │ │ │ │ ├── oeb12.ent │ │ │ │ └── oebpkg12.dtd │ │ │ ├── www.daisy.org/ │ │ │ │ └── z3986/ │ │ │ │ └── 2005/ │ │ │ │ └── ncx-2005-1.dtd │ │ │ └── www.w3.org/ │ │ │ └── TR/ │ │ │ ├── ruby/ │ │ │ │ └── xhtml-ruby-1.mod │ │ │ ├── xhtml-modularization/ │ │ │ │ └── DTD/ │ │ │ │ ├── xhtml-arch-1.mod │ │ │ │ ├── xhtml-attribs-1.mod │ │ │ │ ├── xhtml-base-1.mod │ │ │ │ ├── xhtml-bdo-1.mod │ │ │ │ ├── xhtml-blkphras-1.mod │ │ │ │ ├── xhtml-blkpres-1.mod │ │ │ │ ├── xhtml-blkstruct-1.mod │ │ │ │ ├── xhtml-charent-1.mod │ │ │ │ ├── xhtml-csismap-1.mod │ │ │ │ ├── xhtml-datatypes-1.mod │ │ │ │ ├── xhtml-datatypes-1.mod.1 │ │ │ │ ├── xhtml-edit-1.mod │ │ │ │ ├── xhtml-events-1.mod │ │ │ │ ├── xhtml-form-1.mod │ │ │ │ ├── xhtml-framework-1.mod │ │ │ │ ├── xhtml-hypertext-1.mod │ │ │ │ ├── xhtml-image-1.mod │ │ │ │ ├── xhtml-inlphras-1.mod │ │ │ │ ├── xhtml-inlpres-1.mod │ │ │ │ ├── xhtml-inlstruct-1.mod │ │ │ │ ├── xhtml-inlstyle-1.mod │ │ │ │ ├── xhtml-lat1.ent │ │ │ │ ├── xhtml-link-1.mod │ │ │ │ ├── xhtml-list-1.mod │ │ │ │ ├── xhtml-meta-1.mod │ │ │ │ ├── xhtml-notations-1.mod │ │ │ │ ├── xhtml-object-1.mod │ │ │ │ ├── xhtml-param-1.mod │ │ │ │ ├── xhtml-pres-1.mod │ │ │ │ ├── xhtml-qname-1.mod │ │ │ │ ├── xhtml-script-1.mod │ │ │ │ ├── xhtml-special.ent │ │ │ │ ├── xhtml-ssismap-1.mod │ │ │ │ ├── xhtml-struct-1.mod │ │ │ │ ├── xhtml-style-1.mod │ │ │ │ ├── xhtml-symbol.ent │ │ │ │ ├── xhtml-symbol.ent.1 │ │ │ │ ├── xhtml-table-1.mod │ │ │ │ ├── xhtml-text-1.mod │ │ │ │ └── xhtml11-model-1.mod │ │ │ ├── xhtml1/ │ │ │ │ └── DTD/ │ │ │ │ ├── xhtml-lat1.ent │ │ │ │ ├── xhtml-special.ent │ │ │ │ ├── xhtml-symbol.ent │ │ │ │ ├── xhtml1-strict.dtd │ │ │ │ └── xhtml1-transitional.dtd │ │ │ └── xhtml11/ │ │ │ └── DTD/ │ │ │ └── xhtml11.dtd │ │ └── log4j.properties │ ├── rhino/ │ │ ├── build.gradle │ │ ├── consumer-rules.pro │ │ ├── lib/ │ │ │ └── rhino-1.7.14.jar │ │ └── src/ │ │ └── main/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ └── com/ │ │ └── script/ │ │ ├── AbstractScriptEngine.kt │ │ ├── Bindings.kt │ │ ├── Compilable.kt │ │ ├── CompiledScript.kt │ │ ├── Invocable.kt │ │ ├── RhinoContextFactory.kt │ │ ├── ScriptBindings.kt │ │ ├── ScriptBindingsExtensions.kt │ │ ├── ScriptContext.kt │ │ ├── ScriptEngine.kt │ │ ├── ScriptException.kt │ │ ├── SimpleBindings.kt │ │ ├── SimpleScriptContext.kt │ │ └── rhino/ │ │ ├── ClassNameMatcher.kt │ │ ├── CollectionExtensions.kt │ │ ├── ExternalScriptable.kt │ │ ├── InterfaceImplementor.kt │ │ ├── JSAdapter.kt │ │ ├── JavaAdapter.kt │ │ ├── JavaObjectWrapFactory.kt │ │ ├── ProtectedNativeJavaClass.kt │ │ ├── ReadOnlyJavaObject.kt │ │ ├── RhinoClassShutter.kt │ │ ├── RhinoCompiledScript.kt │ │ ├── RhinoContext.kt │ │ ├── RhinoErrors.kt │ │ ├── RhinoExtensions.kt │ │ ├── RhinoScriptEngine.kt │ │ ├── RhinoTopLevel.kt │ │ ├── RhinoWrapFactory.kt │ │ └── VMBridgeReflect.kt │ └── web/ │ ├── .browserslistrc │ ├── .editorconfig │ ├── .gitignore │ ├── .prettierignore │ ├── .prettierrc.json │ ├── LICENSE │ ├── README.md │ ├── env.d.ts │ ├── eslint.config.mjs │ ├── index.html │ ├── package.json │ ├── scripts/ │ │ └── sync.js │ ├── src/ │ │ ├── App.vue │ │ ├── api/ │ │ │ ├── api.ts │ │ │ ├── axios.ts │ │ │ └── index.ts │ │ ├── assets/ │ │ │ ├── bookshelf.css │ │ │ ├── code.css │ │ │ ├── fonts/ │ │ │ │ ├── iconfont.css │ │ │ │ ├── popfont.css │ │ │ │ └── shelffont.css │ │ │ ├── kbd.css │ │ │ └── sourceeditor.css │ │ ├── auto-imports.d.ts │ │ ├── book.d.ts │ │ ├── components/ │ │ │ ├── BookItems.vue │ │ │ ├── CatalogItem.vue │ │ │ ├── ChapterContent.vue │ │ │ ├── PopCatalog.vue │ │ │ ├── ReadSettings.vue │ │ │ ├── SourceDebug.vue │ │ │ ├── SourceHelp.vue │ │ │ ├── SourceItem.vue │ │ │ ├── SourceJson.vue │ │ │ ├── SourceList.vue │ │ │ ├── SourceTabForm.vue │ │ │ ├── SourceTabTools.vue │ │ │ └── ToolBar.vue │ │ ├── components.d.ts │ │ ├── config/ │ │ │ ├── bookSourceEditConfig.ts │ │ │ ├── rssSourceEditConfig.ts │ │ │ ├── sourceConfig.d.ts │ │ │ └── themeConfig.ts │ │ ├── hooks/ │ │ │ ├── loading.css │ │ │ └── loading.ts │ │ ├── main.ts │ │ ├── pages/ │ │ │ ├── bookshelf/ │ │ │ │ ├── README.md │ │ │ │ ├── index.html │ │ │ │ └── main.js │ │ │ └── source/ │ │ │ ├── README.md │ │ │ ├── index.html │ │ │ └── main.js │ │ ├── plugins/ │ │ │ ├── jump.d.ts │ │ │ └── jump.js │ │ ├── router/ │ │ │ ├── bookRouter.ts │ │ │ ├── index.ts │ │ │ └── sourceRouter.ts │ │ ├── source.d.ts │ │ ├── store/ │ │ │ ├── bookStore.ts │ │ │ ├── connectionStore.ts │ │ │ ├── index.ts │ │ │ └── sourceStore.ts │ │ ├── utils/ │ │ │ ├── souce.ts │ │ │ └── utils.ts │ │ ├── views/ │ │ │ ├── BookChapter.vue │ │ │ ├── BookShelf.vue │ │ │ └── SourceEditor.vue │ │ └── web.d.ts │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── package.json └── settings.gradle ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/01-bugReport.yml ================================================ name: BUG提交 / BUG Report description: Report bugs to developers labels: ["BUG"] body: - type: checkboxes attributes: label: 确认 / Assignments description: 提交issue请确保完成以下前提,否则该issue可能被忽略 / Make sure you read checkboxs below options: - label: 搜索现有issues,不存在相似或相关的issue / No similar or related issues required: true - label: 最新[测试版](https://github.com/gedoor/legado/releases/beta)依然存在此问题 / Latest beta app does not work required: true - label: 此问题和Xposed、Lsposed、Magisk、手机主题、浏览器插件、无障碍服务等无关 / Make sure your machine is not touched by hook frameworks, plugins, accessibility etc required: true - type: textarea attributes: label: 问题描述 / Describe Bugs validations: required: true - type: textarea attributes: label: 复现步骤 / How to reproduce validations: required: true - type: checkboxes attributes: label: 确认 / Assignment options: - label: 已经提交复现所需要的附加资料 / Submit additions related with bugs required: true - type: textarea attributes: label: 其他信息 / Additions description: | 反馈WEB书架前端问题时请提供浏览器版本信息,如Edge 129.0.2792.89 placeholder: "请用```将提交的内容包裹" - type: textarea attributes: label: 日志提交 / Relevant log output description: | 阅读日志位于我的-关于-崩溃日志、保存日志、书架-右上角-日志,或者自行使用log工具抓取日志 如果崩溃日志中包含`java.lang.OutOfMemoryError`,请安装最新测试版,在其他设置里打开记录堆转储,复现崩溃后去关于那里点保存日志,然后去备份目录里将heapDump文件夹里的文件打包压缩一下上传上来 placeholder: "请用```将日志内容包裹" - type: input attributes: label: 阅读版本 / Legado version placeholder: "3.22.110823" validations: required: true - type: input attributes: label: Android版本 / Android version placeholder: "Android 12" validations: required: true - type: input attributes: label: 机型 / Model placeholder: "Redmi K30 Pro" validations: required: true ================================================ FILE: .github/ISSUE_TEMPLATE/02-featureRequest.yml ================================================ name: 功能请求 / Features description: Request new features labels: ["需求"] body: - type: checkboxes attributes: label: 确认 / Assignments description: 提交issue请确保完成以下前提,否则该issue可能被忽略 / Make sure you read checkbox below options: - label: 搜索现有issues,不存在相似或相关的issue / No related requests required: true - label: 最新[测试版](https://github.com/gedoor/legado/releases/beta)没有此功能 / Latest beta app does not this feature required: true - type: textarea attributes: label: 功能描述 / Features placeholder: 请清晰的、详细的描述你想要的功能 validations: required: true - type: textarea attributes: label: 期望实现方式 / How to implement placeholder: 阅读应该如何实现该功能 validations: required: true - type: input attributes: label: 阅读版本 / Legado version placeholder: "3.22.110823" validations: required: true - type: textarea attributes: label: 附加信息 / Additions placeholder: 其他的与功能相关的附加信息 - type: textarea attributes: label: 效果演示 / Demo placeholder: 可以手绘一些草图,或者提供可借鉴的图片 ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: 简繁转化 url: https://github.com/liuyueyi/quick-chinese-transfer/issues/new about: 简繁转化问题请优先到quick-chinese-transfer反馈 - name: 讨论 / Discussions url: https://github.com/gedoor/legado/discussions about: Please ask and answer questions here. - name: 常见问题 / Wiki url: https://github.com/gedoor/legado/wiki about: Read wiki if your are new here. ================================================ FILE: .github/dependabot.yml ================================================ # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 registries: maven-google: type: maven-repository # url: https://maven.google.com url: https://dl.google.com/dl/android/maven2/ password: dummy username: dummy maven-central: type: maven-repository url: https://repo1.maven.org/maven2/ password: dummy username: dummy maven-jitpack: type: maven-repository url: https://jitpack.io password: dummy username: dummy updates: - package-ecosystem: gradle directory: "/" schedule: interval: "weekly" registries: "*" open-pull-requests-limit: 20 groups: kotlin_KSP: patterns: - "org.jetbrains.kotlin:*" - "com.google.devtools.ksp" # Maintain dependencies for GitHub Actions - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" - package-ecosystem: npm directory: "modules/web" schedule: interval: "weekly" ================================================ FILE: .github/scripts/cronet.sh ================================================ #!/usr/bin/env bash #分支Stable Dev Beta branch=$1 #api 最大偏移 max_offset=$2 [ -z $1 ] && branch=Stable [ -z $2 ] && max_offset=3 [ -z $GITHUB_ENV ] && echo "Error: Unexpected github workflow environment" && exit offset=0 function fetch_version() { # 获取最新cronet版本 lastest_cronet_version=`curl -s "https://chromiumdash.appspot.com/fetch_releases?channel=$branch&platform=Android&num=1&offset=$offset" | jq .[0].version -r` echo "lastest_cronet_version: $lastest_cronet_version" #lastest_cronet_version=100.0.4845.0 lastest_cronet_main_version=${lastest_cronet_version%%\.*}.0.0.0 check_version_exit } function check_version_exit() { # 检查版本是否存在 local jar_url="https://storage.googleapis.com/chromium-cronet/android/$lastest_cronet_version/Release/cronet/cronet_api.jar" statusCode=$(curl -s -I -w %{http_code} "$jar_url" -o /dev/null) if [ $statusCode == "404" ]; then echo "storage.googleapis.com return 404 for cronet $lastest_cronet_version" if [[ $max_offset > $offset ]]; then offset=$(expr $offset + 1) echo "retry with offset $offset" fetch_version else exit fi fi } function version_compare() { # 版本号比较 本地版本小于远程版本时返回0 local local_version=$1 local remote_version=$2 if [[ $local_version == $remote_version ]]; then return 1 fi if [[ $(printf '%s\n' "$1" "$2" | sort -V | head -n1) == $remote_version ]]; then return 1 else return 0 fi } # 添加变量到github env function write_github_env_variable() { echo "$1=$2" >> $GITHUB_ENV } function sync_proguard_rules() { local raw_github_git="https://raw.githubusercontent.com/chromium/chromium/$lastest_cronet_version" local proguard_paths=( components/cronet/android/cronet_combined_impl_native_proguard_golden.cfg ) local proguard_rules_path="$GITHUB_WORKSPACE/app/cronet-proguard-rules.pro" rm -f $proguard_rules_path echo "fetch cronet proguard rules from upstream $raw_github_git" for path in ${proguard_paths[@]} do echo "fetching $path ..." curl "$raw_github_git/$path" >> $proguard_rules_path done } ########## # 获取本地cronet版本 path=$GITHUB_WORKSPACE/gradle.properties current_cronet_version=`cat $path | grep CronetVersion | sed s/CronetVersion=//` echo "current_cronet_version: $current_cronet_version" echo "fetch $branch release info from https://chromiumdash.appspot.com ..." fetch_version if version_compare $current_cronet_version $lastest_cronet_version; then # 更新gradle.properties sed -i s/CronetVersion=.*/CronetVersion=$lastest_cronet_version/ $path sed -i s/CronetMainVersion=.*/CronetMainVersion=$lastest_cronet_main_version/ $path # 更新cronet_proguard_rules.pro sync_proguard_rules # 更新cronet版本 sed -i "s/## cronet版本: .*/## cronet版本: $lastest_cronet_version/" $GITHUB_WORKSPACE/app/src/main/assets/updateLog.md # 生成pull request信息 write_github_env_variable PR_TITLE "Bump cronet from $current_cronet_version to $lastest_cronet_version" write_github_env_variable PR_BODY "Changes in the [Git log](https://chromium.googlesource.com/chromium/src/+log/$current_cronet_version..$lastest_cronet_version)" # 生成cronet flag write_github_env_variable cronet ok fi ================================================ FILE: .github/scripts/lzy_web.py ================================================ import requests, os, datetime, sys # Cookie 中 phpdisk_info 的值 cookie_phpdisk_info = os.environ.get('phpdisk_info') # Cookie 中 ylogin 的值 cookie_ylogin = os.environ.get('ylogin') # 请求头 headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.72 Safari/537.36 Edg/89.0.774.45', 'Accept-Language': 'zh-CN,zh;q=0.9', 'Referer': 'https://pc.woozooo.com/account.php?action=login' } # 小饼干 cookie = { 'ylogin': cookie_ylogin, 'phpdisk_info': cookie_phpdisk_info } # 日志打印 def log(msg): utc_time = datetime.datetime.utcnow() china_time = utc_time + datetime.timedelta(hours=8) print(f"[{china_time.strftime('%Y.%m.%d %H:%M:%S')}] {msg}") # 检查是否已登录 def login_by_cookie(): url_account = "https://pc.woozooo.com/account.php" if cookie['phpdisk_info'] is None: log('ERROR: 请指定 Cookie 中 phpdisk_info 的值!') return False if cookie['ylogin'] is None: log('ERROR: 请指定 Cookie 中 ylogin 的值!') return False res = requests.get(url_account, headers=headers, cookies=cookie, verify=True) if '网盘用户登录' in res.text: log('ERROR: 登录失败,请更新Cookie') return False else: log('登录成功') return True # 上传文件 def upload_file(file_dir, folder_id): file_name = os.path.basename(file_dir) url_upload = "https://up.woozooo.com/fileup.php" headers['Referer'] = f'https://up.woozooo.com/mydisk.php?item=files&action=index&u={cookie_ylogin}' post_data = { "task": "1", "folder_id": folder_id, "id": "WU_FILE_0", "name": file_name, } files = {'upload_file': (file_name, open(file_dir, "rb"), 'application/octet-stream')} res = requests.post(url_upload, data=post_data, files=files, headers=headers, cookies=cookie, timeout=120).json() log(f"{file_dir} -> {res['info']}") return res['zt'] == 1 # 上传文件夹内的文件 def upload_folder(folder_dir, folder_id): file_list = sorted(os.listdir(folder_dir), reverse=True) for file in file_list: path = os.path.join(folder_dir, file) if os.path.isfile(path): upload_file(path, folder_id) else: upload_folder(path, folder_id) # 上传 def upload(dir, folder_id): if dir is None: log('ERROR: 请指定上传的文件路径') return if folder_id is None: log('ERROR: 请指定蓝奏云的文件夹id') return if os.path.isfile(dir): upload_file(dir, str(folder_id)) else: upload_folder(dir, str(folder_id)) if __name__ == '__main__': argv = sys.argv[1:] if len(argv) != 2: log('ERROR: 参数错误,请以这种格式重新尝试\npython lzy_web.py 需上传的路径 蓝奏云文件夹id') # 需上传的路径 upload_path = argv[0] # 蓝奏云文件夹id lzy_folder_id = argv[1] if login_by_cookie(): upload(upload_path, lzy_folder_id) ================================================ FILE: .github/scripts/tg_bot.py ================================================ import os, sys, telebot # 上传文件 def upload_file(tb, chat_id, file_dir): doc = open(file_dir, 'rb') tb.send_document(chat_id, doc) # 上传文件夹内的文件 def upload_folder(tb, chat_id, folder_dir): file_list = sorted(os.listdir(folder_dir)) for file in file_list: path = os.path.join(folder_dir, file) if os.path.isfile(path): upload_file(tb, chat_id, path) else: upload_folder(tb, chat_id, path) # 上传 def upload(tb, chat_id, dir): if tb is None: log('ERROR: 输入正确的token') return if chat_id is None: log('ERROR: 输入正确的chat_id') return if dir is None: log('ERROR: 请指定上传的文件路径') return if os.path.isfile(dir): upload_file(tb, chat_id, dir) else: upload_folder(tb, chat_id, dir) if __name__ == '__main__': argv = sys.argv[1:] if len(argv) != 3: log('ERROR: 参数错误,请以这种格式重新尝试\npython tg_bot.py $token $chat_id 待上传的路径') # Token TOKEN = argv[0] # chat_id chat_id = argv[1] # 待上传文件的路径 upload_path = argv[2] #创建连接 tb = telebot.TeleBot(TOKEN) #开始上传 upload(tb, chat_id, upload_path) ================================================ FILE: .github/workflows/autoupdatefork.yml ================================================ #更新fork name: update fork on: schedule: - cron: '0 16 * * *' #0点定时任务 jobs: build: runs-on: ubuntu-latest if: ${{ github.event.repository.owner.id == github.event.sender.id && github.actor != 'gedoor' }} steps: - name: Checkout uses: actions/checkout@v4 - name: Set env run: | git config --global user.email "github-actions@github.com" git config --global user.name "github-actions" - name: Update fork run: | git remote add upstream https://github.com/gedoor/legado.git git remote -v git fetch upstream git checkout master git merge upstream/master git push origin master ================================================ FILE: .github/workflows/cronet.yml ================================================ name: Update Cronet on: schedule: # 周一北京时间9点 - cron: 0 1 * * 1 workflow_dispatch: jobs: build: runs-on: ubuntu-latest if: ${{ github.repository == 'gedoor/legado' }} steps: - uses: actions/checkout@v4 - name: Check Cronet Updates run: source .github/scripts/cronet.sh - name: Set up JDK 17 if: ${{ env.cronet == 'ok' }} uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: 17 - uses: gradle/actions/setup-gradle@v4 if: ${{ env.cronet == 'ok' }} - name: Download Cronet if: ${{ env.cronet == 'ok' }} run: | chmod +x gradlew ./gradlew app:downloadCronet - name: Create Pull Request if: ${{ env.cronet == 'ok' }} uses: peter-evans/create-pull-request@v7 continue-on-error: true with: token: ${{ secrets.ACTIONS_TOKEN }} title: ${{ env.PR_TITLE }} commit-message: | ${{ env.PR_TITLE }} - ${{ env.PR_BODY }} body: ${{ env.PR_BODY }} branch: cronet delete-branch: true add-paths: | *cronet*jar *cronet.json *updateLog.md *gradle.properties *cronet-proguard-rules.pro ================================================ FILE: .github/workflows/release.yml ================================================ name: Release Build on: # push: # branches: # - master # paths: # - 'CHANGELOG.md' workflow_dispatch: jobs: prepare: runs-on: ubuntu-latest if: github.event_name != 'pull_request' && github.ref == 'refs/heads/master' outputs: version: ${{ steps.set-ver.outputs.version }} play: ${{ steps.check.outputs.play }} sign: ${{ steps.check.outputs.sign }} steps: - id: set-ver run: | echo "version=$(date -d "8 hour" -u +3.%y.%m%d%H)" >> $GITHUB_OUTPUT - id: check run: | if [ ! -z "${{ secrets.RELEASE_KEY_STORE }}" ]; then echo "sign=yes" >> $GITHUB_OUTPUT fi if [ ! -z "${{ secrets.SERVICE_ACCOUNT_JSON }}" ]; then echo "play=yes" >> $GITHUB_OUTPUT fi build: needs: prepare if: ${{ needs.prepare.outputs.sign }} strategy: matrix: product: [ app, google ] fail-fast: false runs-on: ubuntu-latest env: product: ${{ matrix.product }} VERSION: ${{ needs.prepare.outputs.version }} play: ${{ needs.prepare.outputs.play }} steps: - uses: actions/checkout@v4 - name: Set up JDK 17 uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: 17 - name: Release Apk Sign run: | # not use this output # echo "KeyStore=yes" >> $GITHUB_OUTPUT echo -e "\n" >> gradle.properties echo RELEASE_KEY_ALIAS='${{ secrets.RELEASE_KEY_ALIAS }}' >> gradle.properties echo RELEASE_KEY_PASSWORD='${{ secrets.RELEASE_KEY_PASSWORD }}' >> gradle.properties echo RELEASE_STORE_PASSWORD='${{ secrets.RELEASE_STORE_PASSWORD }}' >> gradle.properties echo RELEASE_STORE_FILE='./key.jks' >> gradle.properties echo ${{ secrets.RELEASE_KEY_STORE }} | base64 --decode > $GITHUB_WORKSPACE/app/key.jks - name: Unify Version Name run: | echo "统一版本号" sed "/def version/c def version = \"${{ env.VERSION }}\"" $GITHUB_WORKSPACE/app/build.gradle -i - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 - name: Build With Gradle run: | echo "开始进行${{ env.product }}构建" chmod +x gradlew ./gradlew assemble${{ env.product }}release --build-cache --parallel --daemon --warning-mode all - name: Organize the Files run: | mkdir -p ${{ github.workspace }}/apk/ cp -rf ${{ github.workspace }}/app/build/outputs/apk/*/*/*.apk ${{ github.workspace }}/apk/ - name: Upload App To Artifact uses: actions/upload-artifact@v4 with: name: legado_${{ env.product }} path: ${{ github.workspace }}/apk/*.apk - name: Release if: ${{ env.product == 'app' }} uses: softprops/action-gh-release@v2 with: name: legado_app_${{ env.VERSION }} tag_name: ${{ env.VERSION }} body_path: ${{ github.workspace }}/CHANGELOG.md draft: false prerelease: false files: ${{ github.workspace }}/apk/legado_app_*.apk env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Prepare For GooglePlay if: env.product == 'google' && env.play == 'yes' run: | mkdir -p ReleaseNotes ln -s ${{ github.workspace }}/CHANGELOG.md ReleaseNotes/whatsnew-en-US ln -s ${{ github.workspace }}/CHANGELOG.md ReleaseNotes/whatsnew-zh-CN - name: Release To GooglePlay if: env.product == 'google' && env.play == 'yes' uses: r0adkll/upload-google-play@v1 with: serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }} packageName: io.legado.play releaseFiles: ${{ github.workspace }}/apk/legado_google_*.apk track: production whatsNewDirectory: ${{ github.workspace }}/ReleaseNotes - name: Push Assets To "release" Branch if: ${{ github.actor == 'gedoor' }} run: | cd $GITHUB_WORKSPACE/apk/ git init git checkout -b release git config --global user.name "github-actions[bot]" git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" git remote add origin "https://${{ github.actor }}:${{ secrets.ACTIONS_TOKEN }}@github.com/${{ github.actor }}/release" git add *.apk git commit -m "${{ env.VERSION }}" git push -f -u origin release - name: Purge Jsdelivr Cache if: ${{ github.actor == 'gedoor' }} run: | result=$(curl -s https://purge.jsdelivr.net/gh/${{ github.actor }}/release@release/) if echo $result |grep -q 'success.*true'; then echo "jsdelivr缓存更新成功" else echo $result fi ================================================ FILE: .github/workflows/stale.yml ================================================ # This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. # # You can adjust the behavior by modifying this file. # For more information, see: # https://github.com/actions/stale name: closeStaleIssue on: schedule: # 每5天北京时间9点 - cron: '30 1 1/5 * *' workflow_dispatch: jobs: stale: runs-on: ubuntu-latest if: ${{ github.repository == 'gedoor/legado' }} permissions: issues: write steps: - uses: actions/stale@v9 with: repo-token: ${{ secrets.GITHUB_TOKEN }} stale-issue-message: '由于长期没有状态更新,该问题将于5天后自动关闭。如有需要可重新打开。' days-before-stale: 30 days-before-close: 5 operations-per-run: 100 ================================================ FILE: .github/workflows/test.yml ================================================ name: Test Build on: push: branches: - master paths: - '**' # - '!**/assets/**' # - '!**.md' - '!**/ISSUE_TEMPLATE/**' - '!**/modules/web/**' pull_request: paths-ignore: - '**/modules/web/**' workflow_run: workflows: [Build Web] branches: [master] types: - completed workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: prepare: runs-on: ubuntu-latest if: ${{ !startsWith(github.event.head_commit.message, 'Merge pull request') }} outputs: version: ${{ steps.set-ver.outputs.version }} versionL: ${{ steps.set-ver.outputs.versionL }} lanzou: ${{ steps.check.outputs.lanzou }} telegram: ${{ steps.check.outputs.telegram }} steps: - id: set-ver run: | echo "version=$(date -d "8 hour" -u +3.%y.%m%d%H)" >> $GITHUB_OUTPUT echo "versionL=$(date -d "8 hour" -u +3.%y.%m%d%H%M)" >> $GITHUB_OUTPUT - id: check run: | if [ ${{ secrets.LANZOU_ID }} ]; then echo "lanzou=yes" >> $GITHUB_OUTPUT fi if [ ${{ secrets.BOT_TOKEN }} ]; then echo "telegram=yes" >> $GITHUB_OUTPUT fi build: needs: prepare strategy: matrix: product: [ app ] type: [ release, releaseA ] fail-fast: false runs-on: ubuntu-latest env: product: ${{ matrix.product }} type: ${{ matrix.type }} VERSION: ${{ needs.prepare.outputs.version }} VERSIONL: ${{ needs.prepare.outputs.versionL }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up JDK 17 uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: 17 - name: Clear 18PlusList.txt run: | echo "清空18PlusList.txt" echo "">$GITHUB_WORKSPACE/app/src/main/assets/18PlusList.txt - name: Release Apk Sign run: | echo "给apk增加签名" cp $GITHUB_WORKSPACE/.github/workflows/legado.jks $GITHUB_WORKSPACE/app/legado.jks sed '$a\RELEASE_STORE_FILE=./legado.jks' $GITHUB_WORKSPACE/gradle.properties -i sed '$a\RELEASE_KEY_ALIAS=legado' $GITHUB_WORKSPACE/gradle.properties -i sed '$a\RELEASE_STORE_PASSWORD=gedoor_legado' $GITHUB_WORKSPACE/gradle.properties -i sed '$a\RELEASE_KEY_PASSWORD=gedoor_legado' $GITHUB_WORKSPACE/gradle.properties -i - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 - name: Build With Gradle continue-on-error: true run: | if [ ${{ env.type }} == 'release' ]; then typeName="原包名" else typeName="共存" sed "s/'.release'/'.releaseA'/" $GITHUB_WORKSPACE/app/build.gradle -i sed 's/.release/.releaseA/' $GITHUB_WORKSPACE/app/google-services.json -i fi echo "统一版本号" sed "/def version/c def version = \"${{ env.VERSION }}\"" $GITHUB_WORKSPACE/app/build.gradle -i echo "开始${{ env.product }}$typeName构建" chmod +x gradlew ./gradlew assemble${{ env.product }}release --build-cache --parallel --daemon --warning-mode all echo "修改APK文件名" mkdir -p ${{ github.workspace }}/apk/ for file in `ls ${{ github.workspace }}/app/build/outputs/apk/*/*/*.apk`; do mv "$file" ${{ github.workspace }}/apk/legado_${{ env.product }}_${{ env.VERSIONL }}_$typeName.apk done echo "移动mapping文件" mkdir -p ${{ github.workspace }}/mapping/ for file in `ls ${{ github.workspace }}/app/build/outputs/mapping/*/mapping.txt`; do mv "$file" ${{ github.workspace }}/mapping/mapping.txt done - name: Move Missing Rules Files run: | echo "移动missing_rules.txt文件" mkdir -p ${{ github.workspace }}/mapping/ for file in `ls ${{ github.workspace }}/app/build/outputs/mapping/*/missing_rules.txt`; do mv "$file" ${{ github.workspace }}/mapping/missing_rules.txt done - name: Upload Missing Rules File To Artifact uses: actions/upload-artifact@v4 with: name: legado.${{ env.product }}.${{ env.type }}.mapping.missing_rules if-no-files-found: ignore path: ${{ github.workspace }}/mapping/missing_rules.txt - name: Check Build production run: | if [ ! -d ${{ github.workspace }}/apk ]; then echo "Build production not found! Check gradle logs." exit 1 fi cd ${{ github.workspace }}/apk/ if [ ! -e legado_*.apk ]; then echo "Build production not found! Check gradle logs." exit 1 fi - name: Upload App To Artifact uses: actions/upload-artifact@v4 with: name: legado.${{ env.product }}.${{ env.type }} if-no-files-found: ignore path: ${{ github.workspace }}/apk/*.apk - name: Upload Mapping File To Artifact uses: actions/upload-artifact@v4 with: name: legado.${{ env.product }}.${{ env.type }}.mapping if-no-files-found: ignore path: ${{ github.workspace }}/mapping/mapping.txt prerelease: needs: [ prepare, build ] if: github.event_name != 'pull_request' && github.repository == 'gedoor/legado' runs-on: ubuntu-latest env: VERSION: ${{ needs.prepare.outputs.version }} steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: path: apk/ - working-directory: apk/ run: | mv */*.apk . rm -rf */ for file in `ls *.apk`; do if [[ "$file" == *原包名* ]]; then mv "$file" $(echo $file | sed s/原包名/release/) else mv "$file" $(echo $file | sed s/共存/releaseA/) fi done - name: Delete Pre-Release run: | if gh release view beta &>/dev/null; then gh release delete beta -y fi env: GH_TOKEN: ${{ github.token }} - name: Create or update beta tag uses: richardsimko/update-tag@v1 with: tag_name: beta env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Publish Pre-Release uses: ncipollo/release-action@v1 with: name: legado_app_${{ env.VERSION }} tag: "beta" body: | 此版本为测试版,签名与正式版不同,可能存在不稳定情况,升级前请务必备份好数据。 releaseA 为共存版本,可同时安装使用,功能没有区别。 prerelease: true artifacts: ${{ github.workspace }}/apk/*.apk lanzou: needs: [ prepare, build ] if: ${{ github.event_name != 'pull_request' && needs.prepare.outputs.lanzou == 'yes' }} runs-on: ubuntu-latest env: # 登录蓝奏云后在控制台运行document.cookie ylogin: ${{ secrets.LANZOU_ID }} phpdisk_info: ${{ secrets.LANZOU_PSD }} # 蓝奏云里的文件夹ID(阅读3测试版:2670621) LANZOU_FOLDER_ID: ${{ secrets.LANZOU_FOLDER_ID }} #蓝奏云分享链接 LANZOU_URL: ${{ secrets.LANZOU_URL }} steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: path: apk/ - working-directory: apk/ run: mv */*.apk . ;rm -rf */ - name: Upload To Lanzou continue-on-error: true run: | path="$GITHUB_WORKSPACE/apk/" python3 $GITHUB_WORKSPACE/.github/scripts/lzy_web.py "$path" "$LANZOU_FOLDER_ID" echo "[$(date -u -d '+8 hour' '+%Y.%m.%d %H:%M:%S')] 分享链接: $LANZOU_URL" test_Branch: needs: [ prepare, build ] runs-on: ubuntu-latest if: ${{ github.event_name != 'pull_request' && github.actor == 'gedoor' }} steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: path: apk/ - working-directory: apk/ run: mv */*.apk . ;rm -rf */ - name: Push To "test" Branch run: | cd $GITHUB_WORKSPACE/apk/ git init git checkout -b test git config --global user.name "github-actions[bot]" git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" git remote add origin "https://${{ github.actor }}:${{ secrets.ACTIONS_TOKEN }}@github.com/${{ github.actor }}/release" git add *.apk git commit -m "${{ needs.prepare.outputs.versionL }}" git push -f -u origin test telegram: needs: [ prepare, build ] if: ${{ github.event_name != 'pull_request' && needs.prepare.outputs.telegram == 'yes' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: path: apk/ - working-directory: apk/ run: | for file in `ls */*.apk`; do mv "$file" "$(echo "$file"|sed -e 's#.*\/##g' -e "s/_/ /g" -e 's/legado/阅读/')" done rm -rf */ - name: Post to channel uses: xireiki/channel-post@v1 with: chat_id: ${{ secrets.CHANNEL_ID }} bot_token: ${{ secrets.BOT_TOKEN }} context: "#阅读 #Legado #Beta ${{ needs.prepare.outputs.versionL }}" path: apk/* method: sendFile ================================================ FILE: .github/workflows/web.yml ================================================ name: Build Web on: push: branches: - master paths: - '**/modules/web/**' pull_request: paths: - '**/modules/web/**' workflow_dispatch: env: UPSTREAM_REPOSITORY: gedoor/legado jobs: build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Install Node.js uses: actions/setup-node@v4 with: node-version: 22 - uses: pnpm/action-setup@v4 name: Install pnpm id: pnpm-install with: version: 9 run_install: false - name: Get pnpm store directory id: pnpm-cache shell: bash run: | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - uses: actions/cache@v4 name: Setup pnpm cache with: path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/web/package.json') }} restore-keys: | ${{ runner.os }}-pnpm-store- - name: Build and move files working-directory: modules/web run: | pnpm i pnpm build version="v$(date -d "8 hour" -u +3.%y.%m%d%H)" echo "APP_VER=$version" >> $GITHUB_ENV - name: push changes if: ${{ github.event_name != 'pull_request' && github.repository == env.UPSTREAM_REPOSITORY }} uses: stefanzweifel/git-auto-commit-action@v5.1.0 with: commit_message: Bump web ${{ env.APP_VER}} file_pattern: app/src/main/assets/web/vue/ ================================================ FILE: .gitignore ================================================ *.iml .gradle local.properties .DS_Store /build build/ /captures .externalNativeBuild /release /tmp node_modules/ /app/app /app/google /app/gradle.properties package-lock.json .idea/ # Kotlin 2.0 .kotlin/ ================================================ FILE: CHANGELOG.md ================================================ **2022/10/02** * 更新cronet: 106.0.5249.79 * 正文选择菜单朗读按钮长按可切换朗读选择内容和从选择开始处一直朗读 * 源编辑输入框设置最大行数12,在行数特别多的时候更容易滚动到其它输入 * 修复某些情况下无法搜索到标题的bug,净化规则较多的可能会降低搜索速度 by Xwite * 修复文件类书源换源后阅读bug by Xwite * Cronet 支持DnsHttpsSvcb by g2s20150909 * 修复web进度同步问题 by 821938089 * 启用混淆以减小app大小 有bug请带日志反馈 * 其它一些优化 ================================================ FILE: English.md ================================================ # [English](English.md) [中文](README.md) [![icon_android](https://github.com/gedoor/gedoor.github.io/blob/master/static/img/legado/icon_android.png)](https://play.google.com/store/apps/details?id=io.legado.play.release) idea
legado Legado / 开源阅读
gedoor.github.io / legado.top
Legado is a free and open source novel reader for Android.
[![](https://img.shields.io/badge/-Contents:-696969.svg)](#contents) [![](https://img.shields.io/badge/-Function-F5F5F5.svg)](#Function-) [![](https://img.shields.io/badge/-Download-F5F5F5.svg)](#Download-) [![](https://img.shields.io/badge/-Community-F5F5F5.svg)](#Community-) [![](https://img.shields.io/badge/-API-F5F5F5.svg)](#API-) [![](https://img.shields.io/badge/-Other-F5F5F5.svg)](#Other-) [![](https://img.shields.io/badge/-Grateful-F5F5F5.svg)](#Grateful-) [![](https://img.shields.io/badge/-Interface-F5F5F5.svg)](#Interface-) >New user? > >The software does not provide content, you need to add it manually, such as importing book sources, etc. >Take a look at [official help documentation](https://www.yuque.com/legado/wiki),Maybe there's an answer you need inside. # Function [![](https://img.shields.io/badge/-Function-F5F5F5.svg)](#Function-) You can customize the book source, set your own rules, and capture web page data. The rules are simple and easy to understand. There are rules in the software. List bookshelf, grid bookshelf switch freely. The book source rules support search and discovery, and all the functions of finding books and reading books are all customized, making it easier to find books. * Custom ebook sources, set your own rules to capture web data, the rules are simple and easy to understand, the software has a rule description. * eBook sources rules support search and discovery, all find books and read books function all custom, find books more convenient. * Schedule updating your library for new chapters. * Online reading from web sources that can be imported in bulk * Local reading of Auto-download episodes. * Local reading of TXT or EPUB files * ebook Wishlist * Big text viewer. You can open eBook and txt in 1GB size * Automatic text replacement for removing ad in content * List bookshelf, grid bookshelf free to switch. * Subscription content, you can subscribe to any content you want to see, see what you want to see * A configurable reader with fonts, background, page transitions mode and other settings * Timer. Set interval time to listen ebook, time up, ebook turn off completely. * TTS book reader. tts can optionally be install“smartvoice-4.1.0” or ”Speech Services by Google“ Give your baby a storybook to listen to and teach your baby to talk, * Dark mode and E-Ink mode support and Web service support * Create backups to local or WebDav server * Decentralization web3 * Support replacement purification, it is very convenient to remove the content of advertisement replacement. * Support local TXT, EPUB reading, manual browsing, intelligent scanning. * Support highly customized reading interface, switch font, color, background, line spacing, paragraph spacing, bold, simplified and traditional conversion. * Support multiple page turning modes, covering, emulating, sliding, scrolling, etc. # # Download [![](https://img.shields.io/badge/-Download-F5F5F5.svg)](#Download-) #### Android * [Releases](https://github.com/gedoor/legado/releases/latest) * [Google play - $1.99](https://play.google.com/store/apps/details?id=io.legado.play.release) * [Coolapk](https://www.coolapk.com/apk/io.legado.app.release) * [\#Beta](https://kunfei.lanzoui.com/b0f810h4b) * [IzzyOnDroid F-Droid Repository](https://apt.izzysoft.de/fdroid/index/apk/io.legado.app.release) #### IOS * Stopped(No release) - [Github](https://github.com/gedoor/YueDuFlutter) # # Community [![](https://img.shields.io/badge/-Community-F5F5F5.svg)](#Community-) #### Telegram [![Telegram-group](https://img.shields.io/badge/Telegram-group-blue)](https://t.me/yueduguanfang) [![Telegram-channel](https://img.shields.io/badge/Telegram-channel-blue)](https://t.me/legado_channels) #### Discord [![Discord](https://img.shields.io/discord/560731361414086666?color=%235865f2&label=Discord)](https://discord.gg/VtUfRyzRXn) #### Other https://www.yuque.com/legado/wiki/community # # API [![](https://img.shields.io/badge/-API-F5F5F5.svg)](#API-) * Legado 3.0 The API is provided in 2 ways: `Web way` and `Content Provider way`. You can call it yourself as needed in [here](api.md). * One-click import by url recall reading, url format: legado://import/{path}?src={url} * Path Type: bookSource,rssSource,replaceRule,textTocRule,httpTTS,theme,readConfig,dictRule,addToBookshelf * path type explanation: Book source, subscription source, replacement rules, local txt novel directory rules, online reading engine, theme, reading layout, [add to bookshelf](/app/src/main/java/io/legado/app/ui/association/AddToBookshelfDialog.kt) # # Other [![](https://img.shields.io/badge/-Other-F5F5F5.svg)](#Other-) ##### Disclaimers https://gedoor.github.io/Disclaimer ##### Legado 3.0 * [eBook sources rules](https://mgz0227.github.io/The-tutorial-of-Legado/) * [Update Log](/app/src/main/assets/updateLog.md) * [Help Documentation](/app/src/main/assets/web/help/md/appHelp.md) * [web bookshelf](https://github.com/gedoor/legado_web_bookshelf) * [web source editor](https://github.com/gedoor/legado_web_source_editor) # # Grateful [![](https://img.shields.io/badge/-Grateful-F5F5F5.svg)](#Grateful-) > * org.jsoup:jsoup > * cn.wanghaomiao:JsoupXpath > * com.jayway.jsonpath:json-path > * com.github.gedoor:rhino-android > * com.squareup.okhttp3:okhttp > * com.github.bumptech.glide:glide > * org.nanohttpd:nanohttpd > * org.nanohttpd:nanohttpd-websocket > * cn.bingoogolapple:bga-qrcode-zxing > * com.jaredrummler:colorpicker > * org.apache.commons:commons-text > * io.noties.markwon:core > * io.noties.markwon:image-glide > * com.hankcs:hanlp > * com.positiondev.epublib:epublib-core # # Interface [![](https://img.shields.io/badge/-Interface-F5F5F5.svg)](#Interface-) # ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Legado Copyright (C) 2019-2023 gedoor This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.md ================================================ # [English](English.md) [中文](README.md) [![icon_android](https://github.com/gedoor/gedoor.github.io/blob/master/static/img/legado/icon_android.png)](https://play.google.com/store/apps/details?id=io.legado.play.release) idea
legado Legado / 开源阅读
gedoor.github.io / legado.top
Legado is a free and open source novel reader for Android.
[![](https://img.shields.io/badge/-Contents:-696969.svg)](#contents) [![](https://img.shields.io/badge/-Function-F5F5F5.svg)](#Function-主要功能-) [![](https://img.shields.io/badge/-Community-F5F5F5.svg)](#Community-交流社区-) [![](https://img.shields.io/badge/-API-F5F5F5.svg)](#API-) [![](https://img.shields.io/badge/-Other-F5F5F5.svg)](#Other-其他-) [![](https://img.shields.io/badge/-Grateful-F5F5F5.svg)](#Grateful-感谢-) [![](https://img.shields.io/badge/-Interface-F5F5F5.svg)](#Interface-界面-) >新用户? > >软件不提供内容,需要您自己手动添加,例如导入书源等。 >看看 [官方帮助文档](https://www.yuque.com/legado/wiki),也许里面就有你要的答案。 # Function-主要功能 [![](https://img.shields.io/badge/-Function-F5F5F5.svg)](#Function-主要功能-) [English](English.md)
中文 1.自定义书源,自己设置规则,抓取网页数据,规则简单易懂,软件内有规则说明。
2.列表书架,网格书架自由切换。
3.书源规则支持搜索及发现,所有找书看书功能全部自定义,找书更方便。
4.订阅内容,可以订阅想看的任何内容,看你想看
5.支持替换净化,去除广告替换内容很方便。
6.支持本地TXT、EPUB阅读,手动浏览,智能扫描。
7.支持高度自定义阅读界面,切换字体、颜色、背景、行距、段距、加粗、简繁转换等。
8.支持多种翻页模式,覆盖、仿真、滑动、滚动等。
9.软件开源,持续优化,无广告。
# # Community-交流社区 [![](https://img.shields.io/badge/-Community-F5F5F5.svg)](#Community-交流社区-) #### Telegram [![Telegram-group](https://img.shields.io/badge/Telegram-%E7%BE%A4%E7%BB%84-blue)](https://t.me/yueduguanfang) [![Telegram-channel](https://img.shields.io/badge/Telegram-%E9%A2%91%E9%81%93-blue)](https://t.me/legado_channels) #### Discord [![Discord](https://img.shields.io/discord/560731361414086666?color=%235865f2&label=Discord)](https://discord.gg/VtUfRyzRXn) #### Other https://www.yuque.com/legado/wiki/community # # API [![](https://img.shields.io/badge/-API-F5F5F5.svg)](#API-) * 阅读3.0 提供了2种方式的API:`Web方式`和`Content Provider方式`。您可以在[这里](api.md)根据需要自行调用。 * 可通过url唤起阅读进行一键导入,url格式: legado://import/{path}?src={url} * path类型: bookSource,rssSource,replaceRule,textTocRule,httpTTS,theme,readConfig,dictRule,[addToBookshelf](/app/src/main/java/io/legado/app/ui/association/AddToBookshelfDialog.kt) * path类型解释: 书源,订阅源,替换规则,本地txt小说目录规则,在线朗读引擎,主题,阅读排版,添加到书架 # # Other-其他 [![](https://img.shields.io/badge/-Other-F5F5F5.svg)](#Other-其他-) ##### 免责声明 https://gedoor.github.io/Disclaimer ##### 阅读3.0 * [书源规则](https://mgz0227.github.io/The-tutorial-of-Legado/) * [更新日志](/app/src/main/assets/updateLog.md) * [帮助文档](/app/src/main/assets/web/help/md/appHelp.md) * [web端书架](https://github.com/gedoor/legado_web_bookshelf) * [web端源编辑](https://github.com/gedoor/legado_web_source_editor) # # Grateful-感谢 [![](https://img.shields.io/badge/-Grateful-F5F5F5.svg)](#Grateful-感谢-) > * org.jsoup:jsoup > * cn.wanghaomiao:JsoupXpath > * com.jayway.jsonpath:json-path > * com.github.gedoor:rhino-android > * com.squareup.okhttp3:okhttp > * com.github.bumptech.glide:glide > * org.nanohttpd:nanohttpd > * org.nanohttpd:nanohttpd-websocket > * cn.bingoogolapple:bga-qrcode-zxing > * com.jaredrummler:colorpicker > * org.apache.commons:commons-text > * io.noties.markwon:core > * io.noties.markwon:image-glide > * com.hankcs:hanlp > * com.positiondev.epublib:epublib-core # # Interface-界面 [![](https://img.shields.io/badge/-Interface-F5F5F5.svg)](#Interface-界面-) # ================================================ FILE: api.md ================================================ # 阅读[API](/app/src/main/java/io/legado/app/api/controller) ## 对于[Web](/app/src/main/java/io/legado/app/web/)的配置 您需要先在设置中启用"Web 服务"。 ## 使用 ### Web 以下说明假设您的操作在本机进行,且开放端口为1234。 如果您要从远程计算机访问[阅读](),请将`127.0.0.1`替换成手机IP。 #### 插入单个书源 请求BODY内容为`JSON`字符串, 格式参考[这个文件](/app/src/main/java/io/legado/app/data/entities/BookSource.kt) ``` URL = http://127.0.0.1:1234/saveBookSource Method = POST ``` #### 插入多个书源or订阅源 请求BODY内容为`JSON`字符串, 格式参考[这个文件](/app/src/main/java/io/legado/app/data/entities/BookSource.kt),**为数组格式**。 ``` URL = http://127.0.0.1:1234/saveBookSources URL = http://127.0.0.1:1234/saveRssSources Method = POST ``` #### 获取书源 ``` URL = http://127.0.0.1:1234/getBookSource?url=xxx URL = http://127.0.0.1:1234/getRssSource?url=xxx Method = GET ``` #### 获取所有书源or订阅源 ``` URL = http://127.0.0.1:1234/getBookSources URL = http://127.0.0.1:1234/getRssSources Method = GET ``` #### 删除多个书源or订阅源 请求BODY内容为`JSON`字符串, 格式参考[这个文件](/app/src/main/java/io/legado/app/data/entities/BookSource.kt),**为数组格式**。 ``` URL = http://127.0.0.1:1234/deleteBookSources URL = http://127.0.0.1:1234/deleteRssSources Method = POST ``` #### 调试源 key为书源搜索关键词,tag为源链接 ``` URL = ws://127.0.0.1:1235/bookSourceDebug URL = ws://127.0.0.1:1235/rssSourceDebug Message = { key: [String], tag: [String] } ``` #### 获取替换规则 ``` URL = http://127.0.0.1:1234/getReplaceRules Method = GET ``` #### 替换规则管理 请求BODY内容为`JSON`字符串, 替换规则参考[这个文件](/app/src/main/java/io/legado/app/data/entities/ReplaceRule.kt)。 ##### 删除 ``` URL = http://127.0.0.1:1234/deleteReplaceRule Method = POST Body = [ReplaceRule] ``` ##### 插入 ``` URL = http://127.0.0.1:1234/saveReplaceRule Method = POST Body = [ReplaceRule] ``` ##### 测试 返回测试文本text替换结果 ``` URL = http://127.0.0.1:1234/testReplaceRule Method = POST Body = { rule: [ReplaceRule], text: [String] } ``` #### 搜索在线书籍 若想获取对应的书籍的目录正文 请先**插入书籍**以启用缓存,如果试读后决定不添加到书籍,请**删除书籍** ``` URL = ws://127.0.0.1:1235/searchBook Message = { key: [String] } ``` #### 插入书籍 请求BODY内容为`JSON`字符串, 格式参考[这个文件](/app/src/main/java/io/legado/app/data/entities/Book.kt)。 ``` URL = http://127.0.0.1:1234/saveBook Method = POST ``` #### 删除书籍 ``` URL = http://127.0.0.1:1234/deleteBook Method = POST ``` #### 获取所有书籍 ``` URL = http://127.0.0.1:1234/getBookshelf Method = GET ``` 获取APP内的所有书籍。 #### 获取书籍章节列表 ``` URL = http://127.0.0.1:1234/getChapterList?url=xxx Method = GET ``` 获取指定图书的章节列表。 #### 获取书籍内容 ``` URL = http://127.0.0.1:1234/getBookContent?url=xxx&index=1 Method = GET ``` 获取指定图书的第`index`章节的文本内容。 #### 获取封面 ``` URL = http://127.0.0.1:1234/cover?path=xxxxx Method = GET ``` #### 获取正文图片 ``` URL = http://127.0.0.1:1234/image?url=${bookUrl}&path=${picUrl}&width=${width} Method = GET ``` #### 保存书籍进度 请求BODY内容为`JSON`字符串, 格式参考[这个文件](/app/src/main/java/io/legado/app/data/entities/BookProgress.kt)。 ``` URL = http://127.0.0.1:1234/saveBookProgress Method = POST ``` ### [Content Provider](/app/src/main/java/io/legado/app/api/ReaderProvider.kt) * 需声明`io.legado.READ_WRITE`权限 * `providerHost`为`包名.readerProvider`, 如`io.legado.app.release.readerProvider`,不同包的地址不同,防止冲突安装失败 * 以下出现的`providerHost`请自行替换 #### 插入单个书源or订阅源 创建`Key="json"`的`ContentValues`,内容为`JSON`字符串, 格式参考[这个文件](/app/src/main/java/io/legado/app/data/entities/BookSource.kt) ``` URL = content://providerHost/bookSource/insert URL = content://providerHost/rssSource/insert Method = insert ``` #### 插入多个书源or订阅源 创建`Key="json"`的`ContentValues`,内容为`JSON`字符串, 格式参考[这个文件](/app/src/main/java/io/legado/app/data/entities/BookSource.kt),**为数组格式**。 ``` URL = content://providerHost/bookSources/insert URL = content://providerHost/rssSources/insert Method = insert ``` #### 获取书源or订阅源 获取指定URL对应的书源信息。 用`Cursor.getString(0)`取出返回结果。 ``` URL = content://providerHost/bookSource/query?url=xxx URL = content://providerHost/rssSource/query?url=xxx Method = query ``` #### 获取所有书源or订阅源 获取APP内的所有订阅源。 用`Cursor.getString(0)`取出返回结果。 ``` URL = content://providerHost/bookSources/query URL = content://providerHost/rssSources/query Method = query ``` #### 删除多个书源or订阅源 创建`Key="json"`的`ContentValues`,内容为`JSON`字符串, 格式参考[这个文件](/app/src/main/java/io/legado/app/data/entities/BookSource.kt),**为数组格式**。 ``` URL = content://providerHost/bookSources/delete URL = content://providerHost/rssSources/delete Method = delete ``` #### 插入书籍 创建`Key="json"`的`ContentValues`,内容为`JSON`字符串, 格式参考[这个文件](/app/src/main/java/io/legado/app/data/entities/Book.kt)。 ``` URL = content://providerHost/book/insert Method = insert ``` #### 获取所有书籍 获取APP内的所有书籍。 用`Cursor.getString(0)`取出返回结果。 ``` URL = content://providerHost/books/query Method = query ``` #### 获取书籍章节列表 获取指定图书的章节列表。 用`Cursor.getString(0)`取出返回结果。 ``` URL = content://providerHost/book/chapter/query?url=xxx Method = query ``` #### 获取书籍内容 获取指定图书的第`index`章节的文本内容。 用`Cursor.getString(0)`取出返回结果。 ``` URL = content://providerHost/book/content/query?url=xxx&index=1 Method = query ``` #### 获取封面 ``` URL = content://providerHost/book/cover/query?path=xxxx Method = query ``` ================================================ FILE: app/.gitignore ================================================ /build /so ================================================ FILE: app/build.gradle ================================================ plugins { // id "com.android.application" // id 'org.jetbrains.kotlin.android' // id 'kotlin-parcelize' // //id 'kotlin-kapt' // id 'com.google.devtools.ksp' // id "com.google.gms.google-services" alias libs.plugins.android.application alias libs.plugins.kotlin.android alias libs.plugins.kotlin.parcelize alias libs.plugins.room alias libs.plugins.ksp alias libs.plugins.google.services } apply from: 'download.gradle' static def releaseTime() { return new Date().format("yy.MMddHH", TimeZone.getTimeZone("GMT+8")) } def name = "legado" def version = "3." + releaseTime() def gitCommits = Integer.parseInt('git rev-list HEAD --count'.execute().text.trim()) android { compileSdk = compile_sdk_version namespace = 'io.legado.app' kotlin { jvmToolchain { languageVersion.set(JavaLanguageVersion.of(17)) } } signingConfigs { if (project.hasProperty("RELEASE_STORE_FILE")) { myConfig { storeFile file(RELEASE_STORE_FILE) storePassword RELEASE_STORE_PASSWORD keyAlias RELEASE_KEY_ALIAS keyPassword RELEASE_KEY_PASSWORD enableV1Signing = true enableV2Signing = true enableV3Signing = true enableV4Signing = true } } } defaultConfig { applicationId "io.legado.app" minSdk 21 targetSdk 36 versionCode 10000 + gitCommits versionName version testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" project.ext.set("archivesBaseName", name + "_" + version) buildConfigField "String", "Cronet_Version", "\"$CronetVersion\"" buildConfigField "String", "Cronet_Main_Version", "\"$CronetMainVersion\"" javaCompileOptions { annotationProcessorOptions { arguments += [ "room.incremental" : "true", "room.expandProjection": "true", "room.schemaLocation" : "$projectDir/schemas".toString() ] } } } buildFeatures { buildConfig = true viewBinding = true } buildTypes { release { if (project.hasProperty("RELEASE_STORE_FILE")) { signingConfig signingConfigs.myConfig } applicationIdSuffix '.release' if (getApplicationIdSuffix() == '.releaseA') { manifestPlaceholders.put("app_name", "@string/app_name_a") } else { manifestPlaceholders.put("app_name", "@string/app_name") } minifyEnabled true shrinkResources = true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro', 'cronet-proguard-rules.pro' } debug { if (project.hasProperty("RELEASE_STORE_FILE")) { signingConfig signingConfigs.myConfig } manifestPlaceholders.put("app_name", "@string/app_name") applicationIdSuffix '.debug' versionNameSuffix 'debug' minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro', 'cronet-proguard-rules.pro' } } //noinspection GrDeprecatedAPIUsage flavorDimensions = ['mode'] productFlavors { app { dimension "mode" manifestPlaceholders.put("APP_CHANNEL_VALUE", "app") } } android.applicationVariants.configureEach { variant -> variant.outputs.configureEach { def flavor = variant.productFlavors[0].name outputFileName = "${name}_${flavor}_${defaultConfig.versionName}.apk" } } room { schemaDirectory "$projectDir/schemas" } // 设定Room的KSP参数 ksp { arg("room.incremental", "true") arg("room.expandProjection", "true") arg("room.generateKotlin", "false") //arg("room.schemaLocation", "$projectDir/schemas") } compileOptions { // Flag to enable support for the new language APIs coreLibraryDesugaringEnabled = true // Sets Java compatibility sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } packaging { resources.excludes.add('META-INF/*') } sourceSets { // Adds exported schema location as test app assets. androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) } lint { checkDependencies = true } tasks.withType(JavaCompile).tap { configureEach { //options.compilerArgs << "-Xlint:unchecked" } } } dependencies { //compileOnly "com.android.tools.build:gradle:$agp_version" //noinspection GradleDependency,GradlePackageUpdate //coreLibraryDesugaring('com.android.tools:desugar_jdk_libs_nio:2.0.4') coreLibraryDesugaring(libs.desugar) testImplementation(libs.junit) androidTestImplementation(libs.bundles.androidTest) //kotlin //noinspection GradleDependency,DifferentStdlibGradleVersion implementation(libs.kotlin.stdlib) //Kotlin反射 //noinspection GradleDependency,DifferentStdlibGradleVersion //implementation(libs.kotlin.reflect) //协程 //def coroutines_version = '1.7.3' implementation(libs.bundles.coroutines) //图像处理库Toolkit implementation(libs.renderscript.intrinsics.replacement.toolkit) //androidX implementation(libs.core.ktx) implementation(libs.appcompat.appcompat) implementation(libs.activity.ktx) implementation(libs.fragment.ktx) implementation(libs.preference.ktx) implementation(libs.androidx.constraintlayout) implementation(libs.androidx.swiperefreshlayout) implementation(libs.androidx.recyclerview) implementation(libs.androidx.viewpager2) implementation(libs.androidx.webkit) implementation(libs.androidx.documentfile) //google implementation(libs.material) implementation(libs.flexbox) implementation(libs.gson) //lifecycle implementation(libs.lifecycle.common.java8) implementation(libs.lifecycle.service) //media implementation(libs.media.media) // For media playback using ExoPlayer implementation(libs.media3.exoplayer) // For loading data using the OkHttp network stack implementation(libs.media3.datasource.okhttp) // For exposing and controlling media sessions //implementation "androidx.media3:media3-session:$media3_version" //Splitties implementation(libs.splitties.appctx) implementation(libs.splitties.systemservices) implementation(libs.splitties.views) //room sql语句不高亮解决方法https://issuetracker.google.com/issues/234612964#comment6 implementation(libs.room.runtime) implementation(libs.room.ktx) //kapt("androidx.room:room-compiler:$room_version") ksp(libs.room.compiler) androidTestImplementation(libs.room.testing) //liveEventBus implementation(libs.liveeventbus) //规则相关 implementation(libs.jsoup) implementation(libs.json.path) implementation(libs.jsoupxpath) implementation(project(path: ':modules:book')) implementation(project(path: ':modules:rhino')) //JS rhino //网络 implementation(libs.okhttp) implementation(fileTree(dir: 'cronetlib', include: ['*.jar', '*.aar'])) implementation(libs.protobuf.javalite) //Glide implementation(libs.glide.glide) implementation(libs.glide.okhttp) ksp(libs.glide.ksp) //Svg implementation(libs.androidsvg) //Glide svg plugin implementation(libs.glide.svg) //webServer implementation(libs.nanohttpd.nanohttpd) implementation(libs.nanohttpd.websocket) //二维码 //noinspection GradleDependency implementation(libs.zxing.lite) //颜色选择 implementation(libs.colorpicker) //压缩解压 implementation libs.libarchive //apache implementation(libs.commons.text) //MarkDown implementation(libs.markwon.core) implementation(libs.markwon.image.glide) implementation(libs.markwon.ext.tables) implementation(libs.markwon.html) //转换繁体 implementation(libs.quick.chinese.transfer.core) //加解密类库,有些书源使用 //noinspection GradleDependency,GradlePackageUpdate implementation(libs.hutool.crypto) //firebase, 崩溃统计和性能统计 implementation platform(libs.firebase.bom) implementation libs.firebase.analytics implementation libs.firebase.perf implementation libs.glide.recyclerview //LeakCanary, 内存泄露检测 //debugImplementation('com.squareup.leakcanary:leakcanary-android:2.7') //com.github.AmrDeveloper:CodeView代码编辑已集成到应用内 //epubLib集成到应用内 } ================================================ FILE: app/cronet-proguard-rules.pro ================================================ # -------- Config Path: base/android/proguard/shared_with_cronet.flags -------- # Copyright 2016 The Chromium Authors # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. # Contains flags that we want to apply not only to Chromium APKs, but also to # third-party apps that bundle the Cronet library. # WARNING: rules in this file are applied to entire third-party APKs, not just # Chromium code. They MUST be scoped appropriately to avoid side effects on app # code that we do not own. # Keep all CREATOR fields within Parcelable that are kept. -keepclassmembers class !cr_allowunused,org.chromium.** implements android.os.Parcelable { public static *** CREATOR; } # Don't obfuscate Parcelables as they might be marshalled outside Chrome. # If we annotated all Parcelables that get put into Bundles other than # for saveInstanceState (e.g. PendingIntents), then we could actually keep the # names of just those ones. For now, we'll just keep them all. -keepnames,allowaccessmodification class !cr_allowunused,org.chromium.** implements android.os.Parcelable {} # Keep all enum values and valueOf methods. See # http://proguard.sourceforge.net/index.html#manual/examples.html # for the reason for this. Also, see http://crbug.com/248037. -keepclassmembers enum !cr_allowunused,org.chromium.** { public static **[] values(); } # Required to remove fields until b/274802355 is resolved. -assumevalues class !cr_allowunused,** { final org.chromium.base.ThreadUtils$ThreadChecker * return _NONNULL_; } # -------- Config Path: build/android/chromium_annotations.flags -------- # Copyright 2022 The Chromium Authors # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. # Contains flags related to annotations in //build/android that can be safely # shared with Cronet, and thus would be appropriate for third-party apps to # include. # Keep all annotation related attributes that can affect runtime -keepattributes RuntimeVisible*Annotations -keepattributes AnnotationDefault # Keeps for class level annotations. -keep,allowaccessmodification @org.chromium.build.annotations.UsedByReflection class ** {} # Keeps for method level annotations. -keepclasseswithmembers,allowaccessmodification class ** { @org.chromium.build.annotations.UsedByReflection ; } -keepclasseswithmembers,allowaccessmodification class ** { @org.chromium.build.annotations.UsedByReflection ; } # Never inline classes, methods, or fields with this annotation, but allow # shrinking and obfuscation. # Relevant to fields when they are needed to store strong references to objects # that are held as weak references by native code. -if @org.chromium.build.annotations.DoNotInline class * { *** *(...); } -keep,allowobfuscation,allowaccessmodification class <1> { *** <2>(...); } -keepclassmembers,allowobfuscation,allowaccessmodification class * { @org.chromium.build.annotations.DoNotInline ; } -keepclassmembers,allowobfuscation,allowaccessmodification class * { @org.chromium.build.annotations.DoNotInline ; } -alwaysinline class * { @org.chromium.build.annotations.AlwaysInline *; } # Keep all logs (Log.VERBOSE = 2). R8 does not allow setting to 0. -maximumremovedandroidloglevel 1 class ** { @org.chromium.build.annotations.DoNotStripLogs ; } -maximumremovedandroidloglevel 1 @org.chromium.build.annotations.DoNotStripLogs class ** { ; } # Never merge classes horizontally or vertically with this annotation. # Relevant to classes being used as a key in maps or sets. -keep,allowaccessmodification,allowobfuscation,allowshrinking @org.chromium.build.annotations.DoNotClassMerge class * # Mark members annotated with IdentifierNameString as identifier name strings -identifiernamestring class * { @org.chromium.build.annotations.IdentifierNameString *; } # Mark fields with this to help R8 figure out that they cannot be null. # Use assumevalues in addition to assumenosideeffects block because Google3 proguard cannot parse # assumenosideeffects blocks which overwrite return value. -assumevalues class ** { @org.chromium.build.annotations.AssumeNonNull *** *(...) return _NONNULL_; } -assumenosideeffects class ** { @org.chromium.build.annotations.AssumeNonNull *** *(...); } -assumevalues class ** { @org.chromium.build.annotations.AssumeNonNull *** * return _NONNULL_; } -assumenosideeffects class ** { @org.chromium.build.annotations.AssumeNonNull *** *; } # -------- Config Path: components/cronet/android/cronet_impl_common_proguard.cfg -------- # Proguard config for apps that depend on cronet_impl_common_java.jar. # Used through reflection by the API code to figure out the version of the impl # code it's talking to. -keep public class org.chromium.net.impl.ImplVersion { public *; } -dontwarn com.google.errorprone.annotations.DoNotMock # -------- Config Path: components/cronet/android/cronet_impl_native_proguard.cfg -------- # Proguard config for apps that depend on cronet_impl_native_java.jar. # This constructor is called using the reflection from Cronet API (cronet_api.jar). -keep class org.chromium.net.impl.NativeCronetProvider { public (android.content.Context); } # While Chrome doesn't need to keep these with their version of R8, some cronet # users may be on other optimizers which still require the annotation to be # kept in order for the keep rules to work. -keep @interface org.chromium.build.annotations.DoNotInline -keep @interface org.chromium.build.annotations.UsedByReflection -keep @interface org.chromium.build.annotations.IdentifierNameString -keep @interface org.jni_zero.AccessedByNative -keep @interface org.jni_zero.CalledByNative -keep @interface org.jni_zero.CalledByNativeUnchecked # Suppress unnecessary warnings. -dontnote org.chromium.net.ProxyChangeListener$ProxyReceiver -dontnote org.chromium.net.AndroidKeyStore # Needs 'void setTextAppearance(int)' (API level 23). -dontwarn org.chromium.base.ApiCompatibilityUtils # Needs 'boolean onSearchRequested(android.view.SearchEvent)' (API level 23). -dontwarn org.chromium.base.WindowCallbackWrapper # Generated for chrome apk and not included into cronet. -dontwarn org.chromium.base.multidex.ChromiumMultiDexInstaller -dontwarn org.chromium.base.library_loader.LibraryLoader -dontwarn org.chromium.base.SysUtils -dontwarn org.chromium.build.NativeLibraries # Objects of this type are passed around by native code, but the class # is never used directly by native code. Since the class is not loaded, it does # not need to be preserved as an entry point. -dontnote org.chromium.net.UrlRequest$ResponseHeadersMap # https://android.googlesource.com/platform/sdk/+/marshmallow-mr1-release/files/proguard-android.txt#54 -dontwarn android.support.** # This class should be explicitly kept to avoid failure if # class/merging/horizontal proguard optimization is enabled. -keep class org.chromium.base.CollectionUtil # Skip protobuf runtime check for isOnAndroidDevice(). # A nice-to-have optimization shamelessly stolen from //third_party/protobuf/java/lite/proguard.pgcfg. -assumevalues class com.google.protobuf.Android { static boolean ASSUME_ANDROID return true; } # See crbug.com/1440987. We must keep every native that we are manually # registering. If Cronet bumps its min-sdk past 21, we may be able to move to # automatic JNI registration. -keepclasseswithmembers,includedescriptorclasses,allowaccessmodification class org.chromium.**,J.N { native ; } # Protobuf builder uses reflection so make sure ProGuard leaves it alone. See # https://crbug.com/1395764. # Note that we can't simply use the rule from # //third_party/protobuf/java/lite/proguard.pgcfg, because some users who # consume our ProGuard rules do not want all their protos to be kept. Instead, # use a more specific rule that covers Chromium protos only. -keepclassmembers class org.chromium.** extends com.google.protobuf.GeneratedMessageLite { ; } # -------- Config Path: components/cronet/android/cronet_shared_proguard.cfg -------- # Proguard config for apps that depend on cronet_shared_java.jar (which should # be all apps that depend on any part of Cronet) # Part of the Android System SDK, so ProGuard won't be able to resolve it if # running against the standard SDK. -dontwarn android.util.StatsEvent -dontwarn android.util.StatsEvent$* # There is also an undefined reference to android.util.StatsLog.write(), which # R8 appears to be fine with but other processors (e.g. internal Google # ProGuard) may not be. See b/315269496. -dontwarn android.util.StatsLog # -------- Config Path: third_party/androidx/androidx_annotations.flags -------- # Copyright 2023 The Chromium Authors # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. -keep @androidx.annotation.Keep class * -keepclasseswithmembers,allowaccessmodification class * { @androidx.annotation.Keep ; } -keepclasseswithmembers,allowaccessmodification class * { @androidx.annotation.Keep ; } # -------- Config Path: third_party/jni_zero/proguard.flags -------- # Copyright 2023 The Chromium Authors # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. # Keeps for method level annotations. -keepclasseswithmembers,allowaccessmodification class ** { @org.jni_zero.AccessedByNative ; } -keepclasseswithmembers,includedescriptorclasses,allowaccessmodification class ** { @org.jni_zero.CalledByNative ; } -keepclasseswithmembers,includedescriptorclasses,allowaccessmodification class ** { @org.jni_zero.CalledByNativeUnchecked ; } # Allow unused native methods to be removed, but prevent renaming on those that # are kept. # TODO(crbug.com/315973491): Restrict the broad scope of this rule. -keepclasseswithmembernames,includedescriptorclasses,allowaccessmodification class ** { native ; } ================================================ FILE: app/download.gradle ================================================ import java.security.MessageDigest apply plugin: 'de.undercouch.download' def BASE_PATH = "https://storage.googleapis.com/chromium-cronet/android/" + CronetVersion + "/Release/cronet/" def assetsDir = projectDir.toString() + "/src/main/assets" def libPath = projectDir.toString() + "/cronetlib" def soPath = projectDir.toString() + "/so" /** * 从文件生成MD5 * @param file * @return */ static def generateMD5(final file) { MessageDigest digest = MessageDigest.getInstance("MD5") file.withInputStream() { is -> byte[] buffer = new byte[1024] //noinspection GroovyUnusedAssignment int numRead = 0 while ((numRead = is.read(buffer)) > 0) { digest.update(buffer, 0, numRead) } } return String.format("%032x", new BigInteger(1, digest.digest())).toLowerCase() } /** * 下载Cronet相关的jar */ tasks.register('downloadJar', Download) { src([ BASE_PATH + "cronet_api.jar", BASE_PATH + "cronet_impl_common_java.jar", BASE_PATH + "cronet_impl_native_java.jar", BASE_PATH + "cronet_impl_platform_java.jar", BASE_PATH + "cronet_shared_java.jar" ]) dest libPath overwrite true onlyIfModified false } /** * 下载Cronet的arm64-v8a so */ tasks.register('downloadARM64', Download) { src BASE_PATH + "libs/arm64-v8a/libcronet." + CronetVersion + ".so" dest soPath + "/arm64-v8a.so" overwrite true onlyIfModified true } /** * 下载Cronet的armeabi-v7a so */ tasks.register('downloadARMv7', Download) { src BASE_PATH + "libs/armeabi-v7a/libcronet." + CronetVersion + ".so" dest soPath + "/armeabi-v7a.so" overwrite true onlyIfModified true } /** * 下载Cronet的x86_64 so */ tasks.register('downloadX86_64', Download) { src BASE_PATH + "libs/x86_64/libcronet." + CronetVersion + ".so" dest soPath + "/x86_64.so" overwrite true onlyIfModified true } /** * 下载Cronet的x86 so */ tasks.register('downloadX86', Download) { src BASE_PATH + "libs/x86/libcronet." + CronetVersion + ".so" dest soPath + "/x86.so" overwrite true onlyIfModified true } /** * 更新Cronet版本时执行这个task * 先更改gradle.properties 里面的版本号,然后再执行 * gradlew app:downloadCronet */ tasks.register('downloadCronet') { dependsOn downloadJar, downloadARM64, downloadARMv7, downloadX86_64, downloadX86 doLast { StringBuilder sb = new StringBuilder("{") def files = new File(soPath).listFiles() for (File file : files) { println file.name.replace(".so", "") sb.append("\"").append(file.name.replace(".so", "")).append("\":\"").append(generateMD5(file)).append("\",") } sb.append("\"version\":\"").append(CronetVersion).append("\"}") println sb.toString() println assetsDir def f1 = new File(assetsDir + "/cronet.json") if (!f1.exists()) { f1.parentFile.mkdirs() f1.createNewFile() } f1.text = sb.toString() } } ================================================ FILE: app/google-services.json ================================================ { "project_info": { "project_number": "453392274790", "firebase_url": "https://legado-fca69.firebaseio.com", "project_id": "legado-fca69", "storage_bucket": "legado-fca69.appspot.com" }, "client": [ { "client_info": { "mobilesdk_app_id": "1:453392274790:android:c4eac14b1410eec5f624a7", "android_client_info": { "package_name": "io.legado.app.debug" } }, "oauth_client": [ { "client_id": "453392274790-hnbpatpce9hbjiggj76hgo7queu86atq.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { "current_key": "AIzaSyD90mfNLhA7cAzzI9SonpSz5mrF5BnmyJA" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { "client_id": "453392274790-hnbpatpce9hbjiggj76hgo7queu86atq.apps.googleusercontent.com", "client_type": 3 } ] } } }, { "client_info": { "mobilesdk_app_id": "1:453392274790:android:c1481c1c3d3f51eff624a7", "android_client_info": { "package_name": "io.legado.app.release" } }, "oauth_client": [ { "client_id": "453392274790-trrgennt5njr1lhil1sgtf0ogcgd38fo.apps.googleusercontent.com", "client_type": 1, "android_info": { "package_name": "io.legado.app.release", "certificate_hash": "fd67dba87b7b761631266f19ddde249054aac5c1" } }, { "client_id": "453392274790-hnbpatpce9hbjiggj76hgo7queu86atq.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { "current_key": "AIzaSyD90mfNLhA7cAzzI9SonpSz5mrF5BnmyJA" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { "client_id": "453392274790-hnbpatpce9hbjiggj76hgo7queu86atq.apps.googleusercontent.com", "client_type": 3 } ] } } }, { "client_info": { "mobilesdk_app_id": "1:453392274790:android:b891abd2331577dff624a7", "android_client_info": { "package_name": "io.legado.play.release" } }, "oauth_client": [ { "client_id": "453392274790-f8sjn6ohs72rg1dvp0pdvk42nkq54p0k.apps.googleusercontent.com", "client_type": 1, "android_info": { "package_name": "io.legado.play.release", "certificate_hash": "00819ace9891386e535967cbafd6a88f3797bd5b" } }, { "client_id": "453392274790-hnbpatpce9hbjiggj76hgo7queu86atq.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { "current_key": "AIzaSyD90mfNLhA7cAzzI9SonpSz5mrF5BnmyJA" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { "client_id": "453392274790-hnbpatpce9hbjiggj76hgo7queu86atq.apps.googleusercontent.com", "client_type": 3 } ] } } }, { "client_info": { "mobilesdk_app_id": "1:453392274790:android:b891abd2331577dff624a7", "android_client_info": { "package_name": "io.legado.play.debug" } }, "oauth_client": [ { "client_id": "453392274790-f8sjn6ohs72rg1dvp0pdvk42nkq54p0k.apps.googleusercontent.com", "client_type": 1, "android_info": { "package_name": "io.legado.play.debug", "certificate_hash": "00819ace9891386e535967cbafd6a88f3797bd5b" } }, { "client_id": "453392274790-hnbpatpce9hbjiggj76hgo7queu86atq.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { "current_key": "AIzaSyD90mfNLhA7cAzzI9SonpSz5mrF5BnmyJA" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { "client_id": "453392274790-hnbpatpce9hbjiggj76hgo7queu86atq.apps.googleusercontent.com", "client_type": 3 } ] } } } ], "configuration_version": "1" } ================================================ FILE: app/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile # 混合时不使用大小写混合,混合后的类名为小写 -dontusemixedcaseclassnames # 这句话能够使我们的项目混淆后产生映射文件 # 包含有类名->混淆后类名的映射关系 -verbose # 保留Annotation不混淆 -keepattributes *Annotation*,InnerClasses # 避免混淆泛型 -keepattributes Signature # 指定混淆是采用的算法,后面的参数是一个过滤器 # 这个过滤器是谷歌推荐的算法,一般不做更改 -optimizations !code/simplification/cast,!field/*,!class/merging/* -flattenpackagehierarchy ############################################# # # Android开发中一些需要保留的公共部分 # ############################################# # 屏蔽错误Unresolved class name #noinspection ShrinkerUnresolvedReference # 移除Log类打印各个等级日志的代码,打正式包的时候可以做为禁log使用,这里可以作为禁止log打印的功能使用 # 记得proguard-android.txt中一定不要加-dontoptimize才起作用 # 另外的一种实现方案是通过BuildConfig.DEBUG的变量来控制 -assumenosideeffects class android.util.Log { public static int v(...); public static int i(...); public static int w(...); public static int d(...); public static int e(...); } # 保持js引擎调用的java类 -keep class * extends io.legado.app.help.JsExtensions{*;} # 数据类 -keep class **.data.entities.**{*;} # hutool-core hutool-crypto -keep class !cn.hutool.core.util.RuntimeUtil, !cn.hutool.core.util.ClassLoaderUtil, !cn.hutool.core.util.ReflectUtil, !cn.hutool.core.util.SerializeUtil, !cn.hutool.core.util.ClassUtil, cn.hutool.core.codec.**, cn.hutool.core.util.**{*;} -keep class cn.hutool.crypto.**{*;} -dontwarn cn.hutool.** # 缓存 Cookie -keep class **.help.http.CookieStore{*;} -keep class **.help.CacheManager{*;} # StrResponse -keep class **.help.http.StrResponse{*;} # markwon -dontwarn org.commonmark.ext.gfm.** -keep class okhttp3.*{*;} -keep class okio.*{*;} -keep class com.jayway.jsonpath.*{*;} # LiveEventBus -keepclassmembers class androidx.lifecycle.LiveData { *** mObservers; *** mActiveCount; } -keepclassmembers class androidx.arch.core.internal.SafeIterableMap { *** size(); *** putIfAbsent(...); } ## ChangeBookSourceDialog initNavigationView -keepclassmembers class androidx.appcompat.widget.Toolbar { *** mNavButtonView; } # MenuExtensions applyOpenTint -keepnames class androidx.appcompat.view.menu.SubMenuBuilder -keep class androidx.appcompat.view.menu.MenuBuilder { *** setOptionalIconsVisible(...); *** getNonActionItems(); } # FileDocExtensions.kt treeDocumentFileConstructor -keep class androidx.documentfile.provider.TreeDocumentFile { (...); } # JsoupXpath -keep,allowobfuscation class * implements org.seimicrawler.xpath.core.AxisSelector{*;} -keep,allowobfuscation class * implements org.seimicrawler.xpath.core.NodeTest{*;} -keep,allowobfuscation class * implements org.seimicrawler.xpath.core.Function{*;} ## JSOUP -keep class org.jsoup.**{*;} -dontwarn org.jspecify.annotations.NullMarked ## ExoPlayer 反射设置ua 保证该私有变量不被混淆 -keepclassmembers class androidx.media3.datasource.cache.CacheDataSource$Factory { *** upstreamDataSourceFactory; } ## ExoPlayer 如果还不能播放就取消注释这个 # -keep class com.google.android.exoplayer2.** {*;} ## 对外提供api -keep class io.legado.app.api.ReturnData{*;} # Cronet -keepclassmembers class org.chromium.net.X509Util { *** sDefaultTrustManager; *** sTestTrustManager; } # Throwable -keepnames class * extends java.lang.Throwable -keepclassmembernames,allowobfuscation class * extends java.lang.Throwable{*;} ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/1.json ================================================ { "formatVersion": 1, "database": { "version": 1, "identityHash": "d9ed367fc7241a61e9f770d416c4f887", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "useReplaceRule", "columnName": "useReplaceRule", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, PRIMARY KEY(`bookUrl`))", "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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleCategories` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleCategories", "columnName": "ruleCategories", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", "fields": [ { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "chapterName", "columnName": "chapterName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "chapterIndex", "columnName": "chapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "pageIndex", "columnName": "pageIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" } ], "foreignKeys": [] }, { "tableName": "rssArticles", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `categories` TEXT, `read` INTEGER NOT NULL, `star` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "categories", "columnName": "categories", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "star", "columnName": "star", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'd9ed367fc7241a61e9f770d416c4f887')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/10.json ================================================ { "formatVersion": 1, "database": { "version": 10, "identityHash": "a9744f575dad6d4774cccc433921973b", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`name`, `author`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "useReplaceRule", "columnName": "useReplaceRule", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "name", "author" ], "autoGenerate": false }, "indices": [ { "name": "index_books_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", "fields": [ { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "chapterIndex", "columnName": "chapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "pageIndex", "columnName": "pageIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterName", "columnName": "chapterName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`name`))", "fields": [ { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "rule", "columnName": "rule", "affinity": "TEXT", "notNull": true }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "name" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'a9744f575dad6d4774cccc433921973b')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/11.json ================================================ { "formatVersion": 1, "database": { "version": 11, "identityHash": "d3019908fa3212a7ac8eb87ac2f33369", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`name`, `author`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "useReplaceRule", "columnName": "useReplaceRule", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "name", "author" ], "autoGenerate": false }, "indices": [ { "name": "index_books_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", "fields": [ { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "chapterIndex", "columnName": "chapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "pageIndex", "columnName": "pageIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterName", "columnName": "chapterName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'd3019908fa3212a7ac8eb87ac2f33369')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/12.json ================================================ { "formatVersion": 1, "database": { "version": 12, "identityHash": "fa238e7524c215177f66110c847d327d", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`name`, `author`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "useReplaceRule", "columnName": "useReplaceRule", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "name", "author" ], "autoGenerate": false }, "indices": [ { "name": "index_books_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", "fields": [ { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "chapterIndex", "columnName": "chapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "pageIndex", "columnName": "pageIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterName", "columnName": "chapterName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'fa238e7524c215177f66110c847d327d')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/13.json ================================================ { "formatVersion": 1, "database": { "version": 13, "identityHash": "da04f8cb7f257482f105b1274a7a351b", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`name`, `author`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "useReplaceRule", "columnName": "useReplaceRule", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "name", "author" ], "autoGenerate": false }, "indices": [ { "name": "index_books_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", "fields": [ { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "chapterIndex", "columnName": "chapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "pageIndex", "columnName": "pageIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterName", "columnName": "chapterName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'da04f8cb7f257482f105b1274a7a351b')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/14.json ================================================ { "formatVersion": 1, "database": { "version": 14, "identityHash": "139ff0cc002ac7be67a60912cd26bac7", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "useReplaceRule", "columnName": "useReplaceRule", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", "fields": [ { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "chapterIndex", "columnName": "chapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "pageIndex", "columnName": "pageIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterName", "columnName": "chapterName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '139ff0cc002ac7be67a60912cd26bac7')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/15.json ================================================ { "formatVersion": 1, "database": { "version": 15, "identityHash": "07a0976a08cbae60a16550af5663cde5", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "useReplaceRule", "columnName": "useReplaceRule", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", "fields": [ { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "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": "pageIndex", "columnName": "pageIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterName", "columnName": "chapterName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '07a0976a08cbae60a16550af5663cde5')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/16.json ================================================ { "formatVersion": 1, "database": { "version": 16, "identityHash": "ce9320370930dec28d85e2a77fad95e2", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "useReplaceRule", "columnName": "useReplaceRule", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", "fields": [ { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "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": "pageIndex", "columnName": "pageIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterName", "columnName": "chapterName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`bookName`))", "fields": [ { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "readTime", "columnName": "readTime", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'ce9320370930dec28d85e2a77fad95e2')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/17.json ================================================ { "formatVersion": 1, "database": { "version": 17, "identityHash": "ce9320370930dec28d85e2a77fad95e2", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "useReplaceRule", "columnName": "useReplaceRule", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", "fields": [ { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "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": "pageIndex", "columnName": "pageIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterName", "columnName": "chapterName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`bookName`))", "fields": [ { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "readTime", "columnName": "readTime", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'ce9320370930dec28d85e2a77fad95e2')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/18.json ================================================ { "formatVersion": 1, "database": { "version": 18, "identityHash": "ec3badfc86fb8187260ab26fb78a2d3f", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "useReplaceRule", "columnName": "useReplaceRule", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", "fields": [ { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "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": "pageIndex", "columnName": "pageIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterName", "columnName": "chapterName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`bookName`))", "fields": [ { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "readTime", "columnName": "readTime", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "httpTTS", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT 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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'ec3badfc86fb8187260ab26fb78a2d3f')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/19.json ================================================ { "formatVersion": 1, "database": { "version": 19, "identityHash": "c58916ed4a4aece6092e21acf99845a1", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "useReplaceRule", "columnName": "useReplaceRule", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", "fields": [ { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "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": "pageIndex", "columnName": "pageIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterName", "columnName": "chapterName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`androidId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`androidId`, `bookName`))", "fields": [ { "fieldPath": "androidId", "columnName": "androidId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "readTime", "columnName": "readTime", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "androidId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "httpTTS", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT 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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'c58916ed4a4aece6092e21acf99845a1')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/2.json ================================================ { "formatVersion": 1, "database": { "version": 2, "identityHash": "8164f697c57c68c7b82ec8e427214a88", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "useReplaceRule", "columnName": "useReplaceRule", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" } ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", "fields": [ { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "chapterName", "columnName": "chapterName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "chapterIndex", "columnName": "chapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "pageIndex", "columnName": "pageIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" } ], "foreignKeys": [] }, { "tableName": "rssArticles", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, `star` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "star", "columnName": "star", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '8164f697c57c68c7b82ec8e427214a88')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/20.json ================================================ { "formatVersion": 1, "database": { "version": 20, "identityHash": "2c443ea987b87d8daf2a6161a98d2d5c", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "useReplaceRule", "columnName": "useReplaceRule", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", "fields": [ { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "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": "pageIndex", "columnName": "pageIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterName", "columnName": "chapterName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`androidId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`androidId`, `bookName`))", "fields": [ { "fieldPath": "androidId", "columnName": "androidId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "readTime", "columnName": "readTime", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "androidId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "httpTTS", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT 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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '2c443ea987b87d8daf2a6161a98d2d5c')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/21.json ================================================ { "formatVersion": 1, "database": { "version": 21, "identityHash": "eac4e5b7fdad840e82c1f607a3a8a46a", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, `config` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "useReplaceRule", "columnName": "useReplaceRule", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "config", "columnName": "config", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", "fields": [ { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "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": "pageIndex", "columnName": "pageIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterName", "columnName": "chapterName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`androidId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`androidId`, `bookName`))", "fields": [ { "fieldPath": "androidId", "columnName": "androidId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "readTime", "columnName": "readTime", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "androidId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "httpTTS", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT 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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'eac4e5b7fdad840e82c1f607a3a8a46a')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/22.json ================================================ { "formatVersion": 1, "database": { "version": 22, "identityHash": "9cf4f754700355578a8b8bf045c0e8e1", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", "fields": [ { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "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": "pageIndex", "columnName": "pageIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterName", "columnName": "chapterName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`androidId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`androidId`, `bookName`))", "fields": [ { "fieldPath": "androidId", "columnName": "androidId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "readTime", "columnName": "readTime", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "androidId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "httpTTS", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT 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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '9cf4f754700355578a8b8bf045c0e8e1')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/23.json ================================================ { "formatVersion": 1, "database": { "version": 23, "identityHash": "874aa30f88921306741b488dbad38536", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "baseUrl", "columnName": "baseUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", "fields": [ { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "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": "pageIndex", "columnName": "pageIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterName", "columnName": "chapterName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`androidId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`androidId`, `bookName`))", "fields": [ { "fieldPath": "androidId", "columnName": "androidId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "readTime", "columnName": "readTime", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "androidId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "httpTTS", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT 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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '874aa30f88921306741b488dbad38536')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/24.json ================================================ { "formatVersion": 1, "database": { "version": 24, "identityHash": "55416d5a8a8530659ae3e7f948c0058b", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "baseUrl", "columnName": "baseUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", "fields": [ { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "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": "pageIndex", "columnName": "pageIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterName", "columnName": "chapterName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`androidId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`androidId`, `bookName`))", "fields": [ { "fieldPath": "androidId", "columnName": "androidId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "readTime", "columnName": "readTime", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "androidId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "httpTTS", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT 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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] } ], "views": [], "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, '55416d5a8a8530659ae3e7f948c0058b')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/25.json ================================================ { "formatVersion": 1, "database": { "version": 25, "identityHash": "469ee9861faf7f562d7c60bc15a4a58b", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "baseUrl", "columnName": "baseUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", "fields": [ { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "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": "pageIndex", "columnName": "pageIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterName", "columnName": "chapterName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`androidId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`androidId`, `bookName`))", "fields": [ { "fieldPath": "androidId", "columnName": "androidId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "readTime", "columnName": "readTime", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "androidId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "httpTTS", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT 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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "tableName": "sourceSubs", "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, 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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '469ee9861faf7f562d7c60bc15a4a58b')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/26.json ================================================ { "formatVersion": 1, "database": { "version": 26, "identityHash": "e20aa63032efb23c9e9c269afd64e7d7", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "baseUrl", "columnName": "baseUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "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": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`androidId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`androidId`, `bookName`))", "fields": [ { "fieldPath": "androidId", "columnName": "androidId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "readTime", "columnName": "readTime", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "androidId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "httpTTS", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT 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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'e20aa63032efb23c9e9c269afd64e7d7')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/27.json ================================================ { "formatVersion": 1, "database": { "version": 27, "identityHash": "e20aa63032efb23c9e9c269afd64e7d7", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "baseUrl", "columnName": "baseUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "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": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`androidId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`androidId`, `bookName`))", "fields": [ { "fieldPath": "androidId", "columnName": "androidId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "readTime", "columnName": "readTime", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "androidId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "httpTTS", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT 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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'e20aa63032efb23c9e9c269afd64e7d7')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/28.json ================================================ { "formatVersion": 1, "database": { "version": 28, "identityHash": "f77119a40a8930665af834d03c8c5d25", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "baseUrl", "columnName": "baseUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "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": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`androidId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`androidId`, `bookName`))", "fields": [ { "fieldPath": "androidId", "columnName": "androidId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "readTime", "columnName": "readTime", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "androidId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "httpTTS", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT 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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'f77119a40a8930665af834d03c8c5d25')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/29.json ================================================ { "formatVersion": 1, "database": { "version": 29, "identityHash": "85f1e7146f650af82aac6f9137eff815", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "baseUrl", "columnName": "baseUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "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": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`androidId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`androidId`, `bookName`))", "fields": [ { "fieldPath": "androidId", "columnName": "androidId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "readTime", "columnName": "readTime", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "androidId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "httpTTS", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT 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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '85f1e7146f650af82aac6f9137eff815')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/3.json ================================================ { "formatVersion": 1, "database": { "version": 3, "identityHash": "a3ccd8882307290a450c49e09a4435f6", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "useReplaceRule", "columnName": "useReplaceRule", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", "fields": [ { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "chapterName", "columnName": "chapterName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "chapterIndex", "columnName": "chapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "pageIndex", "columnName": "pageIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" } ], "foreignKeys": [] }, { "tableName": "rssArticles", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, `star` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "star", "columnName": "star", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'a3ccd8882307290a450c49e09a4435f6')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/30.json ================================================ { "formatVersion": 1, "database": { "version": 30, "identityHash": "d9c8ef97ef4ffe0c1dbd57ca74bc4de4", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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": "baseUrl", "columnName": "baseUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "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": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, 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 } ], "primaryKey": { "columnNames": [ "deviceId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "httpTTS", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT 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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "epubChapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `href` TEXT NOT NULL, `parentHref` TEXT, PRIMARY KEY(`bookUrl`, `href`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "href", "columnName": "href", "affinity": "TEXT", "notNull": true }, { "fieldPath": "parentHref", "columnName": "parentHref", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl", "href" ], "autoGenerate": false }, "indices": [ { "name": "index_epubChapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_epubChapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_epubChapters_bookUrl_href", "unique": true, "columnNames": [ "bookUrl", "href" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_epubChapters_bookUrl_href` ON `${TABLE_NAME}` (`bookUrl`, `href`)" } ], "foreignKeys": [ { "table": "books", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "bookUrl" ], "referencedColumns": [ "bookUrl" ] } ] } ], "views": [], "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, 'd9c8ef97ef4ffe0c1dbd57ca74bc4de4')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/31.json ================================================ { "formatVersion": 1, "database": { "version": 31, "identityHash": "d1c390e708a1e89c7d016cdd2e0b2e88", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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": "baseUrl", "columnName": "baseUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "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": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, 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 } ], "primaryKey": { "columnNames": [ "deviceId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "httpTTS", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT 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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'd1c390e708a1e89c7d016cdd2e0b2e88')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/32.json ================================================ { "formatVersion": 1, "database": { "version": 32, "identityHash": "d1c390e708a1e89c7d016cdd2e0b2e88", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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": "baseUrl", "columnName": "baseUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "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": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, 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 } ], "primaryKey": { "columnNames": [ "deviceId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "httpTTS", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT 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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'd1c390e708a1e89c7d016cdd2e0b2e88')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/33.json ================================================ { "formatVersion": 1, "database": { "version": 33, "identityHash": "6dad1518f359667b4d740fc6a1f44a21", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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": "baseUrl", "columnName": "baseUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_bookName_bookAuthor", "unique": false, "columnNames": [ "bookName", "bookAuthor" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, 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 } ], "primaryKey": { "columnNames": [ "deviceId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "httpTTS", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT 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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '6dad1518f359667b4d740fc6a1f44a21')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/34.json ================================================ { "formatVersion": 1, "database": { "version": 34, "identityHash": "2e519f1f67ca16091cbc3891c1b71c66", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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": "baseUrl", "columnName": "baseUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_bookName_bookAuthor", "unique": false, "columnNames": [ "bookName", "bookAuthor" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, 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 } ], "primaryKey": { "columnNames": [ "deviceId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "httpTTS", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT 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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '2e519f1f67ca16091cbc3891c1b71c66')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/35.json ================================================ { "formatVersion": 1, "database": { "version": 35, "identityHash": "25948a8defe4d091514bb725b4db7683", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `concurrentRate` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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": "baseUrl", "columnName": "baseUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_bookName_bookAuthor", "unique": false, "columnNames": [ "bookName", "bookAuthor" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, 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 } ], "primaryKey": { "columnNames": [ "deviceId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "httpTTS", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT 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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '25948a8defe4d091514bb725b4db7683')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/36.json ================================================ { "formatVersion": 1, "database": { "version": 36, "identityHash": "25948a8defe4d091514bb725b4db7683", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `concurrentRate` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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": "baseUrl", "columnName": "baseUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_bookName_bookAuthor", "unique": false, "columnNames": [ "bookName", "bookAuthor" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, 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 } ], "primaryKey": { "columnNames": [ "deviceId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "httpTTS", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT 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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '25948a8defe4d091514bb725b4db7683')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/37.json ================================================ { "formatVersion": 1, "database": { "version": 37, "identityHash": "11ebd6a72eb3f9ccd6ca46bc5535bca5", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `concurrentRate` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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": "baseUrl", "columnName": "baseUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_bookName_bookAuthor", "unique": false, "columnNames": [ "bookName", "bookAuthor" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, 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 } ], "primaryKey": { "columnNames": [ "deviceId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "httpTTS", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT 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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '11ebd6a72eb3f9ccd6ca46bc5535bca5')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/38.json ================================================ { "formatVersion": 1, "database": { "version": 38, "identityHash": "5211699415b40f58b06d4136d14173d1", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `concurrentRate` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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": "baseUrl", "columnName": "baseUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_bookName_bookAuthor", "unique": false, "columnNames": [ "bookName", "bookAuthor" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, 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 } ], "primaryKey": { "columnNames": [ "deviceId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "httpTTS", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT 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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '5211699415b40f58b06d4136d14173d1')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/39.json ================================================ { "formatVersion": 1, "database": { "version": 39, "identityHash": "8cfa1fb6bb9a65c04bfe563680126a4f", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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": "baseUrl", "columnName": "baseUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_bookName_bookAuthor", "unique": false, "columnNames": [ "bookName", "bookAuthor" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, 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 } ], "primaryKey": { "columnNames": [ "deviceId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "httpTTS", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT 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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '8cfa1fb6bb9a65c04bfe563680126a4f')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/4.json ================================================ { "formatVersion": 1, "database": { "version": 4, "identityHash": "3b81b2e900be34b8ceb48aaacc6b1004", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "useReplaceRule", "columnName": "useReplaceRule", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", "fields": [ { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "chapterName", "columnName": "chapterName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "chapterIndex", "columnName": "chapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "pageIndex", "columnName": "pageIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" } ], "foreignKeys": [] }, { "tableName": "rssArticles", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssStars", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '3b81b2e900be34b8ceb48aaacc6b1004')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/40.json ================================================ { "formatVersion": 1, "database": { "version": 40, "identityHash": "09617e0520cd8ec1671d812a866b45a4", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT 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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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": "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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_bookName_bookAuthor", "unique": false, "columnNames": [ "bookName", "bookAuthor" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, 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 } ], "primaryKey": { "columnNames": [ "deviceId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "httpTTS", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT 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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '09617e0520cd8ec1671d812a866b45a4')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/41.json ================================================ { "formatVersion": 1, "database": { "version": 41, "identityHash": "6fbd1d1b3918bcc6db113ad108e6b924", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT 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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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": "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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_bookName_bookAuthor", "unique": false, "columnNames": [ "bookName", "bookAuthor" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, 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 } ], "primaryKey": { "columnNames": [ "deviceId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "httpTTS", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `concurrentRate` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `header` TEXT, `loginCheckJs` TEXT, 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": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '6fbd1d1b3918bcc6db113ad108e6b924')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/42.json ================================================ { "formatVersion": 1, "database": { "version": 42, "identityHash": "5bef05ac6abeaa4b82c3ff6e9e6bd7b3", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT 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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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": "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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_bookName_bookAuthor", "unique": false, "columnNames": [ "bookName", "bookAuthor" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, 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 } ], "primaryKey": { "columnNames": [ "deviceId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `loginUrl` TEXT, `loginUi` TEXT, `header` TEXT, `loginCheckJs` TEXT, 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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '5bef05ac6abeaa4b82c3ff6e9e6bd7b3')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/43.json ================================================ { "formatVersion": 1, "database": { "version": 43, "identityHash": "b97eb5297faaacb44c2274233f52d250", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` 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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "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": { "columnNames": [ "word" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "time" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, 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 } ], "primaryKey": { "columnNames": [ "deviceId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `loginUrl` TEXT, `loginUi` TEXT, `header` TEXT, `loginCheckJs` TEXT, 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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'b97eb5297faaacb44c2274233f52d250')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/44.json ================================================ { "formatVersion": 1, "database": { "version": 44, "identityHash": "fdfaa67979c13dd76db0a1b594bc5904", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` 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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "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, `isEnabled` INTEGER NOT NULL DEFAULT 1, `isRegex` INTEGER NOT NULL DEFAULT 1, `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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "scopeTitle", "columnName": "scopeTitle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "scopeContent", "columnName": "scopeContent", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "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": { "columnNames": [ "word" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "time" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, 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 } ], "primaryKey": { "columnNames": [ "deviceId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `loginUrl` TEXT, `loginUi` TEXT, `header` TEXT, `loginCheckJs` TEXT, 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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'fdfaa67979c13dd76db0a1b594bc5904')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/45.json ================================================ { "formatVersion": 1, "database": { "version": 45, "identityHash": "63272539e04e405abfd79e27ea55db75", "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 '', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, 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": "''" }, { "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", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` 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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "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, `isEnabled` INTEGER NOT NULL DEFAULT 1, `isRegex` INTEGER NOT NULL DEFAULT 1, `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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "scopeTitle", "columnName": "scopeTitle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "scopeContent", "columnName": "scopeContent", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "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": { "columnNames": [ "word" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "time" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, 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 } ], "primaryKey": { "columnNames": [ "deviceId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `loginUrl` TEXT, `loginUi` TEXT, `header` TEXT, `loginCheckJs` TEXT, 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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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": { "columnNames": [ "type", "key" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '63272539e04e405abfd79e27ea55db75')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/46.json ================================================ { "formatVersion": 1, "database": { "version": 46, "identityHash": "63272539e04e405abfd79e27ea55db75", "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 '', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, 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": "''" }, { "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", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` 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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "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, `isEnabled` INTEGER NOT NULL DEFAULT 1, `isRegex` INTEGER NOT NULL DEFAULT 1, `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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "scopeTitle", "columnName": "scopeTitle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "scopeContent", "columnName": "scopeContent", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "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": { "columnNames": [ "word" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "time" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, 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 } ], "primaryKey": { "columnNames": [ "deviceId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `loginUrl` TEXT, `loginUi` TEXT, `header` TEXT, `loginCheckJs` TEXT, 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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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": { "columnNames": [ "type", "key" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '63272539e04e405abfd79e27ea55db75')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/47.json ================================================ { "formatVersion": 1, "database": { "version": 47, "identityHash": "985b7a8fbf3f49e6eb444e4f4472bdee", "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 '', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, 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": "''" }, { "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", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` 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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "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, `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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "scopeTitle", "columnName": "scopeTitle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "scopeContent", "columnName": "scopeContent", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "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": { "columnNames": [ "id" ], "autoGenerate": true }, "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "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": { "columnNames": [ "word" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "time" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, 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 } ], "primaryKey": { "columnNames": [ "deviceId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `loginUrl` TEXT, `loginUi` TEXT, `header` TEXT, `loginCheckJs` TEXT, 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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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": { "columnNames": [ "type", "key" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '985b7a8fbf3f49e6eb444e4f4472bdee')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/48.json ================================================ { "formatVersion": 1, "database": { "version": 48, "identityHash": "6348b1307bb9fb4f5482fb94a723338a", "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 '', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, 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": "''" }, { "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", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` 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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "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, `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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "scopeTitle", "columnName": "scopeTitle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "scopeContent", "columnName": "scopeContent", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "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": { "columnNames": [ "id" ], "autoGenerate": true }, "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "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": { "columnNames": [ "word" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "time" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, 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 } ], "primaryKey": { "columnNames": [ "deviceId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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": { "columnNames": [ "type", "key" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '6348b1307bb9fb4f5482fb94a723338a')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/49.json ================================================ { "formatVersion": 1, "database": { "version": 49, "identityHash": "2a6f5ee3d0ed9ac13f15183a04a4af45", "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 '', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, 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": "''" }, { "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", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` 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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "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, `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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "scopeTitle", "columnName": "scopeTitle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "scopeContent", "columnName": "scopeContent", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "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": { "columnNames": [ "id" ], "autoGenerate": true }, "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "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": { "columnNames": [ "word" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "time" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, 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 } ], "primaryKey": { "columnNames": [ "deviceId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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": { "columnNames": [ "type", "key" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '2a6f5ee3d0ed9ac13f15183a04a4af45')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/5.json ================================================ { "formatVersion": 1, "database": { "version": 5, "identityHash": "a355f8e02ebe0b13464573b1420a7b90", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "useReplaceRule", "columnName": "useReplaceRule", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", "fields": [ { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "chapterIndex", "columnName": "chapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "pageIndex", "columnName": "pageIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterName", "columnName": "chapterName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "tableName": "rssArticles", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssStars", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'a355f8e02ebe0b13464573b1420a7b90')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/50.json ================================================ { "formatVersion": 1, "database": { "version": 50, "identityHash": "524cf3564400bd897a49355afd984ac1", "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 '', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, 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": "''" }, { "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", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `bookSourceComment` TEXT, `variableComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` 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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "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, `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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "scopeTitle", "columnName": "scopeTitle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "scopeContent", "columnName": "scopeContent", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "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": { "columnNames": [ "id" ], "autoGenerate": true }, "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "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": { "columnNames": [ "word" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "time" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "readRecord", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, 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 } ], "primaryKey": { "columnNames": [ "deviceId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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": { "columnNames": [ "type", "key" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '524cf3564400bd897a49355afd984ac1')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/51.json ================================================ { "formatVersion": 1, "database": { "version": 51, "identityHash": "028c189be828501449b61b32ab47a36f", "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 '', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, 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": "''" }, { "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", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "bookUrl" ] }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "groupId" ] }, "indices": [], "foreignKeys": [] }, { "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, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `bookSourceComment` TEXT, `variableComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` 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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "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, `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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "scopeTitle", "columnName": "scopeTitle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "scopeContent", "columnName": "scopeContent", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "record" ] }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `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": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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, `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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '028c189be828501449b61b32ab47a36f')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/52.json ================================================ { "formatVersion": 1, "database": { "version": 52, "identityHash": "0221c385ed7393f47afe9579d3106541", "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 '', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, 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": "''" }, { "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", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "bookUrl" ] }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "groupId" ] }, "indices": [], "foreignKeys": [] }, { "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, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `bookSourceComment` TEXT, `variableComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` 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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "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, `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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "scopeTitle", "columnName": "scopeTitle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "scopeContent", "columnName": "scopeContent", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "record" ] }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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, `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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '0221c385ed7393f47afe9579d3106541')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/53.json ================================================ { "formatVersion": 1, "database": { "version": 53, "identityHash": "c55dac79aa9cc3dcdb72e91da4282005", "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 '', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, 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": "''" }, { "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", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `enabledReview` INTEGER, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `bookSourceComment` TEXT, `variableComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` 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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledReview", "columnName": "enabledReview", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleReview", "columnName": "ruleReview", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "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, `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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "scopeTitle", "columnName": "scopeTitle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "scopeContent", "columnName": "scopeContent", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "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": { "columnNames": [ "id" ], "autoGenerate": true }, "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "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": { "columnNames": [ "word" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "time" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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": { "columnNames": [ "deviceId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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": { "columnNames": [ "type", "key" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'c55dac79aa9cc3dcdb72e91da4282005')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/54.json ================================================ { "formatVersion": 1, "database": { "version": 54, "identityHash": "2b32177325d903e84445cc80ad7cbce8", "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 '', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 1, `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, 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": "''" }, { "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", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `enabledReview` INTEGER, `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, `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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledReview", "columnName": "enabledReview", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleReview", "columnName": "ruleReview", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "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, `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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "scopeTitle", "columnName": "scopeTitle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "scopeContent", "columnName": "scopeContent", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "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": { "columnNames": [ "id" ], "autoGenerate": true }, "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "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": { "columnNames": [ "word" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `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, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "time" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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": { "columnNames": [ "deviceId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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": { "columnNames": [ "type", "key" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '2b32177325d903e84445cc80ad7cbce8')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/55.json ================================================ { "formatVersion": 1, "database": { "version": 55, "identityHash": "7dc698b0bf395df06befb13d41df87b9", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, 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", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `enabledReview` INTEGER, `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, `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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledReview", "columnName": "enabledReview", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleReview", "columnName": "ruleReview", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "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, `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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "scopeTitle", "columnName": "scopeTitle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "scopeContent", "columnName": "scopeContent", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "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": { "columnNames": [ "id" ], "autoGenerate": true }, "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "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": { "columnNames": [ "word" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `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, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "time" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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": { "columnNames": [ "deviceId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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": { "columnNames": [ "type", "key" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '7dc698b0bf395df06befb13d41df87b9')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/56.json ================================================ { "formatVersion": 1, "database": { "version": 56, "identityHash": "7dc698b0bf395df06befb13d41df87b9", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, 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", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `enabledReview` INTEGER, `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, `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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledReview", "columnName": "enabledReview", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleReview", "columnName": "ruleReview", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "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, `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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "scopeTitle", "columnName": "scopeTitle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "scopeContent", "columnName": "scopeContent", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "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": { "columnNames": [ "id" ], "autoGenerate": true }, "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "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": { "columnNames": [ "word" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `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, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "time" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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": { "columnNames": [ "deviceId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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": { "columnNames": [ "type", "key" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '7dc698b0bf395df06befb13d41df87b9')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/57.json ================================================ { "formatVersion": 1, "database": { "version": 57, "identityHash": "ae968e35603fe39a0141d0983d993de1", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, 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", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `enabledReview` INTEGER, `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, `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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledReview", "columnName": "enabledReview", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleReview", "columnName": "ruleReview", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "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, `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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "scopeTitle", "columnName": "scopeTitle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "scopeContent", "columnName": "scopeContent", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "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": { "columnNames": [ "id" ], "autoGenerate": true }, "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "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": { "columnNames": [ "word" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `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, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `injectJs` TEXT, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "injectJs", "columnName": "injectJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "time" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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": { "columnNames": [ "deviceId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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": { "columnNames": [ "type", "key" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'ae968e35603fe39a0141d0983d993de1')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/58.json ================================================ { "formatVersion": 1, "database": { "version": 58, "identityHash": "dce5917572677d2b19bbc43809efb40c", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, 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", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, `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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookSort", "columnName": "bookSort", "affinity": "INTEGER", "notNull": true, "defaultValue": "-1" } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `enabledReview` INTEGER, `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, `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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledReview", "columnName": "enabledReview", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleReview", "columnName": "ruleReview", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "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, `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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "scopeTitle", "columnName": "scopeTitle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "scopeContent", "columnName": "scopeContent", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "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": { "columnNames": [ "id" ], "autoGenerate": true }, "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "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": { "columnNames": [ "word" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "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, `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, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `injectJs` TEXT, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "injectJs", "columnName": "injectJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "time" ], "autoGenerate": false }, "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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": { "columnNames": [ "deviceId", "bookName" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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, `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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "deadline", "columnName": "deadline", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "key" ], "autoGenerate": false }, "indices": [ { "name": "index_caches_key", "unique": true, "columnNames": [ "key" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "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": { "columnNames": [ "type", "key" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'dce5917572677d2b19bbc43809efb40c')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/59.json ================================================ { "formatVersion": 1, "database": { "version": 59, "identityHash": "ddae0fd3a6b7ec874a028754a8cdd528", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, 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", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "bookUrl" ] }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, `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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookSort", "columnName": "bookSort", "affinity": "INTEGER", "notNull": true, "defaultValue": "-1" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "groupId" ] }, "indices": [], "foreignKeys": [] }, { "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, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `enabledReview` INTEGER, `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, `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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledReview", "columnName": "enabledReview", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleReview", "columnName": "ruleReview", "affinity": "TEXT", "notNull": false } ], "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "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, `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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "scopeTitle", "columnName": "scopeTitle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "scopeContent", "columnName": "scopeContent", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `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, `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, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWhitelist", "columnName": "contentWhitelist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentBlacklist", "columnName": "contentBlacklist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "record" ] }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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, `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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'ddae0fd3a6b7ec874a028754a8cdd528')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/6.json ================================================ { "formatVersion": 1, "database": { "version": 6, "identityHash": "af70ea583587e17c968d29f41bb3c0d6", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "useReplaceRule", "columnName": "useReplaceRule", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", "fields": [ { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "chapterIndex", "columnName": "chapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "pageIndex", "columnName": "pageIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterName", "columnName": "chapterName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "tableName": "rssArticles", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssStars", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`name`))", "fields": [ { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "rule", "columnName": "rule", "affinity": "TEXT", "notNull": true }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "name" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'af70ea583587e17c968d29f41bb3c0d6')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/60.json ================================================ { "formatVersion": 1, "database": { "version": 60, "identityHash": "19e1f66252eb41afc0990f202fe4527a", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, 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", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "bookUrl" ] }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, `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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookSort", "columnName": "bookSort", "affinity": "INTEGER", "notNull": true, "defaultValue": "-1" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "groupId" ] }, "indices": [], "foreignKeys": [] }, { "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, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `enabledReview` INTEGER, `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, `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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledReview", "columnName": "enabledReview", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleReview", "columnName": "ruleReview", "affinity": "TEXT", "notNull": false } ], "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "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, `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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "scopeTitle", "columnName": "scopeTitle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "scopeContent", "columnName": "scopeContent", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `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, `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, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWhitelist", "columnName": "contentWhitelist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentBlacklist", "columnName": "contentBlacklist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "record" ] }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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, `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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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, 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 } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "name" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '19e1f66252eb41afc0990f202fe4527a')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/61.json ================================================ { "formatVersion": 1, "database": { "version": 61, "identityHash": "9e424026dc2c4dba847b0d9a5f3f788e", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, 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", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "bookUrl" ] }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, `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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookSort", "columnName": "bookSort", "affinity": "INTEGER", "notNull": true, "defaultValue": "-1" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "groupId" ] }, "indices": [], "foreignKeys": [] }, { "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, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `enabledReview` INTEGER, `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, `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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledReview", "columnName": "enabledReview", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleReview", "columnName": "ruleReview", "affinity": "TEXT", "notNull": false } ], "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "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, `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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "scopeTitle", "columnName": "scopeTitle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "scopeContent", "columnName": "scopeContent", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `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, `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, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWhitelist", "columnName": "contentWhitelist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentBlacklist", "columnName": "contentBlacklist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "record" ] }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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, `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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '9e424026dc2c4dba847b0d9a5f3f788e')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/62.json ================================================ { "formatVersion": 1, "database": { "version": 62, "identityHash": "965c5f1d4c60d25b4b46efb17c575c85", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, 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", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "bookUrl" ] }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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, `show` INTEGER NOT NULL, `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", "notNull": false }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "show", "columnName": "show", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookSort", "columnName": "bookSort", "affinity": "INTEGER", "notNull": true, "defaultValue": "-1" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "groupId" ] }, "indices": [], "foreignKeys": [] }, { "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, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `enabledReview` INTEGER, `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, `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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledReview", "columnName": "enabledReview", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleReview", "columnName": "ruleReview", "affinity": "TEXT", "notNull": false } ], "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "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, `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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "scopeTitle", "columnName": "scopeTitle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "scopeContent", "columnName": "scopeContent", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `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, `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, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWhitelist", "columnName": "contentWhitelist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentBlacklist", "columnName": "contentBlacklist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "record" ] }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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, `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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "sortNumber", "columnName": "sortNumber", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '965c5f1d4c60d25b4b46efb17c575c85')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/63.json ================================================ { "formatVersion": 1, "database": { "version": 63, "identityHash": "5763792a4d08dc4cd7ebec9fb3215458", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, 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", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "bookUrl" ] }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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", "notNull": false }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `enabledReview` INTEGER, `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, `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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledReview", "columnName": "enabledReview", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleReview", "columnName": "ruleReview", "affinity": "TEXT", "notNull": false } ], "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "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, `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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "scopeTitle", "columnName": "scopeTitle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "scopeContent", "columnName": "scopeContent", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `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, `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, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWhitelist", "columnName": "contentWhitelist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentBlacklist", "columnName": "contentBlacklist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "record" ] }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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, `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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "sortNumber", "columnName": "sortNumber", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '5763792a4d08dc4cd7ebec9fb3215458')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/64.json ================================================ { "formatVersion": 1, "database": { "version": 64, "identityHash": "c9b9979578e5ae84c1b424b1aa590efa", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, 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", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "bookUrl" ] }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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", "notNull": false }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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, `enabledReview` INTEGER, `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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "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": "enabledReview", "columnName": "enabledReview", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "exploreScreen", "columnName": "exploreScreen", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleReview", "columnName": "ruleReview", "affinity": "TEXT", "notNull": false } ], "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "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, `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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "scopeTitle", "columnName": "scopeTitle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "scopeContent", "columnName": "scopeContent", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `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, `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, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWhitelist", "columnName": "contentWhitelist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentBlacklist", "columnName": "contentBlacklist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "record" ] }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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, `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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "sortNumber", "columnName": "sortNumber", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'c9b9979578e5ae84c1b424b1aa590efa')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/65.json ================================================ { "formatVersion": 1, "database": { "version": 65, "identityHash": "ccfe4ad59c8caef014b1c754b69e6d05", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, 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", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "bookUrl" ] }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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", "notNull": false }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "exploreScreen", "columnName": "exploreScreen", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleReview", "columnName": "ruleReview", "affinity": "TEXT", "notNull": false } ], "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "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, `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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "scopeTitle", "columnName": "scopeTitle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "scopeContent", "columnName": "scopeContent", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `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, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "jsLib", "columnName": "jsLib", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWhitelist", "columnName": "contentWhitelist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentBlacklist", "columnName": "contentBlacklist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "record" ] }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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, `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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "sortNumber", "columnName": "sortNumber", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'ccfe4ad59c8caef014b1c754b69e6d05')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/66.json ================================================ { "formatVersion": 1, "database": { "version": 66, "identityHash": "ef55cf538ab8076ccaf821cbc7f6582f", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, 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", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "bookUrl" ] }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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", "notNull": false }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "exploreScreen", "columnName": "exploreScreen", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleReview", "columnName": "ruleReview", "affinity": "TEXT", "notNull": false } ], "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "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, `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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "scopeTitle", "columnName": "scopeTitle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "scopeContent", "columnName": "scopeContent", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `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, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "jsLib", "columnName": "jsLib", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWhitelist", "columnName": "contentWhitelist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentBlacklist", "columnName": "contentBlacklist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "record" ] }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "jsLib", "columnName": "jsLib", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "sortNumber", "columnName": "sortNumber", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'ef55cf538ab8076ccaf821cbc7f6582f')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/67.json ================================================ { "formatVersion": 1, "database": { "version": 67, "identityHash": "48855e5272b008e4c6b202a41654b575", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, 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", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "bookUrl" ] }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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", "notNull": false }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "exploreScreen", "columnName": "exploreScreen", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleReview", "columnName": "ruleReview", "affinity": "TEXT", "notNull": false } ], "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "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, `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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "scopeTitle", "columnName": "scopeTitle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "scopeContent", "columnName": "scopeContent", "affinity": "INTEGER", "notNull": true, "defaultValue": "1" }, { "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`)" } ], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterWordCountText", "columnName": "chapterWordCountText", "affinity": "TEXT", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `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, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "jsLib", "columnName": "jsLib", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWhitelist", "columnName": "contentWhitelist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentBlacklist", "columnName": "contentBlacklist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "record" ] }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "jsLib", "columnName": "jsLib", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "sortNumber", "columnName": "sortNumber", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '48855e5272b008e4c6b202a41654b575')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/68.json ================================================ { "formatVersion": 1, "database": { "version": 68, "identityHash": "9b6070f339f55e90e4cceaeb89b4a698", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, 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", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "bookUrl" ] }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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", "notNull": false }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "exploreScreen", "columnName": "exploreScreen", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleReview", "columnName": "ruleReview", "affinity": "TEXT", "notNull": false } ], "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterWordCountText", "columnName": "chapterWordCountText", "affinity": "TEXT", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `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, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "jsLib", "columnName": "jsLib", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWhitelist", "columnName": "contentWhitelist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentBlacklist", "columnName": "contentBlacklist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "record" ] }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "jsLib", "columnName": "jsLib", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "sortNumber", "columnName": "sortNumber", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '9b6070f339f55e90e4cceaeb89b4a698')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/69.json ================================================ { "formatVersion": 1, "database": { "version": 69, "identityHash": "9b6070f339f55e90e4cceaeb89b4a698", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, 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", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "bookUrl" ] }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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", "notNull": false }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "exploreScreen", "columnName": "exploreScreen", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleReview", "columnName": "ruleReview", "affinity": "TEXT", "notNull": false } ], "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterWordCountText", "columnName": "chapterWordCountText", "affinity": "TEXT", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `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, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "jsLib", "columnName": "jsLib", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWhitelist", "columnName": "contentWhitelist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentBlacklist", "columnName": "contentBlacklist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "record" ] }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "jsLib", "columnName": "jsLib", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "sortNumber", "columnName": "sortNumber", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '9b6070f339f55e90e4cceaeb89b4a698')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/7.json ================================================ { "formatVersion": 1, "database": { "version": 7, "identityHash": "af70ea583587e17c968d29f41bb3c0d6", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "useReplaceRule", "columnName": "useReplaceRule", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", "fields": [ { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "chapterIndex", "columnName": "chapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "pageIndex", "columnName": "pageIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterName", "columnName": "chapterName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "tableName": "rssArticles", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssStars", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`name`))", "fields": [ { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "rule", "columnName": "rule", "affinity": "TEXT", "notNull": true }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "name" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'af70ea583587e17c968d29f41bb3c0d6')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/70.json ================================================ { "formatVersion": 1, "database": { "version": 70, "identityHash": "ab3b8675fe5c3152003e20a73f022b0c", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, 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", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "bookUrl" ] }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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", "notNull": false }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "exploreScreen", "columnName": "exploreScreen", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleReview", "columnName": "ruleReview", "affinity": "TEXT", "notNull": false } ], "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterWordCountText", "columnName": "chapterWordCountText", "affinity": "TEXT", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "jsLib", "columnName": "jsLib", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWhitelist", "columnName": "contentWhitelist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentBlacklist", "columnName": "contentBlacklist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shouldOverrideUrlLoading", "columnName": "shouldOverrideUrlLoading", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "record" ] }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "jsLib", "columnName": "jsLib", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "sortNumber", "columnName": "sortNumber", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'ab3b8675fe5c3152003e20a73f022b0c')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/71.json ================================================ { "formatVersion": 1, "database": { "version": 71, "identityHash": "6e17b5366286b868f2912c12d6d8e467", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, `syncTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true, "defaultValue": "'loc_book'" }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false }, { "fieldPath": "syncTime", "columnName": "syncTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "bookUrl" ] }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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", "notNull": false }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "exploreScreen", "columnName": "exploreScreen", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleReview", "columnName": "ruleReview", "affinity": "TEXT", "notNull": false } ], "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterWordCountText", "columnName": "chapterWordCountText", "affinity": "TEXT", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "jsLib", "columnName": "jsLib", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWhitelist", "columnName": "contentWhitelist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentBlacklist", "columnName": "contentBlacklist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shouldOverrideUrlLoading", "columnName": "shouldOverrideUrlLoading", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "record" ] }, "indices": [], "foreignKeys": [] }, { "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, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "jsLib", "columnName": "jsLib", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "sortNumber", "columnName": "sortNumber", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '6e17b5366286b868f2912c12d6d8e467')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/72.json ================================================ { "formatVersion": 1, "database": { "version": 72, "identityHash": "53d960f1fdc04a6f157a25b269a0d93b", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, `syncTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true, "defaultValue": "'loc_book'" }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false }, { "fieldPath": "syncTime", "columnName": "syncTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "bookUrl" ] }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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", "notNull": false }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "exploreScreen", "columnName": "exploreScreen", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleReview", "columnName": "ruleReview", "affinity": "TEXT", "notNull": false } ], "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`)" } ], "foreignKeys": [] }, { "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, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterWordCountText", "columnName": "chapterWordCountText", "affinity": "TEXT", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "jsLib", "columnName": "jsLib", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWhitelist", "columnName": "contentWhitelist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentBlacklist", "columnName": "contentBlacklist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shouldOverrideUrlLoading", "columnName": "shouldOverrideUrlLoading", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "tableName": "rssArticles", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": true, "defaultValue": "'默认分组'" }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "record" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "rssStars", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": true, "defaultValue": "'默认分组'" }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "jsLib", "columnName": "jsLib", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "sortNumber", "columnName": "sortNumber", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '53d960f1fdc04a6f157a25b269a0d93b')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/73.json ================================================ { "formatVersion": 1, "database": { "version": 73, "identityHash": "63617164079ebbdaf82176384c39bba5", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, `syncTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true, "defaultValue": "'loc_book'" }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false }, { "fieldPath": "syncTime", "columnName": "syncTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "bookUrl" ] }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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", "notNull": false }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "exploreScreen", "columnName": "exploreScreen", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleReview", "columnName": "ruleReview", "affinity": "TEXT", "notNull": false } ], "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterWordCountText", "columnName": "chapterWordCountText", "affinity": "TEXT", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "jsLib", "columnName": "jsLib", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWhitelist", "columnName": "contentWhitelist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentBlacklist", "columnName": "contentBlacklist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shouldOverrideUrlLoading", "columnName": "shouldOverrideUrlLoading", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "tableName": "rssArticles", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": true, "defaultValue": "'默认分组'" }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "record" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "rssStars", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": true, "defaultValue": "'默认分组'" }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "jsLib", "columnName": "jsLib", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "sortNumber", "columnName": "sortNumber", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '63617164079ebbdaf82176384c39bba5')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/74.json ================================================ { "formatVersion": 1, "database": { "version": 74, "identityHash": "c492bca5d9c4b4b34a0808d8cce9e7ba", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, `syncTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true, "defaultValue": "'loc_book'" }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false }, { "fieldPath": "syncTime", "columnName": "syncTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "bookUrl" ] }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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", "notNull": false }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "exploreScreen", "columnName": "exploreScreen", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleReview", "columnName": "ruleReview", "affinity": "TEXT", "notNull": false } ], "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterWordCountText", "columnName": "chapterWordCountText", "affinity": "TEXT", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "jsLib", "columnName": "jsLib", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWhitelist", "columnName": "contentWhitelist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentBlacklist", "columnName": "contentBlacklist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shouldOverrideUrlLoading", "columnName": "shouldOverrideUrlLoading", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "tableName": "rssArticles", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": true, "defaultValue": "'默认分组'" }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `title` TEXT, `readTime` INTEGER, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": false }, { "fieldPath": "readTime", "columnName": "readTime", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "record" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "rssStars", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": true, "defaultValue": "'默认分组'" }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "jsLib", "columnName": "jsLib", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "sortNumber", "columnName": "sortNumber", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'c492bca5d9c4b4b34a0808d8cce9e7ba')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/75.json ================================================ { "formatVersion": 1, "database": { "version": 75, "identityHash": "e22b36ff0280a224a30d08dd6e9bd7be", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, `syncTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true, "defaultValue": "'loc_book'" }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "readConfig", "columnName": "readConfig", "affinity": "TEXT", "notNull": false }, { "fieldPath": "syncTime", "columnName": "syncTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "bookUrl" ] }, "indices": [ { "name": "index_books_name_author", "unique": true, "columnNames": [ "name", "author" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" } ], "foreignKeys": [] }, { "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", "notNull": false }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceComment", "columnName": "bookSourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "exploreScreen", "columnName": "exploreScreen", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleReview", "columnName": "ruleReview", "affinity": "TEXT", "notNull": false } ], "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "startFragmentId", "columnName": "startFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endFragmentId", "columnName": "endFragmentId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "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", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterWordCountText", "columnName": "chapterWordCountText", "affinity": "TEXT", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "sourceComment", "columnName": "sourceComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variableComment", "columnName": "variableComment", "affinity": "TEXT", "notNull": false }, { "fieldPath": "jsLib", "columnName": "jsLib", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverDecodeJs", "columnName": "coverDecodeJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "singleUrl", "columnName": "singleUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "articleStyle", "columnName": "articleStyle", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWhitelist", "columnName": "contentWhitelist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentBlacklist", "columnName": "contentBlacklist", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shouldOverrideUrlLoading", "columnName": "shouldOverrideUrlLoading", "affinity": "TEXT", "notNull": false }, { "fieldPath": "style", "columnName": "style", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "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`)" } ], "foreignKeys": [] }, { "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`)" } ], "foreignKeys": [] }, { "tableName": "rssArticles", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": true, "defaultValue": "'默认分组'" }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `title` TEXT, `readTime` INTEGER, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": false }, { "fieldPath": "readTime", "columnName": "readTime", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "record" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "rssStars", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `variable` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "sort", "columnName": "sort", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "starTime", "columnName": "starTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "link", "columnName": "link", "affinity": "TEXT", "notNull": true }, { "fieldPath": "pubDate", "columnName": "pubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": true, "defaultValue": "'默认分组'" }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "origin", "link" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "concurrentRate", "columnName": "concurrentRate", "affinity": "TEXT", "notNull": false, "defaultValue": "'0'" }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUi", "columnName": "loginUi", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "jsLib", "columnName": "jsLib", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enabledCookieJar", "columnName": "enabledCookieJar", "affinity": "INTEGER", "notNull": false, "defaultValue": "0" }, { "fieldPath": "loginCheckJs", "columnName": "loginCheckJs", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "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`)" } ], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "sortNumber", "columnName": "sortNumber", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "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, 'e22b36ff0280a224a30d08dd6e9bd7be')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/8.json ================================================ { "formatVersion": 1, "database": { "version": 8, "identityHash": "0c09d0b5970a01069c4381648f793da7", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "useReplaceRule", "columnName": "useReplaceRule", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_books_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", "fields": [ { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "chapterIndex", "columnName": "chapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "pageIndex", "columnName": "pageIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterName", "columnName": "chapterName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "tableName": "rssArticles", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssStars", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`name`))", "fields": [ { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "rule", "columnName": "rule", "affinity": "TEXT", "notNull": true }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "name" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '0c09d0b5970a01069c4381648f793da7')" ] } } ================================================ FILE: app/schemas/io.legado.app.data.AppDatabase/9.json ================================================ { "formatVersion": 1, "database": { "version": 9, "identityHash": "8da976febbd44e9e028b951b42583f9a", "entities": [ { "tableName": "books", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`name`, `author`))", "fields": [ { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "origin", "columnName": "origin", "affinity": "TEXT", "notNull": true }, { "fieldPath": "originName", "columnName": "originName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT", "notNull": true }, { "fieldPath": "kind", "columnName": "kind", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customTag", "columnName": "customTag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customCoverUrl", "columnName": "customCoverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customIntro", "columnName": "customIntro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "charset", "columnName": "charset", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTime", "columnName": "latestChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckTime", "columnName": "lastCheckTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckCount", "columnName": "lastCheckCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "totalChapterNum", "columnName": "totalChapterNum", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTitle", "columnName": "durChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durChapterIndex", "columnName": "durChapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterPos", "columnName": "durChapterPos", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "durChapterTime", "columnName": "durChapterTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "canUpdate", "columnName": "canUpdate", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "useReplaceRule", "columnName": "useReplaceRule", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "name", "author" ], "autoGenerate": false }, "indices": [ { "name": "index_books_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" } ], "foreignKeys": [] }, { "tableName": "book_groups", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", "fields": [ { "fieldPath": "groupId", "columnName": "groupId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupName", "columnName": "groupName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "order", "columnName": "order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "groupId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "book_sources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", "fields": [ { "fieldPath": "bookSourceName", "columnName": "bookSourceName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceGroup", "columnName": "bookSourceGroup", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bookSourceUrl", "columnName": "bookSourceUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookSourceType", "columnName": "bookSourceType", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrlPattern", "columnName": "bookUrlPattern", "affinity": "TEXT", "notNull": false }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enabledExplore", "columnName": "enabledExplore", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loginUrl", "columnName": "loginUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weight", "columnName": "weight", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "exploreUrl", "columnName": "exploreUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleExplore", "columnName": "ruleExplore", "affinity": "TEXT", "notNull": false }, { "fieldPath": "searchUrl", "columnName": "searchUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleSearch", "columnName": "ruleSearch", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleBookInfo", "columnName": "ruleBookInfo", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleToc", "columnName": "ruleToc", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "bookSourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_book_sources_bookSourceUrl", "unique": false, "columnNames": [ "bookSourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "chapters", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` 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": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "index", "columnName": "index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "resourceUrl", "columnName": "resourceUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "TEXT", "notNull": false }, { "fieldPath": "start", "columnName": "start", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "end", "columnName": "end", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "url", "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_chapters_bookUrl", "unique": false, "columnNames": [ "bookUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_chapters_bookUrl_index", "unique": true, "columnNames": [ "bookUrl", "index" ], "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, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "group", "columnName": "group", "affinity": "TEXT", "notNull": false }, { "fieldPath": "pattern", "columnName": "pattern", "affinity": "TEXT", "notNull": true }, { "fieldPath": "replacement", "columnName": "replacement", "affinity": "TEXT", "notNull": true }, { "fieldPath": "scope", "columnName": "scope", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isEnabled", "columnName": "isEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isRegex", "columnName": "isRegex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "order", "columnName": "sortOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_replace_rules_id", "unique": false, "columnNames": [ "id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" } ], "foreignKeys": [] }, { "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, 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", "notNull": false }, { "fieldPath": "coverUrl", "columnName": "coverUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "intro", "columnName": "intro", "affinity": "TEXT", "notNull": false }, { "fieldPath": "wordCount", "columnName": "wordCount", "affinity": "TEXT", "notNull": false }, { "fieldPath": "latestChapterTitle", "columnName": "latestChapterTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tocUrl", "columnName": "tocUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "variable", "columnName": "variable", "affinity": "TEXT", "notNull": false }, { "fieldPath": "originOrder", "columnName": "originOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "bookUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_searchBooks_bookUrl", "unique": true, "columnNames": [ "bookUrl" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" }, { "name": "index_searchBooks_origin", "unique": false, "columnNames": [ "origin" ], "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": { "columnNames": [ "word" ], "autoGenerate": false }, "indices": [ { "name": "index_search_keywords_word", "unique": true, "columnNames": [ "word" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" } ], "foreignKeys": [] }, { "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": { "columnNames": [ "url" ], "autoGenerate": false }, "indices": [ { "name": "index_cookies_url", "unique": true, "columnNames": [ "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" } ], "foreignKeys": [] }, { "tableName": "rssSources", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, 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", "notNull": false }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortUrl", "columnName": "sortUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleArticles", "columnName": "ruleArticles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleNextPage", "columnName": "ruleNextPage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleTitle", "columnName": "ruleTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "rulePubDate", "columnName": "rulePubDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleDescription", "columnName": "ruleDescription", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleImage", "columnName": "ruleImage", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleLink", "columnName": "ruleLink", "affinity": "TEXT", "notNull": false }, { "fieldPath": "ruleContent", "columnName": "ruleContent", "affinity": "TEXT", "notNull": false }, { "fieldPath": "header", "columnName": "header", "affinity": "TEXT", "notNull": false }, { "fieldPath": "enableJs", "columnName": "enableJs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loadWithBaseUrl", "columnName": "loadWithBaseUrl", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "customOrder", "columnName": "customOrder", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "sourceUrl" ], "autoGenerate": false }, "indices": [ { "name": "index_rssSources_sourceUrl", "unique": false, "columnNames": [ "sourceUrl" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" } ], "foreignKeys": [] }, { "tableName": "bookmarks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", "fields": [ { "fieldPath": "time", "columnName": "time", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookUrl", "columnName": "bookUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bookName", "columnName": "bookName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "chapterIndex", "columnName": "chapterIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "pageIndex", "columnName": "pageIndex", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "chapterName", "columnName": "chapterName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "time" ], "autoGenerate": false }, "indices": [ { "name": "index_bookmarks_time", "unique": true, "columnNames": [ "time" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" } ], "foreignKeys": [] }, { "tableName": "rssArticles", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssReadRecords", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", "fields": [ { "fieldPath": "record", "columnName": "record", "affinity": "TEXT", "notNull": true }, { "fieldPath": "read", "columnName": "read", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "record" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "rssStars", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", "fields": [ { "fieldPath": "origin", "columnName": "origin", "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", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "image", "columnName": "image", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "origin", "link" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "txtTocRules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`name`))", "fields": [ { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "rule", "columnName": "rule", "affinity": "TEXT", "notNull": true }, { "fieldPath": "serialNumber", "columnName": "serialNumber", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "enable", "columnName": "enable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "name" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '8da976febbd44e9e028b951b42583f9a')" ] } } ================================================ FILE: app/src/androidTest/java/io/legado/app/AndroidJsTest.kt ================================================ package io.legado.app import cn.hutool.core.lang.JarClassLoader import com.script.ScriptBindings import com.script.rhino.RhinoScriptEngine import dalvik.system.DexClassLoader import org.intellij.lang.annotations.Language import org.junit.Assert import org.junit.Test import org.mozilla.javascript.DefiningClassLoader import java.net.URLClassLoader class AndroidJsTest { @Test fun testPackages() { @Language("js") val js = """ var accessKeyId = '1111'; var accessKeySecret = '2222'; var timestamp = '3333'; var aly = new JavaImporter(Packages.javax.crypto.Mac, Packages.javax.crypto.spec.SecretKeySpec, Packages.javax.xml.bind.DatatypeConverter, Packages.java.net.URLEncoder, Packages.java.lang.String, Packages.android.util.Base64); with (aly) { function percentEncode(value) { return URLEncoder.encode(value, "UTF-8").replace("+", "%20") .replace("*", "%2A").replace("%7E", "~") } function sign(stringToSign, accessKeySecret) { var mac = Mac.getInstance('HmacSHA1'); mac.init(new SecretKeySpec(String(accessKeySecret + '&').getBytes("UTF-8"), "HmacSHA1")); var signData = mac.doFinal(String(stringToSign).getBytes("UTF-8")); var signBase64 = Base64.encodeToString(signData, Base64.NO_WRAP); var signUrlEncode = percentEncode(signBase64); return signUrlEncode; } } var query = 'AccessKeyId=' + accessKeyId + '&Action=CreateToken&Format=JSON&RegionId=cn-shanghai&SignatureMethod=HMAC-SHA1&SignatureNonce=' + "xxccrr" + '&SignatureVersion=1.0&Timestamp=' + percentEncode(timestamp) + '&Version=2019-02-28'; var signStr = sign('GET&' + percentEncode('/') + '&' + percentEncode(query), accessKeySecret); var queryStringWithSign = "Signature=" + signStr + "&" + query; queryStringWithSign """.trimIndent() RhinoScriptEngine.eval(js) @Language("js") val js1 = """ var returnData = new Packages.io.legado.app.api.ReturnData() returnData.getErrorMsg() """.trimIndent() val result1 = RhinoScriptEngine.eval(js1) Assert.assertEquals(result1, "未知错误,请联系开发者!") } @Test fun testPackages1() { URLClassLoader.getSystemClassLoader() DefiningClassLoader.getSystemClassLoader() JarClassLoader.getSystemClassLoader() DexClassLoader.getSystemClassLoader() @Language("js") val js = """ var ji = new JavaImporter(Packages.org.mozilla.javascript.DefiningClassLoader) with(ji) { let x = DefiningClassLoader.getSystemClassLoader() } """.trimIndent() RhinoScriptEngine.eval(js) } @Test fun testMap() { val map = hashMapOf("id" to "3242532321") val bindings = ScriptBindings() bindings["result"] = map @Language("js") val jsMap = "$=result;id=$.id;id" val result = RhinoScriptEngine.eval(jsMap, bindings) Assert.assertEquals("3242532321", result) @Language("js") val jsMap1 = """result.get("id")""" val result1 = RhinoScriptEngine.eval(jsMap1, bindings) Assert.assertEquals("3242532321", result1) } } ================================================ FILE: app/src/androidTest/java/io/legado/app/ExampleInstrumentedTest.kt ================================================ package io.legado.app import android.content.Context import android.net.Uri import android.util.Log import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Test import org.junit.runner.RunWith /** * Instrumented test, which will execute on an Android device. * * See [testing documentation](http://d.android.com/tools/testing). */ @RunWith(AndroidJUnit4::class) class ExampleInstrumentedTest { @Test fun testContentProvider() { // Context of the app under test. val appContext = ApplicationProvider.getApplicationContext() Log.d( "test", appContext.contentResolver.query( Uri.parse("content://io.legado.app.api.ReaderProvider/sources/query"), null, null, null, null ) !!.getString(0) ) } } ================================================ FILE: app/src/androidTest/java/io/legado/app/HttpTest.kt ================================================ package io.legado.app import android.app.DownloadManager import android.net.Uri import android.os.Environment import android.webkit.WebSettings import android.webkit.WebView import io.legado.app.help.config.AppConfig import io.legado.app.utils.runOnUI import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import org.junit.Test import splitties.init.appCtx import splitties.systemservices.downloadManager class HttpTest { @Test fun test() { webViewDownloadTest() } private fun webViewDownloadTest() { runOnUI { val webView = WebView(appCtx) val settings = webView.settings settings.javaScriptEnabled = true settings.domStorageEnabled = true settings.blockNetworkImage = true settings.userAgentString = AppConfig.userAgent settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW webView.setDownloadListener { url, userAgent, contentDisposition, mimetype, contentLength -> print(url) webView.destroy() } webView.loadUrl("https://gj.legado.cc/legado/?url=https://miaogongzi.lanzout.com/iITmP0s7y26d&type=down") } } private fun downloadManagerTest() { runBlocking { val request = DownloadManager.Request(Uri.parse("https://gj.legado.cc/legado/?url=https://miaogongzi.lanzout.com/iITmP0s7y26d&type=down")) // 设置通知 request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN) // 设置下载文件保存的路径和文件名 request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "test.txt") // 添加一个下载任务 val downloadId = downloadManager.enqueue(request) val query = DownloadManager.Query() query.setFilterById(downloadId) repeat(30) { downloadManager.query(query).use { cursor -> if (cursor.moveToFirst()) { val progressIndex = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR) val fileSizeIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES) val statusIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS) val progress = cursor.getInt(progressIndex) val max = cursor.getInt(fileSizeIndex) val status = when (cursor.getInt(statusIndex)) { DownloadManager.STATUS_PAUSED -> appCtx.getString(R.string.pause) DownloadManager.STATUS_PENDING -> appCtx.getString(R.string.wait_download) DownloadManager.STATUS_RUNNING -> appCtx.getString(R.string.downloading) DownloadManager.STATUS_SUCCESSFUL -> { appCtx.getString(R.string.download_success) } DownloadManager.STATUS_FAILED -> appCtx.getString(R.string.download_error) else -> appCtx.getString(R.string.unknown_state) } print(status) delay(1000) } else { return@runBlocking } } } } } } ================================================ FILE: app/src/androidTest/java/io/legado/app/HttpTtsTest.kt ================================================ package io.legado.app import io.legado.app.help.config.AppConfig import io.legado.app.model.analyzeRule.AnalyzeUrl import kotlinx.coroutines.runBlocking import org.junit.Test class HttpTtsTest { @Test fun test() { val url = """ http://tsn.baidu.com/text2audio,{ "method": "POST", "body": "tex={{java.encodeURI(java.encodeURI(speakText))}}&spd={{(speakSpeed + 5) / 10 + 4}}&per=4114&cuid=baidu_speech_demo&idx=1&cod=2&lan=zh&ctp=1&pdt=220&vol=5&aue=6&pit=5&_res_tag_=audio" } """.trimIndent() val analyzeUrl = AnalyzeUrl(url, speakText = "魔神", speakSpeed = AppConfig.speechRatePlay + 5) runBlocking { val response = analyzeUrl.getResponseAwait() response.headers } } } ================================================ FILE: app/src/androidTest/java/io/legado/app/MigrationTest.kt ================================================ package io.legado.app import androidx.room.Room import androidx.room.migration.Migration import androidx.room.testing.MigrationTestHelper import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import io.legado.app.data.AppDatabase import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import java.io.IOException @RunWith(AndroidJUnit4::class) class MigrationTest { private val TEST_DB = "migration-test" private val ALL_MIGRATIONS = arrayOf( ) @get:Rule val helper: MigrationTestHelper = MigrationTestHelper( InstrumentationRegistry.getInstrumentation(), AppDatabase::class.java.canonicalName, FrameworkSQLiteOpenHelperFactory() ) @Test @Throws(IOException::class) fun migrateAll() { // Create earliest version of the database. helper.createDatabase(TEST_DB, 50).apply { close() } // Open latest version of the database. Room will validate the schema // once all migrations execute. Room.databaseBuilder( InstrumentationRegistry.getInstrumentation().targetContext, AppDatabase::class.java, TEST_DB ).addMigrations(*ALL_MIGRATIONS) .build().apply { openHelper.writableDatabase close() } } } ================================================ FILE: app/src/androidTest/java/io/legado/app/UpdateTest.kt ================================================ package io.legado.app import com.google.gson.Gson import io.legado.app.exception.NoStackTraceException import io.legado.app.help.http.okHttpClient import io.legado.app.help.update.GithubRelease import io.legado.app.utils.fromJsonObject import okhttp3.Request import org.junit.Assert.assertTrue import org.junit.Test class UpdateTest { private val lastReleaseUrl = "https://api.github.com/repos/gedoor/legado/releases/latest" private val lastBetaReleaseUrl = "https://api.github.com/repos/gedoor/legado/releases/tags/beta" @Test fun updateApp_beta() { val body = okHttpClient.newCall(Request.Builder().url(lastBetaReleaseUrl).build()).execute() .body!!.string() val releaseList = Gson().fromJsonObject(body) .getOrElse { throw NoStackTraceException("获取新版本出错 " + it.localizedMessage) } .gitReleaseToAppReleaseInfo() .sortedByDescending { it.createdAt } assertTrue(releaseList.size == 2) assertTrue(releaseList.all { it.downloadUrl.isNotBlank() }) assertTrue(releaseList.all { it.versionName.isNotBlank() }) } @Test fun updateApp() { val body = okHttpClient.newCall(Request.Builder().url(lastReleaseUrl).build()).execute() .body!!.string() val releaseList = Gson().fromJsonObject(body) .getOrElse { throw NoStackTraceException("获取新版本出错 " + it.localizedMessage) } .gitReleaseToAppReleaseInfo() .sortedByDescending { it.createdAt } assertTrue(releaseList.size == 1) assertTrue(releaseList.all { it.downloadUrl.isNotBlank() }) assertTrue(releaseList.all { it.versionName.isNotBlank() }) } } ================================================ FILE: app/src/debug/res/values/strings.xml ================================================ legado·D legado·D·search ================================================ FILE: app/src/debug/res/values-zh/strings.xml ================================================ 阅读·D 阅读·D·搜索 ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/assets/18PlusList.txt ================================================ OGN5dS5jb20= c2cwMC54eXo= aXRyYWZmaWNuZXQuY29t eGlhb3FpYW5nNTIw MTIzeGlhb3FpYW5n eGlhb3FpYW5neHM= eGlhb3FpYW5nNTIw MzM1eHM= eGN4czk= eGN4czUyMA== c2h1YmFvYW4= c2h1YmFvd2FuZzEyMw== c2h1YmFvYW4= aGFpdGFuZzEyMw== eXV6aGFpd3VsYQ== cG8xOA== Ymwtbm92ZWw= NXRucw== c2hhb3NodWdl amluamlzaHV3dQ== NDJ3Zw== eWlxdXNodQ== c2h1YmFvd2FuZzEyMw== M2hlYmFv MzNoZWJhbw== bHVvcWl1enc= bXlzaHVnZQ== c3NzeHN3 eWl0ZQ== Y3Vpd2VpanV1 Y3Vpd2VpanV4cw== Y3Vpd2VpanV4 eGlhb3FpYW5nd3g= YXN6dw== YXN6dzY= c2FuaGFveHM= ODdzaHV3dQ== NDh3eA== bG9uZ3Rlbmcy NnF3eA== bG9uZ3Rlbmd4cw== aGF4ZHU= M3R3eA== aGF4d3g1 NjZsZXdlbg== eGJhbnpodQ== aGR5cA== ZHliejk= ZGl5aWJhbnpodTk= ZGl5aWJhbnpodQ== ZGl5aWJhbnpodTc= YnoyMjI= d29kZWFwaTAwMQ== dGFuZ3poZWthbg== YmF4aWFueHM= eGlhb3NodW9zaGVuemhhbg== ZGFtb2tl emh3ZW5wZw== eXV6aGFpZ2U= d21wOA== OXhpYW53ZW4= bmFucmVudmlw cmV5b28= eWZ4aWFvc2h1b2U= c2Fuaml1enc= N3Fpbmc3 cWR4aWFvc2h1bw== Y2hpbmVzZXpq MzlzaHViYW8= a3l4czU= NTZtcw== bml1c2hh bWt4czY= MjIyMjJ4cw== OTVkdXNodQ== YmFuemh1MjI= d3JsdHh0 dHVkb3V0eHQ= cm5neHM= OTl3ZW5rdQ== bGFvc2lqaXhz ZnVzaHV6aGFpMQ== cG8xOA== czUyMTc= c2FuaGFveHM= NTJrc2h1 NDhyeA== ZWNub3ZlbA== bGllaHVvenc= eGlhb3FpYW5nd3g= NTJrc2h1 NDh3eA== NTJrc2h1 MDB1aQ== MDFieg== c2h1YmFvMQ== ZG54aWFvc2h1b2E= am5zaHViYQ== MThzaHV3dQ== bGV4cw== MzM1eHM= dXB1 ZnVndW9kdQ== ODB0eHQ= YWFyZWFk eWlkdWR1MQ== YmFuemh1d2FuZw== cWloYW9xaWhhbw== OHhpYW54cw== amluamlzaHV3dQ== d21wOA== ZXl1c2h1d3U= NTB4c2Y= aGF4d3g1 cG93YW5qdWFu d2luMTBjaXR5 eWV5ZXhzdw== bXlzaHVnZQ== eGlhbmd0eHN3 Y3Vpd2VpanV4 MzY2eHN3 aHVheXVld2Vua3U= eW91ZGlhbmxlbg== c291Nzg= bGFucm91Mg== cXFib29r eW91d3V4cw== cnVpbGlzYWxl MzY1bXd3 ZnV3ZW5o bGVzYmw= YXd1Ym9vaw== bGl5dXhpYW5nMjAyMA== OTJwb3Bv ZnVzaHV0dWFu ODhkYW5tZWk= ZG14cw== eXVsaW56aGFueWU= M2hlYmFv eGd1YWd1YXhz ZGl5aWJhbnpodTY= aXJlYWR4cw== c2h1YmFvOTY= ZGl5aWJhbnpodTU1NQ== c2Fuaml1enc= N3Fpbmc3 NjZsZXdlbg== a3l4czU= MjIyMjJ4cw== c2hhb3NodWdl amlsaW41NQ== bWt4czY= amluc2h1bG91 eGlhbndhbmdz eWlkdWR1 cWR0eHQ= MTZib29rMQ== am1zaHV3dQ== MzY2eHN3 ZHliejk= c2hvdWRhOA== ZnlxMTg= eWlzaHVn eXV6aGFpd3VsYQ== MTFiYW56aHU= MTIzeGlhb3FpYW5n ZGl5aWJhbnpodTk= ZGl5aWJhbnpodQ== MzY2eHN3 ODdzaHV3dQ== NnF3eA== emhlbmh1bnhpYW9zaHVv bG9uZ3Rlbmc1Mg== eGlueGluZ3hpYW5nemhpZmE= ZHliejk= ZHVvemhla2Fu MTIzeGlhb3FpYW5n MzM1eHM= am1zaHV3dQ== c2hhb3NodWdl bGF3ZW54cw== cnVzaHV3dQ== MzY2eHN3 NTB4c2Y= bGV3ZW41NQ== aGFpdGFuZzEyMw== aGViYW81MjA= bHVvcWl1enc= c3NzeHN3 c2h1c2h1d3V4cw== cm5neHM= cWR4aWFvc2h1bw== dHl1ZQ== Y2hlNDM= bG9uZ3Rlbmcy amZ5eHNo aGV0dTI= bGFvc2lqaXhz bG9uZ3Rlbmd4cw== bGllaHVvenc= c2h1YmFvYW4= eHNodW9zaHVv NTIxZGFubWVp YmFuemh1MjI= cWtzaHU= eWZ4aWFvc2h1b2U= a3lnc28= c2h1bG91YmE= NXRucw== N3Fpbmc3 bWlhb2R1NQ== eXVzaHV3ZW4= YWFyZWFk cXRzaHU= MTdzaHV3dQ== c2h1YmFvMnM= YnowMDE= ZGFtb2dl MTMxdGI= aXhpYW9z bXlzaHVnZQ== OXhpYW53ZW4= ZHVvemhla2Fu MTIwdw== c2h1c2h1d3U1MjA= c2h1YmFvMnM= YWd4c3c= OTR4c3c= cG8xOA== eWFvY2hpeHM= eGlhb3FpYW5neHM= Ym9va2Js c2Fuaml1eHM= d29kZXNodWJhbw== em9uZ2NhaXhpYW9zaHVvMg== OWI4OTEzOTRkZjVi MThub3ZlbA== YWFib29r YjF0eHQ= eXVjYWl6dw== Yzl0eHQ= ZGl5aWJhbnpodTU1NQ== MzBtYw== eGlueXVzaHV3dQ== c2h1YmFvd2FuZzEyMw== YWd4cw== YmlxdWdlbmw= c2hpcWlzaHV3dQ== c2lsdWtl ZGl5aWJhbnpodTg= ZGl5aWJhbnpodTk= aGV0dW54cw== OTl3ZW5rdQ== aGFpdGFuZ3NodXd1 OTd5ZA== eXV6aGFpd3UxMQ== Y3Vpd2VpanV4cw== Y2JpcXU= NTIxZGFubWVp c2h1YmFvMzM= c2FuaGFvMQ== dGlhbm1lbmd3ZW5rdQ== eXVzaHV3dTUyMA== c2h1YmFvMjIy c2h1YmFvd2FuZzEyMw== eXVib29r Y2JpcXU= MWxld2Vu MTV4c3c= eG5jd3h3 c2h1YmFvd2FuZzEyMw== c2FuaGFveHM= eXV3YW5nc2hl YmlxdXRz bGFtZWl4cw== eGJhbnpodQ== cWR4aWFvc2h1bw== bWh0bGE= OTl3ZW5rdQ== eGlhb3FpYW5nNTIw dGlhbm1lbmd3ZW5rdQ== YWlmdXNodQ== bWlhb2R1NQ== bWlmZW5neHM= ================================================ FILE: app/src/main/assets/LICENSE.md ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. ## Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. ## TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Legado Copyright (C) 2022 gedoor This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: app/src/main/assets/cronet.json ================================================ {"x86":"2288814a4d9d4ee24154f52abf227fa0","armeabi-v7a":"ac8190c5e795d1a754b7bf2d477119c5","x86_64":"116f2d0f8e6363caacbd5c38bfc45b9f","arm64-v8a":"61a96a20241e56ac7ab107cc3e4c3fda","version":"128.0.6613.40"} ================================================ FILE: app/src/main/assets/defaultData/bookSources.json ================================================ [ { "bookSourceComment": "", "bookSourceGroup": "听书", "bookSourceName": "消消乐听书", "bookSourceType": 1, "bookSourceUrl": "https://www.kaixin7days.com", "customOrder": 0, "enabled": true, "enabledExplore": true, "exploreUrl": "@js:var header = JSON.parsesource.getLoginHeader()\nvar json = ''\nvar j = null\nif (header != null) {\n json = java.connect('https://www.kaixin7days.com/book-service/bookMgt/getBookCategroy,{\"method\":\"POST\",\"body\":{}}', header).body()\n j = JSON.parse(json)\n}\nif (j == null || j.statusCode != 200) {\n json = java.connect('https://www.kaixin7days.com/visitorLogin,{\"method\":\"POST\", \"body\":{} }').body()\n j = JSON.parse(json)\n var accessToken = {\n Authorization: 'Bearer ' + j.content.accessToken\n }\n header = JSON.stringify(accessToken)\n source.putLoginHeader(header)\n json = java.connect('https://www.kaixin7days.com/book-service/bookMgt/getBookCategroy,{\"method\":\"POST\",\"body\":{} }', header).body()\n j = JSON.parse(json)\n}\nvar fls = j.content\nvar fx = []\nfor (var i = 0; i < fls.length; i++) {\n fx.push({\n title: fls[i].categoryName,\n url: '/book-service/bookMgt/getAllBookByCategroyId,{\"method\":\"POST\",\"body\":{\"categoryIds\": \"' + fls[i].associationCategoryIDs + '\",\"pageNum\": {{page}},\"pageSize\": 100}}'\n })\n}\nJSON.stringify(fx)", "searchUrl": "https://www.kaixin7days.com/book-service/bookMgt/findBookName,{\"method\":\"POST\",\"body\":{\"title\": \"searchKey\",\"pageNum\": {{searchPage}},\"pageSize\": 100}}", "lastUpdateTime": 1630656684531, "loginCheckJs": "var strRes = result\nvar c = JSON.parse(result.body())\nif (c.statusCode == 301) {\n var loginInfo = source.getLoginInfo()\n var dl = null\n if (loginInfo) {\n dl = java.connect('https://www.kaixin7days.com/login,{\"method\":\"POST\",\"body\":' + loginInfo + '}').body()\n } else {\n dl = java.connect('https://www.kaixin7days.com/visitorLogin,{\"method\":\"POST\",\"body\":{}}').body()\n }\n c = JSON.parse(dl)\n var accessToken = {\n Authorization: \"Bearer \" + c.content.accessToken\n }\n var header = JSON.stringify(accessToken)\n source.putLoginHeader(header)\n strRes = java.connect(url, header)\n}\nstrRes", "loginUi": "[{\"name\": \"telephone\",\"type\": \"text\"},{\"name\": \"password\",\"type\": \"password\"},{\"name\": \"注册\",\"type\": \"button\",\"action\": \"http://www.yooike.com/xiaoshuo/#/register?title=%E6%B3%A8%E5%86%8C\"}]", "loginUrl": "var loginInfo = source.getLoginInfo()\nvar json = java.connect('https://www.kaixin7days.com/login,{\"method\":\"POST\",\"body\":' + loginInfo + '}').body()\nvar loginRes = JSON.parse(json)\nvar header = null\nif (loginRes.statusCode == 200) {\n var accessToken = {\n Authorization: \"Bearer \" + loginRes.content.accessToken\n }\n header = JSON.stringify(accessToken)\n source.putLoginHeader(header)\n}\nheader", "respondTime": 180000, "ruleBookInfo": {}, "ruleContent": { "content": "", "payAction": "var header = JSON.parse(source.getLoginHeader()); var bookId = book.getVariableMap().get('bookId');var chapterId = java.get('chapterId');\n'http://www.shuidi.online/?name=' + book.getName() + '&type=2&cover=' + book.getCoverUrl() + '&chapterId=' + chapterId + '&chapter=203&allNumber=' + book.getTotalChapterNum() + '&bookId=' + bookId + '&chapterIds=' + chapterId + '&number=' + chapter.getIndex() + '&accessToken=' + header.Authorization.substring(7) + '#/pay'" }, "ruleExplore": { "author": "$.author", "bookList": "$.content.content", "bookUrl": "$.id@js:java.put('bookId', result);'https://www.kaixin7days.com/book-service/bookMgt/getAllChapterByBookId,{ \"method\": \"POST\",\"body\": {\"bookId\": \"'+result+'\",\"pageNum\": \"1\",\"pageSize\": \"10000\"} }'", "coverUrl": "$.cover@js:var cover = JSON.parse(result);'https://www.shuidi.online/fileMgt/getPicture?filePath='+cover.storeFilePath", "intro": "$.desc", "lastChapter": "$.newestChapter", "name": "$.title" }, "ruleSearch": { "author": "$.author", "bookList": "$.content.content", "bookUrl": "$.id@js:java.put('bookId', result);'https://www.kaixin7days.com/book-service/bookMgt/getAllChapterByBookId,{ \"method\": \"POST\",\"body\": {\"bookId\": \"'+result+'\",\"pageNum\": \"1\",\"pageSize\": \"10000\"} }'", "coverUrl": "$.cover@js:var cover = JSON.parse(result);'https://www.shuidi.online/fileMgt/getPicture?filePath='+cover.storeFilePath", "intro": "$.desc", "lastChapter": "$.newestChapter", "name": "$.title" }, "ruleToc": { "chapterList": "$.content.content", "chapterName": "$.chapterTitle", "chapterUrl": "$.id@js:java.put('chapterId', result);'https://www.shuidi.online/fileMgt/getAudioByChapterId?bookId=' + java.get('bookId') + '&chapterId=' + result + \"&pageNum=1&pageSize=50&keyId={{var header = JSON.parse(source.getLoginHeader());var keyId = '1632746188011002';var ks = java.md5Encode(keyId + java.get('chapterId') + header.Authorization);keyId + '&keySecret=' + ks}\" + '}'" }, "weight": 0 } ] ================================================ FILE: app/src/main/assets/defaultData/coverRule.json ================================================ { "enable": false, "searchUrl": "", "coverRule": "" } ================================================ FILE: app/src/main/assets/defaultData/dictRules.json ================================================ [ { "name": "百度汉语", "urlRule": "https://dict.baidu.com/s?wd={{key}}", "showRule": "@js:var jsoup = org.jsoup.Jsoup.parse(result)\njsoup.select(\"script,#search-bar,#right-panel,#copyright\").remove()\njsoup.select(\"#poem-list-items\").html()", "enabled": true, "sortNumber": 2 }, { "name": "海词英文", "urlRule": "https://apii.dict.cn/mini.php?q={{key}}", "showRule": "tag.body@all", "enabled": true, "sortNumber": 1 }, { "name": "海词中文", "urlRule": "https://hanyu.dict.cn/{{key}}", "showRule": "@js:var jsoup = org.jsoup.Jsoup.parse(result)\njsoup.select(\"script,#header,#footer,#page-share,.mslide,.title,#dictHcBtn,#dictHcBtnTop,#dictHc,#dictHc,#dictHcSettingArea,#dictHcClosetip\").remove()\njsoup.select(\"#cy\").html()", "enabled": true, "sortNumber": 0 } ] ================================================ FILE: app/src/main/assets/defaultData/directLinkUpload.json ================================================ [ { "uploadUrl": "https://sy.nyasama.net/shuyuan,{\"method\":\"POST\",\"body\": {\"file\": \"fileRequest\"},\"type\": \"multipart/form-data\"}", "downloadUrlRule": "$.data@js:if (result == '') \n '' \n else \n 'https://shuyuan.nyasama.net/shuyuan/' + result", "summary": "喵公子网盘①(有效期30天·香港G口)", "compress": false }, { "uploadUrl": "https://sy.mgz6.com/shuyuan,{\"method\":\"POST\",\"body\": {\"file\": \"fileRequest\"},\"type\": \"multipart/form-data\"}", "downloadUrlRule": "$.data@js:if (result == '') \n '' \n else \n 'https://shuyuan.mgz6.com/shuyuan/' + result", "summary": "喵公子网盘②(有效期7天)", "compress": false }, { "uploadUrl": "http://v2.jt12.eu/up-v2.php,{\"method\":\"POST\",\"body\": {\"file\": \"fileRequest\"},\"type\": \"multipart/form-data\"}", "downloadUrlRule": "$.msg", "summary": "橘涂书源网盘2.0 Beta(永久有效)", "compress": false } ] ================================================ FILE: app/src/main/assets/defaultData/httpTTS.json ================================================ [ { "id": -100, "name": "1.百度", "url": "http://tts.baidu.com/text2audio,{\n \"method\": \"POST\",\n \"body\": \"tex={{java.encodeURI(java.encodeURI(speakText))}}&spd={{(speakSpeed + 5) / 10 + 4}}&per=3&cuid=baidu_speech_demo&idx=1&cod=2&lan=zh&ctp=1&pdt=160&vol=5&aue=6&pit=5&_res_tag_=audio\"\n}", "contentType": "audio/wav" }, { "id": -29, "name": "2.阿里云语音", "url": "https://nls-gateway.cn-shanghai.aliyuncs.com/stream/v1/tts,{\"method\": \"POST\",\"body\": {\"appkey\":\"{{source.getLoginInfoMap().get('AppKey')}}\",\"text\":\"{{speakText}}\",\"format\":\"mp3\",\"volume\":100,\"speech_rate\":{{String((speakSpeed) * 20 - 400)}} }}", "contentType": "audio/mpeg", "loginUrl": "function login(){var loginInfo = source.getLoginInfoMap();\nvar accessKeyId = loginInfo.get('AccessKeyId');\nvar accessKeySecret = loginInfo.get('AccessKeySecret');\nvar timestamp = java.timeFormatUTC(new Date().getTime(), \"yyyy-MM-dd'T'HH:mm:ss'Z'\", 0);\nvar aly = new JavaImporter(Packages.javax.crypto.Mac, Packages.javax.crypto.spec.SecretKeySpec, Packages.javax.xml.bind.DatatypeConverter, Packages.java.net.URLEncoder, Packages.java.lang.String, Packages.android.util.Base64);\nwith (aly) {\n function percentEncode(value) {\n return URLEncoder.encode(value, \"UTF-8\").replace(\"+\", \"%20\")\n .replace(\"*\", \"%2A\").replace(\"%7E\", \"~\")\n }\n\n function sign(stringToSign, accessKeySecret) {\n var mac = Mac.getInstance('HmacSHA1');\n mac.init(new SecretKeySpec(String(accessKeySecret + '&').getBytes(\"UTF-8\"), \"HmacSHA1\"));\n var signData = mac.doFinal(String(stringToSign).getBytes(\"UTF-8\"));\n var signBase64 = Base64.encodeToString(signData, Base64.NO_WRAP);\n var signUrlEncode = percentEncode(signBase64);\n return signUrlEncode;\n }\n}\nvar query = 'AccessKeyId=' + accessKeyId + '&Action=CreateToken&Format=JSON&RegionId=cn-shanghai&SignatureMethod=HMAC-SHA1&SignatureNonce=' + java.randomUUID() + '&SignatureVersion=1.0&Timestamp=' + percentEncode(timestamp) + '&Version=2019-02-28';\nvar signStr = sign('GET&' + percentEncode('/') + '&' + percentEncode(query), accessKeySecret);\nvar queryStringWithSign = \"Signature=\" + signStr + \"&\" + query;\nvar body = java.ajax('http://nls-meta.cn-shanghai.aliyuncs.com/?' + queryStringWithSign)\nvar res = JSON.parse(body)\nif (res.Message) {\n throw new Error(res.Message)\n}\nvar header = { \"X-NLS-Token\": res.Token.Id };\nsource.putLoginHeader(JSON.stringify(header))}", "loginUi": [ { "name": "AppKey", "type": "text" }, { "name": "AccessKeyId", "type": "text" }, { "name": "AccessKeySecret", "type": "text" } ], "loginCheckJs": "var response = result;\nif (response.headers().get(\"Content-Type\") != \"audio/mpeg\") {\n var body = JSON.parse(response.body().string())\n if (body.status == 40000001) {\n source.login()\n java.getHeaderMap().putAll(source.getHeaderMap(true))\n response = java.getResponse()\n } else {\n throw body.message\n }\n}\nresponse" } ] ================================================ FILE: app/src/main/assets/defaultData/keyboardAssists.json ================================================ [ { "key": "@css:", "value": "@css:", "serialNo": 0 }, { "key": "", "value": "", "serialNo": 1 }, { "key": "{{}}", "value": "{{}}", "serialNo": 2 }, { "key": "##", "value": "##", "serialNo": 3 }, { "key": "&&", "value": "&&", "serialNo": 4 }, { "key": "%%", "value": "%%", "serialNo": 5 }, { "key": "||", "value": "||", "serialNo": 6 }, { "key": "//", "value": "//", "serialNo": 7 }, { "key": "\\", "value": "\\", "serialNo": 8 }, { "key": "$.", "value": "$.", "serialNo": 9 }, { "key": "@", "value": "@", "serialNo": 10 }, { "key": ":", "value": ":", "serialNo": 11 }, { "key": "class", "value": "class", "serialNo": 12 }, { "key": "text", "value": "text", "serialNo": 13 }, { "key": "href", "value": "href", "serialNo": 14 }, { "key": "textNodes", "value": "textNodes", "serialNo": 15 }, { "key": "ownText", "value": "ownText", "serialNo": 16 }, { "key": "all", "value": "all", "serialNo": 17 }, { "key": "html", "value": "html", "serialNo": 18 }, { "key": "[", "value": "[", "serialNo": 19 }, { "key": "]", "value": "]", "serialNo": 20 }, { "key": "<", "value": "<", "serialNo": 21 }, { "key": ">", "value": ">", "serialNo": 22 }, { "key": "#", "value": "#", "serialNo": 23 }, { "key": "!", "value": "!", "serialNo": 24 }, { "key": ".", "value": ".", "serialNo": 25 }, { "key": "+", "value": "+", "serialNo": 26 }, { "key": "-", "value": "-", "serialNo": 27 }, { "key": "*", "value": "*", "serialNo": 28 }, { "key": "/", "value": "/", "serialNo": 29 }, { "key": "=", "value": "=", "serialNo": 30 }, { "key": "useWebView", "value": ",{\"webView\": true}", "serialNo": 31 } ] ================================================ FILE: app/src/main/assets/defaultData/readConfig.json ================================================ [ { "bgStr": "#ffc0edc6", "bgStrEInk": "#FFFFFF", "bgStrNight": "#000000", "bgType": 0, "bgTypeEInk": 0, "bgTypeNight": 0, "darkStatusIcon": true, "darkStatusIconEInk": true, "darkStatusIconNight": false, "footerMode": 0, "footerPaddingBottom": 10, "footerPaddingLeft": 13, "footerPaddingRight": 17, "footerPaddingTop": 0, "headerMode": 0, "headerPaddingBottom": 0, "headerPaddingLeft": 19, "headerPaddingRight": 16, "headerPaddingTop": 10, "letterSpacing": 0, "lineSpacingExtra": 10, "name": "微信读书", "paddingBottom": 4, "paddingLeft": 22, "paddingRight": 22, "paddingTop": 5, "paragraphIndent": "  ", "paragraphSpacing": 6, "showFooterLine": true, "showHeaderLine": true, "textBold": 0, "textColor": "#ff0b0b0b", "textColorEInk": "#000000", "textColorNight": "#ADADAD", "textSize": 24, "tipColor": -10461088, "tipFooterLeft": 7, "tipFooterMiddle": 0, "tipFooterRight": 6, "tipHeaderLeft": 1, "tipHeaderMiddle": 0, "tipHeaderRight": 2, "titleBottomSpacing": 0, "titleMode": 0, "titleSize": 4, "titleTopSpacing": 0 }, { "name": "预设1", "bgStr": "#FFFFFF", "bgStrNight": "#000000", "textColor": "#000000", "textColorNight": "#FFFFFF", "bgType": 0, "bgTypeNight": 0, "darkStatusIcon": true, "darkStatusIconNight": false }, { "name": "预设2", "bgStr": "#DDC090", "bgStrNight": "#3C3F43", "textColor": "#3E3422", "textColorNight": "#DCDFE1", "bgType": 0, "bgTypeNight": 0, "darkStatusIcon": true, "darkStatusIconNight": false }, { "name": "预设3", "bgStr": "#C2D8AA", "bgStrNight": "#3C3F43", "textColor": "#596C44", "textColorNight": "#88C16F", "bgType": 0, "bgTypeNight": 0, "darkStatusIcon": false, "darkStatusIconNight": false }, { "name": "预设4", "bgStr": "#DBB8E2", "bgStrNight": "#3C3F43", "textColor": "#68516C", "textColorNight": "#F6AEAE", "bgType": 0, "bgTypeNight": 0, "darkStatusIcon": false, "darkStatusIconNight": false }, { "name": "预设5", "bgStr": "#ABCEE0", "bgStrNight": "#3C3F43", "textColor": "#3D4C54", "textColorNight": "#90BFF5", "bgType": 0, "bgTypeNight": 0, "darkStatusIcon": false, "darkStatusIconNight": false } ] ================================================ FILE: app/src/main/assets/defaultData/rssSources.json ================================================ [ { "customOrder": 2, "enableJs": true, "enabled": true, "singleUrl": true, "sourceGroup": "legado", "sourceIcon": "https://cdn.jsdelivr.net/gh/gedoor/legado@master/app/src/main/res/mipmap-hdpi/ic_launcher.png", "sourceName": "使用说明", "sourceUrl": "https://www.yuque.com/legado" }, { "customOrder": 3, "enableJs": true, "enabled": true, "singleUrl": true, "sourceGroup": "legado", "sourceIcon": "http://mmbiz.qpic.cn/mmbiz_png/hpfMV8hEuL2eS6vnCxvTzoOiaCAibV6exBzJWq9xMic9xDg3YXAick87tsfafic0icRwkQ5ibV0bJ84JtSuxhPuEDVquA/0?wx_fmt=png", "sourceName": "小说拾遗", "sourceUrl": "snssdk1128://user/profile/562564899806367" }, { "customOrder": 4, "enableJs": true, "enabled": true, "singleUrl": true, "sourceGroup": "legado", "sourceIcon": "https://cdn.jsdelivr.net/gh/mgz0227/meowcloud/icon.png", "sourceName": "Meow云", "sourceUrl": "https://pan.miaogongzi.net" }, { "customOrder": 5, "enableJs": true, "enabled": true, "singleUrl": true, "sourceGroup": "legado", "sourceIcon": "https://cdn.jsdelivr.net/gh/gedoor/legado@master/app/src/main/res/mipmap-hdpi/ic_launcher.png", "sourceName": "烏雲净化", "sourceUrl": "https://www.lanzoux.com/b0bw8jwoh" } ] ================================================ FILE: app/src/main/assets/defaultData/themeConfig.json ================================================ [ { "themeName": "默认", "isNightTheme": false, "primaryColor": "#795548", "accentColor": "#E53935", "backgroundColor": "#F5F5F5", "bottomBackground": "#EEEEEE" }, { "themeName": "典雅蓝", "isNightTheme": false, "primaryColor": "#03A9F4", "accentColor": "#AD1457", "backgroundColor": "#F5F5F5", "bottomBackground": "#EEEEEE" }, { "themeName": "黑白", "isNightTheme": true, "primaryColor": "#303030", "accentColor": "#E0E0E0", "backgroundColor": "#424242", "bottomBackground": "#424242" }, { "themeName": "A屏黑", "isNightTheme": true, "primaryColor": "#000000", "accentColor": "#FFFFFF", "backgroundColor": "#000000", "bottomBackground": "#000000" } ] ================================================ FILE: app/src/main/assets/defaultData/txtTocRule.json ================================================ [ { "id": -1, "enable": true, "name": "目录(去空白)", "rule": "(?<=[ \\s])(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和]))).{0,30}$", "example": "第一章 假装第一章前面有空白但我不要", "serialNumber": 0 }, { "id": -2, "enable": true, "name": "目录", "rule": "^[  \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和])|部(?![分赛游])|篇(?!张))).{0,30}$", "example": "第一章 标准的粤语就是这样", "serialNumber": 1 }, { "id": -3, "enable": false, "name": "目录(匹配简介)", "rule": "(?<=[ \\s])(?:(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和])|部(?![分赛游])|回(?![合来事去])|场(?![和合比电是])|篇(?!张))).{0,30}$", "example": "简介 老夫诸葛村夫", "serialNumber": 2 }, { "id": -4, "enable": false, "name": "目录(古典、轻小说备用)", "rule": "^[  \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和])|部(?![分赛游])|回(?![合来事去])|场(?![和合比电是])|话|篇(?!张))).{0,30}$", "example": "第一章 比上面只多了回和话", "serialNumber": 3 }, { "id": -5, "enable": false, "name": "数字(纯数字标题)", "rule": "(?<=[ \\s])\\d+\\.?[  \\t]{0,4}$", "example": "12", "serialNumber": 4 }, { "id": -6, "enable": false, "name": "大写数字(纯数字标题)", "rule": "(?<=[ \\s])[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,12}[  \\t]{0,4}$", "example": "一百七十", "serialNumber": 5 }, { "id": -7, "enable": false, "name": "数字混合(纯数字标题)", "rule": "(?<=[ \\s])[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟\\d]{1,12}[  \\t]{0,4}$", "example": "12\n一百七十", "serialNumber": 6 }, { "id": -8, "enable": true, "name": "数字 分隔符 标题名称", "rule": "^[  \\t]{0,4}\\d{1,5}[::,., 、_—\\-].{1,30}$", "example": "1、这个就是标题", "serialNumber": 7 }, { "id": -9, "enable": true, "name": "大写数字 分隔符 标题名称", "rule": "^[  \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章?)[ 、_—\\-].{1,30}$", "example": "一、只有前面的数字有差别\n二十四章 我瞎编的标题", "serialNumber": 8 }, { "id": -10, "enable": false, "name": "数字混合 分隔符 标题名称", "rule": "^[  \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章?[ 、_—\\-]|\\d{1,5}章?[::,., 、_—\\-]).{0,30}$", "example": "1、人参公鸡\n二百二十章 boy next door", "serialNumber": 9 }, { "id": -11, "enable": true, "name": "正文 标题/序号", "rule": "^[  \\t]{0,4}正文[  ]{1,4}.{0,20}$", "example": "正文 我奶常山赵子龙", "serialNumber": 10 }, { "id": -12, "enable": true, "name": "Chapter/Section/Part/Episode 序号 标题", "rule": "^[  \\t]{0,4}(?:[Cc]hapter|[Ss]ection|[Pp]art|PART|[Nn][oO][.、]|[Ee]pisode|(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外)\\s{0,4}\\d{1,4}.{0,30}$", "example": "Chapter 1 MyGrandmaIsNB", "serialNumber": 11 }, { "id": -13, "enable": false, "name": "Chapter(去简介)", "rule": "^[  \\t]{0,4}(?:[Cc]hapter|[Ss]ection|[Pp]art|PART|[Nn][Oo]\\.|[Ee]pisode)\\s{0,4}\\d{1,4}.{0,30}$", "example": "Chapter 1 MyGrandmaIsNB", "serialNumber": 12 }, { "id": -14, "enable": true, "name": "特殊符号 序号 标题", "rule": "(?<=[\\s ])[【〔〖「『〈[\\[](?:第|[Cc]hapter)[\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,10}[章节].{0,20}$", "example": "【第一章 后面的符号可以没有", "serialNumber": 13 }, { "id": -15, "enable": false, "name": "特殊符号 标题(成对)", "rule": "(?<=[\\s ]{0,4})(?:[\\[〈「『〖〔《(【\\(].{1,30}[\\)】)》〕〗』」〉\\]]?|(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外)[  ]{0,4}$", "example": "『加个直角引号更专业』\n(11)我奶常山赵子聋", "serialNumber": 14 }, { "id": -16, "enable": true, "name": "特殊符号 标题(单个)", "rule": "(?<=[\\s ]{0,4})(?:[☆★✦✧].{1,30}|(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外)[  ]{0,4}$", "example": "☆、晋江作者最喜欢的格式", "serialNumber": 15 }, { "id": -17, "enable": true, "name": "章/卷 序号 标题", "rule": "^[ \\t ]{0,4}(?:(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|[卷章][\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8})[  ]{0,4}.{0,30}$", "example": "卷五 开源盛世", "serialNumber": 16 }, { "id": -18, "enable": false, "name": "顶格标题", "rule": "^\\S.{1,20}$", "example": "20字以内顶格写的都是标题", "serialNumber": 17 }, { "id": -19, "enable": false, "name": "双标题(前向)", "rule": "(?m)(?<=[ \\t ]{0,4})第[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章.{0,30}$(?=[\\s ]{0,8}第[\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章)", "example": "第一章 真正的标题\n第一章 这个不要", "serialNumber": 18 }, { "id": -20, "enable": false, "name": "双标题(后向)", "rule": "(?m)(?<=[ \\t ]{0,4}第[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章.{0,30}$[\\s ]{0,8})第[\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章.{0,30}$", "example": "第一章 这个标题不要\n第一章真正的标题", "serialNumber": 19 }, { "id": -21, "enable": true, "name": "书名 括号 序号", "rule": "^[一-龥]{1,20}[  \\t]{0,4}[((][\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}[))][  \\t]{0,4}$", "example": "标题后面数字有括号(12)", "serialNumber": 20 }, { "id": -22, "enable": true, "name": "书名 序号", "rule": "^[一-龥]{1,20}[  \\t]{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}[  \\t]{0,4}$", "example": "标题后面数字没有括号124", "serialNumber": 21 }, { "id": -23, "enable": false, "name": "特定字符 标题 特定符号", "rule": "(?<=\\={3,6}).{1,40}?(?=\\=)", "example": "===起这种标题干什么===", "serialNumber": 22 }, { "id": -24, "enable": true, "name": "字数分割 分节阅读", "rule": "(?<=[  \\t]{0,4})(?:.{0,15}分[页节章段]阅读[-_ ]|第\\s{0,4}[\\d零一二两三四五六七八九十百千万]{1,6}\\s{0,4}[页节]).{0,30}$", "example": "分节|分页|分段阅读\n第一页", "serialNumber": 23 }, { "id": -25, "enable": false, "name": "通用规则", "rule": "(?im)^.{0,6}(?:[引楔]子|正文(?!完|结)|[引序前]言|[序终]章|扉页|[上中下][部篇卷]|卷首语|后记|尾声|番外|={2,4}|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|页[、  ]|集(?![合和])|部(?![分是门落])|篇(?!张))).{0,40}$|^.{0,6}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟a-z]{1,8}[、.  ].{0,20}$", "example": "激进规则,适配更多非常用格式", "serialNumber": 24 }, { "id": -100, "enable": false, "name": "默认分章规则", "rule": "", "example": "兜底规则,请勿改动此内容", "serialNumber": 99 } ] ================================================ FILE: app/src/main/assets/disclaimer.md ================================================ # 免责声明(Disclaimer) * 阅读是一款解析指定规则并获取内容的工具,为广大网络文学爱好者提供一种方便、快捷舒适的试读体验。 * 当您搜索一本书的时,阅读会您所使用的规则将该书的书名以关键词的形式提交到各个第三方网络文学网站。 各第三方网站返回的内容与阅读无关,阅读对其概不负责,亦不承担任何法律责任。 任何通过使用阅读而链接到的第三方网页均系他人制作或提供,您可能从第三方网页上获得其他服务,阅读对其合法性概不负责,亦不承担任何法律责任。 第三方搜索引擎结果根据您提交的书名自动搜索获得并提供试读,不代表阅读赞成或被搜索链接到的第三方网页上的内容或立场。 您应该对使用搜索引擎的结果自行承担风险。 * 阅读不做任何形式的保证:不保证第三方搜索引擎的搜索结果满足您的要求,不保证搜索服务不中断,不保证搜索结果的安全性、正确性、及时性、合法性。 因网络状况、通讯线路、第三方网站等任何原因而导致您不能正常使用阅读,阅读不承担任何法律责任。 阅读尊重并保护所有使用阅读用户的个人隐私权,您注册的用户名、电子邮件地址等个人资料,非经您亲自许可或根据相关法律、法规的强制性规定,阅读不会主动地泄露给第三方。 * 阅读致力于最大程度地减少网络文学阅读者在自行搜寻过程中的无意义的时间浪费,通过专业搜索展示不同网站中网络文学的最新章节。 阅读在为广大小说爱好者提供方便、快捷舒适的试读体验的同时,也使优秀网络文学得以迅速、更广泛的传播,从而达到了在一定程度促进网络文学充分繁荣发展之目的。 阅读鼓励广大小说爱好者通过阅读发现优秀网络小说及其提供商,并建议阅读正版图书。 任何单位或个人认为通过阅读搜索链接到的第三方网页内容可能涉嫌侵犯其信息网络传播权,应该及时向阅读提出书面权力通知,并提供身份证明、权属证明及详细侵权情况证明。 阅读在收到上述法律文件后,将会依法尽快断开相关链接内容。 ================================================ FILE: app/src/main/assets/epub/chapter.html ================================================ Chapter

{title}

{content} ================================================ FILE: app/src/main/assets/epub/cover.html ================================================ Cover

{name}

{author} / 著
================================================ FILE: app/src/main/assets/epub/fonts.css ================================================ @charset "utf-8"; /*---常用---*/ @font-face { font-family: "zw"; src: local("宋体"),local("明体"),local("明朝"), local("Songti"),local("Songti SC"),local("Songti TC"), /*iOS6+iBooks3*/ local("Song S"),local("Song T"),local("STBShusong"),local("TBMincho"),local("HYMyeongJo"), /*Kindle Paperwihite*/ local("DK-SONGTI"), url(../Fonts/zw.ttf), url(res:///opt/sony/ebook/FONT/zw.ttf), url(res:///Data/FONT/zw.ttf), url(res:///opt/sony/ebook/FONT/tt0011m_.ttf), url(res:///ebook/fonts/../../mnt/sdcard/fonts/zw.ttf), url(res:///ebook/fonts/../../mnt/extsd/fonts/zw.ttf), url(res:///ebook/fonts/zw.ttf), url(res:///ebook/fonts/DroidSansFallback.ttf), url(res:///fonts/ttf/zw.ttf), url(res:///../../media/mmcblk0p1/fonts/zw.ttf), url(file:///mnt/us/DK_System/system/fonts/zw.ttf), /*Duokan Old Path*/ url(file:///mnt/us/DK_System/xKindle/res/userfonts/zw.ttf), /*Duokan 2012 Path*/ url(res:///abook/fonts/zw.ttf), url(res:///system/fonts/zw.ttf), url(res:///system/media/sdcard/fonts/zw.ttf), url(res:///media/fonts/zw.ttf), url(res:///sdcard/fonts/zw.ttf), url(res:///system/fonts/DroidSansFallback.ttf), url(res:///mnt/MOVIFAT/font/zw.ttf), url(res:///media/flash/fonts/zw.ttf), url(res:///media/sd/fonts/zw.ttf), url(res:///opt/onyx/arm/lib/fonts/AdobeHeitiStd-Regular.otf), url(res:///../../fonts/zw.ttf), url(res:///../fonts/zw.ttf), url(../../../../../zw.ttf), /*EpubReaderI*/ url(res:///mnt/sdcard/fonts/zw.ttf), /*Nook for Android: fonts in TF Card*/ url(res:///fonts/zw.ttf), /*ADE1,8, 2.0 Program Path*/ url(res:///../../../../Windows/fonts/zw.ttf); /*ADE1,8, 2.0 Windows Path*/; } @font-face { font-family: "fs"; src: local("amasis30"),local("仿宋"),local("仿宋_GB2312"), local("Yuanti"),local("Yuanti SC"),local("Yuanti TC"), /*iOS6+iBooks3*/ local("DK-FANGSONG"), url(../Fonts/fs.ttf), url(res:///opt/sony/ebook/FONT/fs.ttf), url(res:///Data/FONT/fs.ttf), url(res:///opt/sony/ebook/FONT/tt0011m_.ttf), url(res:///ebook/fonts/../../mnt/sdcard/fonts/fs.ttf), url(res:///ebook/fonts/../../mnt/extsd/fonts/fs.ttf), url(res:///ebook/fonts/fs.ttf), url(res:///ebook/fonts/DroidSansFallback.ttf), url(res:///fonts/ttf/fs.ttf), url(res:///../../media/mmcblk0p1/fonts/fs.ttf), url(file:///mnt/us/DK_System/system/fonts/fs.ttf), /*Duokan Old Path*/ url(file:///mnt/us/DK_System/xKindle/res/userfonts/fs.ttf), /*Duokan 2012 Path*/ url(res:///abook/fonts/fs.ttf), url(res:///system/fonts/fs.ttf), url(res:///system/media/sdcard/fonts/fs.ttf), url(res:///media/fonts/fs.ttf), url(res:///sdcard/fonts/fs.ttf), url(res:///system/fonts/DroidSansFallback.ttf), url(res:///mnt/MOVIFAT/font/fs.ttf), url(res:///media/flash/fonts/fs.ttf), url(res:///media/sd/fonts/fs.ttf), url(res:///opt/onyx/arm/lib/fonts/AdobeHeitiStd-Regular.otf), url(res:///../../fonts/fs.ttf), url(res:///../fonts/fs.ttf), url(../../../../../fs.ttf), /*EpubReaderI*/ url(res:///mnt/sdcard/fonts/fs.ttf), /*Nook for Android: fonts in TF Card*/ url(res:///fonts/fs.ttf), /*ADE1,8, 2.0 Program Path*/ url(res:///../../../../Windows/fonts/fs.ttf); /*ADE1,8, 2.0 Windows Path*/; } @font-face { font-family: "kt"; src: local("Caecilia"),local("楷体"),local("楷体_GB2312"), local("Kaiti"),local("Kaiti SC"),local("Kaiti TC"), /*iOS6+iBooks3*/ local("MKai PRC"),local("MKaiGB18030C-Medium"),local("MKaiGB18030C-Bold"), /*Kindle Paperwihite*/ local("DK-KAITI"), url(../Fonts/kt.ttf), url(res:///opt/sony/ebook/FONT/kt.ttf), url(res:///Data/FONT/kt.ttf), url(res:///opt/sony/ebook/FONT/tt0011m_.ttf), url(res:///ebook/fonts/../../mnt/sdcard/fonts/kt.ttf), url(res:///ebook/fonts/../../mnt/extsd/fonts/kt.ttf), url(res:///ebook/fonts/kt.ttf), url(res:///ebook/fonts/DroidSansFallback.ttf), url(res:///fonts/ttf/kt.ttf), url(res:///../../media/mmcblk0p1/fonts/kt.ttf), url(file:///mnt/us/DK_System/system/fonts/kt.ttf), /*Duokan Old Path*/ url(file:///mnt/us/DK_System/xKindle/res/userfonts/kt.ttf), /*Duokan 2012 Path*/ url(res:///abook/fonts/kt.ttf), url(res:///system/fonts/kt.ttf), url(res:///system/media/sdcard/fonts/kt.ttf), url(res:///media/fonts/kt.ttf), url(res:///sdcard/fonts/kt.ttf), url(res:///system/fonts/DroidSansFallback.ttf), url(res:///mnt/MOVIFAT/font/kt.ttf), url(res:///media/flash/fonts/kt.ttf), url(res:///media/sd/fonts/kt.ttf), url(res:///opt/onyx/arm/lib/fonts/AdobeHeitiStd-Regular.otf), url(res:///../../fonts/kt.ttf), url(res:///../fonts/kt.ttf), url(../../../../../kt.ttf), /*EpubReaderI*/ url(res:///mnt/sdcard/fonts/kt.ttf), /*Nook for Android: fonts in TF Card*/ url(res:///fonts/kt.ttf), /*ADE1,8, 2.0 Program Path*/ url(res:///../../../../Windows/fonts/kt.ttf); /*ADE1,8, 2.0 Windows Path*/; } @font-face { font-family: "ht"; src: local("黑体"),local("微软雅黑"), local("Heiti"),local("Heiti SC"),local("Heiti TC"), /*iOS6+iBooks3*/ local("MYing Hei S"),local("MYing Hei T"),local("TBGothic"), /*Kindle Paperwihite*/ local("DK-HEITI"), url(../Fonts/ht.ttf), url(res:///opt/sony/ebook/FONT/ht.ttf), url(res:///Data/FONT/ht.ttf), url(res:///opt/sony/ebook/FONT/tt0011m_.ttf), url(res:///ebook/fonts/../../mnt/sdcard/fonts/ht.ttf), url(res:///ebook/fonts/../../mnt/extsd/fonts/ht.ttf), url(res:///ebook/fonts/ht.ttf), url(res:///ebook/fonts/DroidSansFallback.ttf), url(res:///fonts/ttf/ht.ttf), url(res:///../../media/mmcblk0p1/fonts/ht.ttf), url(file:///mnt/us/DK_System/system/fonts/ht.ttf), /*Duokan Old Path*/ url(file:///mnt/us/DK_System/xKindle/res/userfonts/ht.ttf), /*Duokan 2012 Path*/ url(res:///abook/fonts/ht.ttf), url(res:///system/fonts/ht.ttf), url(res:///system/media/sdcard/fonts/ht.ttf), url(res:///media/fonts/ht.ttf), url(res:///sdcard/fonts/ht.ttf), url(res:///system/fonts/DroidSansFallback.ttf), url(res:///mnt/MOVIFAT/font/ht.ttf), url(res:///media/flash/fonts/ht.ttf), url(res:///media/sd/fonts/ht.ttf), url(res:///opt/onyx/arm/lib/fonts/AdobeHeitiStd-Regular.otf), url(res:///../../fonts/ht.ttf), url(res:///../fonts/ht.ttf), url(../../../../../ht.ttf), /*EpubReaderI*/ url(res:///mnt/sdcard/fonts/ht.ttf), /*Nook for Android: fonts in TF Card*/ url(res:///fonts/ht.ttf), /*ADE1,8, 2.0 Program Path*/ url(res:///../../../../Windows/fonts/ht.ttf); /*ADE1,8, 2.0 Windows Path*/; } @font-face { font-family:"h1"; src: local("方正兰亭特黑长_GBK"),local("方正兰亭特黑长简体"),local("方正兰亭特黑长繁体"), local("LantingTeheichang"), local("Yuanti"),local("Yuanti SC"),local("Yuanti TC"), local("DK-HEITI"), url(../Fonts/h1.ttf), url(res:///opt/sony/ebook/FONT/h1.ttf), url(res:///Data/FONT/h1.ttf), url(res:///opt/sony/ebook/FONT/tt0011m_.ttf), url(res:///ebook/fonts/../../mnt/sdcard/fonts/h1.ttf), url(res:///ebook/fonts/../../mnt/extsd/fonts/h1.ttf), url(res:///ebook/fonts/h1.ttf), url(res:///ebook/fonts/DroidSansFallback.ttf), url(res:///fonts/ttf/h1.ttf), url(res:///../../media/mmcblk0p1/fonts/h1.ttf), url(file:///mnt/us/DK_System/system/fonts/h1.ttf), /*Duokan Old Path*/ url(file:///mnt/us/DK_System/xKindle/res/userfonts/h1.ttf), /*Duokan 2012 Path*/ url(res:///abook/fonts/h1.ttf), url(res:///system/fonts/h1.ttf), url(res:///system/media/sdcard/fonts/h1.ttf), url(res:///media/fonts/h1.ttf), url(res:///sdcard/fonts/h1.ttf), url(res:///system/fonts/DroidSansFallback.ttf), url(res:///mnt/MOVIFAT/font/h1.ttf), url(res:///media/flash/fonts/h1.ttf), url(res:///media/sd/fonts/h1.ttf), url(res:///opt/onyx/arm/lib/fonts/AdobeHeitiStd-Regular.otf), url(res:///../../fonts/h1.ttf), url(res:///../fonts/h1.ttf), url(../../../../../h1.ttf), /*EpubReaderI*/ url(res:///mnt/sdcard/fonts/h1.ttf), /*Nook for Android: fonts in TF Card*/ url(res:///fonts/h1.ttf), /*ADE1,8, 2.0 Program Path*/ url(res:///../../../../Windows/fonts/h1.ttf); /*ADE1,8, 2.0 Windows Path*/ } @font-face { font-family:"h2"; src: local("方正大标宋_GBK"),local("方正大标宋简体"),local("方正大标宋繁体"), local("Dabiaosong"), local("Heiti"),local("Heiti SC"),local("Heiti TC"), local("DK-XIAOBIAOSONG"), url(../Fonts/h2.ttf), url(res:///opt/sony/ebook/FONT/h2.ttf), url(res:///Data/FONT/h2.ttf), url(res:///opt/sony/ebook/FONT/tt0011m_.ttf), url(res:///ebook/fonts/../../mnt/sdcard/fonts/h2.ttf), url(res:///ebook/fonts/../../mnt/extsd/fonts/h2.ttf), url(res:///ebook/fonts/h2.ttf), url(res:///ebook/fonts/DroidSansFallback.ttf), url(res:///fonts/ttf/h2.ttf), url(res:///../../media/mmcblk0p1/fonts/h2.ttf), url(file:///mnt/us/DK_System/system/fonts/h2.ttf), /*Duokan Old Path*/ url(file:///mnt/us/DK_System/xKindle/res/userfonts/h2.ttf), /*Duokan 2012 Path*/ url(res:///abook/fonts/h2.ttf), url(res:///system/fonts/h2.ttf), url(res:///system/media/sdcard/fonts/h2.ttf), url(res:///media/fonts/h2.ttf), url(res:///sdcard/fonts/h2.ttf), url(res:///system/fonts/DroidSansFallback.ttf), url(res:///mnt/MOVIFAT/font/h2.ttf), url(res:///media/flash/fonts/h2.ttf), url(res:///media/sd/fonts/h2.ttf), url(res:///opt/onyx/arm/lib/fonts/AdobeHeitiStd-Regular.otf), url(res:///../../fonts/h2.ttf), url(res:///../fonts/h2.ttf), url(../../../../../h2.ttf), /*EpubReaderI*/ url(res:///mnt/sdcard/fonts/h2.ttf), /*Nook for Android: fonts in TF Card*/ url(res:///fonts/h2.ttf), /*ADE1,8, 2.0 Program Path*/ url(res:///../../../../Windows/fonts/h2.ttf); /*ADE1,8, 2.0 Windows Path*/ } @font-face { font-family:"h3"; src: local("方正华隶_GBK"),local("方正行黑简体"),local("方正行黑繁体"), local("Yuanti"),local("Yuanti SC"),local("Yuanti TC"), local("DK-FANGSONG"), url(../Fonts/h3.ttf), url(res:///opt/sony/ebook/FONT/h3.ttf), url(res:///Data/FONT/h3.ttf), url(res:///opt/sony/ebook/FONT/tt0011m_.ttf), url(res:///ebook/fonts/../../mnt/sdcard/fonts/h3.ttf), url(res:///ebook/fonts/../../mnt/extsd/fonts/h3.ttf), url(res:///ebook/fonts/h3.ttf), url(res:///ebook/fonts/DroidSansFallback.ttf), url(res:///fonts/ttf/h3.ttf), url(res:///../../media/mmcblk0p1/fonts/h3.ttf), url(file:///mnt/us/DK_System/system/fonts/h3.ttf), /*Duokan Old Path*/ url(file:///mnt/us/DK_System/xKindle/res/userfonts/h3.ttf), /*Duokan 2012 Path*/ url(res:///abook/fonts/h3.ttf), url(res:///system/fonts/h3.ttf), url(res:///system/media/sdcard/fonts/h3.ttf), url(res:///media/fonts/h3.ttf), url(res:///sdcard/fonts/h3.ttf), url(res:///system/fonts/DroidSansFallback.ttf), url(res:///mnt/MOVIFAT/font/h3.ttf), url(res:///media/flash/fonts/h3.ttf), url(res:///media/sd/fonts/h3.ttf), url(res:///opt/onyx/arm/lib/fonts/AdobeHeitiStd-Regular.otf), url(res:///../../fonts/h3.ttf), url(res:///../fonts/h3.ttf), url(../../../../../h3.ttf), /*EpubReaderI*/ url(res:///mnt/sdcard/fonts/h3.ttf), /*Nook for Android: fonts in TF Card*/ url(res:///fonts/h3.ttf), /*ADE1,8, 2.0 Program Path*/ url(res:///../../../../Windows/fonts/h3.ttf); /*ADE1,8, 2.0 Windows Path*/ } @font-face { font-family:"luohua"; src:local("汉仪落花体"), url("../Fonts/hylh.ttf"); } ================================================ FILE: app/src/main/assets/epub/intro.html ================================================ Intro

内容简介

{intro} ================================================ FILE: app/src/main/assets/epub/main.css ================================================ @charset "utf-8"; @import url("../Styles/fonts.css"); body { padding: 0%; margin-top: 0%; margin-bottom: 0%; margin-left: 0.5%; margin-right: 0.5%; line-height: 130%; text-align: justify; font-family: "DK-SONGTI","st","宋体","zw",sans-serif; } p { text-align: justify; text-indent: 2em; line-height: 130%; margin-right: 0.5%; margin-left: 0.5%; font-family: "DK-SONGTI","st","宋体","zw",sans-serif; } p.kaiti { font-family: "DK-KAITI","kt","楷体","zw",serif; } p.fangsong { font-family: "DK-FANGSONG","fs","仿宋","zw",serif; } span.xinli { font-family: "DK-KAITI","kt","楷体","zw",serif; color: #4e753f; } /** 英文斜体字 **/ span.english{ font-style: italic; } div { margin: 0px; padding: 0px; line-height: 120%; text-align: justify; font-family: "zw"; } div.foot { text-indent: 2em; margin: 30% 5% 0 5%; padding: 8px 0; } p.foot { font-family: "DK-KAITI","kt","楷体","zw",serif; } /*扉页*/ .booksubtitle { padding: 10px 0 0px 0; text-indent: 0em; font-size: 75%; font-family: "ht"; } .booktitle { padding: 9% 0 0 0; font-size: 1.3em; font-family: "方正小标宋_GBK","DK-XIAOBIAOSONG"; font-weight: normal; text-indent: 0em; color: #000; text-align: center; line-height: 1.6; } .booktitle0 { font-size: 1.2em; font-family: "fs"; text-indent: 0em; text-align: center; line-height: 1.8; } .booktitle1 { padding: 0 0 0 0; font-size: 0.85em; font-family: "fs"; text-indent: 0em; text-align: center; line-height: 1.6; } .bookauthor { font-family: "DK-FANGSONG",仿宋,"fs","fangsong",sans-serif; padding: 5% 5px 0px 5px; text-indent: 0em; text-align: center; color: #000; font-size: 90%; line-height: 1.3; } .booktranslator { padding: 1% 5px 0px 5px; text-indent: 0em; text-align: center; font-size: 85%; line-height: 1.3; } .bookpub { font-family: "DK-KAITI","kt","楷体","楷体_gb2312"; padding: 30% 5px 5px 5px; text-indent: 0em; color: #000; text-align: center; font-size: 80%; } /*标题页*/ body.head { background-repeat:no-repeat no-repeat; background-size:160px 229px; background-position:bottom right; background-attachment:fixed; } body.xhead { background-color: #FDF5E6; } h1.head { font-family: "DK-HEITI",黑体,sans-serif; font-size: 1.2em; font-weight: bold; color: #311a02; text-indent: 0em; font-weight: normal; duokan-text-indent: 0em; padding: auto; text-align: center; margin-top: -8em; } div.head { border: solid 2px #ffffff; padding: 2px; margin: 2em auto 0.7em auto; text-align: center; width: 1em; } h1.head b { font-family: "方正小标宋_GBK","DK-XIAOBIAOSONG"; font-weight: bold; font-size: 1.2em; text-align: center; text-indent: 0em; duokan-text-indent: 0em; color: #311a02; margin: 0.5em auto; line-height: 140%; } div.back { text-align: center; text-indent: 0em; duokan-text-indent: 0em; margin: 4em auto; } img.back { width: 70%; } img.back2 { width: 40%; margin: 2em 0 0 0; } /*正文*/ /**楷体引文**/ .titou { font-family: "DK-FANGSONG",仿宋,"fs","fangsong",sans-serif; } .yinwen { font-family: "DK-KAITI","kt","楷体","zw",serif; margin-left: 2em; text-indent: 0em; } .nicename { font-family: "DK-HEITI",黑体,sans-serif; font-weight: bold; font-size: 0.9em; } body.head3 { background-color: #a7bdcc; color: #354f66; } body.head4 { background-color: #bfd19b; color: #4e753f; } h2.head { font-family: "小标宋"; text-align: left; font-weight: bold; font-size: 1.1em; margin: 1em 2em 2em 0; color: #3f83e8; line-height: 140%; } h2.head span { font-family: "仿宋"; font-size: 0.7em; background-color: #3f83e8; border-radius: 9px; padding: 4px; color: #fff; } div.logo { margin: -2em 0% 0 0; text-align: right; } img.logo { width: 40%; } .imgl { /*图片居右*/ margin: -8.8em 1em 4em 0em; width: 80%; text-align: right; } h1.head { line-height:130%; font-size:1.4em; text-align: center; color: #BA2213; font-weight: bold; margin-top: 2em; margin-bottom: 1em; font-family: "方正小标宋_GBK","DK-XIAOBIAOSONG"; } h3 { font-family: "DK-HEITI",黑体,sans-serif; font-size: 1.1em; margin: 1em 0; border-left: 1.2em solid #00a1e9; line-height: 120%; padding-left: 3px; color: #00a1e9; } h4 { font-family: "DK-HEITI",黑体,sans-serif; font-size: 1.1em; text-align: center; margin: 1em 0; line-height: 120%; color: #000; } h1.post { font-family: "方正小标宋_GBK","DK-XIAOBIAOSONG"; text-align: center; font-size: 1.3em; color: #026fca; margin: 3em auto 2em auto; } .banquan { font-family: "DK-FANGSONG",仿宋,"fs","fangsong",sans-serif; text-align: left; color: #000; font-size:1.1em; margin-bottom:1em; text-indent: 1em; duokan-text-indent: 1em; } p.post { font-family: "DK-FANGSONG",仿宋,"fs","fangsong",sans-serif; } p.zy { font-family: "DK-FANGSONG",仿宋,"fs","fangsong",sans-serif; margin: 1em 0 0 1em; padding: 5px 0px 5px 10px; text-indent: 0em; border-left: 5px solid #a9b5c1; } .sign { font-family: "DK-KAITI","kt","楷体","zw",serif; margin: 1em 2px 0 auto; text-align: right; font-size: 0.8em; text-indent: 0em; duokan-text-indent: 0em; } .mark { font-family: "DK-HEITI",黑体,sans-serif; font-size: 0.9em; color: #fff; text-indent: 0em; duokan-text-indent: 0em; background-color: maroon; text-align: center; padding: 0px; margin: 2em 30%; } /*出版社*/ .chubanshe img{ width:106px; height:28px; } .chubanshe { margin-top:20px; } .cr { font-size:0.9em; } /*多看画廊*/ div.duokan-image-single { text-align: center; margin: 0.5em auto; /*插图盒子上下外边距为0.5em,左右设置auto是为了水平居中这个盒子*/ } img.picture-80 { margin: 0; /*清除img元素的外边距*/ width: 80%; /*预览窗口的宽度*/ box-shadow: 3px 3px 10px #bfbfbf; /*给图片添加阴影效果*/ } p.duokan-image-maintitle { margin: 1em 0 0; /*图片说明的段间距*/ font-family: "楷体"; /*图片说明使用的字体*/ font-size: 0.9em; /*字体大小*/ text-indent: 0; /*首行缩进为零,当你使用单标签p来指定首行缩进为2em时,记得在需要居中的文本中清除缩进,因为样式是叠加的*/ text-align: center; /*图片说明水平居中*/ color: #a52a2a; /*字体颜色*/ line-height: 1.25em; /*行高,防止有很长的图片说明*/ } /*制作说明页*/ body.description { background-image: url(../Images/001.png); background-position: bottom center; background-repeat: no-repeat; background-size: cover; padding: 25% 10% 0; font-size: 0.9em; } div.description-body { width: 55%; padding: 2em 1.3em; border-radius: 0.5em; font-size: 0.9em; border-style: solid; border-color: #393939; border-width: 0.3em; border-radius: 5em; background-color: #5a5a5a; box-shadow: 2px 2px 3px #828281; } h1.description-title { text-align: center; font-family: "黑体"; font-size: 1.2em; margin: 0 0 1em 0; color: #FF9; text-shadow: 1px 1px 0 black; } p.description-text { color: #f9ddd2; font-family: "准圆"; margin: 0; text-align: justify; text-indent: 0; duokan-text-indent: 0; } hr.description-hr { margin: 0.5em -1em; border-style: dotted; border-color: #9C9; border-width: 0.05em 0 0 0; } p.tips { text-align: justify; text-indent: 0; duokan-text-indent: 0; font-family: "楷体"; font-size: 0.7em; color: #FFC; margin: 0; } /*版本说明页*/ .ver { font-family: "DK-CODE","DK-XIHEITI",细黑体,"xihei",sans-serif; font-weight: bold; font-size: 100%; color: #000; margin: 1em 0 1em 0; text-align: center; } .vertitle { font-family: "DK-FANGSONG",仿宋,"fs","fangsong",sans-serif; font-size: 100%; text-indent: 0em; text-align: left; duokan-text-indent: 0em; } .vertxt { font-family: "DK-FANGSONG",仿宋,"fs","fangsong",sans-serif; line-height: 100%; font-size: 85%; text-indent: 0em; text-align: left; duokan-text-indent: 0em; } .verchar { font-family: "DK-KAITI","kt","楷体","楷体_gb2312"; text-align: left; text-indent: 1em; duokan-text-indent: 1em; margin-bottom: 1em; margin-top: 1em; } .vernote { font-family: "DK-FANGSONG",仿宋,"fs","fangsong",sans-serif; font-size: 75%; color: #686d70; text-indent: 0em; text-align: left; duokan-text-indent: 0em; padding-bottom: 15px; } .line { border: dotted #A2906A; border-width: 1px 0 0 0; } .entry { margin-left: 18px; font-size: 83%; color: #8fe0a3; text-indent: 0em; duokan-text-indent: 0em; } /*版权信息*/ .vol { text-indent: 0em; text-align: center; padding: 0.8em; margin: 0 auto 3px auto; color: #000; font-family: "方正小标宋_GBK","DK-XIAOBIAOSONG"; font-size: 130%; text-shadow: none; } .cp { font-family: "DK-CODE","DK-XIHEITI",细黑体,"xihei",sans-serif; color: #412938; font-size: 70%; text-align: left; text-indent: 0em; duokan-text-indent: 0em; } .xchar { font-family: "DK-KAITI","kt","楷体","楷体_gb2312"; text-indent: 0em; duokan-text-indent: 0em; } /*多看弹注*/ sup img { line-height: 100%; width: auto; height: 1.0em; margin: 0em; padding: 0em; vertical-align: text-top; } ol { margin-bottom:0; padding:0 auto; list-style-type: decimal; } .hr { width:50%; margin:2em 0 0 0.5em; padding:0; height:2px; background-color: #F3221D; } .duokan-footnote-content{ padding:0 auto; text-align: left; } .duokan-footnote-item { font-family:"DK-XIHEITI",细黑体,"xihei",sans-serif; text-align: left; font-size: 80%; line-height: 100%; clear: both; color:#000; list-style-type:decimal; } li.duokan-footnote-item a { font-family:"DK-HEITI"; text-align: left; } a{ text-decoration: none; color: #222; } a:hover {background: #81caf9} a:active {background: yellow} .duokan-image-maintitle { font-family:"DK-HEITI",黑体,"hei",sans-serif; text-align: center; text-indent: 0em; duokan-text-indent: 0em; font-size:90%; color: #1F4150; margin-top: 1em; } .duokan-image-subtitle { font-family:"DK-XIHEITI",细黑体,"xihei",sans-serif; text-align: center; text-indent: 0em; duokan-text-indent: 0em; font-size:70%; color: #3A3348; margin-top: 1em; } ================================================ FILE: app/src/main/assets/privacyPolicy.md ================================================ * 本应用没有服务端,不收集任何用户信息,只采用了Google Firebase收集崩溃报告和性能报告. * 本应用网络同步和备份采用webDav协议,由用户自己提供同步服务. * 存储权限用来打开本地文件和本地备份恢复. * 其它一些权限是Google Firebase需要. * 本应用为开源软件,内置js引擎,因书源调用js发生的任何问题由用户自行承担. ================================================ FILE: app/src/main/assets/storageHelp.md ================================================ * 由于安卓的存储访问限制,阅读需要设置**公共目录下的子目录**来实现书籍拷贝、下载,例如Documents/Books、Download/Books * 如不设置,将无法正常使用本地书籍、webDav书籍的相关功能 ================================================ FILE: app/src/main/assets/updateLog.md ================================================ # 更新日志 * 关注公众号 **[开源阅读]** 菜单•软件下载 提前享受新版本。 * 关注合作公众号 **[小说拾遗]** 获取好看的小说。 ## cronet版本: 128.0.6613.40 ## **必读** 【温馨提醒】 *更新前一定要做好备份,以免数据丢失!* * 阅读只是一个转码工具,不提供内容,第一次安装app,需要自己手动导入书源,可以从公众号 **[开源阅读]** 、QQ群、酷安评论里获取由书友制作分享的书源。 * 正文出现缺字漏字、内容缺失、排版错乱等情况,有可能是净化规则或简繁转换出现问题。 * 漫画源看书显示乱码,**阅读与其他软件的源并不通用**,请导入阅读的支持的漫画源! **2025/03/26** * 目前阅读被恶意注册软著,并建立了多个公众号 * 官方公众号仅有:开源阅读、开源阅读软件,其他公众号与本软件无关 **2024/10/03** * web书架支持加载网络字体、试读非书架书籍后弹窗、自定义后端IP * rss收藏添加分组管理 * 朗读功能添加流式播放音频、来电自动暂停播放功能 * 其它bug修复 **2024/02/27** * 添加备用URL导出 * 更新书源制作的帮助文档链接 **2024/02/20** * 更新cronet: 121.0.6167.180 * 更新 kotlin->1.9.22 ksp->1.0.17 * 界面绘制优化 * Bug修复和其他优化 ---- * [2023年日志](https://github.com/gedoor/legado/blob/record2023/app/src/main/assets/updateLog.md) * [2022年日志](https://github.com/gedoor/legado/blob/record2022/app/src/main/assets/updateLog.md) * [2021年日志](https://github.com/gedoor/legado/blob/record2021/app/src/main/assets/updateLog.md) ================================================ FILE: app/src/main/assets/web/assets/css/main.css ================================================ /* Forty by HTML5 UP html5up.net | @ajlkn Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) */ html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article, aside, canvas, details, embed, figure, figcaption, footer, header, hgroup, menu, nav, output, ruby, section, summary, time, mark, audio, video { margin: 0; padding: 0; border: 0; font-size: 100%; font: inherit; vertical-align: baseline;} article, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section { display: block;} body { line-height: 1; background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.6)),url("../../images/bg.jpg") no-repeat center center fixed; -webkit-background-size: cover; -moz-background-size: cover; -o-background-size: cover; background-size: cover; } ol, ul { list-style: none; } blockquote, q { quotes: none; } blockquote:before, blockquote:after, q:before, q:after { content: ''; content: none; } table { border-collapse: collapse; border-spacing: 0; } body { -webkit-text-size-adjust: none; } mark { background-color: transparent; color: inherit; } input::-moz-focus-inner { border: 0; padding: 0; } input, select, textarea { -moz-appearance: none; -webkit-appearance: none; -ms-appearance: none; appearance: none; } /* Basic */ @-ms-viewport { width: device-width; } body { -ms-overflow-style: scrollbar; } @media screen and (max-width: 480px) { html, body { min-width: 320px; } } html { box-sizing: border-box; height: 100vh; } *, *:before, *:after { box-sizing: inherit; } body { /* background: #242943; */ height: 100vh; } body.is-preload *, body.is-preload *:before, body.is-preload *:after { -moz-animation: none !important; -webkit-animation: none !important; -ms-animation: none !important; animation: none !important; -moz-transition: none !important; -webkit-transition: none !important; -ms-transition: none !important; transition: none !important; } /* Type */ body, input, select, textarea { color: #ffffff; font-family: "Source Sans Pro", Helvetica, sans-serif; font-size: 17pt; font-weight: 300; letter-spacing: 0.025em; line-height: 1.65; } @media screen and (max-width: 1680px) { body, input, select, textarea { font-size: 14pt; } } @media screen and (max-width: 1280px) { body, input, select, textarea { font-size: 12pt; } } @media screen and (max-width: 360px) { body, input, select, textarea { font-size: 11pt; } } a { -moz-transition: color 0.2s ease-in-out, border-bottom-color 0.2s ease-in-out; -webkit-transition: color 0.2s ease-in-out, border-bottom-color 0.2s ease-in-out; -ms-transition: color 0.2s ease-in-out, border-bottom-color 0.2s ease-in-out; transition: color 0.2s ease-in-out, border-bottom-color 0.2s ease-in-out; border-bottom: dotted 1px; color: inherit; text-decoration: none; } a:hover { border-bottom-color: transparent; color: #9bf1ff !important; } a:active { color: #53e3fb !important; } strong, b { color: #ffffff; font-weight: 600; } em, i { font-style: italic; } p { margin: 0 0 2em 0; } h1, h2, h3, h4, h5, h6 { color: #ffffff; font-weight: 600; line-height: 1.65; margin: 0 0 1em 0; } h1 a, h2 a, h3 a, h4 a, h5 a, h6 a { color: inherit; border-bottom: 0; } h1 { font-size: 2.5em; } h2 { font-size: 1.75em; } h3 { font-size: 1.35em; } h4 { font-size: 1.1em; } h5 { font-size: 0.9em; } h6 { font-size: 0.7em; } @media screen and (max-width: 736px) { h1 { font-size: 2em; } h2 { font-size: 1.5em; } h3 { font-size: 1.25em; } } sub { font-size: 0.8em; position: relative; top: 0.5em; } sup { font-size: 0.8em; position: relative; top: -0.5em; } blockquote { border-left: solid 4px rgba(212, 212, 255, 0.1); font-style: italic; margin: 0 0 2em 0; padding: 0.5em 0 0.5em 2em; } code { background: rgba(212, 212, 255, 0.035); font-family: "Courier New", monospace; font-size: 0.9em; margin: 0 0.25em; padding: 0.25em 0.65em; } pre { -webkit-overflow-scrolling: touch; font-family: "Courier New", monospace; font-size: 0.9em; margin: 0 0 2em 0; } pre code { display: block; line-height: 1.75; padding: 1em 1.5em; overflow-x: auto; } hr { border: 0; border-bottom: solid 1px rgba(212, 212, 255, 0.1); margin: 2em 0; } hr.major { margin: 3em 0; } .align-left { text-align: left; } .align-center { text-align: center; } .align-right { text-align: right; } /* Row */ .row { display: flex; flex-wrap: wrap; box-sizing: border-box; align-items: stretch; } .row > * { box-sizing: border-box; } .row.gtr-uniform > * > :last-child { margin-bottom: 0; } .row.aln-left { justify-content: flex-start; } .row.aln-center { justify-content: center; } .row.aln-right { justify-content: flex-end; } .row.aln-top { align-items: flex-start; } .row.aln-middle { align-items: center; } .row.aln-bottom { align-items: flex-end; } .row > .imp { order: -1; } .row > .col-1 { width: 8.33333%; } .row > .off-1 { margin-left: 8.33333%; } .row > .col-2 { width: 16.66667%; } .row > .off-2 { margin-left: 16.66667%; } .row > .col-3 { width: 25%; } .row > .off-3 { margin-left: 25%; } .row > .col-4 { width: 33.33333%; } .row > .off-4 { margin-left: 33.33333%; } .row > .col-5 { width: 41.66667%; } .row > .off-5 { margin-left: 41.66667%; } .row > .col-6 { width: 50%; } .row > .off-6 { margin-left: 50%; } .row > .col-7 { width: 58.33333%; } .row > .off-7 { margin-left: 58.33333%; } .row > .col-8 { width: 66.66667%; } .row > .off-8 { margin-left: 66.66667%; } .row > .col-9 { width: 75%; } .row > .off-9 { margin-left: 75%; } .row > .col-10 { width: 83.33333%; } .row > .off-10 { margin-left: 83.33333%; } .row > .col-11 { width: 91.66667%; } .row > .off-11 { margin-left: 91.66667%; } .row > .col-12 { width: 100%; } .row > .off-12 { margin-left: 100%; } .row.gtr-0 { margin-top: 0; margin-left: 0em; } .row.gtr-0 > * { padding: 0 0 0 0em; } .row.gtr-0.gtr-uniform { margin-top: 0em; } .row.gtr-0.gtr-uniform > * { padding-top: 0em; } .row.gtr-25 { margin-top: 0; margin-left: -0.5em; } .row.gtr-25 > * { padding: 0 0 0 0.5em; } .row.gtr-25.gtr-uniform { margin-top: -0.5em; } .row.gtr-25.gtr-uniform > * { padding-top: 0.5em; } .row.gtr-50 { margin-top: 0; margin-left: -1em; } .row.gtr-50 > * { padding: 0 0 0 1em; } .row.gtr-50.gtr-uniform { margin-top: -1em; } .row.gtr-50.gtr-uniform > * { padding-top: 1em; } .row { margin-top: 0; margin-left: -2em; } .row > * { padding: 0 0 0 2em; } .row.gtr-uniform { margin-top: -2em; } .row.gtr-uniform > * { padding-top: 2em; } .row.gtr-150 { margin-top: 0; margin-left: -3em; } .row.gtr-150 > * { padding: 0 0 0 3em; } .row.gtr-150.gtr-uniform { margin-top: -3em; } .row.gtr-150.gtr-uniform > * { padding-top: 3em; } .row.gtr-200 { margin-top: 0; margin-left: -4em; } .row.gtr-200 > * { padding: 0 0 0 4em; } .row.gtr-200.gtr-uniform { margin-top: -4em; } .row.gtr-200.gtr-uniform > * { padding-top: 4em; } @media screen and (max-width: 1680px) { .row { display: flex; flex-wrap: wrap; box-sizing: border-box; align-items: stretch; } .row > * { box-sizing: border-box; } .row.gtr-uniform > * > :last-child { margin-bottom: 0; } .row.aln-left { justify-content: flex-start; } .row.aln-center { justify-content: center; } .row.aln-right { justify-content: flex-end; } .row.aln-top { align-items: flex-start; } .row.aln-middle { align-items: center; } .row.aln-bottom { align-items: flex-end; } .row > .imp-xlarge { order: -1; } .row > .col-1-xlarge { width: 8.33333%; } .row > .off-1-xlarge { margin-left: 8.33333%; } .row > .col-2-xlarge { width: 16.66667%; } .row > .off-2-xlarge { margin-left: 16.66667%; } .row > .col-3-xlarge { width: 25%; } .row > .off-3-xlarge { margin-left: 25%; } .row > .col-4-xlarge { width: 33.33333%; } .row > .off-4-xlarge { margin-left: 33.33333%; } .row > .col-5-xlarge { width: 41.66667%; } .row > .off-5-xlarge { margin-left: 41.66667%; } .row > .col-6-xlarge { width: 50%; } .row > .off-6-xlarge { margin-left: 50%; } .row > .col-7-xlarge { width: 58.33333%; } .row > .off-7-xlarge { margin-left: 58.33333%; } .row > .col-8-xlarge { width: 66.66667%; } .row > .off-8-xlarge { margin-left: 66.66667%; } .row > .col-9-xlarge { width: 75%; } .row > .off-9-xlarge { margin-left: 75%; } .row > .col-10-xlarge { width: 83.33333%; } .row > .off-10-xlarge { margin-left: 83.33333%; } .row > .col-11-xlarge { width: 91.66667%; } .row > .off-11-xlarge { margin-left: 91.66667%; } .row > .col-12-xlarge { width: 100%; } .row > .off-12-xlarge { margin-left: 100%; } .row.gtr-0 { margin-top: 0; margin-left: 0em; } .row.gtr-0 > * { padding: 0 0 0 0em; } .row.gtr-0.gtr-uniform { margin-top: 0em; } .row.gtr-0.gtr-uniform > * { padding-top: 0em; } .row.gtr-25 { margin-top: 0; margin-left: -0.5em; } .row.gtr-25 > * { padding: 0 0 0 0.5em; } .row.gtr-25.gtr-uniform { margin-top: -0.5em; } .row.gtr-25.gtr-uniform > * { padding-top: 0.5em; } .row.gtr-50 { margin-top: 0; margin-left: -1em; } .row.gtr-50 > * { padding: 0 0 0 1em; } .row.gtr-50.gtr-uniform { margin-top: -1em; } .row.gtr-50.gtr-uniform > * { padding-top: 1em; } .row { margin-top: 0; margin-left: -2em; } .row > * { padding: 0 0 0 2em; } .row.gtr-uniform { margin-top: -2em; } .row.gtr-uniform > * { padding-top: 2em; } .row.gtr-150 { margin-top: 0; margin-left: -3em; } .row.gtr-150 > * { padding: 0 0 0 3em; } .row.gtr-150.gtr-uniform { margin-top: -3em; } .row.gtr-150.gtr-uniform > * { padding-top: 3em; } .row.gtr-200 { margin-top: 0; margin-left: -4em; } .row.gtr-200 > * { padding: 0 0 0 4em; } .row.gtr-200.gtr-uniform { margin-top: -4em; } .row.gtr-200.gtr-uniform > * { padding-top: 4em; } } @media screen and (max-width: 1280px) { .row { display: flex; flex-wrap: wrap; box-sizing: border-box; align-items: stretch; } .row > * { box-sizing: border-box; } .row.gtr-uniform > * > :last-child { margin-bottom: 0; } .row.aln-left { justify-content: flex-start; } .row.aln-center { justify-content: center; } .row.aln-right { justify-content: flex-end; } .row.aln-top { align-items: flex-start; } .row.aln-middle { align-items: center; } .row.aln-bottom { align-items: flex-end; } .row > .imp-large { order: -1; } .row > .col-1-large { width: 8.33333%; } .row > .off-1-large { margin-left: 8.33333%; } .row > .col-2-large { width: 16.66667%; } .row > .off-2-large { margin-left: 16.66667%; } .row > .col-3-large { width: 25%; } .row > .off-3-large { margin-left: 25%; } .row > .col-4-large { width: 33.33333%; } .row > .off-4-large { margin-left: 33.33333%; } .row > .col-5-large { width: 41.66667%; } .row > .off-5-large { margin-left: 41.66667%; } .row > .col-6-large { width: 50%; } .row > .off-6-large { margin-left: 50%; } .row > .col-7-large { width: 58.33333%; } .row > .off-7-large { margin-left: 58.33333%; } .row > .col-8-large { width: 66.66667%; } .row > .off-8-large { margin-left: 66.66667%; } .row > .col-9-large { width: 75%; } .row > .off-9-large { margin-left: 75%; } .row > .col-10-large { width: 83.33333%; } .row > .off-10-large { margin-left: 83.33333%; } .row > .col-11-large { width: 91.66667%; } .row > .off-11-large { margin-left: 91.66667%; } .row > .col-12-large { width: 100%; } .row > .off-12-large { margin-left: 100%; } .row.gtr-0 { margin-top: 0; margin-left: 0em; } .row.gtr-0 > * { padding: 0 0 0 0em; } .row.gtr-0.gtr-uniform { margin-top: 0em; } .row.gtr-0.gtr-uniform > * { padding-top: 0em; } .row.gtr-25 { margin-top: 0; margin-left: -0.375em; } .row.gtr-25 > * { padding: 0 0 0 0.375em; } .row.gtr-25.gtr-uniform { margin-top: -0.375em; } .row.gtr-25.gtr-uniform > * { padding-top: 0.375em; } .row.gtr-50 { margin-top: 0; margin-left: -0.75em; } .row.gtr-50 > * { padding: 0 0 0 0.75em; } .row.gtr-50.gtr-uniform { margin-top: -0.75em; } .row.gtr-50.gtr-uniform > * { padding-top: 0.75em; } .row { margin-top: 0; margin-left: -1.5em; } .row > * { padding: 0 0 0 1.5em; } .row.gtr-uniform { margin-top: -1.5em; } .row.gtr-uniform > * { padding-top: 1.5em; } .row.gtr-150 { margin-top: 0; margin-left: -2.25em; } .row.gtr-150 > * { padding: 0 0 0 2.25em; } .row.gtr-150.gtr-uniform { margin-top: -2.25em; } .row.gtr-150.gtr-uniform > * { padding-top: 2.25em; } .row.gtr-200 { margin-top: 0; margin-left: -3em; } .row.gtr-200 > * { padding: 0 0 0 3em; } .row.gtr-200.gtr-uniform { margin-top: -3em; } .row.gtr-200.gtr-uniform > * { padding-top: 3em; } } @media screen and (max-width: 980px) { .row { display: flex; flex-wrap: wrap; box-sizing: border-box; align-items: stretch; } .row > * { box-sizing: border-box; } .row.gtr-uniform > * > :last-child { margin-bottom: 0; } .row.aln-left { justify-content: flex-start; } .row.aln-center { justify-content: center; } .row.aln-right { justify-content: flex-end; } .row.aln-top { align-items: flex-start; } .row.aln-middle { align-items: center; } .row.aln-bottom { align-items: flex-end; } .row > .imp-medium { order: -1; } .row > .col-1-medium { width: 8.33333%; } .row > .off-1-medium { margin-left: 8.33333%; } .row > .col-2-medium { width: 16.66667%; } .row > .off-2-medium { margin-left: 16.66667%; } .row > .col-3-medium { width: 25%; } .row > .off-3-medium { margin-left: 25%; } .row > .col-4-medium { width: 33.33333%; } .row > .off-4-medium { margin-left: 33.33333%; } .row > .col-5-medium { width: 41.66667%; } .row > .off-5-medium { margin-left: 41.66667%; } .row > .col-6-medium { width: 50%; } .row > .off-6-medium { margin-left: 50%; } .row > .col-7-medium { width: 58.33333%; } .row > .off-7-medium { margin-left: 58.33333%; } .row > .col-8-medium { width: 66.66667%; } .row > .off-8-medium { margin-left: 66.66667%; } .row > .col-9-medium { width: 75%; } .row > .off-9-medium { margin-left: 75%; } .row > .col-10-medium { width: 83.33333%; } .row > .off-10-medium { margin-left: 83.33333%; } .row > .col-11-medium { width: 91.66667%; } .row > .off-11-medium { margin-left: 91.66667%; } .row > .col-12-medium { width: 100%; } .row > .off-12-medium { margin-left: 100%; } .row.gtr-0 { margin-top: 0; margin-left: 0em; } .row.gtr-0 > * { padding: 0 0 0 0em; } .row.gtr-0.gtr-uniform { margin-top: 0em; } .row.gtr-0.gtr-uniform > * { padding-top: 0em; } .row.gtr-25 { margin-top: 0; margin-left: -0.375em; } .row.gtr-25 > * { padding: 0 0 0 0.375em; } .row.gtr-25.gtr-uniform { margin-top: -0.375em; } .row.gtr-25.gtr-uniform > * { padding-top: 0.375em; } .row.gtr-50 { margin-top: 0; margin-left: -0.75em; } .row.gtr-50 > * { padding: 0 0 0 0.75em; } .row.gtr-50.gtr-uniform { margin-top: -0.75em; } .row.gtr-50.gtr-uniform > * { padding-top: 0.75em; } .row { margin-top: 0; margin-left: -1.5em; } .row > * { padding: 0 0 0 1.5em; } .row.gtr-uniform { margin-top: -1.5em; } .row.gtr-uniform > * { padding-top: 1.5em; } .row.gtr-150 { margin-top: 0; margin-left: -2.25em; } .row.gtr-150 > * { padding: 0 0 0 2.25em; } .row.gtr-150.gtr-uniform { margin-top: -2.25em; } .row.gtr-150.gtr-uniform > * { padding-top: 2.25em; } .row.gtr-200 { margin-top: 0; margin-left: -3em; } .row.gtr-200 > * { padding: 0 0 0 3em; } .row.gtr-200.gtr-uniform { margin-top: -3em; } .row.gtr-200.gtr-uniform > * { padding-top: 3em; } } @media screen and (max-width: 736px) { .row { display: flex; flex-wrap: wrap; box-sizing: border-box; align-items: stretch; } .row > * { box-sizing: border-box; } .row.gtr-uniform > * > :last-child { margin-bottom: 0; } .row.aln-left { justify-content: flex-start; } .row.aln-center { justify-content: center; } .row.aln-right { justify-content: flex-end; } .row.aln-top { align-items: flex-start; } .row.aln-middle { align-items: center; } .row.aln-bottom { align-items: flex-end; } .row > .imp-small { order: -1; } .row > .col-1-small { width: 8.33333%; } .row > .off-1-small { margin-left: 8.33333%; } .row > .col-2-small { width: 16.66667%; } .row > .off-2-small { margin-left: 16.66667%; } .row > .col-3-small { width: 25%; } .row > .off-3-small { margin-left: 25%; } .row > .col-4-small { width: 33.33333%; } .row > .off-4-small { margin-left: 33.33333%; } .row > .col-5-small { width: 41.66667%; } .row > .off-5-small { margin-left: 41.66667%; } .row > .col-6-small { width: 50%; } .row > .off-6-small { margin-left: 50%; } .row > .col-7-small { width: 58.33333%; } .row > .off-7-small { margin-left: 58.33333%; } .row > .col-8-small { width: 66.66667%; } .row > .off-8-small { margin-left: 66.66667%; } .row > .col-9-small { width: 75%; } .row > .off-9-small { margin-left: 75%; } .row > .col-10-small { width: 83.33333%; } .row > .off-10-small { margin-left: 83.33333%; } .row > .col-11-small { width: 91.66667%; } .row > .off-11-small { margin-left: 91.66667%; } .row > .col-12-small { width: 100%; } .row > .off-12-small { margin-left: 100%; } .row.gtr-0 { margin-top: 0; margin-left: 0em; } .row.gtr-0 > * { padding: 0 0 0 0em; } .row.gtr-0.gtr-uniform { margin-top: 0em; } .row.gtr-0.gtr-uniform > * { padding-top: 0em; } .row.gtr-25 { margin-top: 0; margin-left: -0.3125em; } .row.gtr-25 > * { padding: 0 0 0 0.3125em; } .row.gtr-25.gtr-uniform { margin-top: -0.3125em; } .row.gtr-25.gtr-uniform > * { padding-top: 0.3125em; } .row.gtr-50 { margin-top: 0; margin-left: -0.625em; } .row.gtr-50 > * { padding: 0 0 0 0.625em; } .row.gtr-50.gtr-uniform { margin-top: -0.625em; } .row.gtr-50.gtr-uniform > * { padding-top: 0.625em; } .row { margin-top: 0; margin-left: -1.25em; } .row > * { padding: 0 0 0 1.25em; } .row.gtr-uniform { margin-top: -1.25em; } .row.gtr-uniform > * { padding-top: 1.25em; } .row.gtr-150 { margin-top: 0; margin-left: -1.875em; } .row.gtr-150 > * { padding: 0 0 0 1.875em; } .row.gtr-150.gtr-uniform { margin-top: -1.875em; } .row.gtr-150.gtr-uniform > * { padding-top: 1.875em; } .row.gtr-200 { margin-top: 0; margin-left: -2.5em; } .row.gtr-200 > * { padding: 0 0 0 2.5em; } .row.gtr-200.gtr-uniform { margin-top: -2.5em; } .row.gtr-200.gtr-uniform > * { padding-top: 2.5em; } } @media screen and (max-width: 480px) { .row { display: flex; flex-wrap: wrap; box-sizing: border-box; align-items: stretch; } .row > * { box-sizing: border-box; } .row.gtr-uniform > * > :last-child { margin-bottom: 0; } .row.aln-left { justify-content: flex-start; } .row.aln-center { justify-content: center; } .row.aln-right { justify-content: flex-end; } .row.aln-top { align-items: flex-start; } .row.aln-middle { align-items: center; } .row.aln-bottom { align-items: flex-end; } .row > .imp-xsmall { order: -1; } .row > .col-1-xsmall { width: 8.33333%; } .row > .off-1-xsmall { margin-left: 8.33333%; } .row > .col-2-xsmall { width: 16.66667%; } .row > .off-2-xsmall { margin-left: 16.66667%; } .row > .col-3-xsmall { width: 25%; } .row > .off-3-xsmall { margin-left: 25%; } .row > .col-4-xsmall { width: 33.33333%; } .row > .off-4-xsmall { margin-left: 33.33333%; } .row > .col-5-xsmall { width: 41.66667%; } .row > .off-5-xsmall { margin-left: 41.66667%; } .row > .col-6-xsmall { width: 50%; } .row > .off-6-xsmall { margin-left: 50%; } .row > .col-7-xsmall { width: 58.33333%; } .row > .off-7-xsmall { margin-left: 58.33333%; } .row > .col-8-xsmall { width: 66.66667%; } .row > .off-8-xsmall { margin-left: 66.66667%; } .row > .col-9-xsmall { width: 75%; } .row > .off-9-xsmall { margin-left: 75%; } .row > .col-10-xsmall { width: 83.33333%; } .row > .off-10-xsmall { margin-left: 83.33333%; } .row > .col-11-xsmall { width: 91.66667%; } .row > .off-11-xsmall { margin-left: 91.66667%; } .row > .col-12-xsmall { width: 100%; } .row > .off-12-xsmall { margin-left: 100%; } .row.gtr-0 { margin-top: 0; margin-left: 0em; } .row.gtr-0 > * { padding: 0 0 0 0em; } .row.gtr-0.gtr-uniform { margin-top: 0em; } .row.gtr-0.gtr-uniform > * { padding-top: 0em; } .row.gtr-25 { margin-top: 0; margin-left: -0.3125em; } .row.gtr-25 > * { padding: 0 0 0 0.3125em; } .row.gtr-25.gtr-uniform { margin-top: -0.3125em; } .row.gtr-25.gtr-uniform > * { padding-top: 0.3125em; } .row.gtr-50 { margin-top: 0; margin-left: -0.625em; } .row.gtr-50 > * { padding: 0 0 0 0.625em; } .row.gtr-50.gtr-uniform { margin-top: -0.625em; } .row.gtr-50.gtr-uniform > * { padding-top: 0.625em; } .row { margin-top: 0; margin-left: -1.25em; } .row > * { padding: 0 0 0 1.25em; } .row.gtr-uniform { margin-top: -1.25em; } .row.gtr-uniform > * { padding-top: 1.25em; } .row.gtr-150 { margin-top: 0; margin-left: -1.875em; } .row.gtr-150 > * { padding: 0 0 0 1.875em; } .row.gtr-150.gtr-uniform { margin-top: -1.875em; } .row.gtr-150.gtr-uniform > * { padding-top: 1.875em; } .row.gtr-200 { margin-top: 0; margin-left: -2.5em; } .row.gtr-200 > * { padding: 0 0 0 2.5em; } .row.gtr-200.gtr-uniform { margin-top: -2.5em; } .row.gtr-200.gtr-uniform > * { padding-top: 2.5em; } } /* Section/Article */ section.special, article.special { text-align: center; } header.major { width: -moz-max-content; width: -webkit-max-content; width: -ms-max-content; width: max-content; margin-bottom: 2em; } header.major > :first-child { margin-bottom: 0; width: calc(100% + 0.5em); } header.major > :first-child:after { content: ''; background-color: #ffffff; display: block; height: 2px; margin: 0.325em 0 0.5em 0; width: 100%; } header.major > p { font-size: 0.7em; font-weight: 600; letter-spacing: 0.25em; margin-bottom: 0; text-transform: uppercase; } body.is-ie header.major > :first-child:after { max-width: 9em; } body.is-ie header.major > h1:after { max-width: 100% !important; } @media screen and (max-width: 736px) { header.major > p br { display: none; } } /* Form */ form { margin: 0 0 2em 0; } form > :last-child { margin-bottom: 0; } form > .fields { display: -moz-flex; display: -webkit-flex; display: -ms-flex; display: flex; -moz-flex-wrap: wrap; -webkit-flex-wrap: wrap; -ms-flex-wrap: wrap; flex-wrap: wrap; width: calc(100% + 3em); margin: -1.5em 0 2em -1.5em; } form > .fields > .field { -moz-flex-grow: 0; -webkit-flex-grow: 0; -ms-flex-grow: 0; flex-grow: 0; -moz-flex-shrink: 0; -webkit-flex-shrink: 0; -ms-flex-shrink: 0; flex-shrink: 0; padding: 1.5em 0 0 1.5em; width: calc(100% - 1.5em); } form > .fields > .field.half { width: calc(50% - 0.75em); } form > .fields > .field.third { width: calc(100%/3 - 0.5em); } form > .fields > .field.quarter { width: calc(25% - 0.375em); } @media screen and (max-width: 480px) { form > .fields { width: calc(100% + 3em); margin: -1.5em 0 2em -1.5em; } form > .fields > .field { padding: 1.5em 0 0 1.5em; width: calc(100% - 1.5em); } form > .fields > .field.half { width: calc(100% - 1.5em); } form > .fields > .field.third { width: calc(100% - 1.5em); } form > .fields > .field.quarter { width: calc(100% - 1.5em); } } label { color: #ffffff; display: block; font-size: 0.8em; font-weight: 600; letter-spacing: 0.25em; margin: 0 0 1em 0; text-transform: uppercase; } input[type="text"], input[type="password"], input[type="email"], input[type="tel"], input[type="search"], input[type="url"], select, textarea { -moz-appearance: none; -webkit-appearance: none; -ms-appearance: none; appearance: none; background: rgba(212, 212, 255, 0.035); border: none; border-radius: 0; color: inherit; display: block; outline: 0; padding: 0 1em; text-decoration: none; width: 100%; } input[type="text"]:invalid, input[type="password"]:invalid, input[type="email"]:invalid, input[type="tel"]:invalid, input[type="search"]:invalid, input[type="url"]:invalid, select:invalid, textarea:invalid { box-shadow: none; } input[type="text"]:focus, input[type="password"]:focus, input[type="email"]:focus, input[type="tel"]:focus, input[type="search"]:focus, input[type="url"]:focus, select:focus, textarea:focus { border-color: #9bf1ff; box-shadow: 0 0 0 2px #9bf1ff; } select { background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' preserveAspectRatio='none' viewBox='0 0 40 40'%3E%3Cpath d='M9.4,12.3l10.4,10.4l10.4-10.4c0.2-0.2,0.5-0.4,0.9-0.4c0.3,0,0.6,0.1,0.9,0.4l3.3,3.3c0.2,0.2,0.4,0.5,0.4,0.9 c0,0.4-0.1,0.6-0.4,0.9L20.7,31.9c-0.2,0.2-0.5,0.4-0.9,0.4c-0.3,0-0.6-0.1-0.9-0.4L4.3,17.3c-0.2-0.2-0.4-0.5-0.4-0.9 c0-0.4,0.1-0.6,0.4-0.9l3.3-3.3c0.2-0.2,0.5-0.4,0.9-0.4S9.1,12.1,9.4,12.3z' fill='rgba(212, 212, 255, 0.1)' /%3E%3C/svg%3E"); background-size: 1.25rem; background-repeat: no-repeat; background-position: calc(100% - 1rem) center; height: 2.75em; padding-right: 2.75em; text-overflow: ellipsis; } select option { color: #ffffff; background: #242943; } select:focus::-ms-value { background-color: transparent; } select::-ms-expand { display: none; } input[type="text"], input[type="password"], input[type="email"], input[type="tel"], input[type="search"], input[type="url"], select { height: 2.75em; } textarea { padding: 0.75em 1em; } input[type="checkbox"], input[type="radio"] { -moz-appearance: none; -webkit-appearance: none; -ms-appearance: none; appearance: none; display: block; float: left; margin-right: -2em; opacity: 0; width: 1em; z-index: -1; } input[type="checkbox"] + label, input[type="radio"] + label { text-decoration: none; color: #ffffff; cursor: pointer; display: inline-block; font-weight: 300; padding-left: 2.65em; padding-right: 0.75em; position: relative; } input[type="checkbox"] + label:before, input[type="radio"] + label:before { -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; display: inline-block; font-style: normal; font-variant: normal; text-rendering: auto; line-height: 1; text-transform: none !important; font-family: 'Font Awesome 5 Free'; font-weight: 900; } input[type="checkbox"] + label:before, input[type="radio"] + label:before { background: rgba(212, 212, 255, 0.035); content: ''; display: inline-block; font-size: 0.8em; height: 2.0625em; left: 0; letter-spacing: 0; line-height: 2.0625em; position: absolute; text-align: center; top: 0; width: 2.0625em; } input[type="checkbox"]:checked + label:before, input[type="radio"]:checked + label:before { background: #ffffff; border-color: #9bf1ff; content: '\f00c'; color: #242943; } input[type="checkbox"]:focus + label:before, input[type="radio"]:focus + label:before { box-shadow: 0 0 0 2px #9bf1ff; } input[type="radio"] + label:before { border-radius: 100%; } ::-webkit-input-placeholder { color: rgba(244, 244, 255, 0.2) !important; opacity: 1.0; } :-moz-placeholder { color: rgba(244, 244, 255, 0.2) !important; opacity: 1.0; } ::-moz-placeholder { color: rgba(244, 244, 255, 0.2) !important; opacity: 1.0; } :-ms-input-placeholder { color: rgba(244, 244, 255, 0.2) !important; opacity: 1.0; } /* Box */ .box { border: solid 1px rgba(212, 212, 255, 0.1); margin-bottom: 2em; padding: 1.5em; } .box > :last-child, .box > :last-child > :last-child, .box > :last-child > :last-child > :last-child { margin-bottom: 0; } .box.alt { border: 0; border-radius: 0; padding: 0; } /* Icon */ .icon { text-decoration: none; border-bottom: none; position: relative; } .icon:before { -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; display: inline-block; font-style: normal; font-variant: normal; text-rendering: auto; line-height: 1; text-transform: none !important; font-family: 'Font Awesome 5 Free'; font-weight: 400; } .icon > .label { display: none; } .icon:before { line-height: inherit; } .icon.solid:before { font-weight: 900; } .icon.brands:before { font-family: 'Font Awesome 5 Brands'; } .icon.alt:before { background-color: #ffffff; border-radius: 100%; color: #242943; display: inline-block; height: 2em; line-height: 2em; text-align: center; width: 2em; } a.icon.alt:before { -moz-transition: background-color 0.2s ease-in-out; -webkit-transition: background-color 0.2s ease-in-out; -ms-transition: background-color 0.2s ease-in-out; transition: background-color 0.2s ease-in-out; } a.icon.alt:hover:before { background-color: #6fc3df; } a.icon.alt:active:before { background-color: #37a6cb; } /* Image */ .image { border: 0; display: inline-block; position: relative; } .image img { display: block; } .image.left, .image.right { max-width: 30%; } .image.left img, .image.right img { width: 100%; } .image.left { float: left; margin: 0 1.5em 1.25em 0; top: 0.25em; } .image.right { float: right; margin: 0 0 1.25em 1.5em; top: 0.25em; } .image.fit { display: block; margin: 0 0 2em 0; width: 100%; } .image.fit img { width: 100%; } .image.main { display: block; margin: 2.5em 0; width: 100%; } .image.main img { width: 100%; } @media screen and (max-width: 736px) { .image.main { margin: 1.5em 0; } } /* List */ ol { list-style: decimal; margin: 0 0 2em 0; padding-left: 1.25em; } ol li { padding-left: 0.25em; } ul { list-style: disc; margin: 0 0 2em 0; padding-left: 1em; } ul li { padding-left: 0.5em; } ul.alt { list-style: none; padding-left: 0; } ul.alt li { border-top: solid 1px rgba(212, 212, 255, 0.1); padding: 0.5em 0; } ul.alt li:first-child { border-top: 0; padding-top: 0; } dl { margin: 0 0 2em 0; } dl dt { display: block; font-weight: 600; margin: 0 0 1em 0; } dl dd { margin-left: 2em; } /* Actions */ ul.actions { display: -moz-flex; display: -webkit-flex; display: -ms-flex; display: flex; cursor: default; list-style: none; margin-left: -1em; padding-left: 0; } ul.actions li { padding: 0 0 0 1em; vertical-align: middle; } ul.actions.special { -moz-justify-content: center; -webkit-justify-content: center; -ms-justify-content: center; justify-content: center; width: 100%; margin-left: 0; } ul.actions.special li:first-child { padding-left: 0; } ul.actions.stacked { -moz-flex-direction: column; -webkit-flex-direction: column; -ms-flex-direction: column; flex-direction: column; margin-left: 0; } ul.actions.stacked li { padding: 1.3em 0 0 0; } ul.actions.stacked li:first-child { padding-top: 0; } ul.actions.fit { width: calc(100% + 1em); } ul.actions.fit li { -moz-flex-grow: 1; -webkit-flex-grow: 1; -ms-flex-grow: 1; flex-grow: 1; -moz-flex-shrink: 1; -webkit-flex-shrink: 1; -ms-flex-shrink: 1; flex-shrink: 1; width: 100%; } ul.actions.fit li > * { width: 100%; } ul.actions.fit.stacked { width: 100%; } /* Icons */ ul.icons { cursor: default; list-style: none; padding-left: 0; } ul.icons li { display: inline-block; padding: 0 1em 0 0; } ul.icons li:last-child { padding-right: 0; } @media screen and (max-width: 736px) { ul.icons li { padding: 0 0.75em 0 0; } } /* Pagination */ ul.pagination { cursor: default; list-style: none; padding-left: 0; } ul.pagination li { display: inline-block; padding-left: 0; vertical-align: middle; } ul.pagination li > .page { -moz-transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out; -webkit-transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out; -ms-transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out; transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out; border-bottom: 0; display: inline-block; font-size: 0.8em; font-weight: 600; height: 1.5em; line-height: 1.5em; margin: 0 0.125em; min-width: 1.5em; padding: 0 0.5em; text-align: center; } ul.pagination li > .page.active { background-color: #ffffff; color: #242943; } ul.pagination li > .page.active:hover { background-color: #9bf1ff; color: #242943 !important; } ul.pagination li > .page.active:active { background-color: #53e3fb; } ul.pagination li:first-child { padding-right: 0.75em; } ul.pagination li:last-child { padding-left: 0.75em; } @media screen and (max-width: 480px) { ul.pagination li:nth-child(n+2):nth-last-child(n+2) { display: none; } ul.pagination li:first-child { padding-right: 0; } } /* Table */ .table-wrapper { -webkit-overflow-scrolling: touch; overflow-x: auto; } table { margin: 0 0 2em 0; width: 100%; } table tbody tr { border: solid 1px rgba(212, 212, 255, 0.1); border-left: 0; border-right: 0; } table tbody tr:nth-child(2n + 1) { background-color: rgba(212, 212, 255, 0.035); } table td { padding: 0.75em 0.75em; } table th { color: #ffffff; font-size: 0.9em; font-weight: 600; padding: 0 0.75em 0.75em 0.75em; text-align: left; } table thead { border-bottom: solid 2px rgba(212, 212, 255, 0.1); } table tfoot { border-top: solid 2px rgba(212, 212, 255, 0.1); } table.alt { border-collapse: separate; } table.alt tbody tr td { border: solid 1px rgba(212, 212, 255, 0.1); border-left-width: 0; border-top-width: 0; } table.alt tbody tr td:first-child { border-left-width: 1px; } table.alt tbody tr:first-child td { border-top-width: 1px; } table.alt thead { border-bottom: 0; } table.alt tfoot { border-top: 0; } /* Button */ input[type="submit"], input[type="reset"], input[type="button"], button, .button { -moz-appearance: none; -webkit-appearance: none; -ms-appearance: none; appearance: none; -moz-transition: background-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out, color 0.2s ease-in-out; -webkit-transition: background-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out, color 0.2s ease-in-out; -ms-transition: background-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out, color 0.2s ease-in-out; transition: background-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out, color 0.2s ease-in-out; background-color: transparent; border: 0; border-radius: 0; box-shadow: inset 0 0 0 2px #ffffff; color: #ffffff; cursor: pointer; display: inline-block; font-size: 0.8em; font-weight: 600; height: 3.5em; letter-spacing: 0.25em; line-height: 3.5em; padding: 0 1.75em; text-align: center; text-decoration: none; text-transform: uppercase; white-space: nowrap; } input[type="submit"]:hover, input[type="submit"]:active, input[type="reset"]:hover, input[type="reset"]:active, input[type="button"]:hover, input[type="button"]:active, button:hover, button:active, .button:hover, .button:active { box-shadow: inset 0 0 0 2px #9bf1ff; color: #9bf1ff; } input[type="submit"]:active, input[type="reset"]:active, input[type="button"]:active, button:active, .button:active { background-color: rgba(155, 241, 255, 0.1); box-shadow: inset 0 0 0 2px #53e3fb; color: #53e3fb; } input[type="submit"].icon:before, input[type="reset"].icon:before, input[type="button"].icon:before, button.icon:before, .button.icon:before { margin-right: 0.5em; } input[type="submit"].fit, input[type="reset"].fit, input[type="button"].fit, button.fit, .button.fit { width: 100%; } input[type="submit"].small, input[type="reset"].small, input[type="button"].small, button.small, .button.small { font-size: 0.6em; } input[type="submit"].large, input[type="reset"].large, input[type="button"].large, button.large, .button.large { font-size: 1.25em; height: 3em; line-height: 3em; } input[type="submit"].next, input[type="reset"].next, input[type="button"].next, button.next, .button.next { padding-right: 4.5em; position: relative; } input[type="submit"].next:before, input[type="submit"].next:after, input[type="reset"].next:before, input[type="reset"].next:after, input[type="button"].next:before, input[type="button"].next:after, button.next:before, button.next:after, .button.next:before, .button.next:after { -moz-transition: opacity 0.2s ease-in-out; -webkit-transition: opacity 0.2s ease-in-out; -ms-transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out; background-position: center right; background-repeat: no-repeat; background-size: 36px 24px; content: ''; display: block; height: 100%; position: absolute; right: 1.5em; top: 0; vertical-align: middle; width: 36px; } input[type="submit"].next:before, input[type="reset"].next:before, input[type="button"].next:before, button.next:before, .button.next:before { background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='36px' height='24px' viewBox='0 0 36 24' zoomAndPan='disable'%3E%3Cstyle%3Eline %7B stroke: %23ffffff%3B stroke-width: 2px%3B %7D%3C/style%3E%3Cline x1='0' y1='12' x2='34' y2='12' /%3E%3Cline x1='25' y1='4' x2='34' y2='12.5' /%3E%3Cline x1='25' y1='20' x2='34' y2='11.5' /%3E%3C/svg%3E"); } input[type="submit"].next:after, input[type="reset"].next:after, input[type="button"].next:after, button.next:after, .button.next:after { background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='36px' height='24px' viewBox='0 0 36 24' zoomAndPan='disable'%3E%3Cstyle%3Eline %7B stroke: %239bf1ff%3B stroke-width: 2px%3B %7D%3C/style%3E%3Cline x1='0' y1='12' x2='34' y2='12' /%3E%3Cline x1='25' y1='4' x2='34' y2='12.5' /%3E%3Cline x1='25' y1='20' x2='34' y2='11.5' /%3E%3C/svg%3E"); opacity: 0; z-index: 1; } input[type="submit"].next:hover:after, input[type="submit"].next:active:after, input[type="reset"].next:hover:after, input[type="reset"].next:active:after, input[type="button"].next:hover:after, input[type="button"].next:active:after, button.next:hover:after, button.next:active:after, .button.next:hover:after, .button.next:active:after { opacity: 1; } @media screen and (max-width: 1280px) { input[type="submit"].next, input[type="reset"].next, input[type="button"].next, button.next, .button.next { padding-right: 5em; } } input[type="submit"].primary, input[type="reset"].primary, input[type="button"].primary, button.primary, .button.primary { background-color: #ffffff; box-shadow: none; color: #242943; } input[type="submit"].primary:hover, input[type="submit"].primary:active, input[type="reset"].primary:hover, input[type="reset"].primary:active, input[type="button"].primary:hover, input[type="button"].primary:active, button.primary:hover, button.primary:active, .button.primary:hover, .button.primary:active { background-color: #9bf1ff; color: #242943 !important; } input[type="submit"].primary:active, input[type="reset"].primary:active, input[type="button"].primary:active, button.primary:active, .button.primary:active { background-color: #53e3fb; } input[type="submit"].disabled, input[type="submit"]:disabled, input[type="reset"].disabled, input[type="reset"]:disabled, input[type="button"].disabled, input[type="button"]:disabled, button.disabled, button:disabled, .button.disabled, .button:disabled { pointer-events: none; cursor: default; opacity: 0.25; } /* Tiles */ .tiles { display: -moz-flex; display: -webkit-flex; display: -ms-flex; display: flex; -moz-flex-wrap: wrap; -webkit-flex-wrap: wrap; -ms-flex-wrap: wrap; flex-wrap: wrap; border-top: 0 !important; } .tiles + * { border-top: 0 !important; } .tiles article { -moz-align-items: center; -webkit-align-items: center; -ms-align-items: center; align-items: center; display: -moz-flex; display: -webkit-flex; display: -ms-flex; display: flex; -moz-transition: -moz-transform 0.25s ease, opacity 0.25s ease, -moz-filter 1s ease, -webkit-filter 1s ease; -webkit-transition: -webkit-transform 0.25s ease, opacity 0.25s ease, -webkit-filter 1s ease, -webkit-filter 1s ease; -ms-transition: -ms-transform 0.25s ease, opacity 0.25s ease, -ms-filter 1s ease, -webkit-filter 1s ease; transition: transform 0.25s ease, opacity 0.25s ease, filter 1s ease, -webkit-filter 1s ease; padding: 4em 4em 2em 4em ; background-position: center; background-repeat: no-repeat; background-size: cover; cursor: default; height: 40vh; max-height: 40em; min-height: 23em; overflow: hidden; position: relative; width: 40%; } .tiles article .image { display: none; } .tiles article header { position: relative; z-index: 3; } .tiles article h3 { font-size: 1.75em; } .tiles article h3 a:hover { color: inherit !important; } .tiles article .link.primary { border: 0; height: 100%; left: 0; position: absolute; top: 0; width: 100%; z-index: 4; } .tiles article:before { -moz-transition: opacity 0.5s ease; -webkit-transition: opacity 0.5s ease; -ms-transition: opacity 0.5s ease; transition: opacity 0.5s ease; bottom: 0; content: ''; display: block; height: 100%; left: 0; opacity: 0.85; position: absolute; width: 100%; z-index: 2; } .tiles article:after { background-color: rgba(36, 41, 67, 0.25); content: ''; display: block; height: 100%; left: 0; position: absolute; top: 0; width: 100%; z-index: 1; } .tiles article:hover:before { opacity: 0; } .tiles article.is-transitioning { -moz-transform: scale(0.95); -webkit-transform: scale(0.95); -ms-transform: scale(0.95); transform: scale(0.95); -moz-filter: blur(0.5em); -webkit-filter: blur(0.5em); -ms-filter: blur(0.5em); filter: blur(0.5em); opacity: 0; } .tiles article:nth-child(4n - 1), .tiles article:nth-child(4n - 2) { width: 60%; } .tiles article:nth-child(6n - 5):before { background-color: #6fc3df; } .tiles article:nth-child(6n - 4):before { background-color: #8d82c4; } .tiles article:nth-child(6n - 3):before { background-color: #ec8d81; } .tiles article:nth-child(6n - 2):before { background-color: #e7b788; } .tiles article:nth-child(6n - 1):before { background-color: #8ea9e8; } .tiles article:nth-child(6n):before { background-color: #87c5a4; } @media screen and (max-width: 1280px) { .tiles article { padding: 4em 3em 2em 3em ; height: 30vh; max-height: 30em; min-height: 20em; } } @media screen and (max-width: 980px) { .tiles article { width: 50% !important; } } @media screen and (max-width: 736px) { .tiles article { padding: 3em 1.5em 1em 1.5em ; height: 16em; max-height: none; min-height: 0; } .tiles article h3 { font-size: 1.5em; } } @media screen and (max-width: 480px) { .tiles { display: block; } .tiles article { height: 20em; width: 100% !important; } } /* Contact Method */ .contact-method { margin: 0 0 2em 0; padding-left: 3.25em; position: relative; } .contact-method .icon { left: 0; position: absolute; top: 0; } .contact-method h3 { margin: 0 0 0.5em 0; } /* Spotlights */ .spotlights { border-top: 0 !important; } .spotlights + * { border-top: 0 !important; } .spotlights > section { display: -moz-flex; display: -webkit-flex; display: -ms-flex; display: flex; -moz-flex-direction: row; -webkit-flex-direction: row; -ms-flex-direction: row; flex-direction: row; background-color: #2e3450; } .spotlights > section > .image { background-position: center center; background-size: cover; border-radius: 0; display: block; position: relative; width: 30%; } .spotlights > section > .image img { border-radius: 0; display: block; width: 100%; } .spotlights > section > .image:before { background: rgba(36, 41, 67, 0.9); content: ''; display: block; height: 100%; left: 0; opacity: 0; position: absolute; top: 0; width: 100%; } .spotlights > section > .content { display: -moz-flex; display: -webkit-flex; display: -ms-flex; display: flex; -moz-flex-direction: column; -webkit-flex-direction: column; -ms-flex-direction: column; flex-direction: column; -moz-justify-content: center; -webkit-justify-content: center; -ms-justify-content: center; justify-content: center; -moz-align-items: center; -webkit-align-items: center; -ms-align-items: center; align-items: center; padding: 2em 3em 0.1em 3em ; width: 70%; } .spotlights > section > .content > .inner { margin: 0 auto; max-width: 100%; width: 65em; } .spotlights > section:nth-child(2n) { -moz-flex-direction: row-reverse; -webkit-flex-direction: row-reverse; -ms-flex-direction: row-reverse; flex-direction: row-reverse; background-color: #333856; } .spotlights > section:nth-child(2n) > .content { -moz-align-items: -moz-flex-end; -webkit-align-items: -webkit-flex-end; -ms-align-items: -ms-flex-end; align-items: flex-end; } @media screen and (max-width: 1680px) { .spotlights > section > .image { width: 40%; } .spotlights > section > .content { width: 60%; } } @media screen and (max-width: 1280px) { .spotlights > section > .image { width: 45%; } .spotlights > section > .content { width: 55%; } } @media screen and (max-width: 980px) { .spotlights > section { display: block; } .spotlights > section > .image { width: 100%; } .spotlights > section > .content { padding: 4em 3em 2em 3em ; width: 100%; } } @media screen and (max-width: 736px) { .spotlights > section > .content { padding: 3em 1.5em 1em 1.5em ; } } /* Header */ @-moz-keyframes reveal-header { 0% { top: -4em; opacity: 0; } 100% { top: 0; opacity: 1; } } @-webkit-keyframes reveal-header { 0% { top: -4em; opacity: 0; } 100% { top: 0; opacity: 1; } } @-ms-keyframes reveal-header { 0% { top: -4em; opacity: 0; } 100% { top: 0; opacity: 1; } } @keyframes reveal-header { 0% { top: -4em; opacity: 0; } 100% { top: 0; opacity: 1; } } #header { display: -moz-flex; display: -webkit-flex; display: -ms-flex; display: flex; background-color: #2a2f4a; box-shadow: 0 0 0.25em 0 rgba(0, 0, 0, 0.15); cursor: default; font-weight: 600; height: 3.25em; left: 0; letter-spacing: 0.25em; line-height: 3.25em; margin: 0; position: fixed; text-transform: uppercase; top: 0; width: 100%; z-index: 10000; } #header .logo { border: 0; display: inline-block; font-size: 0.8em; height: inherit; line-height: inherit; padding: 0 1.5em; } #header .logo strong { -moz-transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out; -webkit-transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out; -ms-transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out; transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out; background-color: #ffffff; color: #242943; display: inline-block; line-height: 1.65em; margin-right: 0.325em; padding: 0 0.125em 0 0.375em; } #header .logo:hover strong { background-color: #9bf1ff; } #header .logo:active strong { background-color: #53e3fb; } #header nav { display: -moz-flex; display: -webkit-flex; display: -ms-flex; display: flex; -moz-justify-content: -moz-flex-end; -webkit-justify-content: -webkit-flex-end; -ms-justify-content: -ms-flex-end; justify-content: flex-end; -moz-flex-grow: 1; -webkit-flex-grow: 1; -ms-flex-grow: 1; flex-grow: 1; height: inherit; line-height: inherit; } #header nav a { border: 0; display: block; font-size: 0.8em; height: inherit; line-height: inherit; padding: 0 0.75em; position: relative; vertical-align: middle; } #header nav a:last-child { padding-right: 1.5em; } #header nav a[href="#menu"] { padding-right: 3.325em !important; } #header nav a[href="#menu"]:before, #header nav a[href="#menu"]:after { background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='32' viewBox='0 0 24 32' preserveAspectRatio='none'%3E%3Cstyle%3Eline %7B stroke-width: 2px%3B stroke: %23ffffff%3B %7D%3C/style%3E%3Cline x1='0' y1='11' x2='24' y2='11' /%3E%3Cline x1='0' y1='21' x2='24' y2='21' /%3E%3Cline x1='0' y1='16' x2='24' y2='16' /%3E%3C/svg%3E"); background-position: center; background-repeat: no-repeat; background-size: 24px 32px; content: ''; display: block; height: 100%; position: absolute; right: 1.5em; top: 0; vertical-align: middle; width: 24px; } #header nav a[href="#menu"]:after { -moz-transition: opacity 0.2s ease-in-out; -webkit-transition: opacity 0.2s ease-in-out; -ms-transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out; background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='32' viewBox='0 0 24 32' preserveAspectRatio='none'%3E%3Cstyle%3Eline %7B stroke-width: 2px%3B stroke: %239bf1ff%3B %7D%3C/style%3E%3Cline x1='0' y1='11' x2='24' y2='11' /%3E%3Cline x1='0' y1='21' x2='24' y2='21' /%3E%3Cline x1='0' y1='16' x2='24' y2='16' /%3E%3C/svg%3E"); opacity: 0; z-index: 1; } #header nav a[href="#menu"]:hover:after, #header nav a[href="#menu"]:active:after { opacity: 1; } #header nav a[href="#menu"]:last-child { padding-right: 3.875em !important; } #header nav a[href="#menu"]:last-child:before, #header nav a[href="#menu"]:last-child:after { right: 2em; } #header.reveal { -moz-animation: reveal-header 0.35s ease; -webkit-animation: reveal-header 0.35s ease; -ms-animation: reveal-header 0.35s ease; animation: reveal-header 0.35s ease; } #header.alt { -moz-transition: opacity 2.5s ease; -webkit-transition: opacity 2.5s ease; -ms-transition: opacity 2.5s ease; transition: opacity 2.5s ease; -moz-transition-delay: 0.75s; -webkit-transition-delay: 0.75s; -ms-transition-delay: 0.75s; transition-delay: 0.75s; -moz-animation: none; -webkit-animation: none; -ms-animation: none; animation: none; background-color: transparent; box-shadow: none; position: absolute; } #header.alt.style1 .logo strong { color: #6fc3df; } #header.alt.style2 .logo strong { color: #8d82c4; } #header.alt.style3 .logo strong { color: #ec8d81; } #header.alt.style4 .logo strong { color: #e7b788; } #header.alt.style5 .logo strong { color: #8ea9e8; } #header.alt.style6 .logo strong { color: #87c5a4; } body.is-preload #header.alt { opacity: 0; } @media screen and (max-width: 1680px) { #header nav a[href="#menu"] { padding-right: 3.75em !important; } #header nav a[href="#menu"]:last-child { padding-right: 4.25em !important; } } @media screen and (max-width: 1280px) { #header nav a[href="#menu"] { padding-right: 4em !important; } #header nav a[href="#menu"]:last-child { padding-right: 4.5em !important; } } @media screen and (max-width: 736px) { #header { height: 2.75em; line-height: 2.75em; } #header .logo { padding: 0 1em; } #header nav a { padding: 0 0.5em; } #header nav a:last-child { padding-right: 1em; } #header nav a[href="#menu"] { padding-right: 3.25em !important; } #header nav a[href="#menu"]:before, #header nav a[href="#menu"]:after { right: 0.75em; } #header nav a[href="#menu"]:last-child { padding-right: 4em !important; } #header nav a[href="#menu"]:last-child:before, #header nav a[href="#menu"]:last-child:after { right: 1.5em; } } @media screen and (max-width: 480px) { #header .logo span { display: none; } #header nav a[href="#menu"] { overflow: hidden; padding-right: 0 !important; text-indent: 5em; white-space: nowrap; width: 5em; } #header nav a[href="#menu"]:before, #header nav a[href="#menu"]:after { right: 0; width: inherit; } #header nav a[href="#menu"]:last-child:before, #header nav a[href="#menu"]:last-child:after { width: 4em; right: 0; } } /* Banner */ #banner { -moz-align-items: center; -webkit-align-items: center; -ms-align-items: center; align-items: center; display: -moz-flex; display: -webkit-flex; display: -ms-flex; display: flex; padding: 12em 0 2em 0 ; background-attachment: fixed; background-position: center; background-repeat: no-repeat; background-size: cover; border-bottom: 0 !important; cursor: default; height: 60vh; margin-bottom: -3.25em; max-height: 32em; min-height: 22em; position: relative; top: -3.25em; } #banner:after { -moz-transition: opacity 2.5s ease; -webkit-transition: opacity 2.5s ease; -ms-transition: opacity 2.5s ease; transition: opacity 2.5s ease; -moz-transition-delay: 0.75s; -webkit-transition-delay: 0.75s; -ms-transition-delay: 0.75s; transition-delay: 0.75s; pointer-events: none; background-color: transparent; content: ''; display: block; height: 100%; left: 0; opacity: 0.85; position: absolute; top: 0; width: 100%; z-index: 1; } #banner h1 { font-size: 2.5em; } #banner > .inner { -moz-transition: opacity 1.5s ease, -moz-transform 0.5s ease-out, -moz-filter 0.5s ease, -webkit-filter 0.5s ease; -webkit-transition: opacity 1.5s ease, -webkit-transform 0.5s ease-out, -webkit-filter 0.5s ease, -webkit-filter 0.5s ease; -ms-transition: opacity 1.5s ease, -ms-transform 0.5s ease-out, -ms-filter 0.5s ease, -webkit-filter 0.5s ease; transition: opacity 1.5s ease, transform 0.5s ease-out, filter 0.5s ease, -webkit-filter 0.5s ease; padding: 0 !important; position: relative; z-index: 2; } #banner > .inner .image { display: none; } #banner > .inner header { width: auto; } #banner > .inner header > :first-child { width: auto; } #banner > .inner header > :first-child:after { max-width: 100%; } #banner > .inner .content { display: -moz-flex; display: -webkit-flex; display: -ms-flex; display: flex; -moz-align-items: center; -webkit-align-items: center; -ms-align-items: center; align-items: center; margin: 0 0 2em 0; } #banner > .inner .content > * { margin-right: 1.5em; margin-bottom: 0; } #banner > .inner .content > :last-child { margin-right: 0; } #banner > .inner .content p { font-size: 0.7em; font-weight: 600; letter-spacing: 0.25em; text-transform: uppercase; } #banner.major { height: 75vh; min-height: 30em; max-height: 50em; } #banner.major.alt { opacity: 0.75; } #banner.style1:after { background-color: #6fc3df; } #banner.style2:after { background-color: #8d82c4; } #banner.style3:after { background-color: #ec8d81; } #banner.style4:after { background-color: #e7b788; } #banner.style5:after { background-color: #8ea9e8; } #banner.style6:after { background-color: #87c5a4; } body.is-preload #banner:after { opacity: 1.0; } body.is-preload #banner > .inner { -moz-filter: blur(0.125em); -webkit-filter: blur(0.125em); -ms-filter: blur(0.125em); filter: blur(0.125em); -moz-transform: translateX(-0.5em); -webkit-transform: translateX(-0.5em); -ms-transform: translateX(-0.5em); transform: translateX(-0.5em); opacity: 0; } @media screen and (max-width: 1280px) { #banner { background-attachment: scroll; } } @media screen and (max-width: 736px) { #banner { padding: 5em 0 1em 0 ; height: auto; margin-bottom: -2.75em; max-height: none; min-height: 0; top: -2.75em; } #banner h1 { font-size: 2em; } #banner > .inner .content { display: block; } #banner > .inner .content > * { margin-right: 0; margin-bottom: 2em; } #banner.major { height: 75vh; min-height: 0; max-height: none; } } @media screen and (max-width: 480px) { #banner { padding: 6em 0 2em 0 ; } #banner > .inner .content p br { display: none; } #banner.major { padding: 8em 0 4em 0 ; } } /* Main */ #main { background-color: #2a2f4a; } #main > * { border-top: solid 1px rgba(212, 212, 255, 0.1); } #main > *:first-child { border-top: 0; } #main > * > .inner { padding: 4em 0 2em 0 ; margin: 0 auto; max-width: 65em; width: calc(100% - 6em); } @media screen and (max-width: 736px) { #main > * > .inner { padding: 3em 0 1em 0 ; width: calc(100% - 3em); } } #main.alt { background-color: transparent; border-bottom: solid 1px rgba(212, 212, 255, 0.1); } /* Contact */ #contact { border-bottom: solid 1px rgba(212, 212, 255, 0.1); overflow-x: hidden; } #contact > .inner { display: -moz-flex; display: -webkit-flex; display: -ms-flex; display: flex; padding: 0 !important; } #contact > .inner > :nth-child(2n - 1) { padding: 4em 3em 2em 0 ; border-right: solid 1px rgba(212, 212, 255, 0.1); width: 60%; } #contact > .inner > :nth-child(2n) { padding-left: 3em; width: 40%; } #contact > .inner > .split { padding: 0; } #contact > .inner > .split > * { padding: 3em 0 1em 3em ; position: relative; } #contact > .inner > .split > *:before { border-top: solid 1px rgba(212, 212, 255, 0.1); content: ''; display: block; margin-left: -3em; position: absolute; top: 0; width: calc(100vw + 3em); } #contact > .inner > .split > :first-child:before { display: none; } @media screen and (max-width: 980px) { #contact > .inner { display: block; } #contact > .inner > :nth-child(2n - 1) { padding: 4em 0 2em 0 ; border-right: 0; width: 100%; } #contact > .inner > :nth-child(2n) { padding-left: 0; width: 100%; } #contact > .inner > .split > * { padding: 3em 0 1em 0 ; } #contact > .inner > .split > :first-child:before { display: block; } } @media screen and (max-width: 736px) { #contact > .inner > :nth-child(2n - 1) { padding: 3em 0 1em 0 ; } } /* Footer */ #footer .copyright { font-size: 0.8em; list-style: none; padding-left: 0; } #footer .copyright li { border-left: solid 1px rgba(212, 212, 255, 0.1); color: rgba(244, 244, 255, 0.2); display: inline-block; line-height: 1; margin-left: 1em; padding-left: 1em; } #footer .copyright li:first-child { border-left: 0; margin-left: 0; padding-left: 0; } @media screen and (max-width: 480px) { #footer .copyright li { display: block; border-left: 0; margin-left: 0; padding-left: 0; line-height: inherit; } } /* Wrapper */ #wrapper { -moz-transition: -moz-filter 0.35s ease, -webkit-filter 0.35s ease, opacity 0.375s ease-out; -webkit-transition: -webkit-filter 0.35s ease, -webkit-filter 0.35s ease, opacity 0.375s ease-out; -ms-transition: -ms-filter 0.35s ease, -webkit-filter 0.35s ease, opacity 0.375s ease-out; transition: filter 0.35s ease, -webkit-filter 0.35s ease, opacity 0.375s ease-out; padding-top: 3.25em; height: 100vh; } #wrapper.is-transitioning { opacity: 0; } #wrapper > * > .inner { padding: 4em 0 2em 0 ; margin: 0 auto; max-width: 65em; width: calc(100% - 6em); } @media screen and (max-width: 736px) { #wrapper > * > .inner { padding: 3em 0 1em 0 ; width: calc(100% - 3em); } } @media screen and (max-width: 736px) { #wrapper { padding-top: 2.75em; } } /* Menu */ #menu { -moz-transition: -moz-transform 0.35s ease, opacity 0.35s ease, visibility 0.35s; -webkit-transition: -webkit-transform 0.35s ease, opacity 0.35s ease, visibility 0.35s; -ms-transition: -ms-transform 0.35s ease, opacity 0.35s ease, visibility 0.35s; transition: transform 0.35s ease, opacity 0.35s ease, visibility 0.35s; -moz-align-items: center; -webkit-align-items: center; -ms-align-items: center; align-items: center; display: -moz-flex; display: -webkit-flex; display: -ms-flex; display: flex; -moz-justify-content: center; -webkit-justify-content: center; -ms-justify-content: center; justify-content: center; pointer-events: none; background: rgb(0 0 0 / 50%);; box-shadow: none; height: 100%; left: 0; opacity: 0; overflow: hidden; padding: 3em 2em; position: fixed; top: 0; visibility: hidden; width: 100%; z-index: 10002; } #menu .inner { -moz-transition: -moz-transform 0.35s ease-out, opacity 0.35s ease, visibility 0.35s; -webkit-transition: -webkit-transform 0.35s ease-out, opacity 0.35s ease, visibility 0.35s; -ms-transition: -ms-transform 0.35s ease-out, opacity 0.35s ease, visibility 0.35s; transition: transform 0.35s ease-out, opacity 0.35s ease, visibility 0.35s; -moz-transform: rotateX(20deg); -webkit-transform: rotateX(20deg); -ms-transform: rotateX(20deg); transform: rotateX(20deg); -webkit-overflow-scrolling: touch; max-width: 100%; max-height: 100vh; opacity: 0; overflow: auto; text-align: center; visibility: hidden; width: 18em; } #menu .inner > :first-child { margin-top: 2em; } #menu .inner > :last-child { margin-bottom: 3em; } #menu ul { margin: 0 0 1em 0; } #menu ul.links { list-style: none; padding: 0; } #menu ul.links > li { padding: 0; } #menu ul.links > li > a:not(.button) { border: 0; border-top: solid 1px rgba(212, 212, 255, 0.1); display: block; font-size: 0.8em; font-weight: 600; letter-spacing: 0.25em; line-height: 4em; text-decoration: none; text-transform: uppercase; } #menu ul.links > li > .button { display: block; margin: 0.5em 0 0 0; } #menu ul.links > li:first-child > a:not(.button) { border-top: 0 !important; } #menu .close { -moz-transition: color 0.2s ease-in-out; -webkit-transition: color 0.2s ease-in-out; -ms-transition: color 0.2s ease-in-out; transition: color 0.2s ease-in-out; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); border: 0; cursor: pointer; display: block; height: 4em; line-height: 4em; overflow: hidden; padding-right: 1.25em; position: absolute; right: 0; text-align: right; text-indent: 8em; top: 0; vertical-align: middle; white-space: nowrap; width: 8em; } #menu .close:before, #menu .close:after { -moz-transition: opacity 0.2s ease-in-out; -webkit-transition: opacity 0.2s ease-in-out; -ms-transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out; background-position: center; background-repeat: no-repeat; content: ''; display: block; height: 4em; position: absolute; right: 0; top: 0; width: 4em; } #menu .close:before { background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='20px' height='20px' viewBox='0 0 20 20' zoomAndPan='disable'%3E%3Cstyle%3Eline %7B stroke: %23ffffff%3B stroke-width: 2%3B %7D%3C/style%3E%3Cline x1='0' y1='0' x2='20' y2='20' /%3E%3Cline x1='20' y1='0' x2='0' y2='20' /%3E%3C/svg%3E"); } #menu .close:after { background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='20px' height='20px' viewBox='0 0 20 20' zoomAndPan='disable'%3E%3Cstyle%3Eline %7B stroke: %239bf1ff%3B stroke-width: 2%3B %7D%3C/style%3E%3Cline x1='0' y1='0' x2='20' y2='20' /%3E%3Cline x1='20' y1='0' x2='0' y2='20' /%3E%3C/svg%3E"); opacity: 0; } #menu .close:hover:after, #menu .close:active:after { opacity: 1; } body.is-ie #menu { background: rgba(42, 47, 74, 0.975); } body.is-menu-visible #wrapper { -moz-filter: blur(0.5em); -webkit-filter: blur(0.5em); -ms-filter: blur(0.5em); filter: blur(0.5em); } body.is-menu-visible #menu { pointer-events: auto; opacity: 1; visibility: visible; } body.is-menu-visible #menu .inner { -moz-transform: none; -webkit-transform: none; -ms-transform: none; transform: none; opacity: 1; visibility: visible; } ================================================ FILE: app/src/main/assets/web/assets/js/dist.js ================================================ /*! * Powered by uglifiyJS v2.6.1, Build by http://tool.uis.cc/jsmin/ * build time: Sun Jul 25 2021 19:58:00 GMT+0800 (中国标准时间) */ !function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}function d(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&t>0&&t-1 in e)}function A(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase()}function j(e,n,r){return m(n)?k.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?k.grep(e,function(e){return e===n!==r}):"string"!=typeof n?k.grep(e,function(e){return-1c;c++)(r=e[c]).style&&(n=r.style.display,t?("none"===n&&(l[c]=Q.get(r,"display")||null,l[c]||(r.style.display="")),""===r.style.display&&se(r)&&(l[c]=(u=a=o=void 0,a=(i=r).ownerDocument,s=i.nodeName,(u=ce[s])||(o=a.body.appendChild(a.createElement(s)),u=k.css(o,"display"),o.parentNode.removeChild(o),"none"===u&&(u="block"),ce[s]=u)))):"none"!==n&&(l[c]="none",Q.set(r,"display",n)));for(c=0;f>c;c++)null!=l[c]&&(e[c].style.display=l[c]);return e}function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?k.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;r>n;n++)Q.set(e[n],"globalEval",!t||Q.get(t[n],"globalEval"))}function we(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;h>d;d++)if((o=e[d])||0===o)if("object"===w(o))k.merge(p,o.nodeType?[o]:o);else if(be.test(o)){for(a=a||f.appendChild(t.createElement("div")),s=(de.exec(o)||["",""])[1].toLowerCase(),u=ge[s]||ge._default,a.innerHTML=u[1]+k.htmlPrefilter(o)+u[2],c=u[0];c--;)a=a.lastChild;k.merge(p,a.childNodes),(a=f.firstChild).textContent=""}else p.push(t.createTextNode(o));for(f.textContent="",d=0;o=p[d++];)if(r&&-1n;n++)k.event.add(t,i,l[i][n]);J.hasData(e)&&(s=J.access(e),u=k.extend({},s),J.set(t,u))}}function Ie(n,r,i,o){r=g.apply([],r);var e,t,a,s,u,l,c=0,f=n.length,p=f-1,d=r[0],h=m(d);if(h||f>1&&"string"==typeof d&&!y.checkClone&&Le.test(d))return n.each(function(e){var t=n.eq(e);h&&(r[0]=d.call(this,e,t.html())),Ie(t,r,i,o)});if(f&&(t=(e=we(r,n[0].ownerDocument,!1,n,o)).firstChild,1===e.childNodes.length&&(e=t),t||o)){for(s=(a=k.map(ve(e,"script"),Pe)).length;f>c;c++)u=e,c!==p&&(u=k.clone(u,!0,!0),s&&k.merge(a,ve(u,"script"))),i.call(n[c],u,c);if(s)for(l=a[a.length-1].ownerDocument,k.map(a,Re),c=0;s>c;c++)u=a[c],he.test(u.type||"")&&!Q.access(u,"globalEval")&&k.contains(l,u)&&(u.src&&"module"!==(u.type||"").toLowerCase()?k._evalUrl&&!u.noModule&&k._evalUrl(u.src,{nonce:u.nonce||u.getAttribute("nonce")}):b(u.textContent.replace(He,""),u,l))}return n}function We(e,t,n){for(var r,i=t?k.filter(t,e):e,o=0;null!=(r=i[o]);o++)n||1!==r.nodeType||k.cleanData(ve(r)),r.parentNode&&(n&&oe(r)&&ye(ve(r,"script")),r.parentNode.removeChild(r));return e}function _e(e,t,n){var r,i,o,a,s=e.style;return(n=n||Fe(e))&&(""!==(a=n.getPropertyValue(t)||n[t])||oe(e)||(a=k.style(e,t)),!y.pixelBoxStyles()&&$e.test(a)&&Be.test(t)&&(r=s.width,i=s.minWidth,o=s.maxWidth,s.minWidth=s.maxWidth=s.width=a,a=n.width,s.width=r,s.minWidth=i,s.maxWidth=o)),void 0!==a?a+"":a}function ze(e,t){return{get:function(){return e()?void delete this.get:(this.get=t).apply(this,arguments)}}}function Ge(e){var t=k.cssProps[e]||Ve[e];return t||(e in Xe?e:Ve[e]=function(e){for(var t=e[0].toUpperCase()+e.slice(1),n=Ue.length;n--;)if((e=Ue[n]+t)in Xe)return e}(e)||e)}function Ze(e,t,n){var r=ne.exec(t);return r?Math.max(0,r[2]-(n||0))+(r[3]||"px"):t}function et(e,t,n,r,i,o){var a="width"===t?1:0,s=0,u=0;if(n===(r?"border":"content"))return 0;for(;4>a;a+=2)"margin"===n&&(u+=k.css(e,n+re[a],!0,i)),r?("content"===n&&(u-=k.css(e,"padding"+re[a],!0,i)),"margin"!==n&&(u-=k.css(e,"border"+re[a]+"Width",!0,i))):(u+=k.css(e,"padding"+re[a],!0,i),"padding"!==n?u+=k.css(e,"border"+re[a]+"Width",!0,i):s+=k.css(e,"border"+re[a]+"Width",!0,i));return!r&&o>=0&&(u+=Math.max(0,Math.ceil(e["offset"+t[0].toUpperCase()+t.slice(1)]-o-u-s-.5))||0),u}function tt(e,t,n){var r=Fe(e),i=(!y.boxSizingReliable()||n)&&"border-box"===k.css(e,"boxSizing",!1,r),o=i,a=_e(e,t,r),s="offset"+t[0].toUpperCase()+t.slice(1);if($e.test(a)){if(!n)return a;a="auto"}return(!y.boxSizingReliable()&&i||"auto"===a||!parseFloat(a)&&"inline"===k.css(e,"display",!1,r))&&e.getClientRects().length&&(i="border-box"===k.css(e,"boxSizing",!1,r),(o=s in e)&&(a=e[s])),(a=parseFloat(a)||0)+et(e,t,n||(i?"border":"content"),o,r,a)+"px"}function nt(e,t,n,r,i){return new nt.prototype.init(e,t,n,r,i)}function lt(){it&&(!1===E.hidden&&C.requestAnimationFrame?C.requestAnimationFrame(lt):C.setTimeout(lt,k.fx.interval),k.fx.tick())}function ct(){return C.setTimeout(function(){rt=void 0}),rt=Date.now()}function ft(e,t){var n,r=0,i={height:e};for(t=t?1:0;4>r;r+=2-t)i["margin"+(n=re[r])]=i["padding"+n]=e;return t&&(i.opacity=i.width=e),i}function pt(e,t,n){for(var r,i=(dt.tweeners[t]||[]).concat(dt.tweeners["*"]),o=0,a=i.length;a>o;o++)if(r=i[o].call(n,t,e))return r}function dt(o,e,t){var n,a,r=0,i=dt.prefilters.length,s=k.Deferred().always(function(){delete u.elem}),u=function(){if(a)return!1;for(var e=rt||ct(),t=Math.max(0,l.startTime+l.duration-e),n=1-(t/l.duration||0),r=0,i=l.tweens.length;i>r;r++)l.tweens[r].run(n);return s.notifyWith(o,[l,n,t]),1>n&&i?t:(i||s.notifyWith(o,[l,1,0]),s.resolveWith(o,[l]),!1)},l=s.promise({elem:o,props:k.extend({},e),opts:k.extend(!0,{specialEasing:{},easing:k.easing._default},t),originalProperties:e,originalOptions:t,startTime:rt||ct(),duration:t.duration,tweens:[],createTween:function(e,t){var n=k.Tween(o,l.opts,e,t,l.opts.specialEasing[e]||l.opts.easing);return l.tweens.push(n),n},stop:function(e){var t=0,n=e?l.tweens.length:0;if(a)return this;for(a=!0;n>t;t++)l.tweens[t].run(1);return e?(s.notifyWith(o,[l,1,0]),s.resolveWith(o,[l,e])):s.rejectWith(o,[l,e]),this}}),c=l.props;for((!function(e,t){var n,r,i,o,a;for(n in e)if(i=t[r=V(n)],o=e[n],Array.isArray(o)&&(i=o[1],o=e[n]=o[0]),n!==r&&(e[r]=o,delete e[n]),(a=k.cssHooks[r])&&"expand"in a)for(n in o=a.expand(o),delete e[r],o)n in e||(e[n]=o[n],t[n]=i);else t[r]=i}(c,l.opts.specialEasing));i>r;r++)if(n=dt.prefilters[r].call(l,o,c,l.opts))return m(n.stop)&&(k._queueHooks(l.elem,l.opts.queue).stop=n.stop.bind(n)),n;return k.map(c,pt,l),m(l.opts.start)&&l.opts.start.call(o,l),l.progress(l.opts.progress).done(l.opts.done,l.opts.complete).fail(l.opts.fail).always(l.opts.always),k.fx.timer(k.extend(u,{elem:o,anim:l,queue:l.opts.queue})),l}function mt(e){return(e.match(R)||[]).join(" ")}function xt(e){return e.getAttribute&&e.getAttribute("class")||""}function bt(e){return Array.isArray(e)?e:"string"==typeof e&&e.match(R)||[]}function qt(n,e,r,i){var t;if(Array.isArray(e))k.each(e,function(e,t){r||Nt.test(n)?i(n,t):qt(n+"["+("object"==typeof t&&null!=t?e:"")+"]",t,r,i)});else if(r||"object"!==w(e))i(n,e);else for(t in e)qt(n+"["+t+"]",e[t],r,i)}function Bt(o){return function(e,t){"string"!=typeof e&&(t=e,e="*");var n,r=0,i=e.toLowerCase().match(R)||[];if(m(t))for(;n=i[r++];)"+"===n[0]?(n=n.slice(1)||"*",(o[n]=o[n]||[]).unshift(t)):(o[n]=o[n]||[]).push(t)}}function _t(t,i,o,a){function l(e){var r;return s[e]=!0,k.each(t[e]||[],function(e,t){var n=t(i,o,a);return"string"!=typeof n||u||s[n]?u?!(r=n):void 0:(i.dataTypes.unshift(n),l(n),!1)}),r}var s={},u=t===Wt;return l(i.dataTypes[0])||!s["*"]&&l("*")}function zt(e,t){var n,r,i=k.ajaxSettings.flatOptions||{};for(n in t)void 0!==t[n]&&((i[n]?e:r||(r={}))[n]=t[n]);return r&&k.extend(!0,e,r),e}var t=[],E=C.document,r=Object.getPrototypeOf,s=t.slice,g=t.concat,u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType},x=function(e){return null!=e&&e===e.window},c={type:!0,src:!0,nonce:!0,noModule:!0},f="3.4.1",k=function(e,t){return new k.fn.init(e,t)},p=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g;k.fn=k.prototype={jquery:f,constructor:k,length:0,toArray:function(){return s.call(this)},get:function(e){return null==e?s.call(this):0>e?this[e+this.length]:this[e]},pushStack:function(e){var t=k.merge(this.constructor(),e);return t.prevObject=this,t},each:function(e){return k.each(this,e)},map:function(n){return this.pushStack(k.map(this,function(e,t){return n.call(e,t,e)}))},slice:function(){return this.pushStack(s.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(e){var t=this.length,n=+e+(0>e?t:0);return this.pushStack(n>=0&&t>n?[this[n]]:[])},end:function(){return this.prevObject||this.constructor()},push:u,sort:t.sort,splice:t.splice},k.extend=k.fn.extend=function(){var e,t,n,r,i,o,a=arguments[0]||{},s=1,u=arguments.length,l=!1;for("boolean"==typeof a&&(l=a,a=arguments[s]||{},s++),"object"==typeof a||m(a)||(a={}),s===u&&(a=this,s--);u>s;s++)if(null!=(e=arguments[s]))for(t in e)r=e[t],"__proto__"!==t&&a!==r&&(l&&r&&(k.isPlainObject(r)||(i=Array.isArray(r)))?(n=a[t],o=i&&!Array.isArray(n)?[]:i||k.isPlainObject(n)?n:{},i=!1,a[t]=k.extend(l,o,r)):void 0!==r&&(a[t]=r));return a},k.extend({expando:"jQuery"+(f+Math.random()).replace(/\D/g,""),isReady:!0,error:function(e){throw new Error(e)},noop:function(){},isPlainObject:function(e){var t,n;return!(!e||"[object Object]"!==o.call(e)||(t=r(e))&&("function"!=typeof(n=v.call(t,"constructor")&&t.constructor)||a.call(n)!==l))},isEmptyObject:function(e){var t;for(t in e)return!1;return!0},globalEval:function(e,t){b(e,{nonce:t&&t.nonce})},each:function(e,t){var n,r=0;if(d(e))for(n=e.length;n>r&&!1!==t.call(e[r],r,e[r]);r++);else for(r in e)if(!1===t.call(e[r],r,e[r]))break;return e},trim:function(e){return null==e?"":(e+"").replace(p,"")},makeArray:function(e,t){var n=t||[];return null!=e&&(d(Object(e))?k.merge(n,"string"==typeof e?[e]:e):u.call(n,e)),n},inArray:function(e,t,n){return null==t?-1:i.call(t,e,n)},merge:function(e,t){for(var n=+t.length,r=0,i=e.length;n>r;r++)e[i++]=t[r];return e.length=i,e},grep:function(e,t,n){for(var r=[],i=0,o=e.length,a=!n;o>i;i++)!t(e[i],i)!==a&&r.push(e[i]);return r},map:function(e,t,n){var r,i,o=0,a=[];if(d(e))for(r=e.length;r>o;o++)null!=(i=t(e[o],o,n))&&a.push(i);else for(o in e)null!=(i=t(e[o],o,n))&&a.push(i);return g.apply([],a)},guid:1,support:y}),"function"==typeof Symbol&&(k.fn[Symbol.iterator]=t[Symbol.iterator]),k.each("Boolean Number String Function Array Date RegExp Object Error Symbol".split(" "),function(e,t){n["[object "+t+"]"]=t.toLowerCase()});var h=function(n){function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&((e?e.ownerDocument||e:m)!==C&&T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!A[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&U.test(t)){for((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=k),o=(l=h(t)).length;o--;)l[o]="#"+s+" "+xe(l[o]);c=l.join(","),f=ee.test(t)&&ye(e.parentNode)||e}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){A(t,!0)}finally{s===k&&e.removeAttribute("id")}}}return g(t.replace(B,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[k]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){for(var n=e.split("|"),r=n.length;r--;)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)for(;n=n.nextSibling;)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){for(var n,r=a([],e.length,o),i=r.length;i--;)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}function me(){}function xe(e){for(var t=0,n=e.length,r="";n>t;t++)r+=e[t].value;return r}function be(s,e,t){var u=e.dir,l=e.next,c=l||u,f=t&&"parentNode"===c,p=r++;return e.first?function(e,t,n){for(;e=e[u];)if(1===e.nodeType||f)return s(e,t,n);return!1}:function(e,t,n){var r,i,o,a=[S,p];if(n){for(;e=e[u];)if((1===e.nodeType||f)&&s(e,t,n))return!0}else for(;e=e[u];)if(1===e.nodeType||f)if(i=(o=e[k]||(e[k]={}))[e.uniqueID]||(o[e.uniqueID]={}),l&&l===e.nodeName.toLowerCase())e=e[u]||e;else{if((r=i[c])&&r[0]===S&&r[1]===p)return a[2]=r[2];if((i[c]=a)[2]=s(e,t,n))return!0}return!1}}function we(i){return 1s;s++)(o=e[s])&&(n&&!n(o,r,i)||(a.push(o),l&&t.push(s)));return a}function Ce(d,h,g,v,y,e){return v&&!v[k]&&(v=Ce(v)),y&&!y[k]&&(y=Ce(y,e)),le(function(e,t,n,r){var i,o,a,s=[],u=[],l=t.length,c=e||function(e,t,n){for(var r=0,i=t.length;i>r;r++)se(e,t[r],n);return n}(h||"*",n.nodeType?[n]:n,[]),f=!d||!e&&h?c:Te(c,s,d,n,r),p=g?y||(e?d:l||v)?[]:t:f;if(g&&g(f,p,n,r),v)for(i=Te(p,u),v(i,[],n,r),o=i.length;o--;)(a=i[o])&&(p[u[o]]=!(f[u[o]]=a));if(e){if(y||d){if(y){for(i=[],o=p.length;o--;)(a=p[o])&&i.push(f[o]=a);y(null,p=[],i,r)}for(o=p.length;o--;)(a=p[o])&&-1<(i=y?P(e,a):s[o])&&(e[i]=!(t[i]=a))}}else p=Te(p===t?p.splice(l,p.length):p),y?y(null,t,p,r):H.apply(t,p)})}function Ee(e){for(var i,t,n,r=e.length,o=b.relative[e[0].type],a=o||b.relative[" "],s=o?1:0,u=be(function(e){return e===i},a,!0),l=be(function(e){return-1s;s++)if(t=b.relative[e[s].type])c=[be(we(c),t)];else{if((t=b.filter[e[s].type].apply(null,e[s].matches))[k]){for(n=++s;r>n&&!b.relative[e[n].type];n++);return Ce(s>1&&we(c),s>1&&xe(e.slice(0,s-1).concat({value:" "===e[s-2].type?"*":""})).replace(B,"$1"),t,n>s&&Ee(e.slice(s,n)),r>n&&Ee(e=e.slice(n)),r>n&&xe(e))}c.push(t)}return we(c)}var e,d,b,o,i,h,f,g,w,u,l,T,C,a,E,v,s,c,y,k="sizzle"+1*new Date,m=n.document,S=0,r=0,p=ue(),x=ue(),N=ue(),A=ue(),D=function(e,t){return e===t&&(l=!0),0},j={}.hasOwnProperty,t=[],q=t.pop,L=t.push,H=t.push,O=t.slice,P=function(e,t){for(var n=0,r=e.length;r>n;n++)if(e[n]===t)return n;return-1},R="checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",M="[\\x20\\t\\r\\n\\f]",I="(?:\\\\.|[\\w-]|[^\x00-\\xa0])+",W="\\["+M+"*("+I+")(?:"+M+"*([*^$|!~]?=)"+M+"*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|("+I+"))|)"+M+"*\\]",$=":("+I+")(?:\\((('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|((?:\\\\.|[^\\\\()[\\]]|"+W+")*)|.*)\\)|)",F=new RegExp(M+"+","g"),B=new RegExp("^"+M+"+|((?:^|[^\\\\])(?:\\\\.)*)"+M+"+$","g"),_=new RegExp("^"+M+"*,"+M+"*"),z=new RegExp("^"+M+"*([>+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp($),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+$),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\([\\da-f]{1,6}"+M+"?|("+M+")|.)","ig"),ne=function(e,t,n){var r="0x"+t-65536;return r!=r||n?t:0>r?String.fromCharCode(r+65536):String.fromCharCode(r>>10|55296,1023&r|56320)},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\x00"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(m.childNodes),m.childNodes),t[m.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){for(var n=e.length,r=0;e[n++]=t[r++];);e.length=n-1}}}for(e in d=se.support={},i=se.isXML=function(e){var t=e.namespaceURI,n=(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:m;return r!==C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),m!==C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=k,!C.getElementsByName||!C.getElementsByName(k).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];for(i=t.getElementsByName(e),r=0;o=i[r++];)if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){for(;n=o[i++];)1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){return"undefined"!=typeof t.getElementsByClassName&&E?t.getElementsByClassName(e):void 0},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+k+"-]").length||v.push("~="),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+k+"+*").length||v.push(".#.+[+~]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",$)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)for(;t=t.parentNode;)if(t===e)return!0;return!1},D=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)===(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e===C||e.ownerDocument===m&&y(m,e)?-1:t===C||t.ownerDocument===m&&y(m,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e===C?-1:t===C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);for(n=e;n=n.parentNode;)a.unshift(n);for(n=t;n=n.parentNode;)s.unshift(n);for(;a[r]===s[r];)r++;return r?pe(a[r],s[r]):a[r]===m?-1:s[r]===m?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if((e.ownerDocument||e)!==C&&T(e),d.matchesSelector&&E&&!A[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){A(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=p[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&p(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1=0}}},PSEUDO:function(e,o){var t,a=b.pseudos[e]||b.setFilters[e.toLowerCase()]||se.error("unsupported pseudo: "+e);return a[k]?a(o):1n?n+t:n]}),even:ve(function(e,t){for(var n=0;t>n;n+=2)e.push(n);return e}),odd:ve(function(e,t){for(var n=1;t>n;n+=2)e.push(n);return e}),lt:ve(function(e,t,n){for(var r=0>n?n+t:n>t?t:n;0<=--r;)e.push(r);return e}),gt:ve(function(e,t,n){for(var r=0>n?n+t:n;++r0)for(;l--;)c[l]||f[l]||(f[l]=q.call(r));f=Te(f)}H.apply(r,f),i&&!e&&0:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;k.filter=function(e,t,n){var r=t[0];return n&&(e=":not("+e+")"),1===t.length&&1===r.nodeType?k.find.matchesSelector(r,e)?[r]:[]:k.find.matches(e,k.grep(t,function(e){return 1===e.nodeType}))},k.fn.extend({find:function(e){var t,n,r=this.length,i=this;if("string"!=typeof e)return this.pushStack(k(e).filter(function(){for(t=0;r>t;t++)if(k.contains(i[t],this))return!0}));for(n=this.pushStack([]),t=0;r>t;t++)k.find(e,i[t],n);return r>1?k.uniqueSort(n):n},filter:function(e){return this.pushStack(j(this,e||[],!1))},not:function(e){return this.pushStack(j(this,e||[],!0))},is:function(e){return!!j(this,"string"==typeof e&&N.test(e)?k(e):e||[],!1).length}});var q,L=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/;(k.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||q,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:L.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof k?t[0]:t,k.merge(this,k.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),D.test(r[1])&&k.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(k):k.makeArray(e,this)}).prototype=k.fn,q=k(E);var H=/^(?:parents|prev(?:Until|All))/,O={children:!0,contents:!0,next:!0,prev:!0};k.fn.extend({has:function(e){var t=k(e,this),n=t.length;return this.filter(function(){for(var e=0;n>e;e++)if(k.contains(this,t[e]))return!0})},closest:function(e,t){var n,r=0,i=this.length,o=[],a="string"!=typeof e&&k(e);if(!N.test(e))for(;i>r;r++)for(n=this[r];n&&n!==t;n=n.parentNode)if(n.nodeType<11&&(a?-1=n&&l--}),this},has:function(e){return e?-1i)){if((e=a.apply(n,r))===o.promise())throw new TypeError("Thenable self-resolution");t=e&&("object"==typeof e||"function"==typeof e)&&e.then,m(t)?s?t.call(e,l(u,o,M,s),l(u,o,I,s)):(u++,t.call(e,l(u,o,M,s),l(u,o,I,s),l(u,o,M,o.notifyWith))):(a!==M&&(n=void 0,r=[e]),(s||o.resolveWith)(n,r))}},t=s?e:function(){try{e()}catch(e){k.Deferred.exceptionHook&&k.Deferred.exceptionHook(e,t.stackTrace),i+1>=u&&(a!==I&&(n=void 0,r=[e]),o.rejectWith(n,r))}};i?t():(k.Deferred.getStackHook&&(t.stackTrace=k.Deferred.getStackHook()),C.setTimeout(t))}}var u=0;return k.Deferred(function(e){o[0][3].add(l(0,e,m(r)?r:M,e.notifyWith)),o[1][3].add(l(0,e,m(t)?t:M)),o[2][3].add(l(0,e,m(n)?n:I))}).promise()},promise:function(e){return null!=e?k.extend(e,a):a}},s={};return k.each(o,function(e,t){var n=t[2],r=t[5];a[t[1]]=n.add,r&&n.add(function(){i=r},o[3-e][2].disable,o[3-e][3].disable,o[0][2].lock,o[0][3].lock),n.add(t[3].fire),s[t[0]]=function(){return s[t[0]+"With"](this===s?void 0:this,arguments),this},s[t[0]+"With"]=n.fireWith}),a.promise(s),e&&e.call(s,s),s},when:function(e){var n=arguments.length,t=n,r=Array(t),i=s.call(arguments),o=k.Deferred(),a=function(t){return function(e){r[t]=this,i[t]=1=n&&(W(e,o.done(a(t)).resolve,o.reject,!n),"pending"===o.state()||m(i[t]&&i[t].then)))return o.then();for(;t--;)W(i[t],a(t),o.reject);return o.promise()}});var $=/^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/;k.Deferred.exceptionHook=function(e,t){C.console&&C.console.warn&&e&&$.test(e.name)&&C.console.warn("jQuery.Deferred exception: "+e.message,e.stack,t)},k.readyException=function(e){C.setTimeout(function(){throw e})};var F=k.Deferred();k.fn.ready=function(e){return F.then(e)["catch"](function(e){k.readyException(e)}),this},k.extend({isReady:!1,readyWait:1,ready:function(e){(!0===e?--k.readyWait:k.isReady)||(k.isReady=!0)!==e&&0<--k.readyWait||F.resolveWith(E,[k])}}),k.ready.then=F.then,"complete"===E.readyState||"loading"!==E.readyState&&!E.documentElement.doScroll?C.setTimeout(k.ready):(E.addEventListener("DOMContentLoaded",B),C.addEventListener("load",B));var _=function(e,t,n,r,i,o,a){var s=0,u=e.length,l=null==n;if("object"===w(n))for(s in i=!0,n)_(e,t,s,n[s],!0,o,a);else if(void 0!==r&&(i=!0,m(r)||(a=!0),l&&(a?(t.call(e,r),t=null):(l=t,t=function(e,t,n){return l.call(k(e),n)})),t))for(;u>s;s++)t(e[s],n,a?r:r.call(e[s],s,t(e[s],n)));return i?e:l?t.call(e):u?t(e[0],n):o},z=/^-ms-/,U=/-([a-z])/g,G=function(e){return 1===e.nodeType||9===e.nodeType||!+e.nodeType};Y.uid=1,Y.prototype={cache:function(e){var t=e[this.expando];return t||(t={},G(e)&&(e.nodeType?e[this.expando]=t:Object.defineProperty(e,this.expando,{value:t,configurable:!0}))),t},set:function(e,t,n){var r,i=this.cache(e);if("string"==typeof t)i[V(t)]=n;else for(r in t)i[V(r)]=t[r];return i},get:function(e,t){return void 0===t?this.cache(e):e[this.expando]&&e[this.expando][V(t)]},access:function(e,t,n){return void 0===t||t&&"string"==typeof t&&void 0===n?this.get(e,t):(this.set(e,t,n),void 0!==n?n:t)},remove:function(e,t){var n,r=e[this.expando];if(void 0!==r){if(void 0!==t){n=(t=Array.isArray(t)?t.map(V):(t=V(t))in r?[t]:t.match(R)||[]).length;for(;n--;)delete r[t[n]]}(void 0===t||k.isEmptyObject(r))&&(e.nodeType?e[this.expando]=void 0:delete e[this.expando])}},hasData:function(e){var t=e[this.expando];return void 0!==t&&!k.isEmptyObject(t)}};var Q=new Y,J=new Y,K=/^(?:\{[\w\W]*\}|\[[\w\W]*\])$/,Z=/[A-Z]/g;k.extend({hasData:function(e){return J.hasData(e)||Q.hasData(e)},data:function(e,t,n){return J.access(e,t,n)},removeData:function(e,t){J.remove(e,t)},_data:function(e,t,n){return Q.access(e,t,n)},_removeData:function(e,t){Q.remove(e,t)}}),k.fn.extend({data:function(n,e){var t,r,i,o=this[0],a=o&&o.attributes;if(void 0===n){if(this.length&&(i=J.get(o),1===o.nodeType&&!Q.get(o,"hasDataAttrs"))){for(t=a.length;t--;)a[t]&&0===(r=a[t].name).indexOf("data-")&&(r=V(r.slice(5)),ee(o,r,i[r]));Q.set(o,"hasDataAttrs",!0)}return i}return"object"==typeof n?this.each(function(){J.set(this,n)}):_(this,function(e){var t;return o&&void 0===e?void 0!==(t=J.get(o,n))?t:void 0!==(t=ee(o,n))?t:void 0:void this.each(function(){J.set(this,n,e)})},null,e,1\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i,ge={option:[1,""],thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};ge.optgroup=ge.option,ge.tbody=ge.tfoot=ge.colgroup=ge.caption=ge.thead,ge.th=ge.td;var me,xe,be=/<|&#?\w+;/;me=E.createDocumentFragment().appendChild(E.createElement("div")),(xe=E.createElement("input")).setAttribute("type","radio"),xe.setAttribute("checked","checked"),xe.setAttribute("name","t"),me.appendChild(xe),y.checkClone=me.cloneNode(!0).cloneNode(!0).lastChild.checked,me.innerHTML="",y.noCloneChecked=!!me.cloneNode(!0).lastChild.defaultValue;var Te=/^key/,Ce=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,Ee=/^([^.]*)(?:\.(.+)|)/;k.event={global:{},add:function(t,e,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,v=Q.get(t);if(v)for(n.handler&&(n=(o=n).handler,i=o.selector),i&&k.find.matchesSelector(ie,i),n.guid||(n.guid=k.guid++),(u=v.events)||(u=v.events={}),(a=v.handle)||(a=v.handle=function(e){return"undefined"!=typeof k&&k.event.triggered!==e.type?k.event.dispatch.apply(t,arguments):void 0}),l=(e=(e||"").match(R)||[""]).length;l--;)d=g=(s=Ee.exec(e[l])||[])[1],h=(s[2]||"").split(".").sort(),d&&(f=k.event.special[d]||{},d=(i?f.delegateType:f.bindType)||d,f=k.event.special[d]||{},c=k.extend({type:d,origType:g,data:r,handler:n,guid:n.guid,selector:i,needsContext:i&&k.expr.match.needsContext.test(i),namespace:h.join(".")},o),(p=u[d])||((p=u[d]=[]).delegateCount=0,f.setup&&!1!==f.setup.call(t,r,h,a)||t.addEventListener&&t.addEventListener(d,a)),f.add&&(f.add.call(t,c),c.handler.guid||(c.handler.guid=n.guid)),i?p.splice(p.delegateCount++,0,c):p.push(c),k.event.global[d]=!0)},remove:function(e,t,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,v=Q.hasData(e)&&Q.get(e);if(v&&(u=v.events)){for(l=(t=(t||"").match(R)||[""]).length;l--;)if(d=g=(s=Ee.exec(t[l])||[])[1],h=(s[2]||"").split(".").sort(),d){for(f=k.event.special[d]||{},p=u[d=(r?f.delegateType:f.bindType)||d]||[],s=s[2]&&new RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),a=o=p.length;o--;)c=p[o],!i&&g!==c.origType||n&&n.guid!==c.guid||s&&!s.test(c.namespace)||r&&r!==c.selector&&("**"!==r||!c.selector)||(p.splice(o,1),c.selector&&p.delegateCount--,f.remove&&f.remove.call(e,c));a&&!p.length&&(f.teardown&&!1!==f.teardown.call(e,h,v.handle)||k.removeEvent(e,d,v.handle),delete u[d])}else for(d in u)k.event.remove(e,d+t[l],n,r,!0);k.isEmptyObject(u)&&Q.remove(e,"handle events")}},dispatch:function(e){var t,n,r,i,o,a,s=k.event.fix(e),u=new Array(arguments.length),l=(Q.get(this,"events")||{})[s.type]||[],c=k.event.special[s.type]||{};for(u[0]=s,t=1;tn;n++)void 0===a[i=(r=t[n]).selector+" "]&&(a[i]=r.needsContext?-1\x20\t\r\n\f]*)[^>]*)\/>/gi,qe=/\s*$/g;k.extend({htmlPrefilter:function(e){return e.replace(je,"<$1>")},clone:function(e,t,n){var r,i,o,a,s,u,l,c=e.cloneNode(!0),f=oe(e);if(!(y.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||k.isXMLDoc(e)))for(a=ve(c),r=0,i=(o=ve(e)).length;i>r;r++)s=o[r],u=a[r],"input"===(l=u.nodeName.toLowerCase())&&pe.test(s.type)?u.checked=s.checked:"input"!==l&&"textarea"!==l||(u.defaultValue=s.defaultValue);if(t)if(n)for(o=o||ve(e),a=a||ve(c),r=0,i=o.length;i>r;r++)Me(o[r],a[r]);else Me(e,c);return 0<(a=ve(c,"script")).length&&ye(a,!f&&ve(e,"script")),c},cleanData:function(e){for(var t,n,r,i=k.event.special,o=0;void 0!==(n=e[o]);o++)if(G(n)){if(t=n[Q.expando]){if(t.events)for(r in t.events)i[r]?k.event.remove(n,r):k.removeEvent(n,r,t.handle);n[Q.expando]=void 0}n[J.expando]&&(n[J.expando]=void 0)}}}),k.fn.extend({detach:function(e){return We(this,e,!0)},remove:function(e){return We(this,e)},text:function(e){return _(this,function(e){return void 0===e?k.text(this):this.empty().each(function(){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||(this.textContent=e)})},null,e,arguments.length)},append:function(){return Ie(this,arguments,function(e){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||Oe(this,e).appendChild(e)})},prepend:function(){return Ie(this,arguments,function(e){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var t=Oe(this,e);t.insertBefore(e,t.firstChild)}})},before:function(){return Ie(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return Ie(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},empty:function(){for(var e,t=0;null!=(e=this[t]);t++)1===e.nodeType&&(k.cleanData(ve(e,!1)),e.textContent="");return this},clone:function(e,t){return e=null!=e&&e,t=null==t?e:t,this.map(function(){return k.clone(this,e,t)})},html:function(e){return _(this,function(e){var t=this[0]||{},n=0,r=this.length;if(void 0===e&&1===t.nodeType)return t.innerHTML;if("string"==typeof e&&!qe.test(e)&&!ge[(de.exec(e)||["",""])[1].toLowerCase()]){e=k.htmlPrefilter(e);try{for(;r>n;n++)1===(t=this[n]||{}).nodeType&&(k.cleanData(ve(t,!1)),t.innerHTML=e);t=0}catch(e){}}t&&this.empty().append(e)},null,e,arguments.length)},replaceWith:function(){var n=[];return Ie(this,arguments,function(e){var t=this.parentNode;k.inArray(this,n)<0&&(k.cleanData(ve(this)),t&&t.replaceChild(e,this))},n)}}),k.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(e,a){k.fn[e]=function(e){for(var t,n=[],r=k(e),i=r.length-1,o=0;i>=o;o++)t=o===i?this:this.clone(!0),k(r[o])[a](t),u.apply(n,t.get());return this.pushStack(n)}});var $e=new RegExp("^("+te+")(?!px)[a-z%]+$","i"),Fe=function(e){var t=e.ownerDocument.defaultView;return t&&t.opener||(t=C),t.getComputedStyle(e)},Be=new RegExp(re.join("|"),"i");!function(){function e(){if(u){s.style.cssText="position:absolute;left:-11111px;width:60px;margin-top:1px;padding:0;border:0",u.style.cssText="position:relative;display:block;box-sizing:border-box;overflow:scroll;margin:auto;border:1px;padding:1px;width:60%;top:1%",ie.appendChild(s).appendChild(u);var e=C.getComputedStyle(u);n="1%"!==e.top,a=12===t(e.marginLeft),u.style.right="60%",o=36===t(e.right),r=36===t(e.width),u.style.position="absolute",i=12===t(u.offsetWidth/3),ie.removeChild(s),u=null}}function t(e){return Math.round(parseFloat(e))}var n,r,i,o,a,s=E.createElement("div"),u=E.createElement("div");u.style&&(u.style.backgroundClip="content-box",u.cloneNode(!0).style.backgroundClip="",y.clearCloneStyle="content-box"===u.style.backgroundClip,k.extend(y,{boxSizingReliable:function(){return e(),r},pixelBoxStyles:function(){return e(),o},pixelPosition:function(){return e(),n},reliableMarginLeft:function(){return e(),a},scrollboxSize:function(){return e(),i}}))}();var Ue=["Webkit","Moz","ms"],Xe=E.createElement("div").style,Ve={},Ye=/^(none|table(?!-c[ea]).+)/,Qe=/^--/,Je={position:"absolute",visibility:"hidden",display:"block"},Ke={letterSpacing:"0",fontWeight:"400"};k.extend({cssHooks:{opacity:{get:function(e,t){if(t){var n=_e(e,"opacity");return""===n?"1":n}}}},cssNumber:{animationIterationCount:!0,columnCount:!0,fillOpacity:!0,flexGrow:!0,flexShrink:!0,fontWeight:!0,gridArea:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnStart:!0,gridRow:!0,gridRowEnd:!0,gridRowStart:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},cssProps:{},style:function(e,t,n,r){if(e&&3!==e.nodeType&&8!==e.nodeType&&e.style){var i,o,a,s=V(t),u=Qe.test(t),l=e.style;if(u||(t=Ge(s)),a=k.cssHooks[t]||k.cssHooks[s],void 0===n)return a&&"get"in a&&void 0!==(i=a.get(e,!1,r))?i:l[t];"string"==(o=typeof n)&&(i=ne.exec(n))&&i[1]&&(n=le(e,t,i),o="number"),null!=n&&n==n&&("number"!==o||u||(n+=i&&i[3]||(k.cssNumber[s]?"":"px")),y.clearCloneStyle||""!==n||0!==t.indexOf("background")||(l[t]="inherit"),a&&"set"in a&&void 0===(n=a.set(e,n,r))||(u?l.setProperty(t,n):l[t]=n))}},css:function(e,t,n,r){var i,o,a,s=V(t);return Qe.test(t)||(t=Ge(s)),(a=k.cssHooks[t]||k.cssHooks[s])&&"get"in a&&(i=a.get(e,!0,n)),void 0===i&&(i=_e(e,t,r)),"normal"===i&&t in Ke&&(i=Ke[t]),""===n||n?(o=parseFloat(i),!0===n||isFinite(o)?o||0:i):i}}),k.each(["height","width"],function(e,u){k.cssHooks[u]={get:function(e,t,n){return t?!Ye.test(k.css(e,"display"))||e.getClientRects().length&&e.getBoundingClientRect().width?tt(e,u,n):ue(e,Je,function(){return tt(e,u,n)}):void 0},set:function(e,t,n){var r,i=Fe(e),o=!y.scrollboxSize()&&"absolute"===i.position,a=(o||n)&&"border-box"===k.css(e,"boxSizing",!1,i),s=n?et(e,u,n,a,i):0;return a&&o&&(s-=Math.ceil(e["offset"+u[0].toUpperCase()+u.slice(1)]-parseFloat(i[u])-et(e,u,"border",!1,i)-.5)),s&&(r=ne.exec(t))&&"px"!==(r[3]||"px")&&(e.style[u]=t,t=k.css(e,u)),Ze(0,t,s)}}}),k.cssHooks.marginLeft=ze(y.reliableMarginLeft,function(e,t){return t?(parseFloat(_e(e,"marginLeft"))||e.getBoundingClientRect().left-ue(e,{marginLeft:0},function(){return e.getBoundingClientRect().left}))+"px":void 0}),k.each({margin:"",padding:"",border:"Width"},function(i,o){k.cssHooks[i+o]={expand:function(e){for(var t=0,n={},r="string"==typeof e?e.split(" "):[e];4>t;t++)n[i+re[t]+o]=r[t]||r[t-2]||r[0];return n}},"margin"!==i&&(k.cssHooks[i+o].set=Ze)}),k.fn.extend({css:function(e,t){return _(this,function(e,t,n){var r,i,o={},a=0;if(Array.isArray(t)){for(r=Fe(e),i=t.length;i>a;a++)o[t[a]]=k.css(e,t[a],!1,r);return o}return void 0!==n?k.style(e,t,n):k.css(e,t)},e,t,1r;r++)n=e[r],dt.tweeners[n]=dt.tweeners[n]||[],dt.tweeners[n].unshift(t)},prefilters:[function(e,t,n){var r,i,o,a,s,u,l,c,f="width"in t||"height"in t,p=this,d={},h=e.style,g=e.nodeType&&se(e),v=Q.get(e,"fxshow");for(r in n.queue||(null==(a=k._queueHooks(e,"fx")).unqueued&&(a.unqueued=0,s=a.empty.fire,a.empty.fire=function(){a.unqueued||s()}),a.unqueued++,p.always(function(){p.always(function(){a.unqueued--,k.queue(e,"fx").length||a.empty.fire()})})),t)if(i=t[r],st.test(i)){if(delete t[r],o=o||"toggle"===i,i===(g?"hide":"show")){if("show"!==i||!v||void 0===v[r])continue;g=!0}d[r]=v&&v[r]||k.style(e,r)}if((u=!k.isEmptyObject(t))||!k.isEmptyObject(d))for(r in f&&1===e.nodeType&&(n.overflow=[h.overflow,h.overflowX,h.overflowY],null==(l=v&&v.display)&&(l=Q.get(e,"display")),"none"===(c=k.css(e,"display"))&&(l?c=l:(fe([e],!0),l=e.style.display||l,c=k.css(e,"display"),fe([e]))),("inline"===c||"inline-block"===c&&null!=l)&&"none"===k.css(e,"float")&&(u||(p.done(function(){h.display=l}),null==l&&(c=h.display,l="none"===c?"":c)),h.display="inline-block")),n.overflow&&(h.overflow="hidden",p.always(function(){h.overflow=n.overflow[0],h.overflowX=n.overflow[1],h.overflowY=n.overflow[2]})),u=!1,d)u||(v?"hidden"in v&&(g=v.hidden):v=Q.access(e,"fxshow",{display:l}),o&&(v.hidden=!g),g&&fe([e],!0),p.done(function(){for(r in g||fe([e]),Q.remove(e,"fxshow"),d)k.style(e,r,d[r])})),u=pt(g?v[r]:0,r,p),r in v||(v[r]=u.start,g&&(u.end=u.start,u.start=0))}],prefilter:function(e,t){t?dt.prefilters.unshift(e):dt.prefilters.push(e)}}),k.speed=function(e,t,n){var r=e&&"object"==typeof e?k.extend({},e):{complete:n||!n&&t||m(e)&&e,duration:e,easing:n&&t||t&&!m(t)&&t};return k.fx.off?r.duration=0:"number"!=typeof r.duration&&(r.duration in k.fx.speeds?r.duration=k.fx.speeds[r.duration]:r.duration=k.fx.speeds._default),null!=r.queue&&!0!==r.queue||(r.queue="fx"),r.old=r.complete,r.complete=function(){m(r.old)&&r.old.call(this),r.queue&&k.dequeue(this,r.queue)},r},k.fn.extend({fadeTo:function(e,t,n,r){return this.filter(se).css("opacity",0).show().end().animate({opacity:t},e,n,r)},animate:function(t,e,n,r){var i=k.isEmptyObject(t),o=k.speed(e,n,r),a=function(){var e=dt(this,k.extend({},t),o);(i||Q.get(this,"finish"))&&e.stop(!0)};return a.finish=a,i||!1===o.queue?this.each(a):this.queue(o.queue,a)},stop:function(i,e,o){var a=function(e){var t=e.stop;delete e.stop,t(o)};return"string"!=typeof i&&(o=e,e=i,i=void 0),e&&!1!==i&&this.queue(i||"fx",[]),this.each(function(){var e=!0,t=null!=i&&i+"queueHooks",n=k.timers,r=Q.get(this);if(t)r[t]&&r[t].stop&&a(r[t]);else for(t in r)r[t]&&r[t].stop&&ut.test(t)&&a(r[t]);for(t=n.length;t--;)n[t].elem!==this||null!=i&&n[t].queue!==i||(n[t].anim.stop(o),e=!1,n.splice(t,1));!e&&o||k.dequeue(this,i)})},finish:function(a){return!1!==a&&(a=a||"fx"),this.each(function(){var e,t=Q.get(this),n=t[a+"queue"],r=t[a+"queueHooks"],i=k.timers,o=n?n.length:0;for(t.finish=!0, k.queue(this,a,[]),r&&r.stop&&r.stop.call(this,!0),e=i.length;e--;)i[e].elem===this&&i[e].queue===a&&(i[e].anim.stop(!0),i.splice(e,1));for(e=0;o>e;e++)n[e]&&n[e].finish&&n[e].finish.call(this);delete t.finish})}}),k.each(["toggle","show","hide"],function(e,r){var i=k.fn[r];k.fn[r]=function(e,t,n){return null==e||"boolean"==typeof e?i.apply(this,arguments):this.animate(ft(r,!0),e,t,n)}}),k.each({slideDown:ft("show"),slideUp:ft("hide"),slideToggle:ft("toggle"),fadeIn:{opacity:"show"},fadeOut:{opacity:"hide"},fadeToggle:{opacity:"toggle"}},function(e,r){k.fn[e]=function(e,t,n){return this.animate(r,e,t,n)}}),k.timers=[],k.fx.tick=function(){var e,t=0,n=k.timers;for(rt=Date.now();to?u:a?o:0;u>r;r++)if(((n=i[r]).selected||r===o)&&!n.disabled&&(!n.parentNode.disabled||!A(n.parentNode,"optgroup"))){if(t=k(n).val(),a)return t;s.push(t)}return s},set:function(e,t){for(var n,r,i=e.options,o=k.makeArray(t),a=i.length;a--;)((r=i[a]).selected=-11?s:c.bindType||d,(l=(Q.get(o,"events")||{})[e.type]&&Q.get(o,"handle"))&&l.apply(o,t),(l=u&&o[u])&&l.apply&&G(o)&&(e.result=l.apply(o,t),!1===e.result&&e.preventDefault());return e.type=d,r||e.isDefaultPrevented()||c._default&&!1!==c._default.apply(p.pop(),t)||!G(n)||u&&m(n[d])&&!x(n)&&((a=n[u])&&(n[u]=null),k.event.triggered=d,e.isPropagationStopped()&&f.addEventListener(d,Ct),n[d](),e.isPropagationStopped()&&f.removeEventListener(d,Ct),k.event.triggered=void 0,a&&(n[u]=a)),e.result}},simulate:function(e,t,n){var r=k.extend(new k.Event,n,{type:e,isSimulated:!0});k.event.trigger(r,null,t)}}),k.fn.extend({trigger:function(e,t){return this.each(function(){k.event.trigger(e,t,this)})},triggerHandler:function(e,t){var n=this[0];return n?k.event.trigger(e,t,n,!0):void 0}}),y.focusin||k.each({focus:"focusin",blur:"focusout"},function(n,r){var i=function(e){k.event.simulate(r,e.target,k.event.fix(e))};k.event.special[r]={setup:function(){var e=this.ownerDocument||this,t=Q.access(e,r);t||e.addEventListener(n,i,!0),Q.access(e,r,(t||0)+1)},teardown:function(){var e=this.ownerDocument||this,t=Q.access(e,r)-1;t?Q.access(e,r,t):(e.removeEventListener(n,i,!0),Q.remove(e,r))}}});var Et=C.location,kt=Date.now(),St=/\?/;k.parseXML=function(e){var t;if(!e||"string"!=typeof e)return null;try{t=(new C.DOMParser).parseFromString(e,"text/xml")}catch(e){t=void 0}return t&&!t.getElementsByTagName("parsererror").length||k.error("Invalid XML: "+e),t};var Nt=/\[\]$/,At=/\r?\n/g,Dt=/^(?:submit|button|image|reset|file)$/i,jt=/^(?:input|select|textarea|keygen)/i;k.param=function(e,t){var n,r=[],i=function(e,t){var n=m(t)?t():t;r[r.length]=encodeURIComponent(e)+"="+encodeURIComponent(null==n?"":n)};if(null==e)return"";if(Array.isArray(e)||e.jquery&&!k.isPlainObject(e))k.each(e,function(){i(this.name,this.value)});else for(n in e)qt(n,e[n],t,i);return r.join("&")},k.fn.extend({serialize:function(){return k.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var e=k.prop(this,"elements");return e?k.makeArray(e):this}).filter(function(){var e=this.type;return this.name&&!k(this).is(":disabled")&&jt.test(this.nodeName)&&!Dt.test(e)&&(this.checked||!pe.test(e))}).map(function(e,t){var n=k(this).val();return null==n?null:Array.isArray(n)?k.map(n,function(e){return{name:t.name,value:e.replace(At,"\r\n")}}):{name:t.name,value:n.replace(At,"\r\n")}}).get()}});var Lt=/%20/g,Ht=/#.*$/,Ot=/([?&])_=[^&]*/,Pt=/^(.*?):[ \t]*([^\r\n]*)$/gm,Rt=/^(?:GET|HEAD)$/,Mt=/^\/\//,It={},Wt={},$t="*/".concat("*"),Ft=E.createElement("a");Ft.href=Et.href,k.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:Et.href,type:"GET",isLocal:/^(?:about|app|app-storage|.+-extension|file|res|widget):$/.test(Et.protocol),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":$t,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/\bxml\b/,html:/\bhtml/,json:/\bjson\b/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":JSON.parse,"text xml":k.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(e,t){return t?zt(zt(e,k.ajaxSettings),t):zt(k.ajaxSettings,e)},ajaxPrefilter:Bt(It),ajaxTransport:Bt(Wt),ajax:function(e,t){function l(e,t,n,r){var i,o,a,s,u,l=t;h||(h=!0,d&&C.clearTimeout(d),c=void 0,p=r||"",T.readyState=e>0?4:0,i=e>=200&&300>e||304===e,n&&(s=function(e,t,n){for(var r,i,o,a,s=e.contents,u=e.dataTypes;"*"===u[0];)u.shift(),void 0===r&&(r=e.mimeType||t.getResponseHeader("Content-Type"));if(r)for(i in s)if(s[i]&&s[i].test(r)){u.unshift(i);break}if(u[0]in n)o=u[0];else{for(i in n){if(!u[0]||e.converters[i+" "+u[0]]){o=i;break}a||(a=i)}o=o||a}return o?(o!==u[0]&&u.unshift(o),n[o]):void 0}(v,T,n)),s=function(e,t,n,r){var i,o,a,s,u,l={},c=e.dataTypes.slice();if(c[1])for(a in e.converters)l[a.toLowerCase()]=e.converters[a];for(o=c.shift();o;)if(e.responseFields[o]&&(n[e.responseFields[o]]=t),!u&&r&&e.dataFilter&&(t=e.dataFilter(t,e.dataType)),u=o,o=c.shift())if("*"===o)o=u;else if("*"!==u&&u!==o){if(!(a=l[u+" "+o]||l["* "+o]))for(i in l)if((s=i.split(" "))[1]===o&&(a=l[u+" "+s[0]]||l["* "+s[0]])){!0===a?a=l[i]:!0!==l[i]&&(o=s[0],c.unshift(s[1]));break}if(!0!==a)if(a&&e["throws"])t=a(t);else try{t=a(t)}catch(e){return{state:"parsererror",error:a?e:"No conversion from "+u+" to "+o}}}return{state:"success",data:t}}(v,s,T,i),i?(v.ifModified&&((u=T.getResponseHeader("Last-Modified"))&&(k.lastModified[f]=u),(u=T.getResponseHeader("etag"))&&(k.etag[f]=u)),204===e||"HEAD"===v.type?l="nocontent":304===e?l="notmodified":(l=s.state,o=s.data,i=!(a=s.error))):(a=l,!e&&l||(l="error",0>e&&(e=0))),T.status=e,T.statusText=(t||l)+"",i?x.resolveWith(y,[o,l,T]):x.rejectWith(y,[T,l,a]),T.statusCode(w),w=void 0,g&&m.trigger(i?"ajaxSuccess":"ajaxError",[T,v,i?o:a]),b.fireWith(y,[T,l]),g&&(m.trigger("ajaxComplete",[T,v]),--k.active||k.event.trigger("ajaxStop")))}"object"==typeof e&&(t=e,e=void 0),t=t||{};var c,f,p,n,d,r,h,g,i,o,v=k.ajaxSetup({},t),y=v.context||v,m=v.context&&(y.nodeType||y.jquery)?k(y):k.event,x=k.Deferred(),b=k.Callbacks("once memory"),w=v.statusCode||{},a={},s={},u="canceled",T={readyState:0,getResponseHeader:function(e){var t;if(h){if(!n)for(n={};t=Pt.exec(p);)n[t[1].toLowerCase()+" "]=(n[t[1].toLowerCase()+" "]||[]).concat(t[2]);t=n[e.toLowerCase()+" "]}return null==t?null:t.join(", ")},getAllResponseHeaders:function(){return h?p:null},setRequestHeader:function(e,t){return null==h&&(e=s[e.toLowerCase()]=s[e.toLowerCase()]||e,a[e]=t),this},overrideMimeType:function(e){return null==h&&(v.mimeType=e),this},statusCode:function(e){var t;if(e)if(h)T.always(e[T.status]);else for(t in e)w[t]=[w[t],e[t]];return this},abort:function(e){var t=e||u;return c&&c.abort(t),l(0,t),this}};if(x.promise(T),v.url=((e||v.url||Et.href)+"").replace(Mt,Et.protocol+"//"),v.type=t.method||t.type||v.method||v.type,v.dataTypes=(v.dataType||"*").toLowerCase().match(R)||[""],null==v.crossDomain){r=E.createElement("a");try{r.href=v.url,r.href=r.href,v.crossDomain=Ft.protocol+"//"+Ft.host!=r.protocol+"//"+r.host}catch(e){v.crossDomain=!0}}if(v.data&&v.processData&&"string"!=typeof v.data&&(v.data=k.param(v.data,v.traditional)),_t(It,v,t,T),h)return T;for(i in(g=k.event&&v.global)&&0==k.active++&&k.event.trigger("ajaxStart"),v.type=v.type.toUpperCase(),v.hasContent=!Rt.test(v.type),f=v.url.replace(Ht,""),v.hasContent?v.data&&v.processData&&0===(v.contentType||"").indexOf("application/x-www-form-urlencoded")&&(v.data=v.data.replace(Lt,"+")):(o=v.url.slice(f.length),v.data&&(v.processData||"string"==typeof v.data)&&(f+=(St.test(f)?"&":"?")+v.data,delete v.data),!1===v.cache&&(f=f.replace(Ot,"$1"),o=(St.test(f)?"&":"?")+"_="+kt++ +o),v.url=f+o),v.ifModified&&(k.lastModified[f]&&T.setRequestHeader("If-Modified-Since",k.lastModified[f]),k.etag[f]&&T.setRequestHeader("If-None-Match",k.etag[f])),(v.data&&v.hasContent&&!1!==v.contentType||t.contentType)&&T.setRequestHeader("Content-Type",v.contentType),T.setRequestHeader("Accept",v.dataTypes[0]&&v.accepts[v.dataTypes[0]]?v.accepts[v.dataTypes[0]]+("*"!==v.dataTypes[0]?", "+$t+"; q=0.01":""):v.accepts["*"]),v.headers)T.setRequestHeader(i,v.headers[i]);if(v.beforeSend&&(!1===v.beforeSend.call(y,T,v)||h))return T.abort();if(u="abort",b.add(v.complete),T.done(v.success),T.fail(v.error),c=_t(Wt,v,t,T)){if(T.readyState=1,g&&m.trigger("ajaxSend",[T,v]),h)return T;v.async&&0").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}:void 0});var Vt,Gt=[],Yt=/(=)\?(?=&|$)|\?\?/;k.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Gt.pop()||k.expando+"_"+kt++;return this[e]=!0,e}}),k.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Yt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Yt.test(e.data)&&"data");return a||"jsonp"===e.dataTypes[0]?(r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Yt,"$1"+r):!1!==e.jsonp&&(e.url+=(St.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||k.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?k(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Gt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"):void 0}),y.createHTMLDocument=((Vt=E.implementation.createHTMLDocument("").body).innerHTML="
",2===Vt.childNodes.length),k.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=D.exec(e))?[t.createElement(i[1])]:(i=we([e],t,o),o&&o.length&&k(o).remove(),k.merge([],i.childNodes)));var r,i,o},k.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return s>-1&&(r=mt(e.slice(s)),e=e.slice(0,s)),m(t)?(n=t,t=void 0):t&&"object"==typeof t&&(i="POST"),0").append(k.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},k.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){k.fn[t]=function(e){return this.on(t,e)}}),k.expr.pseudos.animated=function(t){return k.grep(k.timers,function(e){return t===e.elem}).length},k.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=k.css(e,"position"),c=k(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=k.css(e,"top"),u=k.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,k.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},k.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){k.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===k.css(r,"position"))t=r.getBoundingClientRect();else{for(t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;e&&(e===n.body||e===n.documentElement)&&"static"===k.css(e,"position");)e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=k(e).offset()).top+=k.css(e,"borderTopWidth",!0),i.left+=k.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-k.css(r,"marginTop",!0),left:t.left-i.left-k.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){for(var e=this.offsetParent;e&&"static"===k.css(e,"position");)e=e.offsetParent;return e||ie})}}),k.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;k.fn[t]=function(e){return _(this,function(e,t,n){var r;return x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n?r?r[i]:e[t]:void(r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n)},t,e,arguments.length)}}),k.each(["top","left"],function(e,n){k.cssHooks[n]=ze(y.pixelPosition,function(e,t){return t?(t=_e(e,n),$e.test(t)?k(e).position()[n]+"px":t):void 0})}),k.each({Height:"height",Width:"width"},function(a,s){k.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){k.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return _(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?k.css(e,t,i):k.style(e,t,n,i)},s,n?e:void 0,n)}})}),k.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){k.fn[n]=function(e,t){return 01){for(o=0;o1){for(var r=0;r=i&&o>=t};break;case"bottom":h=function(t,e,n,i,o){return n>=i&&o>=n};break;case"middle":h=function(t,e,n,i,o){return e>=i&&o>=e};break;case"top-only":h=function(t,e,n,i,o){return i>=t&&n>=i};break;case"bottom-only":h=function(t,e,n,i,o){return n>=o&&o>=t};break;default:case"default":h=function(t,e,n,i,o){return n>=i&&o>=t}}return c=function(t){var i,o,l,s,r,a,u=this.state,h=!1,c=this.$element.offset();i=n.height(),o=t+i/2,l=t+i,s=this.$element.outerHeight(),r=c.top+e(this.options.top,s,i),a=c.top+s-e(this.options.bottom,s,i),h=this.test(t,o,l,r,a),h!=u&&(this.state=h,h?this.options.enter&&this.options.enter.apply(this.element):this.options.leave&&this.options.leave.apply(this.element)),this.options.scroll&&this.options.scroll.apply(this.element,[(o-r)/(a-r)])},p={id:a,options:u,test:h,handler:c,state:null,element:this,$element:s,timeoutId:null},o[a]=p,s.data("_scrollexId",p.id),p.options.initialize&&p.options.initialize.apply(this),s},jQuery.fn.unscrollex=function(){var e=t(this);if(0==this.length)return e;if(this.length>1){for(var n=0;n0:!!("ontouchstart"in window),e.mobile="wp"==e.os||"android"==e.os||"ios"==e.os||"bb"==e.os}};return e.init(),e}();!function(e,n){"function"==typeof define&&define.amd?define([],n):"object"==typeof exports?module.exports=n():e.browser=n()}(this,function(){return browser});var breakpoints=function(){"use strict";function e(e){t.init(e)}var t={list:null,media:{},events:[],init:function(e){t.list=e,window.addEventListener("resize",t.poll),window.addEventListener("orientationchange",t.poll),window.addEventListener("load",t.poll),window.addEventListener("fullscreenchange",t.poll)},active:function(e){var n,a,s,i,r,d,c;if(!(e in t.media)){if(">="==e.substr(0,2)?(a="gte",n=e.substr(2)):"<="==e.substr(0,2)?(a="lte",n=e.substr(2)):">"==e.substr(0,1)?(a="gt",n=e.substr(1)):"<"==e.substr(0,1)?(a="lt",n=e.substr(1)):"!"==e.substr(0,1)?(a="not",n=e.substr(1)):(a="eq",n=e),n&&n in t.list)if(i=t.list[n],Array.isArray(i)){if(r=parseInt(i[0]),d=parseInt(i[1]),isNaN(r)){if(isNaN(d))return;c=i[1].substr(String(d).length)}else c=i[0].substr(String(r).length);if(isNaN(r))switch(a){case"gte":s="screen";break;case"lte":s="screen and (max-width: "+d+c+")";break;case"gt":s="screen and (min-width: "+(d+1)+c+")";break;case"lt":s="screen and (max-width: -1px)";break;case"not":s="screen and (min-width: "+(d+1)+c+")";break;default:s="screen and (max-width: "+d+c+")"}else if(isNaN(d))switch(a){case"gte":s="screen and (min-width: "+r+c+")";break;case"lte":s="screen";break;case"gt":s="screen and (max-width: -1px)";break;case"lt":s="screen and (max-width: "+(r-1)+c+")";break;case"not":s="screen and (max-width: "+(r-1)+c+")";break;default:s="screen and (min-width: "+r+c+")"}else switch(a){case"gte":s="screen and (min-width: "+r+c+")";break;case"lte":s="screen and (max-width: "+d+c+")";break;case"gt":s="screen and (min-width: "+(d+1)+c+")";break;case"lt":s="screen and (max-width: "+(r-1)+c+")";break;case"not":s="screen and (max-width: "+(r-1)+c+"), screen and (min-width: "+(d+1)+c+")";break;default:s="screen and (min-width: "+r+c+") and (max-width: "+d+c+")"}}else s="("==i.charAt(0)?"screen and "+i:i;t.media[e]=!!s&&s}return t.media[e]!==!1&&window.matchMedia(t.media[e]).matches},on:function(e,n){t.events.push({query:e,handler:n,state:!1}),t.active(e)&&n()},poll:function(){var e,n;for(e=0;e'+$this.text()+"")}),b.join("")},$.fn.panel=function(userConfig){if(0==this.length)return $this;if(this.length>1){for(var i=0;idiffY&&diffY>-1*boundary&&diffX>delta;break;case"right":result=boundary>diffY&&diffY>-1*boundary&&-1*delta>diffX;break;case"top":result=boundary>diffX&&diffX>-1*boundary&&diffY>delta;break;case"bottom":result=boundary>diffX&&diffX>-1*boundary&&-1*delta>diffY}if(result)return $this.touchPosX=null,$this.touchPosY=null,$this._hide(),!1}($this.scrollTop()<0&&0>diffY||ts>th-2&&th+2>ts&&diffY>0)&&(event.preventDefault(),event.stopPropagation())}}),$this.on("click touchend touchstart touchmove",function(event){event.stopPropagation()}),$this.on("click",'a[href="#'+id+'"]',function(event){event.preventDefault(),event.stopPropagation(),config.target.removeClass(config.visibleClass)}),$body.on("click touchend",function(event){$this._hide(event)}),$body.on("click",'a[href="#'+id+'"]',function(event){event.preventDefault(),event.stopPropagation(),config.target.toggleClass(config.visibleClass)}),config.hideOnEscape&&$window.on("keydown",function(event){27==event.keyCode&&$this._hide(event)}),$this},$.fn.placeholder=function(){if("undefined"!=typeof document.createElement("input").placeholder)return $(this);if(0==this.length)return $this;if(this.length>1){for(var i=0;i").append(i.clone()).remove().html().replace(/type="password"/i,'type="text"').replace(/type=password/i,"type=text"));""!=i.attr("id")&&x.attr("id",i.attr("id")+"-polyfill-field"),""!=i.attr("name")&&x.attr("name",i.attr("name")+"-polyfill-field"),x.addClass("polyfill-placeholder").val(x.attr("placeholder")).insertAfter(i),""==i.val()?i.hide():x.hide(),i.on("blur",function(event){event.preventDefault();var x=i.parent().find("input[name="+i.attr("name")+"-polyfill-field]");""==i.val()&&(i.hide(),x.show())}),x.on("focus",function(event){event.preventDefault();var i=x.parent().find("input[name="+x.attr("name").replace("-polyfill-field","")+"]");x.hide(),i.show().focus()}).on("keypress",function(event){event.preventDefault(),x.val("")})}),$this.on("submit",function(){$this.find("input[type=text],input[type=password],textarea").each(function(event){var i=$(this);i.attr("name").match(/-polyfill-field$/)&&i.attr("name",""),i.val()==i.attr("placeholder")&&(i.removeClass("polyfill-placeholder"),i.val(""))})}).on("reset",function(event){event.preventDefault(),$this.find("select").val($("option:first").val()),$this.find("input,textarea").each(function(){var x,i=$(this);switch(i.removeClass("polyfill-placeholder"),this.type){case"submit":case"reset":break;case"password":i.val(i.attr("defaultValue")),x=i.parent().find("input[name="+i.attr("name")+"-polyfill-field]"),""==i.val()?(i.hide(),x.show()):(i.show(),x.hide());break;case"checkbox":case"radio":i.attr("checked",i.attr("defaultValue"));break;case"text":case"textarea":i.val(i.attr("defaultValue")),""==i.val()&&(i.addClass("polyfill-placeholder"),i.val(i.attr("placeholder")));break;default:i.val(i.attr("defaultValue"))}})}),$this},$.prioritize=function($elements,condition){var key="__prioritize";"jQuery"!=typeof $elements&&($elements=$($elements)),$elements.each(function(){var $p,$e=$(this),$parent=$e.parent();if(0!=$parent.length)if($e.data(key)){if(condition)return;$p=$e.data(key),$e.insertAfter($p),$e.removeData(key)}else{if(!condition)return;if($p=$e.prev(),0==$p.length)return;$e.prependTo($parent),$e.data(key,$p)}})}}(jQuery),function($){var $window=$(window),$body=$("body"),$wrapper=$("#wrapper"),$header=$("#header"),$banner=$("#banner");breakpoints({xlarge:["1281px","1680px"],large:["981px","1280px"],medium:["737px","980px"],small:["481px","736px"],xsmall:["361px","480px"],xxsmall:[null,"360px"]}),$.fn._parallax="ie"==browser.name||"edge"==browser.name||browser.mobile?function(){return $(this)}:function(intensity){var $window=$(window),$this=$(this);if(0==this.length||0===intensity)return $this;if(this.length>1){for(var i=0;imedium",on)}),$window.off("load._parallax resize._parallax").on("load._parallax resize._parallax",function(){$window.trigger("scroll")}),$(this)},$window.on("load",function(){window.setTimeout(function(){$body.removeClass("is-preload")},100)}),$window.on("unload pagehide",function(){window.setTimeout(function(){$(".is-transitioning").removeClass("is-transitioning")},250)}),("ie"==browser.name||"edge"==browser.name)&&$body.addClass("is-ie"),$(".scrolly").scrolly({offset:function(){return $header.height()-2}});var $tiles=$(".tiles > article");$tiles.each(function(){var x,$this=$(this),$image=$this.find(".image"),$img=$image.find("img"),$link=$this.find(".link");$this.css("background-image","url("+$img.attr("src")+")"),(x=$img.data("position"))&&$image.css("background-position",x),$image.hide(),$link.length>0&&($x=$link.clone().text("").addClass("primary").appendTo($this),$link=$link.add($x),$link.on("click",function(event){var href=$link.attr("href");event.stopPropagation(),event.preventDefault(),"_blank"==$link.attr("target")?window.open(href):($this.addClass("is-transitioning"),$wrapper.addClass("is-transitioning"),window.setTimeout(function(){location.href=href},500))}))}),$banner.length>0&&$header.hasClass("alt")&&($window.on("resize",function(){$window.trigger("scroll")}),$window.on("load",function(){$banner.scrollex({bottom:$header.height()+10,terminate:function(){$header.removeClass("alt")},enter:function(){$header.addClass("alt")},leave:function(){$header.removeClass("alt"),$header.addClass("reveal")}}),window.setTimeout(function(){$window.triggerHandler("scroll")},100)})),$banner.each(function(){var $this=$(this),$image=$this.find(".image"),$img=$image.find("img");$this._parallax(.275),$image.length>0&&($this.css("background-image","url("+$img.attr("src")+")"),$image.hide())});var $menuInner,$menu=$("#menu");$menu.wrapInner('
'),$menuInner=$menu.children(".inner"),$menu._locked=!1,$menu._lock=function(){return $menu._locked?!1:($menu._locked=!0,window.setTimeout(function(){$menu._locked=!1},350),!0)},$menu._show=function(){$menu._lock()&&$body.addClass("is-menu-visible")},$menu._hide=function(){$menu._lock()&&$body.removeClass("is-menu-visible")},$menu._toggle=function(){$menu._lock()&&$body.toggleClass("is-menu-visible")},$menuInner.on("click",function(event){event.stopPropagation()}).on("click","a",function(event){var href=$(this).attr("href");event.preventDefault(),event.stopPropagation(),$menu._hide(),window.setTimeout(function(){window.location.href=href},250)}),$menu.appendTo($body).on("click",function(event){event.stopPropagation(),event.preventDefault(),$body.removeClass("is-menu-visible")}).append('Close'),$body.on("click",'a[href="#menu"]',function(event){event.stopPropagation(),event.preventDefault(),$menu._toggle()}).on("click",function(event){$menu._hide()}).on("keydown",function(event){27==event.keyCode&&$menu._hide()})}(jQuery); ================================================ FILE: app/src/main/assets/web/assets/js/md5.js ================================================ /* * A JavaScript implementation of the RSA Data Security, Inc. MD5 Message * Digest Algorithm, as defined in RFC 1321. * Version 2.1 Copyright (C) Paul Johnston 1999 - 2002. * Other contributors: Greg Holt, Andrew Kepert, Ydnar, Lostinet * Distributed under the BSD License * See http://pajhome.org.uk/crypt/md5 for more info. */ /* * Configurable variables. You may need to tweak these to be compatible with * the server-side, but the defaults work in most cases. */ var hexcase = 0; /* hex output format. 0 - lowercase; 1 - uppercase */ var b64pad = ""; /* base-64 pad character. "=" for strict RFC compliance */ var chrsz = 8; /* bits per input character. 8 - ASCII; 16 - Unicode */ /* * These are the functions you'll usually want to call * They take string arguments and return either hex or base-64 encoded strings */ function hex_md5(s){ return binl2hex(core_md5(str2binl(s), s.length * chrsz));} function b64_md5(s){ return binl2b64(core_md5(str2binl(s), s.length * chrsz));} function str_md5(s){ return binl2str(core_md5(str2binl(s), s.length * chrsz));} function hex_hmac_md5(key, data) { return binl2hex(core_hmac_md5(key, data)); } function b64_hmac_md5(key, data) { return binl2b64(core_hmac_md5(key, data)); } function str_hmac_md5(key, data) { return binl2str(core_hmac_md5(key, data)); } /* * Perform a simple self-test to see if the VM is working */ function md5_vm_test() { return hex_md5("abc") == "900150983cd24fb0d6963f7d28e17f72"; } /* * Calculate the MD5 of an array of little-endian words, and a bit length */ function core_md5(x, len) { /* append padding */ x[len >> 5] |= 0x80 << ((len) % 32); x[(((len + 64) >>> 9) << 4) + 14] = len; var a = 1732584193; var b = -271733879; var c = -1732584194; var d = 271733878; for(var i = 0; i < x.length; i += 16) { var olda = a; var oldb = b; var oldc = c; var oldd = d; a = md5_ff(a, b, c, d, x[i+ 0], 7 , -680876936); d = md5_ff(d, a, b, c, x[i+ 1], 12, -389564586); c = md5_ff(c, d, a, b, x[i+ 2], 17, 606105819); b = md5_ff(b, c, d, a, x[i+ 3], 22, -1044525330); a = md5_ff(a, b, c, d, x[i+ 4], 7 , -176418897); d = md5_ff(d, a, b, c, x[i+ 5], 12, 1200080426); c = md5_ff(c, d, a, b, x[i+ 6], 17, -1473231341); b = md5_ff(b, c, d, a, x[i+ 7], 22, -45705983); a = md5_ff(a, b, c, d, x[i+ 8], 7 , 1770035416); d = md5_ff(d, a, b, c, x[i+ 9], 12, -1958414417); c = md5_ff(c, d, a, b, x[i+10], 17, -42063); b = md5_ff(b, c, d, a, x[i+11], 22, -1990404162); a = md5_ff(a, b, c, d, x[i+12], 7 , 1804603682); d = md5_ff(d, a, b, c, x[i+13], 12, -40341101); c = md5_ff(c, d, a, b, x[i+14], 17, -1502002290); b = md5_ff(b, c, d, a, x[i+15], 22, 1236535329); a = md5_gg(a, b, c, d, x[i+ 1], 5 , -165796510); d = md5_gg(d, a, b, c, x[i+ 6], 9 , -1069501632); c = md5_gg(c, d, a, b, x[i+11], 14, 643717713); b = md5_gg(b, c, d, a, x[i+ 0], 20, -373897302); a = md5_gg(a, b, c, d, x[i+ 5], 5 , -701558691); d = md5_gg(d, a, b, c, x[i+10], 9 , 38016083); c = md5_gg(c, d, a, b, x[i+15], 14, -660478335); b = md5_gg(b, c, d, a, x[i+ 4], 20, -405537848); a = md5_gg(a, b, c, d, x[i+ 9], 5 , 568446438); d = md5_gg(d, a, b, c, x[i+14], 9 , -1019803690); c = md5_gg(c, d, a, b, x[i+ 3], 14, -187363961); b = md5_gg(b, c, d, a, x[i+ 8], 20, 1163531501); a = md5_gg(a, b, c, d, x[i+13], 5 , -1444681467); d = md5_gg(d, a, b, c, x[i+ 2], 9 , -51403784); c = md5_gg(c, d, a, b, x[i+ 7], 14, 1735328473); b = md5_gg(b, c, d, a, x[i+12], 20, -1926607734); a = md5_hh(a, b, c, d, x[i+ 5], 4 , -378558); d = md5_hh(d, a, b, c, x[i+ 8], 11, -2022574463); c = md5_hh(c, d, a, b, x[i+11], 16, 1839030562); b = md5_hh(b, c, d, a, x[i+14], 23, -35309556); a = md5_hh(a, b, c, d, x[i+ 1], 4 , -1530992060); d = md5_hh(d, a, b, c, x[i+ 4], 11, 1272893353); c = md5_hh(c, d, a, b, x[i+ 7], 16, -155497632); b = md5_hh(b, c, d, a, x[i+10], 23, -1094730640); a = md5_hh(a, b, c, d, x[i+13], 4 , 681279174); d = md5_hh(d, a, b, c, x[i+ 0], 11, -358537222); c = md5_hh(c, d, a, b, x[i+ 3], 16, -722521979); b = md5_hh(b, c, d, a, x[i+ 6], 23, 76029189); a = md5_hh(a, b, c, d, x[i+ 9], 4 , -640364487); d = md5_hh(d, a, b, c, x[i+12], 11, -421815835); c = md5_hh(c, d, a, b, x[i+15], 16, 530742520); b = md5_hh(b, c, d, a, x[i+ 2], 23, -995338651); a = md5_ii(a, b, c, d, x[i+ 0], 6 , -198630844); d = md5_ii(d, a, b, c, x[i+ 7], 10, 1126891415); c = md5_ii(c, d, a, b, x[i+14], 15, -1416354905); b = md5_ii(b, c, d, a, x[i+ 5], 21, -57434055); a = md5_ii(a, b, c, d, x[i+12], 6 , 1700485571); d = md5_ii(d, a, b, c, x[i+ 3], 10, -1894986606); c = md5_ii(c, d, a, b, x[i+10], 15, -1051523); b = md5_ii(b, c, d, a, x[i+ 1], 21, -2054922799); a = md5_ii(a, b, c, d, x[i+ 8], 6 , 1873313359); d = md5_ii(d, a, b, c, x[i+15], 10, -30611744); c = md5_ii(c, d, a, b, x[i+ 6], 15, -1560198380); b = md5_ii(b, c, d, a, x[i+13], 21, 1309151649); a = md5_ii(a, b, c, d, x[i+ 4], 6 , -145523070); d = md5_ii(d, a, b, c, x[i+11], 10, -1120210379); c = md5_ii(c, d, a, b, x[i+ 2], 15, 718787259); b = md5_ii(b, c, d, a, x[i+ 9], 21, -343485551); a = safe_add(a, olda); b = safe_add(b, oldb); c = safe_add(c, oldc); d = safe_add(d, oldd); } return Array(a, b, c, d); } /* * These functions implement the four basic operations the algorithm uses. */ function md5_cmn(q, a, b, x, s, t) { return safe_add(bit_rol(safe_add(safe_add(a, q), safe_add(x, t)), s),b); } function md5_ff(a, b, c, d, x, s, t) { return md5_cmn((b & c) | ((~b) & d), a, b, x, s, t); } function md5_gg(a, b, c, d, x, s, t) { return md5_cmn((b & d) | (c & (~d)), a, b, x, s, t); } function md5_hh(a, b, c, d, x, s, t) { return md5_cmn(b ^ c ^ d, a, b, x, s, t); } function md5_ii(a, b, c, d, x, s, t) { return md5_cmn(c ^ (b | (~d)), a, b, x, s, t); } /* * Calculate the HMAC-MD5, of a key and some data */ function core_hmac_md5(key, data) { var bkey = str2binl(key); if(bkey.length > 16) bkey = core_md5(bkey, key.length * chrsz); var ipad = Array(16), opad = Array(16); for(var i = 0; i < 16; i++) { ipad[i] = bkey[i] ^ 0x36363636; opad[i] = bkey[i] ^ 0x5C5C5C5C; } var hash = core_md5(ipad.concat(str2binl(data)), 512 + data.length * chrsz); return core_md5(opad.concat(hash), 512 + 128); } /* * Add integers, wrapping at 2^32. This uses 16-bit operations internally * to work around bugs in some JS interpreters. */ function safe_add(x, y) { var lsw = (x & 0xFFFF) + (y & 0xFFFF); var msw = (x >> 16) + (y >> 16) + (lsw >> 16); return (msw << 16) | (lsw & 0xFFFF); } /* * Bitwise rotate a 32-bit number to the left. */ function bit_rol(num, cnt) { return (num << cnt) | (num >>> (32 - cnt)); } /* * Convert a string to an array of little-endian words * If chrsz is ASCII, characters >255 have their hi-byte silently ignored. */ function str2binl(str) { var bin = Array(); var mask = (1 << chrsz) - 1; for(var i = 0; i < str.length * chrsz; i += chrsz) bin[i>>5] |= (str.charCodeAt(i / chrsz) & mask) << (i%32); return bin; } /* * Convert an array of little-endian words to a string */ function binl2str(bin) { var str = ""; var mask = (1 << chrsz) - 1; for(var i = 0; i < bin.length * 32; i += chrsz) str += String.fromCharCode((bin[i>>5] >>> (i % 32)) & mask); return str; } /* * Convert an array of little-endian words to a hex string. */ function binl2hex(binarray) { var hex_tab = hexcase ? "0123456789ABCDEF" : "0123456789abcdef"; var str = ""; for(var i = 0; i < binarray.length * 4; i++) { str += hex_tab.charAt((binarray[i>>2] >> ((i%4)*8+4)) & 0xF) + hex_tab.charAt((binarray[i>>2] >> ((i%4)*8 )) & 0xF); } return str; } /* * Convert an array of little-endian words to a base-64 string */ function binl2b64(binarray) { var tab = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; var str = ""; for(var i = 0; i < binarray.length * 4; i += 3) { var triplet = (((binarray[i >> 2] >> 8 * ( i %4)) & 0xFF) << 16) | (((binarray[i+1 >> 2] >> 8 * ((i+1)%4)) & 0xFF) << 8 ) | ((binarray[i+2 >> 2] >> 8 * ((i+2)%4)) & 0xFF); for(var j = 0; j < 4; j++) { if(i * 8 + j * 6 > binarray.length * 32) str += b64pad; else str += tab.charAt((triplet >> 6*(3-j)) & 0x3F); } } return str; } ================================================ FILE: app/src/main/assets/web/help/index.html ================================================ help
================================================ FILE: app/src/main/assets/web/help/js/main.js ================================================ require.config({ baseUrl: 'js', paths: { marked: 'marked.min', markedHighlight: 'marked-highlight.umd', highlight: 'highlight.min', }, shim: { marked: { exports: 'marked', }, markedHighlight: { exports: 'markedHighlight', }, highlight: { exports: 'hljs', }, }, }); require(['marked', 'markedHighlight', 'highlight'], (marked, mdhl, hljs) => { marked.use( mdhl.markedHighlight({ langPrefix: 'theme-vs2015-min hljs language-', highlight(code, lang) { const language = hljs.getLanguage(lang) ? lang : 'txt'; const result = hljs.highlight(code, {language}); return result.value; }, }) ); const path = '/help/md/'; const file = location.hash.slice(1).trim(); if (!file) return; fetch(`${path}${file}.md`) .then((response) => response.text()) .then((md_text) => { document.getElementById('mdviewer').innerHTML = marked.parse(md_text); }); }); ================================================ FILE: app/src/main/assets/web/help/js/marked-highlight.umd.js ================================================ (function(global,factory){typeof exports==='object'&&typeof module!=='undefined'?factory(exports):typeof define==='function'&&define.amd?define(['exports'],factory):(global=typeof globalThis!=='undefined'?globalThis:global||self,factory(global.markedHighlight={}))})(this,(function(exports){'use strict';function markedHighlight(options){if(typeof options==='function'){options={highlight:options}}if(!options||typeof options.highlight!=='function'){throw new Error('Must provide highlight function');}if(typeof options.langPrefix!=='string'){options.langPrefix='language-'}return{async:!!options.async,walkTokens(token){if(token.type!=='code'){return}const lang=getLang(token.lang);if(options.async){return Promise.resolve(options.highlight(token.text,lang,token.lang||'')).then(updateToken(token))}const code=options.highlight(token.text,lang,token.lang||'');if(code instanceof Promise){throw new Error('markedHighlight is not set to async but the highlight function is async. Set the async option to true on markedHighlight to await the async highlight function.');}updateToken(token)(code)},renderer:{code(code,infoString,escaped){const lang=getLang(infoString);const classAttr=`${options.langPrefix}${escape(lang||'txt')}`;code=code.replace(/\n$/,'');return`
${escaped?code:escape(code,true)}\n${lang||'txt'}
`}}}}function getLang(lang){return(lang||'').match(/\S*/)[0]}function updateToken(token){return(code)=>{if(typeof code==='string'&&code!==token.text){token.escaped=true;token.text=code}}}const escapeTest=/[&<>"']/;const escapeReplace=new RegExp(escapeTest.source,'g');const escapeTestNoEncode=/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/;const escapeReplaceNoEncode=new RegExp(escapeTestNoEncode.source,'g');const escapeReplacements={'&':'&','<':'<','>':'>','"':'"',"'":'''};const getEscapeReplacement=(ch)=>escapeReplacements[ch];function escape(html,encode){if(encode){if(escapeTest.test(html)){return html.replace(escapeReplace,getEscapeReplacement)}}else{if(escapeTestNoEncode.test(html)){return html.replace(escapeReplaceNoEncode,getEscapeReplacement)}}return html}exports.markedHighlight=markedHighlight})); ================================================ FILE: app/src/main/assets/web/help/js/require.js ================================================ /** vim: et:ts=4:sw=4:sts=4 * @license RequireJS 2.3.6 Copyright jQuery Foundation and other contributors. * Released under MIT license, https://github.com/requirejs/requirejs/blob/master/LICENSE */ var requirejs,require,define;!function(global,setTimeout){var req,s,head,baseElement,dataMain,src,interactiveScript,currentlyAddingScript,mainScript,subPath,version="2.3.6",commentRegExp=/\/\*[\s\S]*?\*\/|([^:"'=]|^)\/\/.*$/gm,cjsRequireRegExp=/[^.]\s*require\s*\(\s*["']([^'"\s]+)["']\s*\)/g,jsSuffixRegExp=/\.js$/,currDirRegExp=/^\.\//,op=Object.prototype,ostring=op.toString,hasOwn=op.hasOwnProperty,isBrowser=!("undefined"==typeof window||"undefined"==typeof navigator||!window.document),isWebWorker=!isBrowser&&"undefined"!=typeof importScripts,readyRegExp=isBrowser&&"PLAYSTATION 3"===navigator.platform?/^complete$/:/^(complete|loaded)$/,defContextName="_",isOpera="undefined"!=typeof opera&&"[object Opera]"===opera.toString(),contexts={},cfg={},globalDefQueue=[],useInteractive=!1;function commentReplace(e,t){return t||""}function isFunction(e){return"[object Function]"===ostring.call(e)}function isArray(e){return"[object Array]"===ostring.call(e)}function each(e,t){var i;if(e)for(i=0;i name + "作者:" + author - 导出文件名: > Legado 是最好的在线阅读软件 作者: kunfei **【注】** *name、author 等变量与字符串的拼接都需要在 JSON 上下文环境中进行,即必须使用 `{}` 将变量与字符串包裹起来。* ### 7. 为什么我打开本地的 TXT 文件,显示内容却是乱码? 部分编码在阅读上会识别错误,建议先用文本编辑器转换为常用的 UTF-8 格式。 ### 8. 阅读对部分把正文(如所有含引号的句子)识别成标题,如何解决? 点击右上角更换目录规则即可。 ## 书籍界面相关 ### 1. 如何刷新书架? 在书架界面下拉即可刷新。 ### 2. 书架界面书籍右上角的红色或者灰色背景小数字代表什么? 红色代表书籍有更新,灰色代表无更新,数字代表未读章节。 ### 3. 如何查看书籍详情? 长按书籍即可查看。 ### 4. 如何对书架上的书进行删除、切换书架的操作? 书籍详情页操作即可。 ### 5. 如何禁止或允许某本书更新? 书籍详情页,点击右上角——“**允许更新**”。 ### 6. 如何更换小说封面、名字、作者或简介? 书籍详情页,点击右上角修改按钮。 ### 7. 怎么使用自定义字体? 阅读界面——“**字体**”——点击右上角选择字体文件路径。 ### 8. 目前支持哪些格式的字体文件? 目前支持 TTF 和 OTF 格式。 ### 9. 书籍经常“正在加载中”怎么办? 在线书籍出现这个问题通常是由于源质量不好或不兼容引起的,可以换其它源多试试;本地书籍出现这个问题大概率是目录规则问题,手动切换规则可以解决。 ### 10. 书籍内容只有标题,正文内容是路径怎么办? 通常是缓存路径引起的,更换缓存路径即可。 ### 11. 看书时如遇到“目录为空”、“加载失败”或长串英文等情况怎么办? 在线书籍一般是书源问题,切换或更新书源即可。本地书籍请尝试手动更换目录规则。 ### 12. 为什么每一章的最后一页,阅读的文字和横线背景总是对不齐? 请在“**设置**”——“**文字底部对齐**”选项中关闭底部对齐,再调整排版。 ### 13. 漫画源或图片章节只能看到第一页,如何解决? 请先查看原网页是否正常,若正常,请在书籍阅读界面点击右上角的“**⁝**”按钮,在弹出的菜单中,选择“**翻页动画(本书)**”,将翻页动画更改为“**滚动**”。 ### 14. 阅读图片章节、漫画或 EPUB 插图时,图片被缩放到一页中,以至无法看清,如何处理? * 临时处理方案:长按图片可以进行双指缩放。图片章节请先参考 Q13 中的方案将翻页动画更改为“**滚动**”。 * 3.0 旧版可以点击书籍界面的章节标题进入“**编辑书源**”界面,在“**正文**”——“**图片样式**”中填入 *`full`*,保存更改,刷新当前章节即可。 * 3.0 新版可以直接在书籍阅读界面点击右上角的“**⁝**”按钮,选择“**图片样式**”——***`full`***。 ## 替换净化相关 ### 1. 替换净化是什么? 替换净化可以去除书籍内容里的广告、错别字、屏蔽词等。 ### 2. 如何自己填写净化替换规则? * 第一行:替换规则名称。请根据自己需求对替换净化规则进行命名; * 第二行:分组。净化规则的分组组别; * 第三行:替换规则。填写需要被替换的内容; * 第四行:替换为。填写想替换成的内容(如不填则默认表示删除第三行里填写的内容); * 第五行:替换范围,选填书名或者源名。填写此替换净化规则需要对哪本书籍或者哪个书源生效(如不填则对所有书籍和书源生效)。 **【注】** *如常规去除方法去除不掉,则需要勾选“使用正则表达式”,同时第三行里的替换规则也需要按照正则表达式来填写(正则表达式填写方法可自行网上搜索学习)。* ## 备份相关 ### 1. 云备份在哪? “**我的**”——“**备份与恢复**”——“**WebDav 设置**”。 ### 2. 如何操作进行云备份? * 侧栏设置,WebDav 设置; * 正确填写 WebDAV 服务器地址、账号和密码; * 无需操作,APP 默认每天自动云备份一次。 作者在此诚挚推荐使用【坚果云】进行 WebDav 备份。 如果直接在手机上注册,须下载【坚果云】APP,步骤较为繁琐。推荐在电脑上进行操作: 1. 打开注册链接:https://www.jianguoyun.com/d/signup ; 2. 注册后,进入坚果云; 3. 点击右上角账户名处选择“**账户信息**”,然后选择“**安全选项**”; 4. 在“**安全选项**”中找到“**第三方应用管理**”,并选择“**添加应用**”,输入名称(如“阅读”)后,会生成密码,选择完成; 5. 其中 `https://dav.jianguoyun.com/dav/` 就是填入“**WebDAV 服务器地址**”的内容,“**使用情况**”后面的邮箱地址就是你的“**WebDAV 账号**”,点击“**显示密码**“后得到的密码就是你的“**WebDAV 密码**”。 ### 3. 关于云备份的相关说明 在正确设置好云备份的情况下,APP 默认每天自动云备份一次,当日多次手动云备份会对当日的旧云备份文件进行覆盖,并不会覆盖之前及之后不同日期的备份文件,每天所自动云备份的文件会按照日期进行命名。 ### 4. 本地备份和云备份都能备份哪些东西? 书架、看书进度、搜索记录、书源、替换和 APP 设置等都会备份,基本涵盖所有内容。 ### 5. 出现某些未知 Bug 怎么办? 清除软件数据试试看,不行再进行反馈。 ## 其他 ### 1. 如何听书? 可以使用手机自带的朗读引擎,也可使用第三方如 Google(谷歌)或小米等朗读引擎。 【具体操作】*安装——系统设置——其他高级设置——辅助功能——TTS 输出——选择安装的朗读引擎(不同品牌手机的操作方法及步骤也不同,视情况而定)。* ### 2. 如何设置屏幕方向、屏幕显示时长、显示/隐藏状态栏、显示/隐藏导航栏、音量键翻页、长按选择文本、点击总是翻下一页或自定义翻页按键? 阅读界面——“**设置**”(可上划,下面还有其他设置)。 ### 3. 搜索的时候感觉手机卡顿,如何解决? “**我的**”——“**其他设置**”——调低“**更新和搜索线程数**”。 ================================================ FILE: app/src/main/assets/web/help/md/debugHelp.md ================================================ # 书源调试 * 调试搜索>>输入关键字,如: ``` 系统 ``` * 调试发现>>输入发现URL,如: ``` 月票榜::https://www.qidian.com/rank/yuepiao?page={{page}} ``` * 调试详情页>>输入详情页URL,如: ``` https://m.qidian.com/book/1015609210 ``` * 调试目录页>>输入目录页URL,如: ``` ++https://www.zhaishuyuan.com/read/30394 ``` * 调试正文页>>输入正文页URL,如: ``` --https://www.zhaishuyuan.com/chapter/30394/20940996 ``` ================================================ FILE: app/src/main/assets/web/help/md/dictRuleHelp.md ================================================ ## 字典规则说明 * 字典规则是用在正文文字选择菜单字典里的规则,通常用来做翻译或者查找 * urlRule * 同书源的url规则 * showRule * 用来提取显示到对话框里面内容的规则 ================================================ FILE: app/src/main/assets/web/help/md/httpTTSHelp.md ================================================ # 在线朗读规则说明 * 在线朗读规则为url规则,同书源url * js参数 ``` speakText //朗读文本 speakSpeed //朗读速度,5-50 ``` * 例: ``` http://tts.baidu.com/text2audio,{ "method": "POST", "body": "tex={{java.encodeURI(java.encodeURI(speakText))}}&spd={{String((speakSpeed + 5) / 10 + 4)}}&per=5003&cuid=baidu_speech_demo&idx=1&cod=2&lan=zh&ctp=1&pdt=1&vol=5&pit=5&_res_tag_=audio" } ``` ================================================ FILE: app/src/main/assets/web/help/md/jsHelp.md ================================================ # js变量和函数 > 阅读使用[Rhino v1.8.0](https://github.com/mozilla/rhino) 作为JavaScript引擎以便于[调用Java类和方法](https://m.jb51.net/article/92138.htm),查看[ECMAScript兼容性表格](https://mozilla.github.io/rhino/compat/engines.html) > [Rhino运行时](https://github.com/mozilla/rhino/blob/master/rhino/src/main/java/org/mozilla/javascript/ScriptRuntime.java)懒加载导入的Java类和方法 |构造函数|函数|对象|调用类|简要说明| |------|-----|------|----|------| |JavaImporter|importClass importPackage| |[ImporterTopLevel](https://github.com/mozilla/rhino/blob/master/rhino/src/main/java/org/mozilla/javascript/ImporterTopLevel.java)|导入Java类到JavaScript| ||getClass|Packages java javax ...|[NativeJavaTopPackage](https://github.com/mozilla/rhino/blob/master/rhino/src/main/java/org/mozilla/javascript/NativeJavaTopPackage.java)|默认导入JavaScript中的Java类| |JavaAdapter|||[JavaAdapter](https://github.com/mozilla/rhino/blob/master/rhino/src/main/java//org/mozilla/javascript/JavaAdapter.java)|继承Java类| > 注意`java`变量指向已经被阅读修改,如果想要调用`java.*`下的包,请使用`Packages.java.*` > 在书源规则中使用`@js` `` `{{}}`可使用JavaScript调用阅读部分内置的类和方法 > 注意为了安全,阅读会屏蔽部分java类调用,见[RhinoClassShutter](https://github.com/gedoor/legado/blob/master/modules/rhino/src/main/java/com/script/rhino/RhinoClassShutter.kt) > 不同的书源规则中支持的调用的Java类和方法可能有所不同 > 注意使用 `const` 声明的变量不支持块级作用域,在循环里使用会出现值不变的问题,请改用 `var` 声明 |变量名|调用类| |------|-----| |java|当前类| |baseUrl|当前url,String | |result|上一步的结果| |book|[书籍类](https://github.com/gedoor/legado/blob/master/app/src/main/java/io/legado/app/data/entities/Book.kt)| |rssArticle|[Article类](https://github.com/gedoor/legado/blob/master/app/src/main/java/io/legado/app/data/entities/RssArticle.kt)| |chapter|[章节类](https://github.com/gedoor/legado/blob/master/app/src/main/java/io/legado/app/data/entities/BookChapter.kt)| |source|[基础书源类](https://github.com/gedoor/legado/blob/master/app/src/main/java/io/legado/app/data/entities/BaseSource.kt)| |cookie|[cookie操作类](https://github.com/gedoor/legado/blob/master/app/src/main/java/io/legado/app/help/http/CookieStore.kt)| |cache|[缓存操作类](https://github.com/gedoor/legado/blob/master/app/src/main/java/io/legado/app/help/CacheManager.kt)| |title|章节当前标题 String| |src| 请求返回的源码| |nextChapterUrl|下一章节url| ## 当前类对象的可使用的部分方法 ### [RssJsExtensions](https://github.com/gedoor/legado/blob/master/app/src/main/java/io/legado/app/ui/rss/read/RssJsExtensions.kt) > 只能在订阅源`shouldOverrideUrlLoading`规则中使用 > 订阅添加跳转url拦截, js, 返回true拦截,js变量url,可以通过js打开url > url跳转拦截规则不能执行耗时操作 > 例子https://github.com/gedoor/legado/discussions/3259 * 调用阅读搜索 ```js java.searchBook(bookName: String) ``` * 添加书架 ```js java.addBook(bookUrl: String) ``` ### [AnalyzeUrl](https://github.com/gedoor/legado/blob/master/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeUrl.kt) 部分函数 > js中通过java.调用,只在`登录检查JS`规则中有效 ```js initUrl() //重新解析url,可以用于登录检测js登录后重新解析url重新访问 getHeaderMap().putAll(source.getHeaderMap(true)) //重新设置登录头 getStrResponse( jsStr: String? = null, sourceRegex: String? = null) //返回访问结果,文本类型,书源内部重新登录后可调用此方法重新返回结果 getResponse(): Response //返回访问结果,网络朗读引擎采用的是这个,调用登录后在调用这方法可以重新访问,参考阿里云登录检测 ``` ### [AnalyzeRule](https://github.com/gedoor/legado/blob/master/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt) 部分函数 * 获取文本/文本列表 > `mContent` 待解析源代码,默认为当前页面 > `isUrl` 链接标识,默认为`false` ```js java.getString(ruleStr: String?, mContent: Any? = null, isUrl: Boolean = false) java.getStringList(ruleStr: String?, mContent: Any? = null, isUrl: Boolean = false) ``` * 设置解析内容 ```js java.setContent(content: Any?, baseUrl: String? = null): ``` * 获取Element/Element列表 > 如果要改变解析源代码,请先使用`java.setContent` ```js java.getElement(ruleStr: String) java.getElements(ruleStr: String) ``` * 重新搜索书籍/重新获取目录url > 只能在刷新目录之前使用,有些书源书籍地址和目录url会变 ```js java.reGetBook() java.refreshTocUrl() ``` * 变量存取 ```js java.get(key) java.put(key, value) ``` ### [js扩展类](https://github.com/gedoor/legado/blob/master/app/src/main/java/io/legado/app/help/JsExtensions.kt) 部分函数 * 链接解析[JsURL](https://github.com/gedoor/legado/blob/master/app/src/main/java/io/legado/app/utils/JsURL.kt) ```js java.toURL(url): JsURL java.toURL(url, baseUrl): JsURL ``` * 获取SystemWebView User-Agent ```js java.getWebViewUA(): String ``` * 网络请求 ```js java.ajax(urlStr): String java.ajaxAll(urlList: Array): Array //返回StrResponse 方法body() code() message() headers() raw() toString() java.connect(urlStr): StrResponse java.post(url: String, body: String, headerMap: Map): Connection.Response java.get(url: String, headerMap: Map): Connection.Response java.head(url: String, headerMap: Map): Connection.Response * 使用webView访问网络 * @param html 直接用webView载入的html, 如果html为空直接访问url * @param url html内如果有相对路径的资源不传入url访问不了 * @param js 用来取返回值的js语句, 没有就返回整个源代码 * @return 返回js获取的内容 java.webView(html: String?, url: String?, js: String?): String? * 使用webView获取跳转url java.webViewGetOverrideUrl(html: String?, url: String?, js: String?, overrideUrlRegex: String): String? * 使用webView获取资源url java.webViewGetSource(html: String?, url: String?, js: String?, sourceRegex: String): String? * 使用内置浏览器打开链接,可用于获取验证码 手动验证网站防爬 * @param url 要打开的链接 * @param title 浏览器的标题 java.startBrowser(url: String, title: String) * 使用内置浏览器打开链接,并等待网页结果 .body()获取网页内容 java.startBrowserAwait(url: String, title: String, refetchAfterSuccess: Boolean? = true): StrResponse ``` * 调试 ```js java.log(msg) java.logType(var) ``` * 获取用户输入的验证码 ```js java.getVerificationCode(imageUrl) ``` * 弹窗提示 ```js java.longToast(msg: Any?) java.toast(msg: Any?) ``` * 从网络(由java.cacheFile实现)、本地读取JavaScript文件,导入上下文请手动`eval(String(...))` ```js java.importScript(url) //相对路径支持android/data/{package}/cache java.importScript(relativePath) java.importScript(absolutePath) ``` * 缓存网络文件 ```js 获取 java.cacheFile(url) java.cacheFile(url,saveTime) 执行内容 eval(String(java.cacheFile(url))) 使缓存失效 cache.delete(java.md5Encode16(url)) ``` * 获取网络压缩文件里面指定路径的数据 *可替换Zip Rar 7Z ```js java.get*StringContent(url: String, path: String): String java.get*StringContent(url: String, path: String, charsetName: String): String java.get*ByteArrayContent(url: String, path: String): ByteArray? ``` * URI编码 ```js java.encodeURI(str: String) //默认enc="UTF-8" java.encodeURI(str: String, enc: String) ``` * base64 > flags参数可省略,默认Base64.NO_WRAP,查看[flags参数说明](https://blog.csdn.net/zcmain/article/details/97051870) ```js java.base64Decode(str: String) java.base64Decode(str: String, charset: String) java.base64DecodeToByteArray(str: String, flags: Int) java.base64Encode(str: String, flags: Int) ``` * ByteArray ```js Str转Bytes java.strToBytes(str: String) java.strToBytes(str: String, charset: String) Bytes转Str java.bytesToStr(bytes: ByteArray) java.bytesToStr(bytes: ByteArray, charset: String) ``` * Hex ```js HexString 解码为字节数组 java.hexDecodeToByteArray(hex: String) hexString 解码为utf8String java.hexDecodeToString(hex: String) utf8 编码为hexString java.hexEncodeToString(utf8: String) ``` * 标识id ```js java.randomUUID() java.androidId() ``` * 繁简转换 ```js 将文本转换为简体 java.t2s(text: String): String 将文本转换为繁体 java.s2t(text: String): String ``` * 时间格式化 ```js java.timeFormatUTC(time: Long, format: String, sh: Int): String? java.timeFormat(time: Long): String ``` * html格式化 ```js java.htmlFormat(str: String): String ``` * 文件 > 所有对于文件的读写删操作都是相对路径,只能操作阅读缓存/android/data/{package}/cache/内的文件 ```js //文件下载 url用于生成文件名,返回文件路径 downloadFile(url: String): String //文件解压,zipPath为压缩文件路径,返回解压路径 unArchiveFile(zipPath: String): String unzipFile(zipPath: String): String unrarFile(zipPath: String): String un7zFile(zipPath: String): String //文件夹内所有文件读取 getTxtInFolder(unzipPath: String): String //读取文本文件 readTxtFile(path: String): String //删除文件 deleteFile(path: String) ``` ### [js加解密类](https://github.com/gedoor/legado/blob/master/app/src/main/java/io/legado/app/help/JsEncodeUtils.kt) 部分函数 > 提供在JavaScript环境中快捷调用crypto算法的函数,由[hutool-crypto](https://www.hutool.cn/docs/#/crypto/概述)实现 > 由于兼容性问题,hutool-crypto当前版本为5.8.22 > 注意:如果输入的参数不是Utf8String 可先调用`java.hexDecodeToByteArray java.base64DecodeToByteArray`转成ByteArray * 对称加密 > 输入参数key iv 支持ByteArray|**Utf8String** ```js // 创建Cipher java.createSymmetricCrypto(transformation, key, iv) ``` >解密加密参数 data支持ByteArray|Base64String|HexString|InputStream ```js //解密为ByteArray String cipher.decrypt(data) cipher.decryptStr(data) //加密为ByteArray Base64字符 HEX字符 cipher.encrypt(data) cipher.encryptBase64(data) cipher.encryptHex(data) ``` * 非对称加密 > 输入参数 key支持ByteArray|**Utf8String** ```js //创建cipher java.createAsymmetricCrypto(transformation) //设置密钥 .setPublicKey(key) .setPrivateKey(key) ``` > 解密加密参数 data支持ByteArray|Base64String|HexString|InputStream ```js //解密为ByteArray String cipher.decrypt(data, usePublicKey: Boolean? = true ) cipher.decryptStr(data, usePublicKey: Boolean? = true ) //加密为ByteArray Base64字符 HEX字符 cipher.encrypt(data, usePublicKey: Boolean? = true ) cipher.encryptBase64(data, usePublicKey: Boolean? = true ) cipher.encryptHex(data, usePublicKey: Boolean? = true ) ``` * 签名 > 输入参数 key 支持ByteArray|**Utf8String** ```js //创建Sign java.createSign(algorithm) //设置密钥 .setPublicKey(key) .setPrivateKey(key) ``` > 签名参数 data支持ByteArray|inputStream|String ```js //签名输出 ByteArray HexString sign.sign(data) sign.signHex(data) ``` * 摘要 ```js java.digestHex(data: String, algorithm: String,): String? java.digestBase64Str(data: String, algorithm: String,): String? ``` * md5 ```js java.md5Encode(str) java.md5Encode16(str) ``` * HMac ```js java.HMacHex(data: String, algorithm: String, key: String): String java.HMacBase64(data: String, algorithm: String, key: String): String ``` ## book对象的可用属性 ### 属性 > 使用方法: 在js中或{{}}中使用book.属性的方式即可获取.如在正文内容后加上 ##{{book.name+"正文卷"+title}} 可以净化 书名+正文卷+章节名称(如 我是大明星正文卷第二章我爸是豪门总裁) 这一类的字符. ```js bookUrl // 详情页Url(本地书源存储完整文件路径) tocUrl // 目录页Url (toc=table of Contents) origin // 书源URL(默认BookType.local) originName //书源名称 or 本地书籍文件名 name // 书籍名称(书源获取) author // 作者名称(书源获取) kind // 分类信息(书源获取) customTag // 分类信息(用户修改) coverUrl // 封面Url(书源获取) customCoverUrl // 封面Url(用户修改) intro // 简介内容(书源获取) customIntro // 简介内容(用户修改) charset // 自定义字符集名称(仅适用于本地书籍) type // 0:text 1:audio group // 自定义分组索引号 latestChapterTitle // 最新章节标题 latestChapterTime // 最新章节标题更新时间 lastCheckTime // 最近一次更新书籍信息的时间 lastCheckCount // 最近一次发现新章节的数量 totalChapterNum // 书籍目录总数 durChapterTitle // 当前章节名称 durChapterIndex // 当前章节索引 durChapterPos // 当前阅读的进度(首行字符的索引位置) durChapterTime // 最近一次阅读书籍的时间(打开正文的时间) canUpdate // 刷新书架时更新书籍信息 order // 手动排序 originOrder //书源排序 variable // 自定义书籍变量信息(用于书源规则检索书籍信息) ``` ## chapter对象的部分可用属性 > 使用方法: 在js中或{{}}中使用chapter.属性的方式即可获取.如在正文内容后加上 ##{{chapter.title+chapter.index}} 可以净化 章节标题+序号(如 第二章 天仙下凡2) 这一类的字符. ```js url // 章节地址 title // 章节标题 baseUrl //用来拼接相对url bookUrl // 书籍地址 index // 章节序号 resourceUrl // 音频真实URL tag // start // 章节起始位置 end // 章节终止位置 variable //变量 ``` ## source对象的部分可用函数 * 获取书源url ```js source.getKey() ``` * 书源变量存取 ```js source.setVariable(variable: String?) source.getVariable() ``` * 登录头操作 ```js 获取登录头 source.getLoginHeader() 获取登录头某一键值 source.getLoginHeaderMap().get(key: String) 保存登录头 source.putLoginHeader(header: String) 清除登录头 source.removeLoginHeader() ``` * 用户登录信息操作 > 使用`登录UI`规则,并成功登录,阅读自动加密保存登录UI规则中除type为button的信息 ```js login函数获取登录信息 source.getLoginInfo() login函数获取登录信息键值 source.getLoginInfoMap().get(key: String) 清除登录信息 source.removeLoginInfo() ``` ## cookie对象的部分可用函数 ```js 获取全部cookie cookie.getCookie(url) 获取cookie某一键值 cookie.getKey(url,key) 设置cookie cookie.setCookie(url,cookie) 替换cookie cookie.replaceCookie(url,cookie) 删除cookie cookie.removeCookie(url) ``` ## cache对象的部分可用函数 > saveTime单位:秒,可省略 > 保存至数据库和缓存文件(50M),保存的内容较大时请使用`getFile putFile` ```js 保存 cache.put(key: String, value: String, saveTime: Int) 读取数据库 cache.get(key: String): String? 删除 cache.delete(key: String) 缓存文件内容 cache.putFile(key: String, value: String, saveTime: Int) 读取文件内容 cache.getFile(key: String): String? 保存到内存 cache.putMemory(key: String, value: Any) 读取内存 cache.getFromMemory(key: String): Any? 删除内存 cache.deleteMemory(key: String) ``` ## 跳转外部链接/应用函数 ```js // 跳转外部链接,传入http链接或者scheme跳转到浏览器或其他应用 java.openUrl(url:String) // 指定mimeType,可以跳转指定类型应用,例如(video/*) java.openUrl(url:String,mimeType:String) ================================================ FILE: app/src/main/assets/web/help/md/readMenuHelp.md ================================================ # 阅读界面帮助文档 ## 阅读界面主菜单 * 顶部操作 * 章节名称:点击可编辑书源 * 章节url:点击可打开浏览器浏览 * 菜单:**不同类型的书籍显示的菜单不同**。详情请查看菜单文字,长按菜单图标可显示文字 * 中间左侧-亮度调节 * 亮度调节的顶端有跟随系统亮度的开关,打开后亮度跟随系统,关闭后才可以调节亮度条 * 底部操作 * 4个圆形按钮依次为 全文搜索✧自动翻页✧替换净化✧切换夜间模式 * 上一章✧下一章中间的进度条为页数进度,要快速跳转章节点击目录按钮进入目录快速跳转 * 目录->目录和书签界面 * 朗读->单击开始朗读,长按进入朗读设置界面 * 界面->所有排版设置都在里面 * 设置->其它一些设置,找不到的设置去这里看看,可滚动 ## 全文搜索 搜索本地缓存或者本地文件,不能搜索在线内容 书籍字数、净化规则数量、简繁转化、文章分段都会影响到搜索速度,请酌情启用 ## 朗读设置界面 * 后台->进入后台朗读,可以做一些其它事 * 设置->朗读引擎设置,可以切换本地TTS和在线朗读,在线朗读可自定义 ## 排版设置界面 * 白天模式和夜间模式背景不同布局相同 * 共用布局->启用共用布局时所有背景使用同一布局,关闭共用布局则每个背景单独布局 * 长按背景可进入文字颜色和背景设置界面 ## 其它设置界面 * 屏幕方向 * 屏幕超时 * 隐藏状态栏 * 扩展到刘海 * 隐藏导航栏 * 文字两端对齐 * 文字底部对齐 * 音量键翻页 * 点击翻页 * 朗读时音量键翻页 * 自动换源->书源被删除时自动切换到其它书源 * 长按选择文本 * 显示亮度调节控件 * 点击区域设置 * 自定义翻页按键 ================================================ FILE: app/src/main/assets/web/help/md/regexHelp.md ================================================ # 正则表达式学习 - [基本匹配] - [元字符] - [英文句号] - [字符集] - [否定字符集] - [重复] - [星号] - [加号] - [问号] - [花括号] - [字符组] - [分支结构] - [转义特殊字符] - [定位符] - [插入符号] - [美元符号] - [简写字符集] - [断言] - [正向先行断言] - [负向先行断言] - [正向后行断言] - [负向后行断言] - [标记] - [不区分大小写] - [全局搜索] - [多行匹配] - [常用正则表达式] ## 1. 基本匹配 正则表达式只是我们用于在文本中检索字母和数字的模式。例如正则表达式 `cat`,表示: 字母 `c` 后面跟着一个字母 `a`,再后面跟着一个字母 `t`。
"cat" => The cat sat on the mat
正则表达式 `123` 会匹配字符串 "123"。通过将正则表达式中的每个字符逐个与要匹配的字符串中的每个字符进行比较,来完成正则匹配。 正则表达式通常区分大小写,因此正则表达式 `Cat` 与字符串 "cat" 不匹配。
"Cat" => The cat sat on the Cat
## 2. 元字符 元字符是正则表达式的基本组成元素。元字符在这里跟它通常表达的意思不一样,而是以某种特殊的含义去解释。有些元字符写在方括号内的时候有特殊含义。 元字符如下: |元字符|描述| |:----:|----| |.|匹配除换行符以外的任意字符。| |[ ]|字符类,匹配方括号中包含的任意字符。| |[^ ]|否定字符类。匹配方括号中不包含的任意字符| |*|匹配前面的子表达式零次或多次| |+|匹配前面的子表达式一次或多次| |?|匹配前面的子表达式零次或一次,或指明一个非贪婪限定符。| |{n,m}|花括号,匹配前面字符至少 n 次,但是不超过 m 次。| |(xyz)|字符组,按照确切的顺序匹配字符xyz。| |||分支结构,匹配符号之前的字符或后面的字符。| |\|转义符,它可以还原元字符原来的含义,允许你匹配保留字符 [ ] ( ) { } . * + ? ^ $ \ || |^|匹配行的开始| |$|匹配行的结束| ## 2.1 英文句号 英文句号 `.` 是元字符的最简单的例子。元字符 `.` 可以匹配任意单个字符。它不会匹配换行符和新行的字符。例如正则表达式 `.ar`,表示: 任意字符后面跟着一个字母 `a`, 再后面跟着一个字母 `r`。
".ar" => The car parked in the garage.
## 2.2 字符集 字符集也称为字符类。方括号被用于指定字符集。使用字符集内的连字符来指定字符范围。方括号内的字符范围的顺序并不重要。 例如正则表达式 `[Tt]he`,表示: 大写 `T` 或小写 `t` ,后跟字母 `h`,再后跟字母 `e`。
"[Tt]he" => The car parked in the garage.
然而,字符集中的英文句号表示它字面的含义。正则表达式 `ar[.]`,表示小写字母 `a`,后面跟着一个字母 `r`,再后面跟着一个英文句号 `.` 字符。
"ar[.]" => A garage is a good place to park a car.
### 2.2.1 否定字符集 一般来说插入字符 `^` 表示一个字符串的开始,但是当它在方括号内出现时,它会取消字符集。例如正则表达式 `[^c]ar`,表示: 除了字母 `c` 以外的任意字符,后面跟着字符 `a`, 再后面跟着一个字母 `r`。
"[^c]ar" => The car parked in the garage.
## 2.3 重复 以下元字符 `+`,`*` 或 `?` 用于指定子模式可以出现多少次。这些元字符在不同情况下的作用不同。 ### 2.3.1 星号 该符号 `*` 表示匹配上一个匹配规则的零次或多次。正则表达式 `a*` 表示小写字母 `a` 可以重复零次或者多次。但是它如果出现在字符集或者字符类之后,它表示整个字符集的重复。 例如正则表达式 `[a-z]*`,表示: 一行中可以包含任意数量的小写字母。
"[a-z]*" => The car parked in the garage #21.
该 `*` 符号可以与元符号 `.` 用在一起,用来匹配任意字符串 `.*`。该 `*` 符号可以与空格符 `\s` 一起使用,用来匹配一串空格字符。 例如正则表达式 `\s*cat\s*`,表示: 零个或多个空格,后面跟小写字母 `c`,再后面跟小写字母 `a`,再再后面跟小写字母 `t`,后面再跟零个或多个空格。
"\s*cat\s*" => The fat cat sat on the cat.
### 2.3.2 加号 该符号 `+` 匹配上一个字符的一次或多次。例如正则表达式 `c.+t`,表示: 一个小写字母 `c`,后跟任意数量的字符,后跟小写字母 `t`。
"c.+t" => The fat cat sat on the mat.
### 2.3.3 问号 在正则表达式中,元字符 `?` 用来表示前一个字符是可选的。该符号匹配前一个字符的零次或一次。 例如正则表达式 `[T]?he`,表示: 可选的大写字母 `T`,后面跟小写字母 `h`,后跟小写字母 `e`。
"[T]he" => The car is parked in the garage.
"[T]?he" => The car is parked in the garage.
## 2.4 花括号 在正则表达式中花括号(也被称为量词 ?)用于指定字符或一组字符可以重复的次数。例如正则表达式 `[0-9]{2,3}`,表示: 匹配至少2位数字但不超过3位(0到9范围内的字符)。
"[0-9]{2,3}" => The number was 9.9997 but we rounded it off to 10.0.
我们可以省略第二个数字。例如正则表达式 `[0-9]{2,}`,表示: 匹配2个或更多个数字。如果我们也删除逗号,则正则表达式 `[0-9]{2}`,表示: 匹配正好为2位数的数字。
"[0-9]{2,}" => The number was 9.9997 but we rounded it off to 10.0.
"[0-9]{2}" => The number was 9.9997 but we rounded it off to 10.0.
## 2.5 字符组 字符组是一组写在圆括号内的子模式 `(...)`。正如我们在正则表达式中讨论的那样,如果我们把一个量词放在一个字符之后,它会重复前一个字符。 但是,如果我们把量词放在一个字符组之后,它会重复整个字符组。 例如正则表达式 `(ab)*` 表示匹配零个或多个的字符串 "ab"。我们还可以在字符组中使用元字符 `|`。例如正则表达式 `(c|g|p)ar`,表示: 小写字母 `c`、`g` 或 `p` 后面跟字母 `a`,后跟字母 `r`。
"(c|g|p)ar" => The car is parked in the garage.
## 2.6 分支结构 在正则表达式中垂直条 `|` 用来定义分支结构,分支结构就像多个表达式之间的条件。现在你可能认为这个字符集和分支机构的工作方式一样。 但是字符集和分支结构巨大的区别是字符集只在字符级别上有作用,然而分支结构在表达式级别上依然可以使用。 例如正则表达式 `(T|t)he|car`,表示: 大写字母 `T` 或小写字母 `t`,后面跟小写字母 `h`,后跟小写字母 `e` 或小写字母 `c`,后跟小写字母 `a`,后跟小写字母 `r`。
"(T|t)he|car" => The car is parked in the garage.
## 2.7 转义特殊字符 正则表达式中使用反斜杠 `\` 来转义下一个字符。这将允许你使用保留字符来作为匹配字符 `{ } [ ] / \ + * . $ ^ | ?`。在特殊字符前面加 `\`,就可以使用它来做匹配字符。 例如正则表达式 `.` 是用来匹配除了换行符以外的任意字符。现在要在输入字符串中匹配 `.` 字符,正则表达式 `(f|c|m)at\.?`,表示: 小写字母 `f`、`c` 或者 `m` 后跟小写字母 `a`,后跟小写字母 `t`,后跟可选的 `.` 字符。
"(f|c|m)at\.?" => The fat cat sat on the mat.
## 2.8 定位符 在正则表达式中,为了检查匹配符号是否是起始符号或结尾符号,我们使用定位符。 定位符有两种类型: 第一种类型是 `^` 检查匹配字符是否是起始字符,第二种类型是 `$`,它检查匹配字符是否是输入字符串的最后一个字符。 ### 2.8.1 插入符号 插入符号 `^` 符号用于检查匹配字符是否是输入字符串的第一个字符。如果我们使用正则表达式 `^a` (如果a是起始符号)匹配字符串 `abc`,它会匹配到 `a`。 但是如果我们使用正则表达式 `^b`,它是匹配不到任何东西的,因为在字符串 `abc` 中 "b" 不是起始字符。 让我们来看看另一个正则表达式 `^(T|t)he`,这表示: 大写字母 `T` 或小写字母 `t` 是输入字符串的起始符号,后面跟着小写字母 `h`,后跟小写字母 `e`。
"(T|t)he" => The car is parked in the garage.
"^(T|t)he" => The car is parked in the garage.
### 2.8.2 美元符号 美元 `$` 符号用于检查匹配字符是否是输入字符串的最后一个字符。例如正则表达式 `(at\.)$`,表示: 小写字母 `a`,后跟小写字母 `t`,后跟一个 `.` 字符,且这个匹配器必须是字符串的结尾。
"(at\.)" => The fat cat. sat. on the mat.
"(at\.)$" => The fat cat sat on the mat.
## 3. 简写字符集 正则表达式为常用的字符集和常用的正则表达式提供了简写。简写字符集如下: |简写|描述| |:----:|----| |.|匹配除换行符以外的任意字符| |\w|匹配所有字母和数字的字符: `[a-zA-Z0-9_]`| |\W|匹配非字母和数字的字符: `[^\w]`| |\d|匹配数字: `[0-9]`| |\D|匹配非数字: `[^\d]`| |\s|匹配空格符: `[\t\n\f\r\p{Z}]`| |\S|匹配非空格符: `[^\s]`| ## 4. 断言 后行断言和先行断言有时候被称为断言,它们是特殊类型的 ***非捕获组*** (用于匹配模式,但不包括在匹配列表中)。当我们在一种特定模式之前或者之后有这种模式时,会优先使用断言。 例如我们想获取输入字符串 `$4.44 and $10.88` 中带有前缀 `$` 的所有数字。我们可以使用这个正则表达式 `(?<=\$)[0-9\.]*`,表示: 获取包含 `.` 字符且前缀为 `$` 的所有数字。 以下是正则表达式中使用的断言: |符号|描述| |:----:|----| |?=|正向先行断言| |?!|负向先行断言| |?<=|正向后行断言| |?"(T|t)he(?=\sfat)" => The fat cat sat on the mat. ### 4.2 负向先行断言 当我们需要从输入字符串中获取不匹配表达式的内容时,使用负向先行断言。负向先行断言的定义跟我们定义的正向先行断言一样, 唯一的区别是不是等号 `=`,我们使用否定符号 `!`,例如 `(?!...)`。 我们来看看下面的正则表达式 `(T|t)he(?!\sfat)`,表示: 从输入字符串中获取全部 `The` 或者 `the` 且不匹配 `fat` 前面加上一个空格字符。
"(T|t)he(?!\sfat)" => The fat cat sat on the mat.
### 4.3 正向后行断言 正向后行断言是用于获取在特定模式之前的所有匹配内容。正向后行断言表示为 `(?<=...)`。例如正则表达式 `(?<=(T|t)he\s)(fat|mat)`,表示: 从输入字符串中获取在单词 `The` 或 `the` 之后的所有 `fat` 和 `mat` 单词。
"(?<=(T|t)he\s)(fat|mat)" => The fat cat sat on the mat.
### 4.4 负向后行断言 负向后行断言是用于获取不在特定模式之前的所有匹配的内容。负向后行断言表示为 `(?"(?<!(T|t)he\s)(cat)" => The cat sat on cat. ## 5. 标记 标记也称为修饰符,因为它会修改正则表达式的输出。这些标志可以以任意顺序或组合使用,并且是正则表达式的一部分。 |标记|描述| |:----:|----| |i|不区分大小写: 将匹配设置为不区分大小写。| |g|全局搜索: 搜索整个输入字符串中的所有匹配。| |m|多行匹配: 会匹配输入字符串每一行。| * **数字**: `\d+$` * **用户名**: `^[\w\d_.]{4,16}$` * **字母数字字符**: `^[a-zA-Z0-9]*$` * **带空格的字母数字字符**: `^[a-zA-Z0-9 ]*$` * **小写字母**: `[a-z]+$` * **大写字母**: `[A-Z]+$` * **网址**: `^(((http|https|ftp):\/\/)?([[a-zA-Z0-9]\-\.])+(\.)([[a-zA-Z0-9]]){2,4}([[a-zA-Z0-9]\/+=%&_\.~?\-]*))*$` * **日期 (MM/DD/YYYY)**: `^(0?[1-9]|1[012])[- /.](0?[1-9]|[12][0-9]|3[01])[- /.](19|20)?[0-9]{2}$` * **日期 (YYYY/MM/DD)**: `^(19|20)?[0-9]{2}[- /.](0?[1-9]|1[012])[- /.](0?[1-9]|[12][0-9]|3[01])$` * **求更求转发致谢**: `[\((【].*?[求更谢乐发推].*?[】)\)]` * **查找最新章节**: `您可以.*?查找最新章节` * **ps/PS**: `(?i)ps\b.*` * **Html标签**: `<[^>]+?>` ================================================ FILE: app/src/main/assets/web/help/md/replaceRuleHelp.md ================================================ # 替换管理界面帮助 * 替换规则是用来替换正文内容的一种规则 * 菜单可以新建和导入规则 * 可以拖动排序 * 可以选择操作 ================================================ FILE: app/src/main/assets/web/help/md/ruleHelp.md ================================================ # 源规则帮助 * [阅读3.0(Legado)规则说明](https://mgz0227.github.io/The-tutorial-of-Legado/) * [书源帮助文档](https://mgz0227.github.io/The-tutorial-of-Legado/Rule/source.html) * [订阅源帮助文档](https://mgz0227.github.io/The-tutorial-of-Legado/Rule/rss.html) * 辅助键盘❓中可插入URL参数模板,打开帮助,js教程,正则教程,选择文件 * 规则标志, {{......}}内使用规则必须有明显的规则标志,没有规则标志当作js执行 ``` @@ 默认规则,直接写时可以省略@@ @XPath: xpath规则,直接写时以//开头可省略@XPath @Json: json规则,直接写时以$.开头可省略@Json : regex规则,不可省略,只可以用在书籍列表和目录列表 ``` * jsLib > 注入JavaScript到RhinoJs引擎中,支持两种格式,可实现[函数共用](https://github.com/gedoor/legado/wiki/JavaScript%E5%87%BD%E6%95%B0%E5%85%B1%E7%94%A8) > `JavaScript Code` 直接填写JavaScript片段 > `{"example":"https://www.example.com/js/example.js", ...}` 自动复用已经下载的js文件 > 注意此处定义的函数可能会被多个线程同时调用,在函数里的全局变量内容将会共享使用,对其进行修改可能会出现竞争问题 > 函数内不可声明全局变量,函数外的全局变量不可再赋值,否则会抛出 `无法修改密封对象的属性` 异常 * 并发率 > 并发限制,单位ms,可填写两种格式 > `1000` 访问间隔1s > `20/60000` 60s内访问次数20 * 书源类型: 文件 > 对于类似知轩藏书提供文件整合下载的网站,可以在书源详情的下载URL规则获取文件链接 > 通过截取下载链接或文件响应头头获取文件信息,获取失败会自动拼接`书名` `作者`和下载链接的`UrlOption`的`type`字段 > 压缩文件解压缓存会在下次启动后自动清理,不会占用额外空间 * CookieJar > 启用后会自动保存每次返回头中的Set-Cookie中的值,适用于验证码图片一类需要session的网站 * 登录UI > 不使用内置webView登录网站,需要使用`登录URL`规则实现登录逻辑,可使用`登录检查JS`检查登录结果 > 版本20221113重要更改:按钮支持调用`登录URL`规则里面的函数,必须实现`login`函数 ``` 规则填写示范 [ { "name": "telephone", "type": "text" }, { "name": "password", "type": "password" }, { "name": "注册", "type": "button", "action": "http://www.yooike.com/xiaoshuo/#/register?title=%E6%B3%A8%E5%86%8C" }, { "name": "获取验证码", "type": "button", "action": "getVerificationCode()", "style": { "layout_flexGrow": 0, "layout_flexShrink": 1, "layout_alignSelf": "auto", "layout_flexBasisPercent": -1, "layout_wrapBefore": false } } ] ``` * 登录URL > 可填写登录链接或者实现登录UI的登录逻辑的JavaScript ``` 示范填写 function login() { java.log("模拟登录请求"); java.log(source.getLoginInfoMap()); } function getVerificationCode() { java.log("登录UI按钮:获取到手机号码"+result.get("telephone")) } 登录按钮函数获取登录信息 result.get("telephone") login函数获取登录信息 source.getLoginInfo() source.getLoginInfoMap().get("telephone") source登录相关方法,可在js内通过source.调用,可以参考阿里云语音登录 login() getHeaderMap(hasLoginHeader: Boolean = false) getLoginHeader(): String? getLoginHeaderMap(): Map? putLoginHeader(header: String) removeLoginHeader() setVariable(variable: String?) getVariable(): String? AnalyzeUrl相关函数,js中通过java.调用 initUrl() //重新解析url,可以用于登录检测js登录后重新解析url重新访问 getHeaderMap().putAll(source.getHeaderMap(true)) //重新设置登录头 getStrResponse( jsStr: String? = null, sourceRegex: String? = null) //返回访问结果,文本类型,书源内部重新登录后可调用此方法重新返回结果 getResponse(): Response //返回访问结果,网络朗读引擎采用的是这个,调用登录后在调用这方法可以重新访问,参考阿里云登录检测 ``` * 发现url格式 ```json [ { "title": "xxx", "url": "", "style": { "layout_flexGrow": 0, "layout_flexShrink": 1, "layout_alignSelf": "auto", "layout_flexBasisPercent": -1, "layout_wrapBefore": false } } ] ``` * 请求头,支持http代理,socks4 socks5代理设置 > 注意请求头的key是区分大小写的 > 正确格式 User-Agent Referer > 错误格式 user-agent referer ``` socks5代理 { "proxy":"socks5://127.0.0.1:1080" } 不支持需要验证的socks代理 http代理 { "proxy":"http://127.0.0.1:1080" } 支持http代理服务器验证 { "proxy":"http://127.0.0.1:1080@用户名@密码" } 注意:这些请求头是无意义的,会被忽略掉 ``` * url添加js参数,解析url时执行,可在访问url时处理url,例 ``` https://www.baidu.com,{"js":"java.headerMap.put('xxx', 'yyy')"} https://www.baidu.com,{"js":"java.url=java.url+'yyyy'"} ``` * 增加js方法,用于重定向拦截 * `java.get(urlStr: String, headers: Map)` * `java.post(urlStr: String, body: String, headers: Map)` * 对于搜索重定向的源,可以使用此方法获得重定向后的url ``` (()=>{ if(page==1){ let url='https://www.yooread.net/e/search/index.php,'+JSON.stringify({ "method":"POST", "body":"show=title&tempid=1&keyboard="+key }); return source.put('surl',String(java.connect(url).raw().request().url())); } else { return source.get('surl')+'&page='+(page-1) } })() 或者 (()=>{ let base='https://www.yooread.net/e/search/'; if(page==1){ let url=base+'index.php'; let body='show=title&tempid=1&keyboard='+key; return base+source.put('surl',java.post(url,body,{}).header("Location")); } else { return base+source.get('surl')+'&page='+(page-1); } })() ``` * 图片链接支持修改headers ``` let options = { "headers": {"User-Agent": "xxxx","Referrer":baseUrl,"Cookie":"aaa=vbbb;"} }; '' ``` * 字体解析使用 > 使用方法,在正文替换规则中使用,原理根据f1字体的字形数据到f2中查找字形对应的编码 ``` (function(){ var b64=String(src).match(/ttf;base64,([^\)]+)/); if(b64){ var f1 = java.queryTTF(b64[1]); var f2 = java.queryTTF("https://alanskycn.gitee.io/teachme/assets/font/Source Han Sans CN Regular.ttf"); // return java.replaceFont(result, f1, f2); return java.replaceFont(result, f1, f2, true); // 过滤掉f1中不存在的字形 } return result; })() ``` * 购买操作 > 可直接填写链接或者JavaScript,如果执行结果是网络链接将会自动打开浏览器,js返回true自动刷新目录和当前章节 * 图片解密 > 适用于图片需要二次解密的情况,直接填写JavaScript,返回解密后的`ByteArray` > 部分变量说明:java(仅支持[js扩展类](https://github.com/gedoor/legado/blob/master/app/src/main/java/io/legado/app/help/JsExtensions.kt)),result为待解密图片的`ByteArray`,src为图片链接 ```js java.createSymmetricCrypto("AES/CBC/PKCS5Padding", key, iv).decrypt(result) ``` ```js function decodeImage(data, key) { var input = new Packages.java.io.ByteArrayInputStream(data) var out = new Packages.java.io.ByteArrayOutputStream() var byte while ((byte = input.read()) != -1) { out.write(byte ^ key) } return out.toByteArray() } decodeImage(result, key) ``` * 封面解密 > 同图片解密 其中result为待解密封面的`inputStream` ```js java.createSymmetricCrypto("AES/CBC/PKCS5Padding", key, iv).decrypt(result) ``` ```js function decodeImage(data, key) { var out = new Packages.java.io.ByteArrayOutputStream() var byte while ((byte = data.read()) != -1) { out.write(byte ^ key) } return out.toByteArray() } decodeImage(result, key) ``` ================================================ FILE: app/src/main/assets/web/help/md/txtTocRuleHelp.md ================================================ ## Txt目录正则说明 ### 菜单区 - 新增目录规则,当Legado自带的规则不能够满足需求时,用户可根据自己的情况自定义目录规则 - 导入默认规则 在旧版本中,Legado自带的规则不会随着软件的更新而更新。用户需要使用最新规则或对自带规则修改后需要重置时,可点击 导入默认规则。**注意:导入默认规则不会重置用户自定义的规则,但如果您对自带的规则进行了修改,则修改的规则会被重置为默认规则。** - 网络导入 为了方便异步调试以及用户导入他人的目录规则,Legado增加目录规则的网络导入功能。点击 网络导入 的输入框,可以通过内置的链接导入在线规则。(在线规则优先比内置的规则更加激进,但适配了更多类型的标题格式,用户需根据自己的情况选择是否导入) - 拆分超长章节 根据目录正则分章时若单章超过三万字左右将自动分为多章 ### 操作区 ![Functions][example] - 按钮① 选中时表示当前书籍使用的目录正则 如果Legado的自动识别的目录情况不太理想,用户可以手动点击各个规则前面的按钮,临时对本书启用该规则,该按钮仅**针对当前书籍生效**。 - 按钮组② 左边的开关被点亮时,表示该规则将会在自动识别目录时尝试进行匹配,该规则针对**所有TXT书籍生效**。开启后会对所有的TXT格式的书籍启用当前规则扫描符合条件的标题格式;中间的按钮表示编辑当前规则,当识别出的目录与你所期望的不一致时,可以修改当前规则以适应你所导入的书籍;右边的按钮表示删除当前规则,当用户不需要当前规则时可直接删除。(默认的规则删除后可点击 导入默认规则 按钮恢复) - 按钮组③ 在当前界面进行操作后,需要点击确认按钮使得选择生效。 [example]:data:image/jpg;base64,/9j/4RZnRXhpZgAATU0AKgAAAAgADAEAAAMAAAABBDgAAAEBAAMAAAABCWAAAAECAAMAAAADAAAAngEGAAMAAAABAAIAAAESAAMAAAABAAEAAAEVAAMAAAABAAMAAAEaAAUAAAABAAAApAEbAAUAAAABAAAArAEoAAMAAAABAAIAAAExAAIAAAAiAAAAtAEyAAIAAAAUAAAA1odpAAQAAAABAAAA7AAAASQACAAIAAgACvyAAAAnEAAK/IAAACcQQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpADIwMjA6MTE6MjQgMTY6NDQ6MjIAAAAABJAAAAcAAAAEMDIyMaABAAMAAAAB//8AAKACAAQAAAABAAABLKADAAQAAAABAAACWAAAAAAAAAAGAQMAAwAAAAEABgAAARoABQAAAAEAAAFyARsABQAAAAEAAAF6ASgAAwAAAAEAAgAAAgEABAAAAAEAAAGCAgIABAAAAAEAABTdAAAAAAAAAEgAAAABAAAASAAAAAH/2P/tAAxBZG9iZV9DTQAB/+4ADkFkb2JlAGSAAAAAAf/bAIQADAgICAkIDAkJDBELCgsRFQ8MDA8VGBMTFRMTGBEMDAwMDAwRDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAENCwsNDg0QDg4QFA4ODhQUDg4ODhQRDAwMDAwREQwMDAwMDBEMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM/8AAEQgAoABQAwEiAAIRAQMRAf/dAAQABf/EAT8AAAEFAQEBAQEBAAAAAAAAAAMAAQIEBQYHCAkKCwEAAQUBAQEBAQEAAAAAAAAAAQACAwQFBgcICQoLEAABBAEDAgQCBQcGCAUDDDMBAAIRAwQhEjEFQVFhEyJxgTIGFJGhsUIjJBVSwWIzNHKC0UMHJZJT8OHxY3M1FqKygyZEk1RkRcKjdDYX0lXiZfKzhMPTdePzRieUpIW0lcTU5PSltcXV5fVWZnaGlqa2xtbm9jdHV2d3h5ent8fX5/cRAAICAQIEBAMEBQYHBwYFNQEAAhEDITESBEFRYXEiEwUygZEUobFCI8FS0fAzJGLhcoKSQ1MVY3M08SUGFqKygwcmNcLSRJNUoxdkRVU2dGXi8rOEw9N14/NGlKSFtJXE1OT0pbXF1eX1VmZ2hpamtsbW5vYnN0dXZ3eHl6e3x//aAAwDAQACEQMRAD8AfHw7cisvqc0vD21Np929znhzqwz2+l7vSs+lb/3xFb0u91T7fUrAYz1IDt0j0vtf0mbm/wA0mdhZWO91TsjHpsYSHs+10tcHDcxzXt9b6bdz2Jw3MDS0Z2PtIII+2UwQQWO/w37rluy5rFZrmMP1nD+LzkPh/M0L5LmSe8cWXhl/zWTukZDHUB9jGfaZ2F27SNn0trHe39J9Nns9iiemX/ZnZQsrNbN8gF0n0ztdt9qkHdQa4OHUKQ5o2tP22mQPAfplEsy3NLTm45a4HcPtlEEHV/8Ahvzvz0BzOPS+Yw+PrguPw/mNa5LmdtP1WX/vWnqlKvY3Q+o5e/7IKcn049T0r6X7Znbv22+3dtR/+avX/wDuL/4LV/6UUw5jAdsuP/Hi1TyfNRNSwZYnsccwf+i5UqdFNmRcymuN1hDRJgamO60v+avX/wDuL/4LV/6USH1W+sAIIxYIMgi2uQR/1xA8xhrTLj/xoqHK57F4MhHX0S/71y3Nc1xa7QjnUH8WqRYWGskg72h+naSRB/zVpf8ANXr/AP3FH/blX/pROfqz9YBDn40hg0m2sw0e6B+k+ikeYw1/O4/8aKRyue/5nJvp6JP/0Oqqu62y9rcJpON9qy93tJbP2nI2y4Mcz32+zI9W2r0sf9LR+lV+/K+szbLxRiVWVi2tuOS4CaiX+vZZN/5rfSd9Fik/p+HXcaxn34773usbQ28Ml1j3WP8ATrI3e+170T9jO/7m5v8A29/5gmjiAArYVu2sxw5JGXEI2Sfk9XqPF6kV+V9Y2m70MStways0EkEusJ/WGbfXr9rWO/RbvT/mrP8ASVpOPXMg203VVV172uqfOhDDZZts/SWWena+nG3/AKP+Zyrf9Eifsj/u9mf9vf8AmCX7I/7vZn/b3/mCVy7fixiGIEEZNjfyFp9Fwbsbq+T6p2FmMwtra8vBF+TnZP6R7wHPsq/m2v8A+uf4Rbiq4mBXi2WWi226y1rGOfc7edtZe5jW6N/OusVpKAoV5q5nJ7mQyBvSIuq2j6v+e431kyPrHRi2v6LS2wtrB3BofYHbv0hrqe73vZX/ADTPSt3oPR8n6w3fV1t3Um2V55uaA4Vt9Y45trbZc/G2bPWbSb/8B9Bnqekt2yyuqt1tr2111gufY8hrWgfSc5zvotaoNy8V+P8Aa231uxiNwvD2msidu71Z9P6Scg5P1XB7cd/5yvX/AHeJBiPzndN3vB+2bbNgtaASQX/Z/VY30G+5vpb/AOj/APF0IPSbOpv6dc7qYIul+zc0Mds2B3uaza3+dNjG+yv2f6T+es0K3stY19Tg9jxLXtILSD3a5vtcomyuzHfZU5r2FrwHNIcJAc13ub+64JMT/9H0PIwX2vt22NbVkbBc0s3P9nt/RWbhs9v7zH+lZ+mq96qZP1aw8l97n33t+02susDCwasNjmt3bHO2/pf66uX5rqnWltQfVj7Te8vDXDcN36KrafV2sO76dX+jp9SxVL/rL0rHsurtc8Ox7a6Hw0GX2F7GBnu93uqckp1TqZSWVkfWXpmO6wWiwNr9OX7WwfVLNu39Ju/Rtursu9v6KtSq+sOBbbZUxtpfTYyl4DQfdZvj6D3/AEW1u9v85/omWWJKdNAyc7BxNv2vJqx98lvqvayY52+o5viiU2suqZaydrxInnw7bmrA+smPZkZrK6sd+RYcSwM9IgOYTbT+m2ufX6uz6bK/9Iz07fTostTZkgWGblsccmQRmSI0SSKG395h9YMb6t9bqey3q1FFpYGNcLqnNBY71GF1bnfvfTa17N6j0nH6H0voo6ZX1nFss9duSbnWVlvqMsrva1tHrfzX6Bjf53/hFtVX/s/posyWOa1rjFctc5jHvd6FRdu2OdVWWMdssf8AyPU+mj5OWKcYXtb6m91TK2aNJNz2U17i76Hut96XqrcfZ/6EvlmjwnFU5YwduOHTr/NObTm9BZgOwrep4trbBaLX+tWJ9YvfZ7X2Wf6X891n8tQ6Q/ouDi2YeL1DGyLb3OeBW+sOc41tpaxldb3ud7alp157X4LswsIbWLC5ggn9EXss2bvT+l6X6Pf6f9hZlvU2dU6bXe2l1OzOxqy15YdfVotDmuqc/wDMsYkeIC7H2f2rIDFOQiIzHEavjifw9t//0vSn42PZa26ypr7WRseQCRB3N/zXe5insZJO1snUmBJKpZTcs3v9MW7iGfZXMdFTT/hftTJ93u/nPUa/9B/RP1hVrx9Z/Wv+znH9M3V/Z98CKZf6+/2uc5+z0v8AydaSnVNdZkljTPPtH9ycNYDIa0EAAEAcDj8qycwfWl7b2YTsat7mVjHfaAQ14c12Q9+3duY9j7K6m+n+j9Fn+l/R2Mk9aN2I7HbU2rUZjHETMs2urPu/RbfV/wCESU3xAEAQBoAFUyun/aMhmSzIuxrmMdVupLNWOc2yHC+q/wDOr/NVK7/nQWA1egHGh0zE+sWv9Pc125v6N3pb9lnpKeI36yfaa35bqPs7q6/UrYBLbB6X2k7vpbbP1j0tn/BoEA7roTlA3Hfbvv4SS/svImf2llk+foEfccVNb0iy6s13dQyba3fSY8Y7mnv7mPxC1Wsv7UaCMT25EiC6NsT7pLw5n0VHOGYcKMfccjdVu9Eta4t31/avROR+jb+h9Xbu/wCrQ4R4/aV/vz7Q/wDC8f8A3rXr6TbUwV1dQyq62/RYwY7Wj4MbibVAdFZXWB9qvdVS4XCiKWVl9Z9Wve3Hx6XfzjG/nqzjjPPTS2yWZhbaGGwtc4GX/ZvUczfU/az0v+/oPS29UbgX/tRznXkvLN5rLgzY3l2M1lf876qXCPH7Sr359OEeUMYP28L/AP/T9JsyceuxtVjw17o0M6bjsr3ujbX6r/ZV6n86/wDm1M2Vjl7RBDTLhyfot/rIF2H6jrALCyrIgZFYAO7aNntefdV6lY9K3+R/Nejb+lVR31a6S+3Iuexzn5VzMi33R763Gxv0Wtds93vZu+gkp0jZWOXtEan3Didvj+8n3skje2QYI3DQ/urKyfqv0jJ3+ox82hrbCHD3BhqNQdLdv6P7OxTP1c6SbTd6bhYbWXbg4iHVt9JrWx7Wsc3fu2/6R6SnRZZW+dj2vjna4GP834qSo9N6Nh9Nn7Nv1Lz7yD/Oel6n0Ws/7j1K8kpi97GDc9wY3iXEAT8XJrrqcet1t9jaq2xue8hrRJ2t1P7znbWqGTjMyaXUWk+k4gkN0Jg7gN3uSyMVl2OMcOdUGmtzHMglpqcy2r+cD2u91XuSUzZfTZSL2WNdSQXCwEbYH0ju/kodeVi5WNZbi3V5FYD2l9Tg9sgGW7mF3uTVYVVeG7Dc51tbxYHufG53ql77Zgf8K5Cw+nVdPw7qanvs9Tc9z7ILidjahwP3KmJKf//U9DyM2yp9pa1hrxgw2tc6LHep9H0G/R/kU7v6Tkfq/wCi/nFUs+smOy3JqGLkWHFuZQ4sDHbnWONbXVtFnqbdzfz2rUdVW97bHMa59c7HloLmzzsd9JilJ8UlOVmfWHGw33NfRc/7O9tb3NAj3B79zTu+j+icms+sVFdVlr8XI21Xeho0Eklr7W2c+2t3p/8AgtS15PiluPikpx2fWbEfaK/RtAOT9l3nZtB949VxDvbX7P8AjH71c6Z1FnUsUZLKrKBuLTXcAHAgNd+aXfvbf+MVzc7xTSTykpxvrF9Y2dDpc847r3BjXNM7GS53pNDnw76P+EUOlfWX9o9CHVfs4qeLm4zq3v21lzrK8f1WX7Hfof03+i+n+hW25rXsdW8B7HgtcxwkEHQtc0/SUW0U11ChlbGUtG0VBoDAP3fTjZtSZDLH7fDwfrL/AJzi/R7cDXoz/W6c7OFc7W2n02O3Sai9hDHuFf0/S/wjGf8ACIPSuqHqnT78g1CksL69rX+oD+jbbua4spd9G3/R/wDgfvWg0BoDWgNDRAA0AA8IUXta2h7WgNaGO0Gg1B8EmN//1e/yqMp97nMa9zjs+z3Ns2tpj+c9SncPU93v9rLftH9Gt9Jijk43XLH2mjLZUw2tdSyBpWAWWUPs9F387/P+p/g/5n/hFatzMaqz03uIcNu4hri1u4wz1bGjZVu/lp7M3CqLhZk1MLHBrw6xrdrnasY/c72vfHtSU0aMX6w+ow5GcwsGS572ta0zjx7KP5ir9Jv9v0voe/1N7ErMb6xm1jq8yoMFxc5hHNMscyufSd72s3s/9Wfo7tmfgVBxtyqawx21++xrYdqdjtzvp+x/tSfn4FZeLMqlhqE2h1jRtE+nNnu9n6T9H/XSU0a8T6xNZQHZ9dhZY43ktAL65r2NBbX9Juy783/Df8Glg431jrsxnZuXVaxhf9qYIl4cGirY8Y9X808Pfs21/wDGrV0Oo1B4ISSU431kwvrBlY1g6Pkik+mAKw703ucHbrNlu32vsr9jH+pX6aD0fA6/R9XG43UbH2Z3rNeGi6LG0C2t9mMcxrvdZ6Tb/wDC/wCE9H1VrZ3U+ndPrdbnZDKGsbvO4+7bO321tl7/AHfR2qGP1jpuVgDqOPf6uKXbA9rXl28uFLavR2+v6rrHsb6fp70mYzyezw8A4OL5+H9L93jVi05remejY8syi2wNeXbywuL/AEP0j/V3ekx1f0/X/wCMvQek43UMfp11fULDba4vczc82FrCxvt9R0v/AJ31X/T/AO2/5mu7XlY9mOckPDaQHFz3ywN2SLfVFux1XpbXep6n82h0ZuJm4ttuJaLWNDmOIkEODd21zXhr2+1zXt/4P9J9BJhf/9b0a7Crte8myxrLY9appAY/b7ffLXP9zP0dnpvr9StCs6RgWPte5tm66xtth9V/02Eurc33ezY538239Eo5WfZTe9jXVN9PZtoeD6l2/wD0BDh/xTPZb+m/nPTrQ7es20Pu39PyX1Uuc0PpYXuO07QTXtr9trd11fpW3s9L+e9G2z0klJLuhdJufZY+gh9zxbY5j3sl43+/2OH+lsSs6H0i31N+M0m2sVPdLt20EPbtfO5r2vYx/qfT3qFfWXWOhvT8uHt31PLBsI2h7dz93s3v/RpmdatLXA9OyfVYCSA32OIn21XPFbrPa3d/Nf8AF70lOk0Na0NaIa0AADsBoE6zX9YvLHHH6bl2WAw1r2itrj+d7/0m3b/KYp09TtutbUMDJql7q3PtaGtDmbN7twLt1Wx1jqrv5u70vTYkpbq3Qum9XqfXmMdue0M9Wtxa4bTvY5v5jnVu+jvYodP+r+B07pg6biusbX6gvNpINhta5lzbXHZ6ftdTV7fS9PYgfWTruX0fFttx8I5G2sPFp3em0l2z9J6bT7Kfp2/pK0Lo/wBY8rP+ro6rfVXjWes2kvduFJaba6HZTZdv9Gv1Xf4X6dP86kzmOb2OIyPs8QFcX6X912KMOijE+yNl1MOadx1IeXOs1Zs2/wA476H0EPFwMfp+HbRj7trg55LjJLizZ4N/NY381Ni5tl/TTmNYLHhthYxsgP8ATL2M2x6231vT/N9f/rqD0jqV/UunXZF1TKnNL2N9NxLXDYLNw3tbt2+p6X/W/wDraTA//9f06DzHHfwSkqhlYVtt7ntYxxfs9LIc4h9G36XpMA/677P57+ZyP0KhdgdWL7rMbqPpOe5xY19fqM2k7mB1b3ba3VM/QM9DZvr/AEl2++z9GlOlJSk+KzGYHWpm3qctcw7621NHv2hvsuaGvZVuH5jPU+n/ANZgel9XNrXnqTtrMl9xYNwBqe5jm43OzbU1rtm+u1JTrSUtVVwsXJxwRdkOyPaAC6eQ6x+73fyHsZ/rUrSSlapCdAO2gAQMum++g1Uv9F5IItBIgA9vTLX+5Qz8e+/CNFZD7N1RO9xrDwx9dlrHvqa9zPWYx7foJKbRmdeVG2fTsnnY7n4FVsbFyGdNOLY8MuLbGh9bnEM3l5q9Owiqz9C17Nn0EDpWDlYHT7qcq31XuL3gh9lgaCxrdrX3/pPptfZ/bSU//9D05JJJJSkkkklKWc+/qV+fkY2LbTRXjNqP6Sl1rnGwOd9JuRj7du391aKyzdbh9Vy7XYt91d7aTW+lm8HYHteD7vbymy6dr1ryZsAv3KETIQ9AkIy9XuY/0Z+n5ONN6PXP+5mN/wCwr/8A3tWfm5/XsawNrfRewW10WWDHLQLLfT9KprTml253rV+/+aUxl5oM7c4/pfUj0D9Cd3o63FNmDp+be2/I6bnGxogFrHMkTu2v9Oxu7hMlVaE35ybGIT4v1mOJj4Y8P/oDbpHWb6a7683G2Wsa9s4jwYcN7f8Atb5qF13VMa+irIuouryvVrIZQ6tzdtVt24Pdk3/6P9xBdmZfqWOprzaqnNDaqRj+2uAG+yLWqWRkW5uViObi5FTcc3WWvtrDGgGi2rkv/wBI9qOmlE3Y6y7rRGZMuOEBDhyfoYYn5JcHqjH99//Z/+0eiFBob3Rvc2hvcCAzLjAAOEJJTQQEAAAAAAAHHAIAAAIAAAA4QklNBCUAAAAAABDo8VzzL8EYoaJ7Z63FZNW6OEJJTQQ6AAAAAADXAAAAEAAAAAEAAAAAAAtwcmludE91dHB1dAAAAAUAAAAAUHN0U2Jvb2wBAAAAAEludGVlbnVtAAAAAEludGUAAAAAQ2xybQAAAA9wcmludFNpeHRlZW5CaXRib29sAAAAAAtwcmludGVyTmFtZVRFWFQAAAABAAAAAAAPcHJpbnRQcm9vZlNldHVwT2JqYwAAAAVoIWg3i75/bgAAAAAACnByb29mU2V0dXAAAAABAAAAAEJsdG5lbnVtAAAADGJ1aWx0aW5Qcm9vZgAAAAlwcm9vZkNNWUsAOEJJTQQ7AAAAAAItAAAAEAAAAAEAAAAAABJwcmludE91dHB1dE9wdGlvbnMAAAAXAAAAAENwdG5ib29sAAAAAABDbGJyYm9vbAAAAAAAUmdzTWJvb2wAAAAAAENybkNib29sAAAAAABDbnRDYm9vbAAAAAAATGJsc2Jvb2wAAAAAAE5ndHZib29sAAAAAABFbWxEYm9vbAAAAAAASW50cmJvb2wAAAAAAEJja2dPYmpjAAAAAQAAAAAAAFJHQkMAAAADAAAAAFJkICBkb3ViQG/gAAAAAAAAAAAAR3JuIGRvdWJAb+AAAAAAAAAAAABCbCAgZG91YkBv4AAAAAAAAAAAAEJyZFRVbnRGI1JsdAAAAAAAAAAAAAAAAEJsZCBVbnRGI1JsdAAAAAAAAAAAAAAAAFJzbHRVbnRGI1B4bEBSAAAAAAAAAAAACnZlY3RvckRhdGFib29sAQAAAABQZ1BzZW51bQAAAABQZ1BzAAAAAFBnUEMAAAAATGVmdFVudEYjUmx0AAAAAAAAAAAAAAAAVG9wIFVudEYjUmx0AAAAAAAAAAAAAAAAU2NsIFVudEYjUHJjQFkAAAAAAAAAAAAQY3JvcFdoZW5QcmludGluZ2Jvb2wAAAAADmNyb3BSZWN0Qm90dG9tbG9uZwAAAAAAAAAMY3JvcFJlY3RMZWZ0bG9uZwAAAAAAAAANY3JvcFJlY3RSaWdodGxvbmcAAAAAAAAAC2Nyb3BSZWN0VG9wbG9uZwAAAAAAOEJJTQPtAAAAAAAQAEgAAAABAAEASAAAAAEAAThCSU0EJgAAAAAADgAAAAAAAAAAAAA/gAAAOEJJTQQNAAAAAAAEAAAAHjhCSU0EGQAAAAAABAAAAB44QklNA/MAAAAAAAkAAAAAAAAAAAEAOEJJTScQAAAAAAAKAAEAAAAAAAAAAThCSU0D9QAAAAAASAAvZmYAAQBsZmYABgAAAAAAAQAvZmYAAQChmZoABgAAAAAAAQAyAAAAAQBaAAAABgAAAAAAAQA1AAAAAQAtAAAABgAAAAAAAThCSU0D+AAAAAAAcAAA/////////////////////////////wPoAAAAAP////////////////////////////8D6AAAAAD/////////////////////////////A+gAAAAA/////////////////////////////wPoAAA4QklNBAAAAAAAAAIAADhCSU0EAgAAAAAADgAAAAAAAAAAAAAAAAAAOEJJTQQwAAAAAAAHAQEBAQEBAQA4QklNBC0AAAAAAAYAAQAAAAI4QklNBAgAAAAAABAAAAABAAACQAAAAkAAAAAAOEJJTQQeAAAAAAAEAAAAADhCSU0EGgAAAAADlwAAAAYAAAAAAAAAAAAAAlgAAAEsAAAAMQBTAGMAcgBlAGUAbgBzAGgAbwB0AF8AMgAwADIAMAAtADEAMQAtADIANAAtADEANgAtADMANAAtADQANQAtADUAMQA0AF8AaQBvAC4AbABlAGcAYQBkAG8ALgBhAHAAcAAuAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAEsAAACWAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAABAAAAABAAAAAAAAbnVsbAAAAAIAAAAGYm91bmRzT2JqYwAAAAEAAAAAAABSY3QxAAAABAAAAABUb3AgbG9uZwAAAAAAAAAATGVmdGxvbmcAAAAAAAAAAEJ0b21sb25nAAACWAAAAABSZ2h0bG9uZwAAASwAAAAGc2xpY2VzVmxMcwAAAAFPYmpjAAAAAQAAAAAABXNsaWNlAAAAEgAAAAdzbGljZUlEbG9uZwAAAAAAAAAHZ3JvdXBJRGxvbmcAAAAAAAAABm9yaWdpbmVudW0AAAAMRVNsaWNlT3JpZ2luAAAADWF1dG9HZW5lcmF0ZWQAAAAAVHlwZWVudW0AAAAKRVNsaWNlVHlwZQAAAABJbWcgAAAABmJvdW5kc09iamMAAAABAAAAAAAAUmN0MQAAAAQAAAAAVG9wIGxvbmcAAAAAAAAAAExlZnRsb25nAAAAAAAAAABCdG9tbG9uZwAAAlgAAAAAUmdodGxvbmcAAAEsAAAAA3VybFRFWFQAAAABAAAAAAAAbnVsbFRFWFQAAAABAAAAAAAATXNnZVRFWFQAAAABAAAAAAAGYWx0VGFnVEVYVAAAAAEAAAAAAA5jZWxsVGV4dElzSFRNTGJvb2wBAAAACGNlbGxUZXh0VEVYVAAAAAEAAAAAAAlob3J6QWxpZ25lbnVtAAAAD0VTbGljZUhvcnpBbGlnbgAAAAdkZWZhdWx0AAAACXZlcnRBbGlnbmVudW0AAAAPRVNsaWNlVmVydEFsaWduAAAAB2RlZmF1bHQAAAALYmdDb2xvclR5cGVlbnVtAAAAEUVTbGljZUJHQ29sb3JUeXBlAAAAAE5vbmUAAAAJdG9wT3V0c2V0bG9uZwAAAAAAAAAKbGVmdE91dHNldGxvbmcAAAAAAAAADGJvdHRvbU91dHNldGxvbmcAAAAAAAAAC3JpZ2h0T3V0c2V0bG9uZwAAAAAAOEJJTQQoAAAAAAAMAAAAAj/wAAAAAAAAOEJJTQQRAAAAAAABAQA4QklNBBQAAAAAAAQAAAAKOEJJTQQMAAAAABT5AAAAAQAAAFAAAACgAAAA8AAAlgAAABTdABgAAf/Y/+0ADEFkb2JlX0NNAAH/7gAOQWRvYmUAZIAAAAAB/9sAhAAMCAgICQgMCQkMEQsKCxEVDwwMDxUYExMVExMYEQwMDAwMDBEMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMAQ0LCw0ODRAODhAUDg4OFBQODg4OFBEMDAwMDBERDAwMDAwMEQwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAz/wAARCACgAFADASIAAhEBAxEB/90ABAAF/8QBPwAAAQUBAQEBAQEAAAAAAAAAAwABAgQFBgcICQoLAQABBQEBAQEBAQAAAAAAAAABAAIDBAUGBwgJCgsQAAEEAQMCBAIFBwYIBQMMMwEAAhEDBCESMQVBUWETInGBMgYUkaGxQiMkFVLBYjM0coLRQwclklPw4fFjczUWorKDJkSTVGRFwqN0NhfSVeJl8rOEw9N14/NGJ5SkhbSVxNTk9KW1xdXl9VZmdoaWprbG1ub2N0dXZ3eHl6e3x9fn9xEAAgIBAgQEAwQFBgcHBgU1AQACEQMhMRIEQVFhcSITBTKBkRShsUIjwVLR8DMkYuFygpJDUxVjczTxJQYWorKDByY1wtJEk1SjF2RFVTZ0ZeLys4TD03Xj80aUpIW0lcTU5PSltcXV5fVWZnaGlqa2xtbm9ic3R1dnd4eXp7fH/9oADAMBAAIRAxEAPwB8fDtyKy+pzS8PbU2n3b3OeHOrDPb6Xu9Kz6Vv/fEVvS73VPt9SsBjPUgO3SPS+1/SZub/ADSZ2FlY73VOyMemxhIez7XS1wcNzHNe31vpt3PYnDcwNLRnY+0ggj7ZTBBBY7/DfuuW7LmsVmuYw/WcP4vOQ+H8zQvkuZJ7xxZeGX/NZO6RkMdQH2MZ9pnYXbtI2fS2sd7f0n02ez2KJ6Zf9mdlCys1s3yAXSfTO1232qQd1Brg4dQpDmja0/baZA8B+mUSzLc0tObjlrgdw+2UQQdX/wCG/O/PQHM49L5jD4+uC4/D+Y1rkuZ20/VZf+9aeqUq9jdD6jl7/sgpyfTj1PSvpftmdu/bb7d21H/5q9f/AO4v/gtX/pRTDmMB2y4/8eLVPJ81E1LBliexxzB/6LlSp0U2ZFzKa43WENEmBqY7rS/5q9f/AO4v/gtX/pRIfVb6wAgjFggyCLa5BH/XEDzGGtMuP/GiocrnsXgyEdfRL/vXLc1zXFrtCOdQfxapFhYaySDvaH6dpJEH/NWl/wA1ev8A/cUf9uVf+lE5+rP1gEOfjSGDSbazDR7oH6T6KR5jDX87j/xopHK57/mcm+nok//Q6qq7rbL2twmk432rL3e0ls/acjbLgxzPfb7Mj1bavSx/0tH6VX78r6zNsvFGJVZWLa245LgJqJf69lk3/mt9J30WKT+n4ddxrGffjvve6xtDbwyXWPdY/wBOsjd77XvRP2M7/ubm/wDb3/mCaOIACthW7azHDkkZcQjZJ+T1eo8XqRX5X1jabvQxK3BrKzQSQS6wn9YZt9ev2tY79Fu9P+as/wBJWk49cyDbTdVVXXva6p86EMNlm2z9JZZ6dr6cbf8Ao/5nKt/0SJ+yP+72Z/29/wCYJfsj/u9mf9vf+YJXLt+LGIYgQRk2N/IWn0XBuxur5PqnYWYzC2try8EX5Odk/pHvAc+yr+ba/wD65/hFuKriYFeLZZaLbbrLWsY59zt521l7mNbo3866xWkoChXmrmcnuZDIG9Ii6raPq/57jfWTI+sdGLa/otLbC2sHcGh9gdu/SGup7ve9lf8ANM9K3eg9HyfrDd9XW3dSbZXnm5oDhW31jjm2ttlz8bZs9ZtJv/wH0Gep6S3bLK6q3W2vbXXWC59jyGtaB9JznO+i1qg3LxX4/wBrbfW7GI3C8PaayJ27vVn0/pJyDk/VcHtx3/nK9f8Ad4kGI/Od03e8H7Zts2C1oBJBf9n9VjfQb7m+lv8A6P8A8XQg9Js6m/p1zupgi6X7NzQx2zYHe5rNrf502Mb7K/Z/pP56zQrey1jX1OD2PEte0gtIPdrm+1yibK7Md9lTmvYWvAc0hwkBzXe5v7rgkxP/0fQ8jBfa+3bY1tWRsFzSzc/2e39FZuGz2/vMf6Vn6ar3qpk/VrDyX3uffe37Tay6wMLBqw2Oa3dsc7b+l/rq5fmuqdaW1B9WPtN7y8NcNw3foqtp9Xaw7vp1f6On1LFUv+svSsey6u1zw7HtrofDQZfYXsYGe73e6pySnVOplJZWR9ZemY7rBaLA2v05ftbB9Us27f0m79G26uy72/oq1Kr6w4FttlTG2l9NjKXgNB91m+PoPf8ARbW72/zn+iZZYkp00DJzsHE2/a8mrH3yW+q9rJjnb6jm+KJTay6plrJ2vEiefDtuasD6yY9mRmsrqx35FhxLAz0iA5hNtP6ba59fq7Ppsr/0jPTt9Oiy1NmSBYZuWxxyZBGZIjRJIobf3mH1gxvq31up7LerUUWlgY1wuqc0FjvUYXVud+99NrXs3qPScfofS+ijplfWcWyz125JudZWW+oyyu9rW0et/NfoGN/nf+EW1Vf+z+mizJY5rWuMVy1zmMe93oVF27Y51VZYx2yx/wDI9T6aPk5Ypxhe1vqb3VMrZo0k3PZTXuLvoe633peqtx9n/oS+WaPCcVTljB244dOv805tOb0FmA7Ct6ni2tsFotf61Yn1i99ntfZZ/pfz3Wfy1DpD+i4OLZh4vUMbItvc54Fb6w5zjW2lrGV1ve53tqWnXntfguzCwhtYsLmCCf0ReyzZu9P6Xpfo9/p/2FmW9TZ1Tptd7aXU7M7GrLXlh19Wi0Oa6pz/AMyxiR4gLsfZ/asgMU5CIjMcRq+OJ/D23//S9KfjY9lrbrKmvtZGx5AJEHc3/Nd7mKexkk7WydSYEkqllNyze/0xbuIZ9lcx0VNP+F+1Mn3e7+c9Rr/0H9E/WFWvH1n9a/7Ocf0zdX9n3wIpl/r7/a5zn7PS/wDJ1pKdU11mSWNM8+0f3Jw1gMhrQQAAQBwOPyrJzB9aXtvZhOxq3uZWMd9oBDXhzXZD37d25j2Psrqb6f6P0Wf6X9HYyT1o3YjsdtTatRmMcRMyza6s+79Ft9X/AIRJTfEAQBAGgAVTK6f9oyGZLMi7GuYx1W6ks1Y5zbIcL6r/AM6v81Urv+dBYDV6AcaHTMT6xa/09zXbm/o3elv2Wekp4jfrJ9prfluo+zurr9StgEtsHpfaTu+lts/WPS2f8GgQDuuhOUDcd9u+/hJL+y8iZ/aWWT5+gR9xxU1vSLLqzXd1DJtrd9Jjxjuae/uY/ELVay/tRoIxPbkSILo2xPukvDmfRUc4Zhwox9xyN1W70S1ri3fX9q9E5H6Nv6H1du7/AKtDhHj9pX+/PtD/AMLx/wDetevpNtTBXV1DKrrb9FjBjtaPgxuJtUB0VldYH2q91VLhcKIpZWX1n1a97cfHpd/OMb+erOOM89NLbJZmFtoYbC1zgZf9m9RzN9T9rPS/7+g9Lb1RuBf+1HOdeS8s3msuDNjeXYzWV/zvqpcI8ftKvfn04R5Qxg/bwv8A/9P0mzJx67G1WPDXujQzpuOyve6Ntfqv9lXqfzr/AObUzZWOXtEENMuHJ+i3+sgXYfqOsAsLKsiBkVgA7to2e1591XqVj0rf5H816Nv6VVHfVrpL7ci57HOflXMyLfdHvrcbG/Ra12z3e9m76CSnSNlY5e0RqfcOJ2+P7yfeySN7ZBgjcND+6srJ+q/SMnf6jHzaGtsIcPcGGo1B0t2/o/s7FM/VzpJtN3puFhtZduDiIdW30mtbHtaxzd+7b/pHpKdFllb52Pa+OdrgY/zfipKj03o2H02fs2/UvPvIP856XqfRaz/uPUrySmL3sYNz3BjeJcQBPxcmuupx63W32NqrbG57yGtEna3U/vOdtaoZOMzJpdRaT6TiCQ3QmDuA3e5LIxWXY4xw51Qaa3McyCWmpzLav5wPa73Ve5JTNl9NlIvZY11JBcLARtgfSO7+Sh15WLlY1luLdXkVgPaX1OD2yAZbuYXe5NVhVV4bsNznW1vFge58bneqXvtmB/wrkLD6dV0/Dupqe+z1Nz3PsguJ2NqHA/cqYkp//9T0PIzbKn2lrWGvGDDa1zosd6n0fQb9H+RTu/pOR+r/AKL+cVSz6yY7LcmoYuRYcW5lDiwMdudY41tdW0Wept3N/PatR1Vb3tscxrn1zseWgubPOx30mKUnxSU5WZ9YcbDfc19Fz/s721vc0CPcHv3NO76P6Jyaz6xUV1WWvxcjbVd6GjQSSWvtbZz7a3en/wCC1LXk+KW4+KSnHZ9ZsR9or9G0A5P2Xedm0H3j1XEO9tfs/wCMfvVzpnUWdSxRksqsoG4tNdwAcCA135pd+9t/4xXNzvFNJPKSnG+sX1jZ0OlzzjuvcGNc0zsZLnek0OfDvo/4RQ6V9Zf2j0IdV+zip4ubjOre/bWXOsrx/VZfsd+h/Tf6L6f6Fbbmtex1bwHseC1zHCQQdC1zT9JRbRTXUKGVsZS0bRUGgMA/d9ONm1JkMsft8PB+sv8AnOL9HtwNejP9bpzs4VztbafTY7dJqL2EMe4V/T9L/CMZ/wAIg9K6oeqdPvyDUKSwvr2tf6gP6Ntu5riyl30bf9H/AOB+9aDQGgNaA0NEADQADwhRe1raHtaA1oY7QaDUHwSY3//V7/Koyn3ucxr3OOz7Pc2za2mP5z1Kdw9T3e/2st+0f0a30mKOTjdcsfaaMtlTDa11LIGlYBZZQ+z0Xfzv8/6n+D/mf+EVq3MxqrPTe4hw27iGuLW7jDPVsaNlW7+WnszcKouFmTUwscGvDrGt2udqxj9zva98e1JTRoxfrD6jDkZzCwZLnva1rTOPHso/mKv0m/2/S+h7/U3sSsxvrGbWOrzKgwXFzmEc0yxzK59J3vazez/1Z+ju2Z+BUHG3KprDHbX77Gth2p2O3O+n7H+1J+fgVl4syqWGoTaHWNG0T6c2e72fpP0f9dJTRrxPrE1lAdn12FljjeS0AvrmvY0Ftf0m7Lvzf8N/waWDjfWOuzGdm5dVrGF/2pgiXhwaKtjxj1fzTw9+zbX/AMatXQ6jUHghJJTjfWTC+sGVjWDo+SKT6YArDvTe5wdus2W7fa+yv2Mf6lfpoPR8Dr9H1cbjdRsfZnes14aLosbQLa32YxzGu91npNv/AML/AIT0fVWtndT6d0+t1udkMoaxu87j7ts7fbW2Xv8Ad9HaoY/WOm5WAOo49/q4pdsD2teXby4Utq9Hb6/qusexvp+nvSZjPJ7PDwDg4vn4f0v3eNWLTmt6Z6NjyzKLbA15dvLC4v8AQ/SP9Xd6THV/T9f/AIy9B6TjdQx+nXV9QsNtri9zNzzYWsLG+31HS/8AnfVf9P8A7b/ma7teVj2Y5yQ8NpAcXPfLA3ZIt9UW7HVeltd6nqfzaHRm4mbi224lotY0OY4iQQ4N3bXNeGvb7XNe3/g/0n0EmF//1vRrsKu17ybLGstj1qmkBj9vt98tc/3M/R2em+v1K0KzpGBY+17m2brrG22H1X/TYS6tzfd7Njnfzbf0SjlZ9lN72NdU309m2h4PqXb/APQEOH/FM9lv6b+c9OtDt6zbQ+7f0/JfVS5zQ+lhe47TtBNe2v22t3XV+lbez0v570bbPSSUku6F0m59lj6CH3PFtjmPeyXjf7/Y4f6WxKzofSLfU34zSbaxU90u3bQQ9u187mva9jH+p9PeoV9ZdY6G9Py4e3fU8sGwjaHt3P3eze/9GmZ1q0tcD07J9VgJIDfY4ifbVc8Vus9rd381/wAXvSU6TQ1rQ1ohrQAAOwGgTrNf1i8sccfpuXZYDDWvaK2uP53v/Sbdv8pinT1O261tQwMmqXurc+1oa0OZs3u3Au3VbHWOqu/m7vS9NiSlurdC6b1ep9eYx257Qz1a3FrhtO9jm/mOdW76O9ih0/6v4HTumDpuK6xtfqC82kg2G1rmXNtcdnp+11NXt9L09iB9ZOu5fR8W23Hwjkbaw8Wnd6bSXbP0nptPsp+nb+krQuj/AFjys/6ujqt9VeNZ6zaS924UlptrodlNl2/0a/Vd/hfp0/zqTOY5vY4jI+zxAVxfpf3XYow6KMT7I2XUw5p3HUh5c6zVmzb/ADjvofQQ8XAx+n4dtGPu2uDnkuMkuLNng381jfzU2Lm2X9NOY1gseG2FjGyA/wBMvYzbHrbfW9P831/+uoPSOpX9S6ddkXVMqc0vY303EtcNgs3De1u3b6npf9b/AOtpMD//1/ToPMcd/BKSqGVhW23ue1jHF+z0shziH0bfpekwD/rvs/nv5nI/QqF2B1Yvusxuo+k57nFjX1+ozaTuYHVvdtrdUz9Az0Nm+v8ASXb77P0aU6UlKT4rMZgdambepy1zDvrbU0e/aG+y5oa9lW4fmM9T6f8A1mB6X1c2teepO2syX3Fg3AGp7mObjc7NtTWu2b67UlOtJS1VXCxcnHBF2Q7I9oALp5DrH7vd/Iexn+tStJKVqkJ0A7aABAy6b76DVS/0Xkgi0EiAD29Mtf7lDPx778I0VkPs3VE73GsPDH12Wse+pr3M9ZjHt+gkptGZ15UbZ9OyedjufgVWxsXIZ004tjwy4tsaH1ucQzeXmr07CKrP0LXs2fQQOlYOVgdPupyrfVe4veCH2WBoLGt2tff+k+m19n9tJT//0PTkkkklKSSSSUpZz7+pX5+RjYttNFeM2o/pKXWucbA530m5GPt27f3VorLN1uH1XLtdi33V3tpNb6Wbwdge14Pu9vKbLp2vWvJmwC/coRMhD0CQjL1e5j/Rn6fk403o9c/7mY3/ALCv/wDe1Z+bn9exrA2t9F7BbXRZYMctAst9P0qmtOaXbnetX7/5pTGXmgztzj+l9SPQP0J3ejrcU2YOn5t7b8jpucbGiAWscyRO7a/07G7uEyVVoTfnJsYhPi/WY4mPhjw/+gNukdZvprvrzcbZaxr2ziPBhw3t/wC1vmoXXdUxr6Ksi6i6vK9WshlDq3N21W3bg92Tf/o/3EF2Zl+pY6mvNqqc0NqpGP7a4Ab7ItapZGRbm5WI5uLkVNxzdZa+2sMaAaLauS//AEj2o6aUTdjrLutEZky44QEOHJ+hhifklweqMf33/9kAOEJJTQQhAAAAAABdAAAAAQEAAAAPAEEAZABvAGIAZQAgAFAAaABvAHQAbwBzAGgAbwBwAAAAFwBBAGQAbwBiAGUAIABQAGgAbwB0AG8AcwBoAG8AcAAgAEMAQwAgADIAMAAxADkAAAABADhCSU0EBgAAAAAABwABAAAAAQEA/+EOxWh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8APD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIiB4bWxuczpwaG90b3Nob3A9Imh0dHA6Ly9ucy5hZG9iZS5jb20vcGhvdG9zaG9wLzEuMC8iIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIgeG1wTU06RG9jdW1lbnRJRD0iYWRvYmU6ZG9jaWQ6cGhvdG9zaG9wOjM2YTcwM2M3LWNiOWMtN2Q0Zi05MDZmLWZkYmEwNjEzYTc5ZSIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDpiMzIyZjg5NS1mNmJjLTZkNDAtYTY2MS1hYzNlZTY0Nzg5OWMiIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0iODU2NzdDMTcyNUEwMDVDMUJCODcwRDIxQTdGMTk2MTciIGRjOmZvcm1hdD0iaW1hZ2UvanBlZyIgcGhvdG9zaG9wOkNvbG9yTW9kZT0iMyIgcGhvdG9zaG9wOklDQ1Byb2ZpbGU9InNSR0IiIHhtcDpDcmVhdGVEYXRlPSIyMDIwLTExLTI0VDE2OjM4OjAyKzA4OjAwIiB4bXA6TW9kaWZ5RGF0ZT0iMjAyMC0xMS0yNFQxNjo0NDoyMiswODowMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAyMC0xMS0yNFQxNjo0NDoyMiswODowMCI+IDx4bXBNTTpIaXN0b3J5PiA8cmRmOlNlcT4gPHJkZjpsaSBzdEV2dDphY3Rpb249InNhdmVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjZkNWJiOTY2LTU5MTYtZTU0Mi1hMGM5LWM3Zjg1YzcxZTY2NCIgc3RFdnQ6d2hlbj0iMjAyMC0xMS0yNFQxNjo0NDoyMiswODowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIiBzdEV2dDpjaGFuZ2VkPSIvIi8+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJzYXZlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDpiMzIyZjg5NS1mNmJjLTZkNDAtYTY2MS1hYzNlZTY0Nzg5OWMiIHN0RXZ0OndoZW49IjIwMjAtMTEtMjRUMTY6NDQ6MjIrMDg6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAyMDE5IChXaW5kb3dzKSIgc3RFdnQ6Y2hhbmdlZD0iLyIvPiA8L3JkZjpTZXE+IDwveG1wTU06SGlzdG9yeT4gPHBob3Rvc2hvcDpUZXh0TGF5ZXJzPiA8cmRmOkJhZz4gPHJkZjpsaSBwaG90b3Nob3A6TGF5ZXJOYW1lPSLikaAiIHBob3Rvc2hvcDpMYXllclRleHQ9IuKRoCIvPiA8cmRmOmxpIHBob3Rvc2hvcDpMYXllck5hbWU9IuKRoSIgcGhvdG9zaG9wOkxheWVyVGV4dD0i4pGhIi8+IDxyZGY6bGkgcGhvdG9zaG9wOkxheWVyTmFtZT0i4pGiIiBwaG90b3Nob3A6TGF5ZXJUZXh0PSLikaIiLz4gPC9yZGY6QmFnPiA8L3Bob3Rvc2hvcDpUZXh0TGF5ZXJzPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8P3hwYWNrZXQgZW5kPSJ3Ij8+/+4ADkFkb2JlAGSAAAAAAf/bAIQADAgICAkIDAkJDBELCgsRFQ8MDA8VGBMTFRMTGBEMDAwMDAwRDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAENCwsNDg0QDg4QFA4ODhQUDg4ODhQRDAwMDAwREQwMDAwMDBEMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM/8AAEQgCWAEsAwEiAAIRAQMRAf/dAAQAE//EAT8AAAEFAQEBAQEBAAAAAAAAAAMAAQIEBQYHCAkKCwEAAQUBAQEBAQEAAAAAAAAAAQACAwQFBgcICQoLEAABBAEDAgQCBQcGCAUDDDMBAAIRAwQhEjEFQVFhEyJxgTIGFJGhsUIjJBVSwWIzNHKC0UMHJZJT8OHxY3M1FqKygyZEk1RkRcKjdDYX0lXiZfKzhMPTdePzRieUpIW0lcTU5PSltcXV5fVWZnaGlqa2xtbm9jdHV2d3h5ent8fX5/cRAAICAQIEBAMEBQYHBwYFNQEAAhEDITESBEFRYXEiEwUygZEUobFCI8FS0fAzJGLhcoKSQ1MVY3M08SUGFqKygwcmNcLSRJNUoxdkRVU2dGXi8rOEw9N14/NGlKSFtJXE1OT0pbXF1eX1VmZ2hpamtsbW5vYnN0dXZ3eHl6e3x//aAAwDAQACEQMRAD8ADJ8UpPimSXTvGryfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFF3H7Lz+f/wB9QUX/ALTf2/8AvqZLeH97/uJL4fLk/u/93B//0AK5gM6S5tn7RtuqII9P0Wh0j87duCppHhdNIWKsx8Y7vHQlwyB4RLwl8ru9T6T0XBtfiMvybM0sBpZtbtc5/wDNtLg1ZDcLMdkHFbQ85LfpU7TvECdWrT+tgJ60QASTVUAByTHZa2X9pPS7KWOY7rzMdn23aD6px5cfTDvzr9uz1v8A1Wqkc0oY8ZJ4zlA+f9A/ven/ACTfny8MmXLER9sYSa4B/ODX0er/ACriU9BzLulvz212mwPa2uoMkPYRJuaf3WovSum4t/TLcy3FvzLWXioVY5IIaWh+8gNcj9Pt6jd9WclmK+6yyq+trG1ucXNr2jc1oafbUm6VZTX9XbnXWZFLPtjQHYn85Owact/RpTyZOGYMtRkjEcN8XCf7qseLFxYyI6SwymeOuHiHX1JcTpGDkZVVD+k59DLHQ617iGt0+k72LFx66P2j6Nrd9XqPZsdYKpjcG7r3fQW/0nIwXdTxm15XU3vL/ay6PTJh3877vorO6O+5vVrmMxnZVVj3i5jK22OA3O2vHrA1s2u/eQhOY9yyTUAQJGcf3v8AOJyY4H2aAF5CCYxhLpH9GCc9P6WAT9mq0/8ANjX/AORVHruFj4Oc2jGBax1NdhaXb4L53RZ+c1dGzGazHza78jBdex7K6LBRWRWXktYMtjW7Get9D+Qsj6zX5IfXh3NcTSAH3vpbUHubIb9ncwbvQa0/R3JuDLI5QASRrdyn2jLi9a7mcMY4CSADoRUcenqlHgPto20fVsmkOq6iwZBAre70w10nZuYfzmtcl1HG+rmHfkYgGY7IplrXTXsL49vg/YpZX9C+r39r/wA+sU/rD1Qfb87E+xY07iz7R6Z9Xhvv9Td/OJ8eMziAZkHjv1/5vJ7fEsmIRxyJjAEe3R4P87i9zhR9L6bhX4FNtuO/Iyb7rK2tbb6TQ2tnrE/Rd+amz+k4YY/IxS+qpuFTmCtx3km1+zY5/wDJarf1bsbZTTSw+/FfkXWl3tY1tlX2enda72fpLHIObm0VUX4lxNOQzp+PiGt4IJtrfusY395u33Ns+g9Azye8QDLQjTf0yn+7/cXCGI8uDKMdQaNAeqOP9/8Av8TR6/i0YnWMnGx2enTWW7GSTEsY4/Sn85yz1q/Wn/l/L+LP/PbFlKzgJOLGSbJhH/otLmQBnygCgJzAA/vKSSSUjEpJJJJSkkkklKSSSSUpJJJJSkkkklKV3pOPRk5FrLhuazHusaJI9zG7mH2qrVYarWWtDXFhDg143NMfvsP0mrd6P1e+7Jua6jFbtxr3gsoY0y1m7aSPzP32KHPKYhLhHTfi4aZ+Vhjlkjxnr8vDxCTz4MgJK7kdVuyaDS+nGY10e6qlrHCNfa9qpKSJkR6hw/XiYpiIPplxDvXCpJJJOWtzpo6Xuu/aO+Nn6H05+l57f/OFTWr0B3UGuyvsVFd5NUWeoYgfm7f3t3+j/PWUFHE+uYvbh63/AM39BlmP1WM1vxa8PD1/f/TUi/8Aab+3/wB9QkTYPS3yZ3Rtn2/R52/vIy3h/e/7iS2Hy5P7v/dwf//RAkkkuneNSOych1ovfa91zY22lxLht+hDz7vanbk5LLzkMusbeSSbg4h5J+l+knd7kJJDhHYdvonilvZ3vfr3S05OTQXGi59Rfo8scWyP5W0+5WMTrHUMHFdjYlnote8WOe0e+QNu3cfbs/sqkkhKEZaSiD5jsmOScTcZGNWND+9u6lP1l65Va2w5TrNhnY+C0+T9u13/AElQblZLHWursdV68+rsJbuBO7adv5qEkgMcBdRiL3odkyzZZVxTkaurPduWdUyX4A6eG1V4/t3+mwNc8t+i65/56H+0M4101Ove6vHeLKWuO4NcPolu79391V0kRjgP0RvxbfpHqo5ch/SO3Dv+iOjq/wDOnr+v62df5Ff/AKTSP1o68WlpyyQRB9lfB/62spJM+74f81D/ABYr/vXMf57J/jybBz8k4A6eCG4wcXua0QXk/wCld/hNn5iPV1vqDLGWucy91dYpZ6zA+GA7xz+fu/PVBJOOOB0MRqSdusvmWDNkFETOgA36R+VLlZN2XkWZOQ7fbaZe6I8uEJJJOAAAA0AWEkkkmydSSpJJJFSkkkklKSSSSUpJJJJSkkkklKSSSSUpTrttqcXVPLHOaWktMS12j2/2lBJAi91AkGxopJJJFSkkkklJKr7qdxpsdWXja7aSJB/NMIaSSFKs1XZSL/2m/t/99QlP1Gen6X5+7dEaRt/eTZbw/vf9xJfD5cn93/u4P//S6P8A5iu/7nD/ALb/APM0v+Yrv+5w/wC2/wDzNC+vWMzL6j0PFsJFeRc+p5boQHuxmO2zu93uRv8AxuOh/wCnyv8APr/9IKU/E+c4pRiRLhrX0R3HF+4vj8A+FjBhy5sksRziUowjDJlr25yxfN78P3Vv+Yrv+5w/7b/8zS/5iu/7nD/tv/zNP/43HQ/9Plf59f8A6QS/8bjof+nyv8+v/wBIJf6R57w+2H/qtH+hPgn+fn/4Tk/+CFv+Yrv+5w/7b/8AM0v+Yrv+5w/7b/8AM0//AI3HQ/8AT5X+fX/6QS/8bjof+nyv8+v/ANIJf6R57w+2H/qtX+hPgn+fn/4Tk/8Aghb/AJiu/wC5w/7b/wDM0v8AmK7/ALnD/tv/AMzT/wDjcdD/ANPlf59f/pBL/wAbjof+nyv8+v8A9IJf6R57w+2H/qtX+hPgn+fn/wCE5P8A4IW/5iu/7nD/ALb/APM0v+Yrv+5w/wC2/wDzNP8A+Nx0P/T5X+fX/wCkEv8AxuOh/wCnyv8APr/9IJf6R57w+2H/AKrV/oT4J/n5/wDhOT/4IW/5iu/7nD/tv/zNL/mK7/ucP+2//M0//jcdD/0+V/n1/wDpBL/xuOh/6fK/z6//AEgl/pHnvD7Yf+q1f6E+Cf5+f/hOT/4IW/5iu/7nD/tv/wAzS/5iu/7nD/tv/wAzT/8AjcdD/wBPlf59f/pBL/xuOh/6fK/z6/8A0gl/pHnvD7Yf+q1f6E+Cf5+f/hOT/wCCFv8AmK7/ALnD/tv/AMzTH6jwQDntBPE18/8Agiy8v6mYD+r19K6dbcXMZ62bda5rm1sOjGMayuvddZ/XVXN6F9W6ut4nR8W7IvstsDMmzeyGT+Y3bT7rP3v3E0/FecG9DXh/Q3/8LZh/xb+FyIEMs5XA5T+qyDgxx/Sn+v8ATxO//wAxXf8Ac4f9t/8AmaX/ADFd/wBzh/23/wCZrE630PA+rHVulZVVtr8d1wstFkOc0Uvqe4t9Ntf5r/3V6K1zXNDmmWuEgjggp8PiXNSMgZcJjXSEt/8AAavNfAuRxQw5Md5ceYSMZEZMX83LgkP5ybyv/MV3/c4f9t/+Zpf8xXf9zh/23/5murSUn3/mf3/+bD/vWp/ozlP83/zp/wDfPKf8xXf9zh/23/5ml/zFd/3OH/bf/ma6tJL7/wAz+/8A82H/AHqv9Gcp/m/+dP8A755T/mK7/ucP+2//ADNL/mK7/ucP+2//ADNdWkl9/wCZ/f8A+bD/AL1X+jOU/wA3/wA6f/fPKf8AMV3/AHOH/bf/AJml/wAxXf8Ac4f9t/8Ama6tJL7/AMz+/wD82H/eq/0Zyn+b/wCdP/vnlP8AmK7/ALnD/tv/AMzS/wCYrv8AucP+2/8AzNdWkl9/5n9//mw/71X+jOU/zf8Azp/988p/zFd/3OH/AG3/AOZpf8xXf9zh/wBt/wDma6tJL7/zP7//ADYf96r/AEZyn+b/AOdP/vnlP+Yrv+5w/wC2/wDzNL/mK7/ucP8Atv8A8zXVpJff+Z/f/wCbD/vVf6M5T/N/86f/AHzyn/MV3/c4f9t/+Zpf8xXf9zh/23/5murSS+/8z+//AM2H/eq/0Zyn+b/50/8AvnlP+Yrv+5w/7b/8zS/5iu/7nD/tv/zNdWkl9/5n9/8A5sP+9V/ozlP83/zp/wDfPKf8xXf9zh/23/5mg/8ANX9e/Zn2sbvT+0b9nb+b2+nv/wCmuxWdtb/zk3QN32ON3eN3CB57mDXr21+WH/epHw3lBdY9xR9U/wC9+8//0+k+t/8Ay59Xf/DR/wDPmKtbqvW3YmRV0/BxzndUvbvZjg7WsYNPtGTb/gqv+rWT9b/+XPq5/wCGj/58xUK6zLrH1tyMSf2nW9jGFur20Ctuw1/nfzfrPaosf85l84/9B1Z4xPleSvWseTQ6RufNSxjj/qep0Tl/XKnda/H6fmtr1sxcax7bgPBj7fY5/wDWWn0vqeL1XDbmYpdsJLHseNr2Pb/OU2s/NsYvJegX5lfWsN/T3OOU+5ohpJL2k/pW2fv17N3qb16T0gNZ9Z+vMo/mD9ne8DgXuY71f7bmbfUUxDFzfKjGCPTxCPGDEcGnFHGYyhcv3/RJ18jLxMUNOVfXQHaNNr2smP3fULdyjj5+DkvLMbJpveBJbVY15A8drHOXN/XBjn9Z6QGtc8+hl6MxRmnnG/7Tv+h/x39hVuh1PZ9a8Pex7JxMqN+A3B/OxvouZ/SP6n+DQc96qzq3SqrHVW5uPXYw7XsdcwOaR+a5rnbmozMnHfR9pZax+PBd6zXAs2t+k71Adm1q4LrHVX4edZ9tFdWU7KbTZjNuc1wa/wB32+qo4Vr7MFtbPVst/wCM/wAIrFAyq/qJm12Fow7cgNqyGbwbMfJyWHKu/Ssp3UOqyHsqvaxnr1fpfTSU9vTYy+pl1LhZVa0Pre3UOa4bmPb/ACXNUce+jJpF+PY26l0htjDLTtJY73D917dqxPrZb9lrryLMS23Cw2l7raMx2GWuJFbMcU0OrsybLPYzHZ/pP0dSx/qlg5uBZiYtmBcMrH/SXh3USRVVkPtfXbd01z3Vu9j/AKO3+erf/hUlPaX3047BZkPbUxzmsa55gFzyK6mSfzrHu2MRII7Lg87puHh9Ryr+sdJstw7r3ek6usZLrLciwDHttyrbaW1/pHbMfDpxtlH+FyLEWnAy+nfVv6wXuxjhGzEcK7GzS55qZYPUfhB97cXI9/uvx8r0sj/R46Sns2X0WXW49djX3UbTdWDLmbxur9Rv5u9o9qZ+RRXdXjvsa264ONVZMOeGQbdg/O9Pd7lxGX0OqvpuRaz6r3VP9BzzcM5s7gw7bHbcrc/YtXKc49C+rWVvLsluR041vP0nG1raMn3H3fpca271ElNropaOv9ca/S82UOPnX6cVf9+WZ1npuF0/r/QRiVCs3ZN1lruXOcTS6XOPu/OWt1jpuc3MZ1jpEHNqb6duO4w2+qd3pl35lrf8G9ZXUOo4+dndPy8rHzcO/pr3vOOcZ1m8u2e1lrHbf8F9NQz0HCRrxWD4cfE6eAmWQZYEmEsRx5IR+aOSGCWCHFj/AHJS+STU/wAZvHTf+v8A/ohdZ0NtrejYDbf5wY9QdPM7G8rjvrFlX9Y6v0ZmVg2YmFZfsrF8NssD30Nu31A/ovbsXfAACBoBwEMWuXJIf1R/zU86TDkOSwyGo92ZNiX+Vnp6f+cpJJJTuWpJJJJSkklX6i61uBkOpn1BW7bHPnH9lJIFkDuaR3dY6ZTYa33gvbo4NBdB8Jasnrf136Z0yoCgOy8p+rKYLAB/pLXuH0FjiIEcdlyXWXOd1TI3dnAN/qgDanAB0uX5HFKdSJIAsi/mejp/xl9WFu6/EofTOrGFzXR/JscX/wDSau36P1fD6xgszcMnY47Xsdo5jx9KuwfvLxldt/ixfb9o6jXr6OytxHbfLm/9QkQKZee5PDHCckI8EoVttIE8L3qwH/XCn7Tk0YnSupdQbiXPx7cjGqY6r1a9Lq2Osuqf+jd7PoLoG/SHxXPfU7+a6x/6eM7/AM+BNcdNhfWmrJ6hj9Pv6b1Dp1uXvGO/LqY2t7q2+s+vfVbdtf6bXP8Actpc31G/J6n1mjpOU63pnTX3OZWBubfnWUt+0vZVbX/RensYzc+3ey3L/mq10hMmUlKVDqXX+idKcWdRzqMWwM9UVWPAeWa+5lf03/R/MV9c/wBdrn60fVt4Alzs2ncQPzsc2t5/lUpKa+D/AIx/qxl3spssfgixpfXdlGtlZjXa59dtvo2bfzL/AE10eLl4ubjsysO5mRj2TsurIcx0Ha7a9v8AKC4GrrmbmdFabrvU9T6t5uZkghvvva5uMy58N+n/ADq7bolQo6L0+kCBXi0tj4VsSU3Vn/8ArRf+gn/floLP/wDWi/8AQT/vySn/1Ok+t/8Ay59Xf/DR/wDPmKtPqfSMx2czq/R7mY/UWM9O1loJpvrGrar9vuY9n+Duasz63/8ALn1d/wDDR/8APmKuqUWP+cy+cf8AoOjzEzDlORI/zeYEHUSH3jJoXmqqPrILHnD6R0/pmRbpZn722QD9J7Kqq2ve7+utjpHSqelYpoY911tjzbk5Nn07bXfTtf8A98arqSlac80pCqER14b9Vd5T4pNDqPRMDqWTj5OX6u/FbYyoVWvp0t2epvdjursd/NN/PUMX6vdNw8+vqGP6wvqrfU31L7Lm7bCxz/bkvt2u/RN/m1pJJMTks+q/SKbcd+Ox+PXjW/afQrdDbbvcWZGZY5rsnJsr3u9P1MhGZ0DpNeJmYVNHpY3UA77RSxzgyXjY91NRJrx3a7v0DGfpP0i0EklOdndBweoVYdeY++x3TyH0WttdW/1A30/XsdSWb79v+EQ6fq10ynOo6hW7J+148tZa/ItsJrd/OY9rb32NfQ936TZ/pfetVJJTkZn1Yws1zjk5Wc9rrBaKvtVgY17XerW6usH9H6Vg31fuIv7Bxjg5eDbkZd9OdWarfXvda5rSCx3oG3d6X0lpJJKcd/1S6I+l1JGVtcw1n9cyToRs/Ov2/RVsdIxR+zhLyzpQjGrJG0uFf2ZltzY99tVO/wBP/jFdSSUpJJJJTyv1v/5c+rv/AIaP/nzFXVLlfrf/AMufV3/w0f8Az5irqlFj/nMvnH/oN/m/9x8j/czf+7GRSSSSlaCkkkklKSSSSU5tv1f6bY8vAfXu1LWOhv8AZaQdqwPrH9RGZLftPTrHNyGiHNsMtcB29o9v9ZdikjZZoczmhISjM6d9Xyer6l/WCy0VmllYmC8vBA+TfcvQvq30GnoeB9nYd9th33WHQud/31rfzFrJJEkr8/O5s4EZkCO/DEVfmoaGVztf1c69h5GY7pXWKsbGzMm3LNN2ILnNfcd9rfW9evczf9D2LokkGs4GP9X+sv6rhdQ6t1WvNb042PopqxhRL7WHHLrLPWu9jWP+jtW+kkkpSz+s9Jd1JuNZRkHDzcG318TJDRYGuLXU2Mtpft9Wm2qx7Ht31/8AGLQSSU83d9V+rZdDsPK6hi14drDRc3EwhTaaHHdbi1XvvubRXb+f+iXRta1jWsYNrGANa3wAENCdJJSlmRV/znnT1fsXjrt3furTWdA/5xzGv2Tnv9JJT//V7nrv1cwuueh9rfaz7Pv2ek5onfs3bt7LP9Gsr/xueif6bK/z6/8A0guqSUcsOORuUQSW3h+I85hgMeLNKEI3wxGw4vUXlf8Axueif6bK/wA+v/0gl/43PRP9Nlf59f8A6QXVJIfd8X7gZP8AS/P/APiibyv/AI3PRP8ATZX+fX/6QS/8bnon+myv8+v/ANILqkkvu+L9wK/0vz//AIom8r/43PRP9Nlf59f/AKQS/wDG56J/psr/AD6//SC6pJL7vi/cCv8AS/P/APiibyv/AI3PRP8ATZX+fX/6QS/8bnon+myv8+v/ANILqkkvu+L9wK/0vz//AIom8r/43PRP9Nlf59f/AKQS/wDG56J/psr/AD6//SC6pJL7vi/cCv8AS/P/APiibyv/AI3PRP8ATZX+fX/6QS/8bnon+myv8+v/ANILqkkvu+L9wK/0vz//AIom8r/43PRP9Nlf59f/AKQS/wDG56J/psr/AD6//SC6pJL7vi/cCv8AS/P/APiibzWJ9Qej4mVTlV25Jsx7G2sDn1kEsIe3dFLfb7V0vySST4QjD5RVtbmOaz8wQc2Q5DEVHi6K+SXySSTmFXyS+SSSSlfJL5JJJKV8kvkkkkpXyS+SSSSlfJL5JJJKV8kvkkkkpXyS+SSSSlfJL5JJJKV8ln/+tF/6Cf8AfloLP/8AWi/9BP8AvySn/9b05U+o9VxOnNZ62591x20Y9Td9tjvCusf9Wrix+ltbkdc6rm2+67HsZiUT+ZUGNtOz931Xv9ySlO69mUt9XM6RlUYw1da0stLR+9ZTU71FqY+RRlUMyMexttNo3V2N1BCIDGoWP0ituL1jquBSNuMDVksYOGPuB9ZjR+buc31ElOwATwnII5C5X632sb1XpVV1tVdD6spzm5GVbh1Fzfs/pn1cX32Wt3P2Vqt0G2kfWjGpxbsd1b8TJdazFzr8xpLXY/puvZlw2nbuf6T2/wAtJT2cHwKbhcF1DJxrMu7KdTdWyzMGK9pbuc3IsIa2i3Z1enZ6jvz/AEaqdi3/AKl33u6Vbi3tf6mHk31Osc5r2n9I97aq3svy/wCj1uZVYx1z/Sf+i9R6SneSSSSUpJJJJSkkkklKSSSSUpJJJJSkkkklKSSSSUpJJAz734+FffX9OthLfj4/2UkgWQB10SPvorO2y1jHeDnAH7iVQ6t9YukdJxxfk3h5dpXVUQ97z/Ja0/8ATcuXPucXPO9ztXOdqSfNcn1qx1nU7p4rIrYPAAJwi6GDkIznUpGgLlT2lP8AjM6c60Nuwr6qif5wOa8jzNY2rrMTLxs3GrysSwXUWiWWN4P/AJk391eJLuP8WWVcXZ+ESTS0MuaOwcSa3x/X2tSIZOd5DFjxHJjuPBVi+LiB9L3axc365/VbAyrMPL6jVXkUnbbXD3bXfuudWxzdy22/SHxXP/UwkU9Yj/y4zv8Az4muU2em/Wz6t9Vyhh9Pz678lwLm1AOaSG6u2eoxm7a1ay5vqXULer9Zp6b0s102YNrhf1a5jXGqwNm/B6VVaP1nOfj/ANKs/mcTG/4RdIefBJSknENY6x5DWNBLnOMAAauc5xSXKfWnpPT876z9B+147bxkty6LWvkteK6Tk47bGtI3em9tiSnZ6d9Y+h9TyPs2BmMuvLS9rAHN3NH0n0+o1jbmt/4JaREaFeZiro2Z0mu49LxK32dCyupufW14Nd1JbTWMbda70atznexd39W8WrD+r/TsapuxrMaokeL3tbZa/X9+xznpKdFZ20f845/7qf8AflorP/8AWi/9BP8AvySn/9f05ZGbi5+F1B/Vem1/aW3ta3OwpDXP2fzeRjud7PXY32uY7+cWukkpyHdeybG+nh9KzH5J0DLmCqtp/wCFuc7bs/qKx0fp1uHVbblWC7OzH+rlWN0bujayqr/gqWexivyfFJJTmdU6PkZ2fiZuPmuwn4ld1Z2VssLxd6X/AHIbZWzb6P7iHi9DzKurUdSyeouzBj020tqfTVXHrGp29r8ZlX+g+jYtdJJTzeR9SsbJzaMnIv8Atm2z1cuzLYLr7Q3eK8Wt014uLifpffXXib1rdJ6U3pVBxKb7LcRkDFot2n0GAfzFdoa22yr9z1/UsZ++rySSlJJJJKUkkkkpSSSSSlJJJJKUkkkkpSSSSSlJJJJKUmc1rmlrhua4EOB4IKdJJTiW/VhheTRkbK+zXt3EeW4ELmPrN9Ss5jvtuG4ZJdpZU0bXafnM3H3L0JIwRB1CNlsY+czY5CQlddCPm83xerpHVrbPSrw7i/iC0tA+Lne1ekfU76vO6LgvdeQ7LySHWkcAD6Fbf6q3hVUDIYAfgpJE2yczz+TPHgIEI7kR/SUDBBXMYeJ9aej3dQqwsLEzcfLzb8yu6zJdS6L3ep6TqvQs91f9ddOkg03nGYn1j6j1vpeb1LExsKjpj7rSacg3OebanYzawz0adv09+/cujSSSUpZXXem5uTZgZ/TjWc3pl5urqvJbXax9b8a+k2sa91L/AE7P0Vnp2e9aqSSnjH/V3ql2G7p+J0jF6WLMR/ThluzX5HpYtr/VyGMxvRZ61u7d6e6xn/GemuxqqZTUylk7K2tY2eYaNoUkklKWf/60X/oJ/wB+Wgs//wBaL/0E/wC/JKf/0PTpSlJJJSpSlJJJSpSlJJJSpSlJJJSpSlJJJSpSlJJJSpSlJJJSpSlJJJSpSlJJJSpSlJJJTz/1o671Ppd+Bj9OqqutznPrDbQfpA1Nqa0iylrdzrfz1j5v1r+tuA7Zl42DS/nY6xu7/MGZvVj69svs6j0OvHf6V77ntqs/deXYwY/+y5Qd03Op6oOkdEYMd1bBbmdWyK/UsscdTtsta7d9P8xVZ8ZnOpSABiBw+MXd5YctDleXMsOGcpwyTySyie2PLOPFLJGX9z/JZJsML60fW/PJbh4uDe4CSxtjdwH9T7ZvVv8AaP8AjC/8q8X/ADh/72Kp0/Ev6pm53S+oOYOp9OIfjdToaK3gz7fU9MMa9uv0F0f1e6lfn4LhlANzMWx2PlAcepX+e3j6bTvRhEnfJPW+sf0d/wBFZzObHjsw5TlpCPDxDhyH05RxY8kZe7Hjxz/uQcf9o/4wv/KvF/zh/wC9iX7R/wAYX/lXi/5w/wDexdWkpPaP+cn9sf8AvWp/pCH/AIj5X/Ey/wDq55T9o/4wv/KvF/zh/wC9iX7R/wAYX/lXi/5w/wDexdWkl7R/zk/tj/3qv9IQ/wDEfK/4mX/1c8p+0f8AGF/5V4v+cP8A3sS/aP8AjC/8q8X/ADh/72Lq0kvaP+cn9sf+9V/pCH/iPlf8TL/6ueU/aP8AjC/8q8X/ADh/72JftH/GF/5V4v8AnD/3sXVpJe0f85P7Y/8Aeq/0hD/xHyv+Jl/9XPKftH/GF/5V4v8AnD/3sS/aP+ML/wAq8X/OH/vYurSS9o/5yf2x/wC9V/pCH/iPlf8AEy/+rnlP2j/jC/8AKvF/zh/72JftH/GF/wCVeL/nD/3sXVpJe0f85P7Y/wDeq/0hD/xHyv8AiZf/AFc8efrH9a8TqOBi9Uw8bHrzrm1AtlxILmMt27Mi3a5vq/nroP8A1ov/AEE/78sX63/8ufV3/wANH/z5ira/9aL/ANBP+/JsASckDOWhjUv0tuJk5nJjjDk+Yjy+IHJDIZ4qn7EuHJPFH08fH/44/wD/0fTkHLzMXCoORl2topboXvMCT+aP3nIyxcalnUuu5mVlD1K+mPbjYdTtWteWiy/J2/6V27YxJSRn1p6I54a+2ylrjDbbqrK6yT/wtjdi1gQQCDIOoI1BBTWsZdW6q5osreIcx4lpB7FpWR0NpwszP6M1xdj4hZbiTqWVXAu9CT+bVY32JKdhJYP1iz+p0dS6fiYL8lrMirIsubh102WE1GkV7vtv6NlX6V30EDpfUOsft/Gwsp+ace/HvsLc6nHrl9TqAz0H4Xu9vqv9RtiSnpUlxuZ9Yn/a7HUdUezGstLaZcKxM7fRY2zpVzt+72+n61tiNjfWPOs+qeZebHP6tRYcYOewtLXX3fZsCwzVjV27arar/ZSzekp6xJc99YMnqeFZh4eHnOx214WXfdkOZXa+w4raPT9U3tc33+o59uxVOhZ3WsjMH7R6uWVVswnml1FLPUfl1utdil4r3s942V7P0iSnrElxFf1k6ofrI7Ffk5DemG92PW4/ZZD2g7tzfS9Z1Ff09m71/S2K7hdY6hfg9Xtxuo/a6Meuh2J1DLqFDGmxrjkuGynGbbsbssq9uz1P8J6aSnqklwr+p/WMCrEp6hbZk1S4NNWP6rnBvswsqr7Q3fcyj1XZv/dr0n0foFtZP1jdi9Jw3485mbm0MtquyG+jQxtkBuV1Kyr9Fj1MdYxnoUb777P0VP8ApElPQJLAwM7P6VnDo/U7X9SDnM9PqFbNz2Ou3bKuo0Vbvs9VlrLfseS39B6X6G70/S9Sy3hZGRV17qPTrrDbWa6c3F3aljLd+PfQD/o2X4/qVf8AHJKdRJJJJSkkkklPJ/XFwb1v6uucYaMokk8AepirW+sXXqej4m4D1My72YtA1LnH84t/0bFjfXrGry+pdCxbZ9O+99b40O178Zjo/wA5Az/qx1V14dmYw641jRXTcMj7PaGCdrbRZ+ic7X6TVXMpCeXhHWOv+C7EMOGXL8ic0wIiOWXt+mHH+vn+nklDH/e9bt/VbpB6di2XZTxb1HNd62W+QSCdfT9v7m7/AD031ZeL7OqZtY/QZGY70XdnNY1tXqN/kvc1YuB9U+o+q51NA6LTcw1ZBFxyL3VuLXOrrc39DXv2fTXYYeJj4WNXiYzdlNLdrG+SfjB9OnCI/i1ubnAHIRk93JmMboR4ccIerh/VyyY/0YcHBP5Eyfa7wKb46juFzmY19GVbS179rXe33Hg+4d1K0Ho0lmdHzLbS/HtcX7RuY48xwWlaaSlIb8jHrdsstrY4ctc9oOvkSit5HxXH/V3oPROpu6xk9SwKMzI/a2az1bmB79rXhtbN7vdsY36DUlPWV30WktrtZY4CSGuDjH9kqa5LKwOi4XW8XH+rXTqB1zGJfa6oenRRVa30nv6m+n3P31/0XE/nfU/SrrTE6cJKUkkuY+s9GTf13pOGzOy8XF6jXlVZFONYKw401/aKy0lj9j7P0jLElPSMupsc5tdjHuZo8NcHEf1g0+1TXmGJ03o+PiUdQ6XZnYWSej5PUqbGX1/o207G/ZbNmMz7Qx73fSs/0a9A+r7bx0PAOTdZkX2UV223WmXl9jRa+SA32tc/bX/ISU4n1v8A+XPq7/4aP/nzFW1/60X/AKCf9+WL9b/+XPq7/wCGj/58xVtf+tF/6Cf9+UWP+cy+cf8AoN/m/wDcfI/3M3/uxkf/0vTli5Lrui9Tv6h6T7umZ212Uamlz6bmjZ65rb7n0Ws+ns+gtpIGOElOTZ9auhBk05P2qw/Qooa59rj+6Kw32/21PouJltOT1DPaK8zPe1zqQZ9Ktg2UUbv32t91i0msY1xc1jWuPLgAD94TpKef+svQsvquf0++inFvqxa722NyzYGg2+j6bq24/v3fo3oHRvq1m4HXqM9+PhY9DMe6p5wzbuLrHUur3tyd3s/RP+gunSSU8hnfVTq2bn0OyLwabsluXmHFe7GprdWIY6jHb6mTfnP9j/tbr62epXv9JXh9Xc/9gZ3R7L6rC8izDy2tc219rS3IZf1Le631Mj7VWz1bqnfpWf4Or6C6FJJTg9c6P1Dqz8DI9DHs9Oi2vNw77rK2E3ehbsF2NXZ61VduP+krf+jvrUW9I6nd1ejqWZ0/p/q1uZvtrycgkNr3tZYMb0K8a6+lltnoOt+h/pF0CSSnkT9VurOftLqxjuzrck/pnEhljr/0n2b0fS9XZcz/ALUKw/o/1iyOiZOBkmmuMCvCxseq1z2PsZ/OZdlllVPo+pDGMr/SLpkklPLZP1Xz6MqzqWK9uZaM5+dVhyaQTY11Wx+RZZZS30/U9R+3F3q5f0XOs6H0voc1+hWMdvUrpMhmP6dprxmR73ZN1Xpeo7+aqW6kkpoY+HfX13Pz3R6OVRjV1wfduqN5t3N/68xRwsLJHV+odRyg0esKsbEaDJFFIc/e7919+Rfa70/3GVrRSSUpJJJJSkkkklPK/W//AJc+rv8A4aP/AJ8xV1S5r63dN6vl5XS8rpdAyLMGx9pDnNaAQaX1bvUfVua70vzEL9o/4w//ACrxf85v/vYoBLgyZLjI2Y1wxMv0XVlgHMcpygjmwQljjljOOXLDFOPFmnKPpk9S4bmkDQngqNdgfpxYPpMPIP8A5Fcx+0f8Yf8A5V4v+c3/AN7FCzM+v1kb+k4jo4O4T9/2xO94fuT/AMSTD/o2f/ijlf8A2oxfxerfYysS8/Bo5J/daFjW9M6lfa+57WB1ji4jeNPBv9lqzWZX1+rMs6TiB3724E/ecxT/AGj/AIw//KvF/wA5v/vYl7w/cn/iSV/o2f8A4o5X/wBqMX8Xe6d0/wCyBz3uDrX6GOAB+aFcXK/tH/GH/wCVeL/nN/8AexL9o/4w/wDyrxf85v8A72Je8P3J/wCJJX+jZ/8Aijlf/ajF/F6saEFcj0zqGZ0K/qmJkdH6jlG/qOTlVXYlLbKnV3u9SrbY62v3bfpqf7R/xh/+VeL/AJzf/exL9o/4w/8Aysxf85v/AL2Je8P3J/4klf6Nn/4o5X/2oxfxY419+f8AWHp1mB0jO6Xj1X5GV1KzJqbTXa6yl1DHP9O2z17vUNa6xcr+0f8AGH/5WYv+c3/3sS/aP+MP/wAq8X/Ob/72Je8P3J/4klf6Nn/4o5X/ANqMX8Xqli/WLHyxldK6ti478s9Lvssux6o9V1V1NmLY6hryxtj697X+lu96z/2j/jD/APKvF/zm/wDvYl+0f8Yf/lXi/wCc3/3sS94fuT/xJK/0bP8A8Ucr/wC1GL+Ljt6c4YB6f0zp/VX5Lum3dIodl0Mppay9/rPyci4u9npfyf8AMXfY1IoxqaAZFNba58drQz+C5n9o/wCMP/ysxf8AOb/72JftH/GH/wCVeL/nN/8AexL3h+5P/Ekr/Rs//FHK/wDtRi/ir63/APLn1d/8NH/z5ira/wDWi/8AQT/vy5q/D+uHVOq9MyOo4FVNWDe2wuqez6JfU61zg7Itc7a2n8xdL/60X/oJ/wB+TYS1yT4ZUTGhw+r5eH5WXmcIMOT5cZsJnCGUTkMsPZhxZZ5Y8WX5Y+l//9P05JJJJSkkkklKSSSSUpJJJJSkkkklKSSSSUpJJJJSkkkklKSSSSUpJJJJSkkkklKSSSSUpJJJJSkkkklKSSSSUpJJJJSkkkklKSSSSUpZ/wD60X/oJ/35aCz/AP1ov/QT/vySn//U9OSJABcSABqSdAAksO2gdc6rkY+SSemdNLa3Y4JAuvcPUcb9v06aWn+bSU6lXUenX2+jTl0228bGWNLvulWFQyOg9FyKfQswqWsH0TWwMc3+VXZXtexyD0S7Jrsy+lZdhvtwHN9K930rKLBuodZ/wjP5t6SnVSWD9ZOv5HScrBxqXY9Qy23PdblC1zR6Xp/o2MxQX7n+r+cgdF+s+TndZr6dbZiZDLaLbi/FbexzDUamjf8Aa2ta5tnrfmJKelSXO5XXeqszLasYU2UtcfTe2iyyWfveozKYx/8AmJ6PrRZZ9V8jrDmVuy6HWUihkwbRacPFbYwufZV61vp72uekp6FJUL87Nw/Qpfg5HULTWPWvxRU2sPHts9uRfS5m53vb/I/PWb9XvrJ1HqWDjW5HS8nde97X5FYqFLQ22yoPLTkG79Gxn6X9Gkp6FJcf1z665uH1C3Ew6McNxjk12OvuDXOfTjty2PFW39GzdZ/6Ef8ABrZ+rfXX9ZxrHW1MquxzW2w1WC1jjZVXkbmPaG7f5z31f4NJTrpJcrlMX61dUvwczMubhYNVFxFV2TaSz05hlLm0/pbMm1jfWp9P+e3/AEElPVpLG6N18ZfRL+rZ5pqbjG43CkuJZXVJm+mz9NRe9jfU+zWfpEmfWGtvUMyvK/RYdLcH0Xljg4OzTYz9Z/0bPUbUz/gklOyksXrPXj07IyGCylzMbEdfdSPdkseXCvGuZRuYzIxrXO9N/vq2P/wvvQ+n9b6u/quLgdVwTg/acYlhOxwfk1APzNj67rHVY1bHN9DfX+m/60kp3klx/UPrtl4XULRfVTh4W2MVmRvdfY5p/S2vrwW5Lsbf7Ps2NkNq9Sv9N6v+Cr0rvrFlU/VXI61ZTQ3Mx6y92My4XMYdwawXW0/yD6lja0lO8kuFv+vmfS7LYMnpT/sdIvDh9oiwkWH0a/5f6L/wRdNZ1PJrzOkl7WjE6ox1bh+dXkGv7XR7/wA6uyuu+nZ+/wCmkp1EkjwkkpUpJc8rCyM3qNGRZSb3H03QDA1HLeySndSVDpnUH5QdVdHqsEhw03Djj95X0lKTwfBIakLj+ldJf127quXm9S6hW+rqWVjV14+VZTW2qlwrpYymv2N2tSU9gQRyEy5TIw6vq/1LAfi9Q6jnZd7nMb0m292Schjhs3n13ivEqxHfrD8t3/Frqzz4pKUkkue+sOb1yrq/TundOyqcWnqVeQDbZQbrK7KGetvr/TVNd6jHe1rm+z0/8Ikp6GCOUl55hu6rhVM6jh/WKzLc/p9/UGU5VFtld1NQbufc23Ld9nv9RzNnortuiX5mT0fCys5zHZWRSy601tLGA2D1QxrC6z+ba7Z9L3pKbqz/AP1o/wD0E/78tBZ//rRf+gn/AH5JT//V9OWGMivo3WskZZ9PC6q9ttGS76Dbw0V2Y9zv8Hv276luKNtVV1bqrmNtreIcx4DmkebXJKRZGdhYtByMjIrqpaJLy4RHl+8qHQ23ZN+Z1m2s0tzixuLW8Q/0Khtrssb+a65zt6LT9Xeg0Wi6rApbY3Vp2zB/ktdua1aPKSnnfrLh9Rv6t0y7Cqy7G1U5LbX4VldLml5x/TbZblfotj9n0PpoHS8HqrPrJiZGVTntorxshhszbqb2hz3Y+1tf2T+ae/Y7+c+mupSSU+f5/wBWupZWZj0jBrwq8nNbktrrpZknHZXvNjsnP2041ePY/wBO39mfrPq/zXrrUq6H1Fn1b6jhOxGVdRFjMhl1ZrNeS+lzMqn7OyllLsen9D6FeNdV+g/0l388usSSU879ZKb+ou6O9uDk5eK+yy3JxK3Gh4DqXGn7Q/1cdtXpW/mPu/nFT6Z0k4f1g6bZhdGyelYwGQMlz7hbUQWfomOZVkZLK/0vu97K11ySSnh7fqtn9U6rm2Omql1uS77VkYtbd7rq24fo0V+s7Itprobupy7fSZ6n+lWn9VOn9S6fX1BllJpyHNqNdFlbKqDYyr0K3VZeJZe26u1tVf2h3osuq/0K6VJJTXwMm7Kwqsi7Hfh3vafUxrYLmPBLHN3M9tle4fo7G/zlS4un6u53Tn9Ud9jcLbLajgu6XWWltjaPbay3Nve2vF9R/pWekz1PW/4P9Gu8SSU8z0zo93Uej/Y88ZePdZbj2dTfmNrc7I9IMfbjV21O/orrK/T3u/wf6NSxMN3W8nr1mZh34eH1KmjFrGS0MsJqbe226usOftax11bqbF0iSSnl+v8ATeom3Ge7HPVsZmA/CzQPbbc6x+PsFLans9C266lttuS9/oYlPqWKv0rpXU+nddxm5VGTn2imoDPtvstx6t1bm9Xex1tntvuvZS2pjqfez+2uwSSU+fv6R1PHzskYODfRU7qb3WHHqfWx+Ft+h+hy8Vl9O938yyln/Gq7T0fLd9Ucvp9eE6jK6jmuq0q9NzKTkD08q/c61/pY+K3dX6luR+YuzSSU8Z1vpHVabesWsyepZbcvEqx6PRZS82WOGU307Q2hmyirez1LP0Pp+t/OLYuxMq3K6FhOqIqwB9ryrR9Br6qTiUY7bPzrH25D3/8AFUrbSSUs4EtO3nskx4e3c3jv5H90p0N9LXO3tLq3nlzTE/1klMyWtaXOMNGpPgufyKc3IyLL/s9gFjpaC3Xbw1boobuDnudYRqNx0H9ke1ESU53ScG3H3XXDa942tZ3A5JctFJJJS45C4vof1m6D0W3rGF1XMZh5P7VzLRVaHB2yx4fVZo13ssZ7mLs08/6wkp4vD6x0TI+tmEfq/mfbbeo23v6s7WxwpZS441XqWs3Y2JTkbPTqrd/OLs08pklKXP8A1nczD6h0TrF4IwsDIuGXaAXCtl9FmO220M3ObV6pZvs/MXQJAkcJKfMWu6bj9L+y43VMfqOYOjZHSMfFxQ99tl+RZvrdUwN/m/T2scvSMKg4+Hj47tDTVXWR5ta1n8EaY4gfAQkkpSz/AP1ov/QT/vy0Fm7nf85duw7fsc75ETu+jt+kkp//1vTkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSln/8ArRf+gn/floQFn/8ArRf+gn/fklP/1/TkkljZdmZ1TqVvTMW52Lh4gb9uyK9LHveNzcWh/wDg/Z7rbElO1tPgmWOfqtgMbuxLsnEyB9HIbc9zgfF7LXOZaj9GzsrIGRiZwaM/BeK73MENsa4bqcmtv5vqs/6aSnRSWN1v60YPRcmrHyWuc64sjaf3y5u1rRuc63azfXX/AIVNg/Wrp+X1D9nuDqbnubXj6PeLLPSblZNQtqrdjtdh7/Tu/TJKdpJYTvrZXYyu3p/Ts3Opfb6b7a6HhoYC6u3IqdH6b0ns/m/8Ijn6y4AwM/M2XVnpg/T491bqrNzmiyitrH/9yNzGVpKdZJZuZ1puE7Dpyaw3IyW+pkMDxtoY1o9W11kfpP1h9OHR/wByb7lWwfrTTmdTt6cMHMqdUamix9DwJuDn/pwW/q7WbfzvppKdtJYB+uXTR1R3TXNIdXu32bhta1rzW+5x+h6Hps9Tfv8Ap/oNinh/XDpWR0y7Pt9Sl2LXXbl4/p2F9Yu/ozPfVV6r7vzPTSU7iSxf+ctn2o1npPUfs/p7hf8AZ3z6k7XUejG76H6T1Urvrb0it3SoslnWHRQ5wc0taWuc17mbHfStayj0/wDhElO0kqFHUeoWZDKrOk5NDHO2uve+gsaP33Cu99m3+oxZtn1wqbm34gxjFL8isW2WtrYXYobZc619jfToq2v9tnqJKehSWJ0n6x2dXwcnKxMP9JQzdXjuuYbHP9+ym6uvdZiut2fovVb72P3rT6fm0dQwcfOxzNOTW21k8gOE7Xfy2fQekpsJJJJKUkkkkpSSSUJKUklHy81j29YzKbX1Prr3VktPP96SnYSVXAz25jXe3Zaz6TeRB/OarSSlJJASQFy2E/60dav6jfjdYr6dj4udfh1Y4xGXe2h3piw3WWMfus+k5JT1KS5u23rnQsjHy+sdapzOm2PNN1RxRTaXvEYwwm4xvtyb3XfTq/0PqWLpCIMJKUkksTrnXepdP6jidNwcCvKs6hXa7Hvtv9JjX0jfa21npWO2MY5j/p/pElO2kuIxOt/XPDH2zMswep4v2azKdQy+utzq6xvfk4D6KnOfRX9H9Nv/ADF1nSM23qHS8XPuo+yvyq23CgP9Ta1431/pNte5zq9v5iSm2s//ANaL/wBBP+/LQWf/AOtF/wCgn/fklP8A/9D05Y3T7GYfXuoYN52vznty8Rx0Fg2tquraf9JU5n0Fsqtn9Owuo0inMr9RrTuY4Etex379VjffW9JTZOgJdoBqSdAB5rH6LY3N6j1LqtX9FuNePjv7WCgEWXN/keo7YxOfqziWAMysrMyqB/2nuuJrI/dft2usb/WWrXXXWxtdbQytgDWMaIAA4a1oSU8b9drLG9VxvTY3NfXUba8NlduRc21m70LPQx76XYeK9z/Uv6hX+m/Q+j+kS+r78Svr9Tmbv2ZZ6ren5AbY6qzqFra/2o1uTc59z2/oHvouyP5+77Zsus9JdoNONJ5KUlJTwX1kws12Xj4eQOk/tDqVtVLRTVaMj0XOLbLa3Osd6bK62u9S+v8Amv5D1qZnSuoYX1TysN1WL6eEKbcajDbYJrxrGZVtdrsqy2y57mVO2e5dSkkp5f6wNw8zrHSsxjWZDWs9assxLsix7bDtpsZkY7PSrqqba+6uvIs2et+sf4NZWPi2dK+sNeBmZguOPZ0/7HSzHLbMgMquwmPa91z2Nrxm2Psy7P8Ag/8ABLvRoIGg8BoE8mInRJT5zk2Yh61m/tHZkYLrH0ZefW3IdW3Gh9j6b+pY979+bbe5mM3pj6X0VV1+lX/o1t/Vk9Qdg5+O9jKuul1T3VZtTgz7OGso6fY9lb/0m/Ho/S+nZ+hzfVru9NdUNBA0Hh2SkpKfPG9Gs6j184FFfSX/ALPZ9pvFFdzaxkC3ZXXmOrt9V9jNr7GYzrPT/PsqWx1/D6jZl9EvzLofS64eh09u3dd6ORdZ6Tcj1dzb6avsnpWf6e9dWlp93CSng+jdPx3dWN2Pg4978N+CzNqo3FlFrxf9p+y/pHNZbg2/ZXZf0/UqrVTJysZvVc67IFlePYLzW8srDr3et6W7Df6DGX+vXUxmR+sY/o7PVs9Rejp9zvFJTyv1Y6nj1/tLLzMmuy2ipluTbTZ6tXpMdk37m2O22epX6r6X1vZ/g6/R/nPZp/VTFuxfq7gU3tLLTWbHMPLfVc/IFZ/4ttuxazvcIdqPA6hJJSkkkklKSSSSUs+dpI1I1hOCHAOGoOoKSGantJNL9gOpY4S2fL91JSRc1mXsvy7bmGWOd7T4ge3d/aXQela/S54LO7GCAf6zvpIsDwH3BJTldExrGufkPBaxzdrJ0nWS7+qtVJJJS7fpD4rnPqhdSyvrAfY1p/bGdo5wB/nB+8uiWXl/Vb6t5uQ/Ky+mY12RaZstfWC5x/ecf3klOXfWzG+t3Tcm+5nUbuo25FOMXRGHSyl2Rsw62Ocz7Rc9n61mP/S2Vfo/0a6hZ2B9XOgdOyPtOB0/HxrwC0W11gOAP0od/KWikpSwPrCa6uv/AFbybnBlIycihz3GGzfjWsqZuP51ljPYt9CysTFzcd+NmUsycez6dVrQ9hjUSx37qSngLeldU6T9Xzf1KttDMP6vZWA5xsYYybrG+hSNrjudZU1m3/MXedOpNHTsShw2mqiphB5BaxrYVLH+qn1Zxb2ZFHS8Zl1ZDq3+mCWuH0Xt3bve395aqSlLOkf849s+77HMd43crRWb6bP+c3qbff8AY9u7y3TCSn//0fTkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSln/APrRf+gn/floQs3eP+cvp+6fsczGn0v3klP/0vTkkllZ+fnW537K6VsbkMaLMvKtG5lDHfzbW1/4XIt/NYkp1UlkO6X12oGzG6w+64a+nk1sNLv5J9INsr/sq10nqX7QoebKzj5eO805WOTOywfuu/Orsb763JKbqSpZ/WOn9PsZVlWFjrNu32mIcSzfv+j7HD9J+emxut9NysqzEquAvrFZNb4Y4+swZFQZW8tt3ek5u9uxJTeSWRlfWz6uYrq22dQocbLhjkVva7Y525u66Hfo6mOZsss/wav4fUMDPY5+Dk1ZTGHa91Lw8NdG7a7Z9F21JTYSVe/OxqMrGw7HH7RmF4orAJJFbfUtsdH0Kq/9I7/CWV1/nodHV8C/pZ6s2wjDYx77HOaQ5gqLm3ssq+m22p7HsfWkpuJLJ/50dGFYsL7mtLnM91FrSHMO2ytwexvvYiZf1h6Vh4NOfk2Oqx8iwVVl7HNcSZ9/pOHqekxrfUfZ/ov0iSnSSWP/AM7vq99qbi/av0j2Me32Pg7yWMZ9Df6nt3ubs/m/0i0Oo5+N03DtzcsltNAl5Gp1O32j5pKbCSzcr6xdHxL3499zxZXG4NoueNRuG2yql9b/AOy5PnfWDpeBktxsqxzbHNY/RjnANtf6FLnuA9u+32JKdFJUMTreDl5b8Klt/r1HbcH0Wsaw7fUa222xja2epX76/wB9DzvrL0Xp+e3p+XktryCz1HCCQwH+bbc5v83Zd/ga/pvSU6aSz7Ov9Gr6ceqHKa/BDix19bX2AOB2uDm0se9u38/2of8Azm6G280X5bMV4pryCMk+gQy0vFYczI9Oxlv6Pe6tzPYxJTqJJmPY9jXscHscA5rmmQQdWua4fmuTpKUkkkSAJJgDUk8QkpSSyLfrLiMeW1VPuaPzwQ0H+ruWF9YPr9ZjN+y9Op2ZTgC+22HhjTxtY36Vjv5aNFnx8rmnIRjHfv8Ate0SXk1P1z+s9Vwt+3Ot1k12Na5h8tga3/or0P6tfWCnr2Ab2t9LIqOzJpmQ10S1zD/o7PzEiKX8xyOXBHilUo94/o+brJJASYXOU9b+svUL809KwcN2Lh5VuGHZORYyxzqDsss2V0WM2Pd9D3oNV6NJYdPVPrFjZVI61hYmPgW7hbl0ZDnCkhu6t2R9oqoZ6dz/ANCz3/zi3ElKSSWR1j6yY/SsurCdiZeVlZNbrMavGq3izZ/OVtsn6df+E/0e9JTrpLkcX65dcre6zq/QMnHw2sc+22mux5pLfdFotbW26vbu3XUrpem59PUsCjPoZYynJbvrba3Y/afoPLNfbY331/yElNlZvqs/5zelPv8Ase7b5boWks//ANaL/wBBP+/JKf/T9OWP0givrPWcazS99zMho7upcxrGOb/JY9uxbCo9S6TTnuqvbY/FzcefQy6o3tB+lW4H220u/wBG9JTeWR0pzbuu9XyaTNANNBcOHW1tPqx/U3bEndM6/eDTk9WDaDo449ArtcPD1XOf6f8AYatHCwsbBxmYuKwV01iGt5Mn6TnO/Oe785ySnj/r4zFb1DFvzqR9nqZ61VxsrpDradxGKx9lN32nKt3/AKHHv/V/S9ZLoOFhVfWwYljmWX4QuzGW2embXX5LKW3Yf6Fvpb+nN3/o6P5mi/GXXP6fgPyjmWY9b8ks9L1ntDnbNf0Y3Ttb7lGnpXTKK6aqMSmuvGebcdrWACux27fbX+5Y/wBR+5JTyHVR1jBqrbh9Py+nNvuZi49dWdV6LH3PP6RtDN26x73us/SP2ep/Oro/q9j5FFd32rDtx73Fgsyci6u+3I2t2Ntudi+xr62fo/oq7d0zp2Rm059+NXbl4w20XvEuYP8Ag5+j9JWUlPKZ2Jm0/WxjendRjM6jW+zLddUy92Ni1D9BXTLq/s+JbkuZWyrb+nu9a/1LPTVfFx7R0r6z5lmQbbGuyca1tbW10XPqZWftrsdu9teZ7vs99lVn6b0/0tfqrqXdK6a9uUx+LU5ueZzAWg+qQNrfW/f2t+ipM6fgV4R6fVj114RaWHHY0Nr2u+m3YyPp/nJKeCvutqtFrabS3H3UY9r6rXX3MybrcjGc/GdWdn2nKr9Kux9db/8AC/za0LK7T0To2JXj111/andMyMoEl1VJv+zW0UNu/Tb8/wBJrcj9yj1a1178LEsc976WOdaGNsJGpFZc6j/tlz3emoVdN6fTi0YlWOxmNiua+ioD2sew+oyxv8vf79ySnlms63+1ukn1asG3IzMoXVOwywl9VD63W7vtVnr120Us9C2p/wDN+l/ovSV//GDjWZX1Yyaa2Mc0uY6yx4D/AE2tcHb6q3/Tuf8AzLf+MW0OmdPHUD1P7PX9vLPT+0kS/Zxta4/R/souTjUZVD8fJrFtNgh9btQRM6pKeHyr+oV2s9DJy66Q7Mr+z4tgqqt+xVMdZ+zq302uxKK7xfTRVvv9T0v5zYp9avxMn6w9OppcMp19WIWj3m6ykG3MruewPq9VzLKPVu2eh+jt/SLsf2fg/bz1L0GHOLPS+0ke/Zz6bXfmtTZPTOm5YeMrEpvFrmvs9RjXS5o9Ot/uH02M9jUlPJ/VvHDOsY17KgwO9c15YYWsyvZ+surt2+pZ+s/pWfbG1epV+kxfUqVOjH6qeoZNWI7G6Jj473UZ2KzOeMjJfYK8r1r8y6m3a/8AS/02mv7b/gPX9L012mP0To2LeMnGwqab2/RtYwBwkbfpJWdF6NbdZkXYOPbde7dbbZUx7nEAMBc6xrvzGpKeW6tecv6h5eLg4oxBjW2YYGJc401spcfXyjewUPvpfXv/AEdjP099n6RCymZdb/s7cYepkHNrspLrn/an1YjG3OffkP8AtVtb7A+nCf8A4JdjX0vp1WJZg1Y1deJcXGzHa0Bji/Wz2N/fUv2fg/bv2h6DPtor9EZBHvFfPptd+a1JTPEdS/EofQw10uqYaq3DaWsLRsY5h+hsb7dqKkkkpSr9Qqsuwciqr+cfWQ0Duf3f7SsJJJBog9tXhR/qPBcl1hr29TyN35zg5vwI0Xr93T8C95stx63vPLiNT8YWP136ndM6nUDSwY17Po2V8/1XNd7XtThJ0eX5/HCdyiQCKPXhfLF23+LGu31+oXa+jtrZPYvBc/8A6hBq/wAW+abQLsoCqdSxnuj+07au36T0rF6ThsxMVu1jdSeSSfpOc785yRIpl57nsU8Rx4zxmdWaIEQPV+k3W/SHxXP/AFNBNPWI/wDLjO/8+Bb6wrvqdgPyb8ijN6hhfarHX204mU+qo2P1ttFTPz7Xe56a5DW6lRbf9ZMCnrgFuFkX2M6Xg1mag6mp2T9v6lvj7RkP2enj4+30Mb+XYumWLg/VXCw8+nPfl52bfjbvs4zMl97GOe30n2Vsf/hPTdsW0kpSwOusn6y/Vtx0Drcykn/jMV7v+qqW+qnU+lYfVMdtGUHj03i2m2p5rtqsbo26i6v312e5JTwOOM2vodb8iq2oYv1XzWONjXNAtNjamMdvH85sq3bf3F33Sa/R6Tg1f6PGpZ91bAs131Rw7YZmZ/Uc7HkF+LkZLnUvjUNurY2v1Gbv8H9BbqSlLP8A/Wi/9BP+/LQWbP8A2TRt/wC0f05E/S+jt+kkp//U9OSS1S1SUpJLVLVJSkktUtUlKSS1S1SUpJLVLVJSkktUtUlKSS1S1SUpJLVLVJSkktUtUlKSS1S1SUpJLVLVJSkktUtUlKSS1S1SUpJLVLVJSkktUtUlKSS1S1SUpJLVLVJSkktUtUlKWdtb/wA5N0Dd9kjdGsbvFaOqz/8A1ov/AEE/78kp/9X05JJZ3Ueqvx768HCo+2dRubvbTO1jGcevk2/4Ov8Ad/fSU6KSyH2fWuhvrOqw8to1dj0l9b48KrbPY9/9ZXundQx+o4rcrHkNJLXseIex7dH1Wt/NexJTZSSQ8e+jJpryMd7bqbQHV2MMtc08OaUlJElTd1no7HFr8/Fa5pILTdWCCNHA+9FpzsHIZ6lGTVdWHBhfXY1zd7tG17mu273bvoJKTpKuzqGDYKzXkVvFzg2ra4HcXB72NaP5TKrHf9bVmDBMccpKWSQ8e+jKx68rGsbdj2tDq7WGWuafzmuU3Oaxpe8hrGguc4mAANXOcUlLpIVOXi5BLaLmWkMZaQxwd+jsk026f4O3a703odnUum1WPqty6K7Ko9RjrWNc2fo+o1ztzN38pJTZSQMfOwcp5rxcmm94G4sqsa8gcbi1jne1Qb1Xpj6G5Lcul1D3NY2wPbtLnO9FjOfz7v0aSm0kma9j27mOD28S0gjTzCAeo9Pa1znZNTGtsdS4ue1oFjP5yr3lvvYkpsJIH2/A9A5P2mn7O07XXeo3YHfuGzds3aoT+s9HYAX5+K0OG5pddWAWzt3CX/R3NSU3EkK/LxcfGdl33MqxmN3uvc4BgaeHb/o+5FBBEgg/ApKUkkkkpSSpXda6ZTYa33S5ujtjS4A+G5qyOufXjpvTag3Ga7Ky36sqILGgf6S15/N/qo0WWGDLMgRgSTto9IkvOaf8ZXWG3brsbHsqnWtu5jo/k2bn/wDSau46P1jD6zgtzcQnaTtsrd9Jjx9KuxIghfn5TNhAlOPpP6QPEG8kksF31ua7IyacLpPUM9mJc/GsyMdlZrNtelzGerfW/wDRu9v0EGu7ySxcP6zjIz8fAyel53T7MveKLMljAxzq2+s+vdTbdsd6bXfTW0kpSSSzup/WHofSX+n1LNqxrdnqip7oe5mvurr+k/6P5qSnRSXLYP8AjJ+rWVe2q578FtjS6u7J9MMMfmWOqst9Cz+RcujxMzFzsZmVh2syMeyfTtrMtdB2u2u/rBJSZZu8f85dms/Y5mDH0v3lpLM3n/nT6cHb9h3T2nfH/UpKf//W9OWR0eD1jrbrP6QL6268ikVt9GP5H01rrN6h0vIfls6l021uPnsb6bxYJqurBkVZDW+72/4O1qSnSWR0wNH1g6y2qPSPoOsjj1iw+p/a2bNyY3fWu4ek3GxMNx0OSbTaG/yq6NrNzv66u9M6bT03G9CtzrXvcbL736vssd9O2xJTS+sRuxsW7qP7Uvwaaa9voUspeLLD7ams9em6x19z3MqZWxZ/1LxM9nT8ai7qV4v6X+rZvTCyjax7R7WOeyn1/ScxzLqbfW/S/wCkW5f0nCyOo09RyGutuxmxjse4mqt2v6xXj/zf2n3bfX+mlb0nBs6lV1TY5mbS0s9Wtzmb6z/gMprDsyamu99bLv5t/wDNpKeJss6Xi5+Z9jyMZuC57fsza78EhrWt/TusHUqr8ht1mU657/crr34NX1Z6nkOqtx3tymV51zdjnE1ip1V7K+mtrxrGsqspbVsZT6j/AOk2Lr8zFGVjvoFlmMXx+mxyK7Gwd3ss2u+kqVf1fw6umv6dVbkMrusNuRcLP01rnmbvXvcHbvW+hZs2fo/oJKeFPT7Mbp1TbLb7RVZZiNY71ab29TyamtwbXvD9v6Ftn2f9Ba+j9LvXRde6X00ZONVYMizJox68dj34mRnVbGlzt/6At/W7H/ztj7n2en+YtBv1M+rjG+kMX9WFbq2Yhe91LDZpbkVVOcfSynt9v2hn6RaN2D6nThgDJyKg1rWDJZZ+seyPc7IeH77Hx+ke76aSnjfqmzp2RmYucMZ2OPVcen/ZsK6gFsWUvdnXNN2D6Fvueymt/wCi/wAJb6v6NdB9cKcy3pNj67Kx0/Ha+/qWO9zq3ZFVY9T7IMpjbfs9Nm39P+i9S/8AmPUqr9RauBhY/TsKnBxQW4+O0MYCS4x+89zvpPc73OQuo9KxOpeg3M32U0PFv2fcRVY4Q6v7VUP59lT2+pXU/wDR+okp551nWbur9Gudj43TcvJof6LqnvtaaK215FvT87HdTj+z9J+gspt/Vb/+DsesvqltLepdTqBBycm+2moV7BYw78Ox1tzrSzbRdTX6LLN/5llda7Kjo2LV1J/U3WX35Lt4r9e11jKW2FptZiUu9lDbNjEx6F08vfZtdvtym5r3SJNjNvpsJj+Ybt/mklPMdCysO09TrLbW034kt+wubY4Bh9O6vGdS++9uVb9pqr/sfyFmtx8W3phsb0rG9S7Ev6gzHFnosY1rX4oqbR9nf9od01tdbv6R/TL/ALX+h9Vdz07oeD03J+0Y28FtRorrc4FjGOsdk2emxrW+625+97n70O36vYdnR6+jstvoxa9zZqeG2OY/d6tNlu0/orPU9+1JSvq8SzoGK+zGZiTSLHVUHcCC31DaPTrq/TXfzn0P5xcXg2ZOPgZ/rU1+qcrOyqas9js7d6DHX2Nf6ttX2B9LPToyL/032i7IrXo1VVdNTKamhldTQxjRwGtG1jf81ZeT9Wem5FGRQXW1fa7LH321PDbC253q5OL6u3c3EvcP0lSSnDyAbfq9ZV+h9SrOpmnGpbhhlzfTyGt2Pue2/wBf9F6VjbKfVrsrWPNX2Dp9wvdUHtutwHiysbbrftVt11uz1Ws2vuw8P9bf6FXr+n6nqrus3oHR86o05OMx9TnMdawaCw1sNFPrR7rPRrd+j/63+4qY+qHT2tc2vKzWNfWKSBkE/omgtZj7rGve6ljXfzb0lOR9bX32X42GMTKzsw1Ndg4fp1fYDexptufki1zPtbvTbs9P3/Z2f0b08j9Ii/VfFxcXrDn5eFZidWysdwr20Nx8VlNZrN9GIxlttllnq2VvuyMn9Pd/wVf6Jb2Z0PBzRhi91w/Z8+ia7XVuO5n2d3qW07LXfo/3XqOL9X+n4mczPpdkOurrfS0W32XN22Fjn+3IdZtd+ib9BJTpKv1E2twMg0z6grdtjnzj+yrCSSQaIPYvCiIEcdlyXWC49UyN3ZwDf6oA2r1a3oHTbbC/a+vdqWsdDZ8m67VgfWL6iVZTftHTnuZkNEOFh3NcPA7Rub/WTgQ6nLc7hjP1WOIVZHyvni7X/Fi+37R1Fgn0dlbj4b5cP+oWPV9SfrBZaK3V11idXl8gf2W+5eg/VzoNPQ8D7Ow77bDvusPLnf8AkW/mNRJFMnP83hOE44SE5Tr5fVw0eK3Wb9IfFc99Tv5rrH/p4zv/AD4F0A0MrnGfVrrmJkZb+k9bbiY2Zk2ZZosw2Xltlx33fpnXV7mb/oexMcZbqV2R1LrNHSsx1nTumWXOrprbLbs6ylv2qz9NV/ROn1tZ+/62UukJkysDG+r3V3dVwuo9W6u3PHTzY/HprxW4/vtYcdzrLG227mtre72LfSUpYHXGn/nR9W3jQudnU7vDfjGwf9Klb6z+sdJPUmY76ch2Hm4Vvr4mU1ofseWuqe2yl/ttqtqsex7PYkp4ynrufl9FZ62Q6zf9Ws3KyAY99zXNxmWv/l/zjV2/RavR6L0+mI9PFoZHwrYFkXfVfqmVQ7Ey+qUDDtYab2YuEyix1Ljutxq7zdd6Ndv5/wCjXRNa1jWsYNrGgNa0dgBDQkpdZ8n/AJxR2+yfjuWgs/8A9aL/ANBP+/JKf//X9OSS+SXySUpJL5JfJJSkkvkl8klKSS+SXySUpJL5JfJJSkkvkl8klKSS+SXySUpJL5JfJJSkkvkl8klKSS+SXySUpJL5JfJJSkkvkl8klKSS+SXySUpJL5JfJJSkkvkl8klKSS+SXySUpJL5JfJJSkkvkl8klKWf/wCtF/6Cf9+Wh8ln/wDrRf8AoJ/35JT/AP/Q9OSSVPqPVcXpwrFofbfedtGNUN9thH7jP3W/nPckpuJLId1zPob62X0fJqxxq+xjmWuaP3n01nf/AJi08bJoyqGZONYLaLRuZY3ghJSRJIAngJ4PgkpZJJJJSkkoTwR2SUskn2nwKaDMd0lKSSSSUpJPtd4FMkpSSSSSlJJJJKUkkkkpSSSSSlJIdmRj1O222sY791zgD9yz+rfWXpHSccXZF4sc7Sumoh73Efutn2/13pLo45yIEYmRO1B1ElxlP+M3BdaG3YN1dRP8417XkDx9P2/9Uutw83FzsavLxLBdRaJY9v5P5Lm/nNRor8vL5cVHJAxvr0/5qZJJYmZ9dPqtg5VuHldQazIodstYGWP2uHLC+qt7NzfzvcgxO2ksjpv1t+rnVMtuFgZzbsl4LmVFljC4NG5+z1q62v2t/NWukpSSSZzmsY6x7gxjAXPc4gAAauc4n81qSl0lmdO+svQuqZAxcHMbde5pexm17N7R9J9LrmVtua3/AIJafCSlLP8A/Wi/9BP+/LQWf/60X/oJ/wB+SU//0fTlj9Ka2/rfVsy3W6ixmJTP5lTWNthn7vqvfucthZObiZ+J1B3VemMGR6zWszcInabNn83fQ8+312N9vv8A5xJTrAxqFj9IY3F6z1bBpG3GBqyWMH0WPuB9ZrR+bv2+ond1zNsbsxOkZbsk6AXhtVbT42Xbnez+orHSOnW4VVtmTYLs7Lf62Xa3RpdG1tdQ/wBFSz2MSU1es14l3V+l4t7bic03VB9WTdQGCqt2VJqx31tudZt9P3rK+pprtNVltXUrchlmU37Zdba/FcK7bqWN/SXuY/8ARNbWz9B9Ni2OqdEys/qGNn09Rsw3YQccetlVT2h72upuscbmlz/Uqdt2IfSeg5/SxVTX1a23Dqe97sZ9NI3Gxz7rAbmM9Vv6a1z/AGJKcfq7MrH6g+rrfW8mvFxmt6j0y5lWO1zn07mZGM0/Z/02TX6lfpY//aijI/wq6D6v09ZZ06qzrOU/Jzb2NssrcytgpJEmhnoV17ts/pN+/wDSKrZ9Vq857reuZl3UrGknGa39Xqx9S6u3EpxzublV/wDcu2227/R+mr/TMLNwq7KsnPs6gzcPs77mMbaxgG307baQxuS7d/hfSY9JTylmbkWfWfrtRe7Jwy3GoDXOOJUHNc4en+06Gvuqsott9Gr/ALm2erV/gla6Vf8AaOl9TfRivw3PxLGknNuy7WOm7Hq3Yzg51O59d1jbanf4NbGV0Bt+dfmttDbH/Z7MdhaSxl2N6/pW3sY+v7Qz1Mr1fS3V/pK0Cj6qUU9MOJ9oecmzFsw7ssCA9l1jsm79XDtrffZc2j3/AKD1UlPI14xswLGW2PopNzd1oZY01HHrqoyG0us+xUstycr2Xfzn6L/hV0GVZkf82MHpdI+y5fVcsYX6DezYwXWPzb6hZZbdW37HjXP/AJ32eoiN+pTqyWVZzX0eq670snHbcXvdo12Y/wBWn7a6j/tP67P0a0sHoFeO7p9uRe7Iu6Wy5mMQNlYF+1v8251z91FLfs9H6b+aSU879Z8twz+qVW5b201jHqrwjdZVW+qyqx+UytmP/OXu/wCE/nP5tF+p/VK7H5F1WTZmVUUTh4bLrrLrK2EMfb9hzTtp/wAHXj/p/U/0i6BvS8ug9QuxMtteb1G4W+vZVvbWxjGUVUei2yr1PTrr+nv+m9E6X0fE6bRjMrHqX4uMzEGS76bq2HfH7rN1nv8Aakp8/wAamqzEc1rW5Li61hyrm2ttLw97Hmxn7Ur/AElT/Z9D/B+xegdDc89Ixa7G3NfRU2lzshobZYa2iv7RtbZd7L49Rn6VUesfVr7e5wxH4mFXZW5l27Crusc53NzLnPr2P2n85li18PFqw8SjDpn0saplNc6nbW0Vt3f5qSkqSSSSlJJJJKUkkkkpSBn3Px8K+6v6dbCW/Hx/so6ZzWuaWuEtcIcDwQeySQQCCddXhj7iXOO5x1c46knxJXJ9asdZ1O7dxWQxg8AAvS7fqxWXk05BYw8Mc3dHlukLmfrN9Sc1rvtuE4ZLiItqA2kx+cyT9JOBDrctzWETFyqxWulPGruP8WWVbvz8MmaQGXNHYPJNb4/rtauTq6L1i2z0mYV2/j3N2gfFzvavSPqf9XndFwXm4h2XkkOuI4AH0K2/yWIyIpl+I58XsGHEJSnXCAeLr8z0DfpD4rn/AKmkinrEaf5Yzv8Az4FvgwZXM4eF9aukXZ9eDi4WXjZebfmV225D6ngXuFnpPqbRZ/N/10xw2XUc+3q/WKem9NdXjuwrnNu6rcxpeyxrd1+D0iu4fpsx9H9Lv/mcXG/feukPPgudZhfWXqHW+l53U8fExKOlvus/QXPue821Oxm17X007Pp7925dEkpS5X60dK6fm/WjoBysavIGQMuixtglrgyk5OO2xv53pvY/auqWV13publPwM7pzqxndMvN1Vd5Irsa9j8a+l9lYe+lzqrP0duyz3/4NJTxAZ0fM6Sy13S8NjrehZXU3vZWQa7qi2mv7NL3ejXuc72Lu/q5iVYf1f6bjVMDGsxqiWj95zG2Wv8A6z7HOeufs+rnVbsN/T8bpWF0ttuK7p/2v7XbkGrFtf6uRXXj+jV61jne6vfaz3rr6q2VVMqZOytrWNnmGjaElMln/wDrRf8AoJ/35aCzf0n/ADl/N9P7H57p3f5u1JT/AP/S9OSSlKUlKkpJSlKSlJJSlKSlJJSlKSlJJSlKSlJJSlKSlJJSlKSlJJSlKSlJJSlKSlJJSlKSlJJSlKSlJJSlKSlJfFKUpSUxFdYMhgB8YUkpSlJSkkpSlJSkkpSlJSkkpSlJSkkpSlJSlmzZ/wA5Y2j0/sf0p1ndxtWlKz//AFov/QT/AL8kp//T9OSSQcvNxMGg5GZa2ilpje88k/mtH0nu/qpKTJLIb9aujFwFj7aGOMNtupsrrP8A117drVrgggOBBBEgjUEHuElKSSSSUpJJJJSkkkklKSSSSUpJJJJSkkkklKSSSSUpJJJJSkkkklKSSSSUpJPB8FXzc7D6fjuyc21uPS3QveY1/db+c938lqSQCTQFk9AnSXP0/Xz6s23Cr7Q+uTAssrc1n+f+b/aW+1zXtD2ODmOALXNMgg8Oa4JUuniyY644She3EOFdJJBszMOp5rtyKq3jlj3taRP8lzkliZJCqysW5xZTfXa4CS1j2uMeMNJRUlKSSSSUpJDqyca5zmU3V2ur+m1j2uLf64aTtRElKWf/AOtF/wCgn/floLN9Nn/OX1I9/wBj27vLckp//9T05YuJSzqPXs3LyR6jOmPbjYdTtWscWi2/I2/6V+7buW0sXJN/RupX9QbU+/pudtOWKhufTawbPtHpj3WU2s/nNn0ElOzY1trHV2tFlbxDmOEtIPZzSsjobPsWbn9Ha4uxsU13YgOuyu4F3oSfza7G+xSf9auhhn6C85dp0Zj0Mc6xx/d2bfZ/bU+i4mWw5PUM9oZmZ7w99QMiqtg2UY+785zW/wA5/LSU1+oZnVszqz+jdIvZg/ZqGZGZnPrFzm+q57MfHx6HuZXvf6Nj7bbFSzeode6Tn9Gx8q13VH3vy2PrxKm1OvDa2OxvVrsf6VTqnl/qW+rXQxaXUujZd2ezqvSssYOe2v0LfUr9am6oE2V15FO6p++mx36G+qz1P5ytRo6L1F2b0/P6j1BuXkYDslx2UippbkMbU2mtrXv2Mx9n07PVstSUs36yttwRlY3T8m60XPxsjHmmt1FtR2215F2RfVjf8T6dtnrITfrhiZAx29OwsvPtysd2UyqptbS1jHnHuZeb7amstqta+v8A4R/82gZP1QssuGRXkUWvGbk5goy6PWxyMoVtLX0+rXuyMb0v1fI3fn2ez9IqeD9W+t9K6ni0dMya2txun21OzLscupc63Kdk+i2iq6r0LKW2epW1tmxJTeyPrXa/K6IemYluVh9VdaLHAMa8emyzdRtuuqdVkY11X6zvbs9Ouz099isD62YJyxUKLjhuyfsQ6iPT9E5G70fT9P1ftfo+v+r/AGr7P9n9b/txMz6suxsbpTMHK2ZHSrrLvWuZ6guOQLW5vq1sfVsdc7Issr2P/RKtjfU4Yme2zHdh/Y25JymmzDZZmDc/134rc579vo+q79Hd6P2qpns9T/CJKbbPrRQ/puf1MYl9WH0+u53r3bGttfQ6yqyqlrLH2O/SVbfU2LIzuqfWDCq6dT1LqJ6VXZiMtyeqOxW3sdmPP6TCvgehhVUtc3Y97avX/wBN+jWo/wCrTndCq6KcgOp+1/aMp5af0lRyX9RfjNG7273OZTvVvqWJ127I9XpvUasal9fp2Y2Rj+uyZP6xU9lmPb6m07fSs9SlJSDJ+sDcI42GK3dVz7MduTb9j9NjPSkV/amnJuZVtyLP6NQy62yxRd9asS1mMel42R1SzKoGWKscNaWUElnqXOyX01se6xllddG/1bLKrFRf9R6aWYX2J+PbZh4ow3DqOOMqt7Gudcy5jA+l2PdXZbd9B/pem/0v8GrY+r3UMN9OR0nOqoym4zMTK9XGaaLW1lz6bq8XGfjNxraX3XbGV/off+kSUtd1/MH1gZ0z7HdXhWYDsp98Vh7DLP0vuu3NbQ1/o2V+l6v2n/gv0iej6yVDFw6sHFzer3Pw6suzaKvVbTY39FbmPssoo+15G1+zHo99myxWcnouRd1HH6g3KAezEswstrq5FrLNtnq1bXt+z2+szf8A4Vip0fVvqXTmY7ukdQrovbh0YOWb6DbXYMZvp4+XVW26p9ORW19ns9Symz+wkpK763YdjqK+nYmT1K3Jx/tdbMdrBFYeabBc7Isp9K2uxjmem7/Cfo02Z105eL0V3SLC13WsmvY4gB7cdgdlZ8seHta9tNP2f/jLUTo/1ap6Tm05NN7rGU4ZxCx49znuudm3ZTrAdv6W2x/6PYh9F+rL+mZGBZZkjIq6ZiWY2O3ZtPqXWC3IyfpO27qq6qWt/rpKcJ/1n6tVjX9UHVsWy2vMtoq6C6pnq2NbkOxK8eqyuz7X9ofV+krf6L/5f6NdI/6w1VdVZ07JxL8Zt1px8fKsNRY+yHuZ+hruflV13Nqf6F1tGyxS6L0DD6UxzvTquynX33HK9JrbIvsff6fqe6z9G2z0vprMr+pr2dSqyvtFBrozjntsOPOXYXGwux8nO9X311+rtq/RfzbK60lMc/65ZLvq6/rXTOnZHpl9TaLbxUGua+xtVlvpev6np/4Kt3+ksrs/mf0i0czqOZZ1bpHT8ZrsY5Aty86t2xz2Y9LdnoWbTdX+ly76Gb6rP8H/ADiH/wA2f+xNn1c+0w6utjGZQZ+fW9uRXZ6Jd9H1GN31+orWF0vIr6rf1XNuZfkW41GKz02FjWNr325Ba177P6Rk2ept3exjGJKdJBy8j7Ni25EbvSaXAeJ7IyjbUy6p9VgllgLXDyKSRVi9r1eMtysq95sute5559xA/stb9Fcl17LuyOoPre9zq8f2MaSSAY979fznLvrfq3nNeRS5ltf5rnHaY/lNhcl9Z/qz1TCudnGr1KbNXmo7y0j85zQPop4IdjlMuH3AOKIsVHpq86u+/wAWvUrrKcrplji6vH220T+aHktsrH8neN64FoL3BjAXvOga0En/ADQvSvqH0G/pmJdl5bdmRlx7Dy1jfotd/LdO5KWzL8SlAcuYyrikRwDrd/N/ivVN5HxXIfVzofRepHrGT1HAx8y/9r5rPVvqbY/a14DGb3gu2M/MauvGhBXI9Mzs/oV3U8W/o3UMv1+o5OXVfiMrfU6u93qVQ999Tt+36fsTHBVlYfRsXreNjfVnpuMOt4hL7rKm+lRj1Wt9J7upPx9jrX217vsuF/Ob/wBP9BdaYnThcpj25Of9YOm24fR8zpWPRfkZXUbMhldTLXW0Ox2vd6Ntvr3us9P6bV1aSlLmPrPj35HXukYgzcvHxeoV5VWRTj2+mHGmr7TVHtdte/8ASMsXTrF+sONmfauldVxMd2W7pd9ll2NWWi11V1VmLYaPUcxj7K/U3+lv/SJKeKxendGpw6M/ppzcLJPRsnqVNteQ39E2nYz7K7Zj1/aK3vd7n2/6Nd/9X23N6FgfaLrMi9+PXZbdcdz3PsaLXy791rn+z+QuOHTH/YD0/pfTeqnJf023pFL8yuqmltd9nrWZGRdv9vpfyG/9bXe49Iox6qAZFNbawfHa0M/gkpIszdZ/zn27P0X2Kd8/nb/ox/VWms//ANaL/wBBP+/JKf/V9OS44SSSUsGtaS5rQ0nkgAFOkkkpSSSSSlJJJJKUkkkkpSSSSSlJJJJKUkkkkpSSSSSlJJJJKUkkkkpSSSSSlJnMa8bXCQnSSU1mdOwmP3sqaHeIABVkAAQNAkkkpSSSSSlJJJJKUkkkkpSSSSSlLP8A/Wi/9BP+/LQWf/60X/oJ/wB+SU//1vTkkkzi1rS5xDWtElxMADzJSUukqtPVel32+jRmUW28bG2NJ+WqtJKUkkkkpSSSSSlJJJJKUkkkkpSSSSSlJJJJKUkkkkpSSSSSlJJJJKUkkkkpSSUpJKUkkkkpSSSeD4JKWSTkEchMkpSSSSSlJJEEchJJSln/APrRf+gn/floLP8A/Wi/9BP+/JKf/9f05YdlA651bJoyiXdM6a5tf2aSG3Xub6j33x9OqlrvZWtxYf2hnRes5Jyz6eB1R7bask/QZeG+nZRc7/B+pt31vSU38jonSMmj0LcOn0/zdjAxzf5Vb2bXscq/RLcmq3L6TlWG+zAc30b3fSfRYN1Pqf8ACV/zau5HUcDFoORkZNVdLRJeXA/5sfS/sqj0Nl2RfmdYurNIzyxuNU8Q8UVDbU+xv5rrnH1ElK6l1fOZ1BvSekYrMrPNQvuffYaqKKnFzKn3WVstsfZfYxzaqKmf8IqWV9Zc/pmR05nW6KcCvJfksyHVvdeHeixj8Z+Jsay39Ysf/R3UeurPUMHq2L1Z3Wej11ZTr6GY+bg3WejvFRe/Gvx8jZa1l1frPZZXY307K1BuF1rN6p0nqWfTRjHCdlGymqw2Fjbq2VY49RzK/Uu3b/V9P2JKbD/rR0FmHRnHKLsfKc5lJrqtseXMn1WHHqqfkVvq2/pG2VexNk/Wv6u4tVN12a3Zk1evR6bX2l9fHqsZQyx+1v8AhP8AR/4RZVvROv49j7MebcezqOVlXYlGS7FfYy4V/ZLHZVTWvb6G231sbf8ApN/5/prP6Tidc6F1bDxaMSrOzaumXerV65raG2Zz7mOZlWVWert3/pfUYx//AFCSnoM/63dJwszpmOX+tX1WXV31h72hm1zqrW+lXY231bG+lt3fo/536Ctf84uiftH9mfa2/bN/o7Nrtnqxv+zfadv2X7Tt/wC0/ressrH6B1TpuJ0M44qy8jpmRfdlVB5pYRli8X/ZXObZ7MV+V+ire39LUxVMT6rdQxs1mLZQcrBZmHLGU7OtZVtNxzW7+l1j+mVP+h/gLX/pbElOtnfWvpzen593TbBl5OG0NFYa/Z61j/slFT7XNbX/AEl36Wtr/UV/IyOpYlOO2vDf1O0jbkPpdVSA5oG5+zJtq9tjt2xlayH9E6gz6n3dOAa7Prtty6WtMhzxlP6lj17tPda3ZWo/Wvp1nVcfEuxulvyMnJaGWZEsbdiUuHqWbKbraa3Zfu2U/wChu/SWfuJKTfVjrnWOq4eNdl9NextxtFmY19IrGx9jG/oRa7I/M9P+b+mqmX9ZOvY+ZmA41LcetwZTS71DewkH0PWNDLaXfaodl+12yimr7Lb+tIPS+hMxOr4s/V442A0NFNzn1vtovZuf61xoyLHXU3/6R7P0WR/xqa76vdXy8ix1Vba6xl9RcfWcGAi92OcW1jXVZG9rvTt9+xJTpdC+sNuRiZd/VrsSluBtbfZWbKwCR/PWjLZV6dWQ39JS3/rah/zj6g1x6vbiOZ9Wz7G2bXfagOf2rZjfzn2B/wDN+hs+1V1frdlX+jD9XMfIqx8+/qGFdYyqjEqFVtYNl9mHVttfRRad1m+/+j2WbN71JvS+pN6V0ht1ROWeqNz8xjDu9IXWX5NrXO/7r+s2qx6SnSzurGl/S8vFsryOnZt7ca17IfPrgtxMim5h27G5DfTs/wCN/kLUWR1jDffZ0vAxqdmO3LZk3vY0CuuvG/WWs9sbX5GR6TK/+urXOuqSlJJJJKUkkkkpR4STOBLTHPZJjw9u5vHceB/dKSl+dCsLJzOoY+RZSchx2OgHTUct7Lcc5rWlzjDRqSsDIozsjIsv+zWAWOloI1DeG/8ARSU6PS89+Tuquj1WDcHDTcOOP3lfWf0rAtx911w2veNrWdwOfctBJS41IXH9I6QOuXdVy87OzxZV1LKxq20ZVtNbaqXCumtlNTmsbtauwHIXFdF+s3QuiXdYwerZQw8o9Vy7hVYyyTXY8Pps9jHt2WM9zElNnJw8foHU8B2DmdQzM65zmt6U/Iff69bhsNln2h/p4lGK/wDT/anf8WurPPiuMwus9FyvrXhf838o5dufbfZ1d+17neiyl/2as231tdRi037PSprfs3rs0lKXPfWHL63X1jp3T+n5lWJT1KvIa6x1HrPrfQz1/Ur/AEtTXeox30f8H6f+EXQrA+s7m4mf0XrFzXfY+n5Fwy7GtL/Trvosx222NrDn+l6pZvf+Ykp5rDHUcKlnUcH6w3ZD7OnX9RZVk0PsZbTUG7nZLbcx7ar/AFHN2eku36JdmZHR8LJzntsysill1rq27Gg2D1Qxte5/821/p/S968+a7plHS/smJ1OrqWYOjZHSMfFxmWOssuyLN9b6xt/m/T2tfuXpGHQcfDx8c801MrMeLGtZ/BJSZZ//AK0X/oJ/35aCz/8A1ov/AEE/78kp/9D05RsrrtrdXaxtlbxDmOALSPNrlJJJTn0fV/oePaLqcChljdWu2TB/k7vorQ5SSSUpJJJJSkkkklKSSSSUpJJJJSkkkklKSSSSUpJJJJSkkkklKSSSSUpDfS1zt4JY/u5hgn+t+8iJJKRihu4Oe51hHG8yB/Z+iiJJJKUkkkkpScOcNJTJJKXLnHQlMkkkpSQMcJJJKX3O8UySSSlLM9Vn/Of0Z9/2LdHlv2rTWf8A+tF/6Cf9+SU//9H06AlASSSUqAlASSSUqAlASSSUqAlASSSUqAlASSSUqAlASSSUqAlASSSUqAlASSSUqAlASSSUqAlASSSU0f250P8A8sMX/t6v/wAml+3Oh/8Alhi/9vV/+TXI/Ur6u9H6p0q3Iz8f1rW3ura7e9vtDKnbYqexv0nuXQf8yfqx/wBwv/Bbf/SqghPNOIkBCj3Mv4OpzPLfDuXzTwzycwZYzwyMYYuH6frG9+3Oh/8Alhi/9vV/+TS/bnQ//LDF/wC3q/8Ayao/8yfqx/3C/wDBbf8A0ql/zJ+rH/cL/wAFt/8ASqdebtD7Zf8AesXD8M/f5r/wvD/6tb37c6H/AOWGL/29X/5NL9udD/8ALDF/7er/APJqj/zJ+rH/AHC/8Ft/9Kpf8yfqx/3C/wDBbf8A0qlebtD7Zf8Aeq4fhn7/ADX/AIXh/wDVre/bnQ//ACwxf+3q/wDyaX7c6H/5YYv/AG9X/wCTVD/mV9V/+4f/AILb/wClVX6j9Wfqd03Efl5eLsqZ4W2kuJ+ixjfV9z3JE5hqRD7Zf96mMPhsiIxlzRkTQAx4f/Vrr/tzof8A5YYv/b1f/k0v250P/wAsMX/t6v8A8muGzOndNq22X4mH0muwbqqsq3JtyC0mGPfTjW/omu/lI2Jg9Aqay3qWDRdgvf6Y6jhX3uqY4/Rbk02W+rQme7kuqh9sv+9bJ+H8lw8QlzBvpGGE34Rl7vBOX9XHN7P9udD/APLDF/7er/8AJpftzof/AJYYv/b1f/k1QH1K+q5AIw5B1BFtv/pVP/zJ+rH/AHC/8Ft/9Kp95u0Ptl/3rV4fhn7/ADX/AIXh/wDVre/bnQ//ACwxf+3q/wDyaX7c6H/5YYv/AG9X/wCTVH/mT9WP+4X/AILb/wClUv8AmT9WP+4X/gtv/pVK83aH2y/71XD8M/f5r/wvD/6tb37c6H/5YYv/AG9X/wCTS/bnQ/8Aywxf+3q//Jqj/wAyfqx/3C/8Ft/9Kpf8yfqx/wBwv/Bbf/SqV5u0Ptl/3quH4Z+/zX/heH/1a62NlYmWw2Yt1eRWDtL6nB4BGu3cwu93uVSW/wDOPbI3fZJ2zrG7wWN/i4/5Dv8A/DT/APz3Qtna3/nJu2jd9jjdGsbuJQ90+z7la1dLvuEf9I/c+M8Pue3x/pP/0vTkkkklKSSSSUpJJJJSkkkklKSSSSUpJJJJSkkkklKSSSSUpJJJJSkkkklPK/4uP+Q7/wDw0/8A890Lqlyv+Lj/AJDv/wDDT/8Az3QuqUXL/wA1Dyb/AMX/AN38x/fUkme9lbS+xwYxurnOMAD+U4obcvEeCWX1OA5h7TH/AElK0EqSg66lu3dYxu8FzJcNQ0bnub+9sb9JMMjHLi0WsLm7ZEj/AAn81/25+YkpIsHPazN+teDhW+6rDx35oYfomwvGPU4/8V9Nq3lhdfbfg52L16lhtrxmupza2iXeg/3eq3/iH+9Mnt4AgnybHK/zhANSlCcYf7SUKj/jfIjzundO6TkZfXcii7qN2QWtFYY2w1iI/RtO39Hta1u5Z/1Jxac6nq9rmMbhZ1uwYYM7B753N/M9tn/QU/8AnB1nEz78mqmzrHSMoh2LZjwfT5Pp/omF25rv0b2XKt0zLyOkv6h1TLxxRm9VePsPTG/zjnS4hz6/pMbus973t/0iiuPGD0HFenf9Li/S4nQGPN93yQJEskxiGOQn+5KP6n2f8lLD/lJ/oO79UbLHdGbRY4vOJZZjBx7trcW1/wCbXtatpZ3QcB/TOkVUZDgbgHW5DyRG95Ntnu/k7tquOy8RoYXXVgWO9Nh3CC+N+wa/S2qaAIjEHs5vMyjLPklHWJnIgjrrulSUXW1MDS57WiwhrCSIc4/Ra395zkhZWbDUHD1GtDnM7hpJDXf2tqcwskkkklPK/wCLj/kO/wD8NP8A/PdC2v8A1ov/AEE/78sX/Fx/yHf/AOGn/wDnuhbX/rRf+gn/AH5Vf/Av+C7v/l//AOrf9y//0/TkkkklKSSSSUpJJJJSkkkklKSSSSUpJJJJSkkkklKSSSSUpJJJJSkkkklPK/4uP+Q7/wDw0/8A890Lqlxn1B6l07E6PdXlZVOPYclzgy2xrCQWUjdte5vt9q6X9u9D/wDLHF/7er/8mocEojFDUbd3T+K4Msue5gjHMgz3EZJepbf2bl742+hZM8fRd4rnmXYtbLszIYy5mI7HsuY70rHem6p2P/gWiv8AnHsW0/rXQLGFlmdiPY76TXW1kH4tLlW+0/VX1/X+2YkyCK/Wr9MODTW1/oh2zfsc5Sccf3h9rR+75v8ANT/xJMnUHB6fhVOexr6arC2osD/0orffvpeP5r0ff/wVlSzsS1jbcJ7LA59luO0s9Sp7QI9P9HQyljq9tdj2+x/6NX/tX1X9KuoZ2OK6q7Kqm/aGHay3Sxrdz/3fZX/o2Kb+ofV2w4+7qVG3F2musZLAwln82+xu/wB7mJccf3h9qvu+b/NT/wASTrJc6FUf270P/wAscX/t6v8A8ml+3eh/+WOL/wBvV/8Ak0uOP7w+1X3fN/mp/wCLJq3fVTotlzr66nY1j/pnHsfUD/Yrc1iP0/6v9J6bYbsWgC93NzyX2a/8JaXub/ZU/wBu9D/8scX/ALer/wDJpft3of8A5Y4v/b1f/k0P1YN+n8GU/fZR4T7xjVUfc2bGa+yvDvfUwW2NreWVmIcQDDXbvauVvAporZuYzZjmoBkNYZfQ2yxjH13fp7a7v0r10LuudCc0td1DFLXAgj1q+Dofz1Vbk/VRjXMZl4jGPa1m1t7AA1h3s2w/2+/3vd/hEeOP7w+1i+75v81P/Ekj9cN6Tim59ZwmZJotZYA5pqY9zKNtv6Pa6n02em/Z71Ypyw/r9rv8BbU3Grf431h2VZV/21d/nssSZ1D6ttpbSc/Gsax5ta6y+tzvUJL/AFtxd/O7ne1R+2fVf7IzD+24voVkPZ+sM3B7XeoLvV9T1PW9T3usS44/vD7Vfd83+an/AIknXSVH9vdEP/eji/8Ab1f/AJNL9u9D/wDLHF/7er/8mlxx/eH2q+75v81P/Ek4f+Lj/kO//wANP/8APdC2v/Wi/wDQT/vyxf8AFx/yHf8A+Gn/APnuhbG8f85dkOn7HO6Pb9L9795V/wDwL/gux/5f/wDq3/cv/9T05JfLiSSn6jSXy4kkp+o0l8uJJKfqNJfLiSSn6jSXy4kkp+o0l8uJJKfqNJfLiSSn6jSXy4kkp+o0l8uJJKfqNJfLiSSn6H/5k/Vj/uF/4Lb/AOlUv+ZP1Y/7hf8Agtv/AKVXzwkoP6N/q/8AmOp/w1/5W/8Atw/Q/wDzJ+rH/cL/AMFt/wDSqX/Mn6sf9wv/AAW3/wBKr54SS/o3+r/5iv8Ahr/yt/8Abh+h/wDmT9WP+4X/AILb/wClUv8AmT9WP+4X/gtv/pVfPCSX9G/1f/MV/wANf+Vv/tw/Q/8AzJ+rH/cL/wAFt/8ASqX/ADJ+rH/cL/wW3/0qvnhJL+jf6v8A5iv+Gv8Ayt/9uH6H/wCZP1Y/7hf+C2/+lUv+ZP1Y/wC4X/gtv/pVfPCSX9G/1f8AzFf8Nf8Alb/7cP0P/wAyfqx/3C/8Ft/9Kpf8yfqx/wBwv/Bbf/Sq+eEkv6N/q/8AmK/4a/8AK3/24fof/mT9WP8AuF/4Lb/6VS/5k/Vj/uF/4Lb/AOlV88JJf0b/AFf/ADFf8Nf+Vv8A7cP0P/zJ+rH/AHC/8Ft/9Kpf8yfqx/3C/wDBbf8A0qvnhJL+jf6v/mK/4a/8rf8A24fpnpvS8HpdDsfAq9GpzjY5u5zvcQ1u6bXPd9FjVW3n/nNsgbfsn0twndunZs+l9D3r5vSUno4P0eD/AJlNL+lfeP8AK/eeL+v944//AErxv//Z ================================================ FILE: app/src/main/assets/web/help/md/webDavBookHelp.md ================================================ # WebDav 书籍简明使用教程 > 本帮助页会在第一次进入时弹出,后续则不再出现,如想查看,请点击右上角 “**⁝**” > 帮助 查看此页。 虽然阅读主要是用来看网络小说的工具,但为了方便书友,也提供了一些本地书籍阅读的简单支持(epub、txt) 但阅读本地书籍的一个难题就是如何在多设备上同步阅读进度以及书籍,假如换了设备之后,原来设备上的本地书籍也要再次手动导入,不太方便。 阅读本身没有自己的服务器,没有类似多看、微信读书那种服务器存储的可能性,但是,阅读支持 WebDav 备份,那么我们也可以利用 WebDav 来同步书籍。 ### 前提条件 1. 配置好书籍存储位置(WebDav书籍下载存储到的位置):依次点击我的/其他设置/书籍存储位置,选择书籍保存位置即可。 2. 配置好 WebDav 备份(WebDav书籍的保存位置):我的/备份与恢复/WebDav设置。这里需要配置 WebDav 备份的服务器地址、账号、密码。详细的配置方案这里不赘述,请看这篇文章:[坚果云注册与配置 · 语雀 (yuque.com)](https://www.yuque.com/legado/wiki/fkx510) 或点击该页面右上角的帮助按钮,查看配置方法。 ### 上传书籍到 WebDav 配置好 WebDav 后,从主界面进入 WebDav 书籍页没有任何书籍显示,这是很正常的,因为我们WebDav的服务器上还没有任何书籍。 目前将书籍上传到 WebDav 的方式有三种: 1. App 上传已导入的本地书籍。 长按已导入的本地书籍进入书籍详情 > 右上角 “**⁝**” 找到 **上传 WebDav** ,点击,等待几秒后即可上传成功。 2. App 上传已缓存的网络书籍。 主界面右上角点击更多设置 > 点击缓存/导出,在此页面右上角 “**⁝**” 找到 **导出到 WebDav** 并勾选。那么在书籍导出的时候便会自动上传一份到 WebDav 服务器中。 3. 使用坚果云客户端/自建WebDav服务客户端上传。 对于大部分用户来说,App上传足够了,但有些用户书籍数量可能比较大,那么我们不建议您一本一本通过 App 上传,更好的方式是使用您所使用的 WebDav 服务的客户端批量上传。 假设我们使用的坚果云的 WebDav 服务,进入 [坚果云官网](https://www.jianguoyun.com/d/home#/) ,下载对应平台的客户端安装运行,找到文件夹目录 legado/books ,这里就是存放书籍的位置,您可以批量将书籍上传到该文件夹下。 **不管是使用上述的任何一种方式上传的书籍,为了确保上传无误,请您最好在上传书籍后进入 WebDav 书籍页 检查是否能看到已经上传的书籍。** ### 下载 WebDav 书籍到本地 与上传方式的多种多样不同,下载书籍到本地的方式比较朴素。 在 **WebDav 书籍页** 浏览已经上传的书籍,找到自己要下载的书籍,点击 **加入书架** 按钮,软件则会自动下载该书籍到本地并加入到书架中。 ### 注意事项 - 如果使用的是坚果云的 WebDav 服务,免费流量限额对于同步App设置等以及 **少量的书籍** 足够了。但是如果是频繁需要上传/下载书籍的用户流量可能就不太够用了,请注意个人的用量,避免出现超出限额影响 App 设置等的同步。 ### 常见问题 - 进入 **WebDav书籍页** 提示 "获取WebDav书籍出错 webDav 没有配置"。 > 这是因为没有配置 WebDav 同步服务,按照上文 前提条件 中提到的配置 Webdav 同步的方法配置好就行了。 - A 设备上传的本地书籍能否在 B 设备上看到,是否能够自动加到书架? > 如果 A 设备和 B 设备配置了相同的 WebDav 服务,那么 B 在 **WebDav 书籍页** 就能看到 A 上传的书籍。但是无法直接在书架上看到该书籍,这个可能后续会想方案来做,目前必须自己在 **WebDav 书籍页** 找到该书籍手动点击 **加入书架** 导入才行。 - 本地书籍的阅读进度/书签等是否同步? > 可以同步。 ================================================ FILE: app/src/main/assets/web/help/md/webDavHelp.md ================================================ # WebDav备份教程 ### 阅读支持云备份,采用WebDav协议,所有支持WebDav的云盘都可以,建议采用坚果云,每月免费1G流量,用来备份阅读足够了,下面就采用坚果云来讲解配置步骤. 1. 打开坚果云网站 https://www.jianguoyun.com/d/home#/ 2. 如果没有注册过坚果云先注册一下 3. 登录坚果云 4. 右上角用户名点开点账户信息 5. 点击安全选项 6. 在第三方管理里添加应用 7. 将应用示例里的服务器地址,用户名,和密码填到阅读的WebDav设置里 8. 阅读的WebDav配置在我的-备份与恢复,创建子文件夹选项保持默认即可 9. 设置完成后手动执行一下备份,看看是否成功 10. 恢复时选择想要恢复的备份文件 ### 自动备份说明 * 设置好备份之后每次退出App会自动进行备份 * WebDav同一天的备份会覆盖,不同日期的备份不会覆盖 ================================================ FILE: app/src/main/assets/web/help/md/xpathHelp.md ================================================ # xpath 路径表达式详解 _注:本文所有代码均通过 Chrome(版本 123.0.6312.86) 验证_ > XPath 规范中定义了 13 种不同的轴(axes)。 > 轴表示与元素的关系,并用于定位元素树上相对于该元素的元素。 - `namespace`(不支持) - `attribute` 元素的属性。它可以缩写为 `@` - `self` 表示元素本身。它可以缩写为 `.` - `parent` 当前元素的父元素。它可以缩写为 `..` - `child` 当前元素的子元素。 - `ancestor` 当前元素的所有直属祖先。 - `ancestor-or-self` 当前元素及其所有直属祖先。 - `descendant` 当前元素的所有递归子元素。 - `descendant-or-self` 当前元素及其所有递归子元素。 - `following` 当前元素之后出现的所有元素。无视元素层级,但不含直属后代。 - `following-sibling` 当前元素之后出现的所有同级元素。 - `preceding` 当前元素之前出现的所有元素。无视元素层级,但不含直属祖先。 - `preceding-sibling` 当前元素之前出现的所有同级元素。 ```js // 轴的用法-> 轴名::表达式 // 例: > $x('//body/ancestor-or-self::*') < [body, html] ``` #### 一、xpath 表达式的基本格式 > xpath 通过"路径表达式"(Path Expression)来选取元素。 > 在形式上,"路径表达式"与传统的文件系统非常类似。 ```txt # "/"斜杠作为路径内部的分割符。 # 同一个元素有绝对路径和相对路径两种写法。 # 绝对路径必须用"/"起首,后面紧跟根元素,比如/step/step/...。 # 相对路径则是除了绝对路径以外的其他写法,比如 step/step,也就是不使用"/"起首。 # "."表示当前元素。 # ".."表示当前元素的父元素 ``` ### 二、选取元素的基本规则 ```txt - "/":表示选取根元素 - "//":表示选取任意位置的某个元素 - nodename:表示选指定名称的元素 - "@": 表示选取某个属性 ``` ### 三、选取元素的实例 ```html 标题
Harry Potter

29.39

usd

Cpp高级编程

39.95

rmb

``` ```js // 例1 > $x('/') // 选取根元素,返回包含被选中元素的数组。 < [document] // 例2 > $x('/html') // 选取根元素下的所有 html 子元素,这是绝对路径写法。 < [html] // 例3 > $x('html/head/meta') // 选取 head 元素下的所有 meta 元素,这是相对路径写法。 < [meta, meta] // , // 例4 > $x('//p') // 选取所有 p 元素,不管它们在哪里 < [p, p, p, p] //

29.39

,

usd

,

39.95

,

rmb

// 例5 > $x('html/body//a') // 选取 body 元素下的所有 a 元素 < [a, a, a] // , , // 例6 > $x('//@lang') // 选取所有名为 lang 的属性。 < [lang, lang] // lang="eng", lang="cn" > $x('html/head/meta/@content') // 选取 head 元素下所有 meta 元素的 content 属性。 < [content] // content="作者" // 例7 > $x('//meta/..') // 选取所有 meta 元素的父元素。(相同的结果只会返回一个) < [head] // ... ``` ### 四、xpath 的谓语条件(Predicate) > 所谓"谓语条件",就是对路径表达式的附加条件。 > 所有的附加条件,都写在方括号 `[]` 中,用于对元素进一步的筛选。 > 方括号内的表达式结果为 true 的元素才会被选取。 ```js // 例8 > $x('html/head/meta[1]') // 选取 head 元素下的第一个 meta 元素 < [meta] // > $x('//p[1]') // 选取所有元素下的第一个 p 元素 < [p, p] //

29.39

,

39.95

// 例9 > $x('html/head/meta[last()]') // 选取 head 元素下的最后一个 meta 元素 < [meta] // // 例10 > $x('html/head/meta[last()-1]') // 选取 head 元素下的倒数第二个 meta 元素 < [meta] // // 例11 > $x('html/head/meta[position()>1]') // 选取 head 元素下的除了第一个元素外的所有 meta 元素 < [meta] // // 例12 > $x('//title[@lang]') // 选取所有具有lang属性的title元素。 < [title, title] // Harry Potter, Cpp高级编程 // 例13 > $x('//title[@lang="eng"]') // 选取所有lang属性的值等于"eng"的title元素。 < [title] // Harry Potter // 例14 > $x('/html/body/div[dl]') // 选择 body 的 div 子元素,且被选中 的 div 元素必须带有 dl 子元素。 < [div] //
...
// 例15 > $x('/html/body/div[p>35.00]') // 选取 body 的 div 子元素,且被选中 div 元素的 p 子元素的值必须大于 35.00。 < [div] //
Cpp高级编程

39.95

rmb

> $x('/html/body/div[p="rmb"]') // 选取 body 的 div 子元素,且被选中 div 元素的 p 子元素的值必须等于 "rmb"。 < [div] //
Cpp高级编程

39.95

rmb

// 例16 > $x('/html/body/div[p="rmb"]/title') // 在例14结果集中,选择title子元素。 < [title] // Cpp高级编程 // 例17 > $x('/html/body/div/p[.>35.00]') // 选择值大于 35.00 的 "/html/body/div" 的 p 子元素。 < [p] //

39.95

``` ### 五、通配符 - `\*` 表示匹配任何元素。 - `@\*` 表示匹配任何属性名。 ```js // 例18 > $x('//*') // 选取所有元素,结果以递归顺序返回 < [html, head, meta, title, meta, body, div, title, p, p, div, title, p, p, div, dl, dd, a, dd, a, dd, a] // 例19 > $x('/*/*') // 选取所有第二层的元素 < [head, body] // ..., ... // 例20 > $x('//dl[@id="list"]/*') // 选取 id="list" 的 dl 元素的所有子元素。 < [dd, dd, dd] //
,
,
// 例21 > $x('//title[@*]') // 选取所有带有属性的 title 元素。 < [title, title] // Harry Potter, Cpp高级编程 ``` ### 六、选择多个路径 - 用 `|` 合并多个表达式的选取结果。 ```js // 例22 > $x('//title | //a') // 选取所有 title 和 a 元素。 < [title, title, title, a, a, a] ``` ### 七、xpath 的函数 > xpath 函数的参数可以是静态字符串或表达式,且函数可以嵌套调用。 > xpath 的索引均从1开始,而不是从0开始。 ```js // boolean(expression) 将表达式选取的结果转换为布尔值。 > $x('boolean(//title)') < true // number([object]) 将表达式选取的结果转换为数字。(HTML元素内容默认均为字符串) > $x('number(//p[1])') < 29.39 // round(decimal) 将数字参数转换为整数并四舍五入。 > $x('round(//p[1])') < 29 // ceiling(number) 将数字参数转换为整数并向上取整。ceiling(5.2)=6 > $x('ceiling(//p[1])') // 仅使用匹配表达式的第一个元素 < 30 // floor(number) 将数字参数转换为整数并向下取整。floor(5.8)=5 > $x('floor(//p[1])') < 29 // concat( string1, string2 [,stringn]* ) 字符串拼接,参数为静态字符串或表达式 > $x('concat("cost:", //p[1], //p[2])') // 仅使用匹配表达式的第一个元素 < 'cost:29.39usd' // contains(haystack, needle) 判断 haystack 是否包含 needle,返回 boolean > $x('contains(//p[1], "29.39")') // 仅使用匹配表达式的第一个元素 < true > $x('//title[contains(., "Harry")]') // 选取内容中包含 "Harry" 的 title 元素。 < [title] // Harry Potter // count( node-set ) 统计表达式选取的元素个数。 > $x('count(//p)') < 4 // id(expression) 根据 id 属性选取元素,若参数为表达式,将获取表达式结果作为id查询。 > $x('id(//dl/@id)') // 等效于 $x('id("list")') < [dl#list] //
...
// last() 返回当前路径表达式匹配的同级元素集合的成员数量。 > $x('//p[last()]') < [p, p] //

usd

,

rmb

// name([node-set]) 返回表达式选取集合的首个成员带命名空间的元素名,HTML中与local-name([node-set])等价。 // local-name([node-set]) 返回表达式选取集合的首个成员本地元素名。 > $x('local-name(//*[@id])') // < 'dl' // namespace-uri([node-set]) 获取选定节点集中第一个节点的命名空间URI。 > $x('namespace-uri(//div)') < 'http://www.w3.org/1999/xhtml' // HTML通常都返回这个固定值 // normalize-space([string]) 去文本内容中的前后空白以及将内部连续的空白替换为单个空格 > $x('normalize-space(" test string ")') < 'test string' // not(expression) 返回表达式的布尔反值。 > $x('//title[not(@lang)]') < [title] // 标题 // position() 返回选定元素处于路径表达式匹配的同级元素集合中的位置。 > $x('//meta[position()=2]') < [meta] // // starts-with(haystack, needle) 检查某个字符串 haystack 是否以另一个字符串 needle 开始。 > $x('//title[starts-with(., "Cpp")]') < [title] // Cpp高级编程</title] // string([object]) 将给定参数转换为字符串 > $x('string(//p)') < '29.39' // string-length([string]) 返回给定字符串的字符数量 > $x('string-length(string(//p))') < 5 // substring(string, start[, length]) 截取字符串 > $x('substring(string(//p), 1, 3)') < '29.' // substring-after(haystack, needle) 返回字符串 haystack 中第一个 needle 之后的字符串。 > $x('substring-after(string(//p), ".")') < '39' // substring-before(haystack, needle) 返回字符串 haystack 中第一个 needle 之前的字符串。 > $x('substring-before(string(//p), ".")') < '29' // sum([node-set]) 对给定集合的数字求和。若给定集合中存在非数字,则返回 NaN > $x('sum(//p[1])') < 69.34 // translate(string, "abc", "XYZ") 依次替换 string 中出现的 a、b、c 为对应位置的 X、Y、Z。 // 若第三个参数中的字符少于第二个参数,那么在第一个参数中相应的字符将被删除。 > $x('translate("aabbcc112233", "ac2", "V8")') < 'VVbb881133' // true() 表示函数中的 true 布尔值 // false() 表示函数中的 false 布尔值 ``` ================================================ FILE: app/src/main/assets/web/index.html ================================================ <!DOCTYPE HTML> <!-- Forty by HTML5 UP html5up.net | @ajlkn Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) --> <html> <head> <title>Legado web 导航
================================================ FILE: app/src/main/assets/web/uploadBook/css/wifi_send.css ================================================ @charset "utf-8"; /*reset*/ html{-webkit-text-size-adjust:100%} body,p,blockquote,ul,ol,li,h1,h2,h3,h4,h5,h6,dl,dd,input,textarea,button{margin:0; padding:0;} body {background:#f9f9f6;font-size:14px;color:#333;font-family:"微软雅黑",Arial,sans-serif,Tahoma,Geneva;line-height:150%;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-touch-callout:none} h1, h2, h3, h4, h5,em,i { font-weight:normal} ul, ol { list-style:none } em,i{ font-style: normal; } img{border: 0;vertical-align: middle} /*common*/ .pc .red{ color: #ea5449; } .pc .black{ color: #202020; } .pc .gray{ color: #999; } .pc .f_20{ font-size: 20px; } .pc .f_18{ font-size: 18px; } .pc .f_36{ font-size: 36px; } .pc .f_14{ font-size: 14px; } .pc .ml_25{ margin-left: 25px; } .pc body{ background: url(../img/background.png) repeat; } .pc .inline-block{ display: inline-block; } .pc .inline{ display: inline; } /*private*/ .pc .title_wrap{ border-bottom: 1px solid #fff; outline: 1px solid #f0efed; width: 100%; } .pc .main_wrap{ width: 900px; margin: 0 auto; } .pc .s_title{ width: 900px; margin: 0 auto; padding: 16px 17px; box-sizing: border-box; } .pc .s_title .up{ vertical-align: 2px; } .pc .s_logo{ display: inline-block; height: 40px; width: 30px; vertical-align: -3px; } .pc .top_cont{ position: relative; padding: 22px 0 22px 20px; overflow: hidden; } .pc .top_cont .status{ font-size: 12px; } .pc .top_cont .type{ font-size: 12px; margin-top: 8px; } .pc .top_cont .select_btn{ position: absolute; right: 0; display: inline-block; line-height: 50px; height: 50px; width: 180px; text-align: center; background-color: #ea5449; color: #fff; border-radius: 3px; } .pc .select_btn input { position: absolute; left: 0; right: 0; top: 0; bottom: 0; width: 100%; height: 50px; opacity: 0; cursor: pointer; filter:alpha(opacity=0); } .pc .s_table table{ width: 100%; background-color: #fff; border-collapse:collapse; table-layout: fixed; } .pc .s_table table tr td span{ display: inline-block; } /*thead*/ .pc .s_table thead tr{ height: 40px; font-size: 12px; } .pc .s_table thead tr td:first-child + td{ width: 40%; text-align: left; padding-left: 15px; } .pc .s_table thead tr td{ text-align: center; border-bottom: 1px solid #f5f5f5; border-right: 1px solid #f5f5f5; } .pc .s_table thead tr td:first-child + td + td + td{ border-right: 0; } /*tbody*/ .pc .s_table tbody tr:nth-child(odd){ background-color: #fbfaf8; } .pc .s_table tbody tr td{ text-align: center; line-height: 20px; padding: 11px 0; font-size: 12px; border-bottom: 1px solid #f5f5f5; border-right: 1px solid #f5f5f5; } .pc .s_table tbody tr td:first-child{ padding: 0 15px; } .pc .s_table tbody tr td:first-child + td + td{ padding: 0 15px; } .pc .s_table tbody tr td:first-child + td + td + td{ padding: 0 15px; border-right: 0; } .pc .s_table tbody tr td:first-child + td{ text-align: left; padding-left: 15px; } .pc .last_row{ color: #999; border-bottom: 0; column-span: 4; padding: 12px; } .pc .op_right{ height: 16px; width: 16px; display: inline-block; background: url("../img/right.png") center no-repeat; background-size: 16px auto; vertical-align: -2px; } .pc .op_wrong{ height: 12px; width: 12px; display: inline-block; background: url("../img/wrong.png") center no-repeat; background-size: 12px auto; } .pc .main_wrap .active{ -webkit-box-shadow: 0 0 15px rgba(0,0,0,.1); -moz-box-shadow: 0 0 15px rgba(0,0,0,.1); box-shadow: 0 0 15px rgba(0,0,0,.1); } .pc .btn{ display: inline-block; line-height: 0; float: right; } .pc .warning{ display: inline-block; line-height: 50px; float: right; color: red; } .pc .warning a{ color: #0078a5; } /*h5*/ .h5 .red{ color: #ea5449; } .h5 .black{ color: #202020; } .h5 .gray{ color: #999; } .h5 .f_18{ font-size: 18px; } .h5 .f_15{ font-size: 15px; } .h5 .f_14{ font-size: 14px; } .h5 .ml_15{ margin-left: 15px; } /*private*/ .h5 .s_title{ line-height: 60px; border-bottom: 1px solid #f0efed; background-color: #fdfdfd; padding: 0 17px; } .h5 .s_title .up{ vertical-align: 2px; } .h5 .s_logo{ display: inline-block; height: 24px; width: 20px; background: url("../img/logo.png") center no-repeat; background-size: 18px auto; vertical-align: -3px; } .h5 .s_table{ margin-bottom: 75px; } .h5 .s_table table{ width: 100%; border-collapse:collapse; table-layout: fixed; } .h5 .s_table thead tr{ height: 56px; font-size: 11px; } .h5 .s_table thead tr td:nth-child(2){ text-align: left; width: 40%; } .h5 .s_table thead tr td{ text-align: center; border-bottom: 1px solid #f0efed; } .h5 .s_table tbody tr td{ text-align: center; line-height: 20px; padding: 20px 0; font-size: 12px; border-bottom: 1px solid #f0efed; } .h5 .s_table tbody tr td:first-child{ padding: 0 10px; } .h5 .s_table tbody tr td:nth-child(3){ padding: 0 10px; } .h5 .s_table tbody tr td:last-child{ padding: 0 10px; } .h5 .s_table tbody tr td:nth-child(2){ text-align: left; font-size: 14px; } .h5 .op_right{ height: 16px; width: 16px; display: inline-block; background: url("../img/right.png") center no-repeat; background-size: 16px auto; vertical-align: -2px; } .h5 .op_wrong{ height: 12px; width: 12px; display: inline-block; background: url("../img/wrong.png") center no-repeat; background-size: 12px auto; } .h5 .bottom_c{ position:fixed; bottom:0; left:0; right:0; text-align: center; } .h5 .bottom_c .type{ line-height: 25px; color: #999; font-size: 10px; border-top: 1px solid #eeedea; background: #f9f9f6; } .h5 .bottom_btn_wrap{ height: 50px; line-height:50px; background-color:#e8554d; font-size:17px; color:#fff; } .h5 .bottom_btn_wrap input{ position: absolute; left: 0; right: 0; top: 0; bottom: 0; width: 100%; display:inline-block; opacity: 0; } .emphasize{ color: #ea5449; font-weight: bold; } .none{ display: none; } .mask{ width:100%; height:100%;position: fixed;top: 0; left: 0; right: 0; background: rgba(0,0,0,.7); z-index: 99; display: none;} .c_tc{ background:url(../img/notice01.png) no-repeat left top; -webkit-background-size:171px 43px;position: fixed;; top: 50%;left: 50%; margin-top: -22px; margin-left: -80px; width: 171px; height: 43px; z-index: 100} .t_tc{ background:url(../img/notice02.png) no-repeat left top; -webkit-background-size:216px 84px;position: fixed;; top: 10px;right: 20px; width: 216px; height: 84px; z-index: 100} .safariWarn{background:url(../img/safari.png) no-repeat left top; -webkit-background-size:216px 84px;} .close{ position: fixed; right: 15px; top: 15px; width: 30px; height: 30px; background: url("../img/close.png") no-repeat center; -webkit-background-size: 15px 15px; } ================================================ FILE: app/src/main/assets/web/uploadBook/index.html ================================================ WiFi 传输 set 限制解除
阅读 | WiFi传输

可支持文件格式:

TXT、EPUB、UMD、PDF、MOBI、AZW3、AZW

选择文件
文件名 大小 操作
请将图书或字体拖拽至此即可上传
================================================ FILE: app/src/main/assets/web/uploadBook/js/common.js ================================================ /** * 公共函数 */ //全局的配置文件 var config = { fileTypes: "txt|epub|umd|pdf|mobi|azw3|azw", //允许上传的文件格式 "txt|epub" // |doc|docx|wps|xls|xlsx|et|ppt|pptx|dps //url : "http://"+location.host+"?action=addBook",//"http://localhost/t/post.php",// url: "../addLocalBook", fileLimitSize : 500 * 1024 *1024 }; //文件对应序号 var fileMap = {}; /** * HTML5 和 flash 公用,所有文件对象集合 * @var array */ var filesUpload = []; // //初始化表格 init(); function init(){ //判断浏览器的高度预留空表格 var tr_num = parseInt(Math.round(((window.innerHeight || document.documentElement.clientHeight) *.5)/43)); var item = '' + '' + '' + '' + '' + ''; var i = 0; var HTML = ''; while(i < tr_num){ HTML = HTML + item; i ++; } $('#drag table tbody').prepend(HTML); } //统计文件大小 function countFileSize(fileSize) { var KB = 1024; var MB = 1024 * 1024; if(KB >= fileSize){ return fileSize+"B"; }else if(MB >= fileSize){ return (fileSize/KB).toFixed(2)+"KB"; }else{ return (fileSize/MB).toFixed(2)+"MB"; } } //如果文件太长进行截取 function substr_string(name) { var maxLen = 30; var len = name.length; if(len < maxLen )return name; var lastIndex = name.lastIndexOf("."); var suffix = name.substr(lastIndex); var pre = name.substr(0,lastIndex); var preLen = pre.length; var preStart = preLen - 20; //前面10个 + 后面5个 var fileName = pre.substr(0,20) + "...." + pre.substr( preStart > 4 ? -4 : -preStart , 4)+suffix; return fileName } function checkFile(file) { if (!file.name || !file.name.toLowerCase().match('('+config.fileTypes+')$')) { return "格式不支持"; } var len = filesUpload.length; for(var i=0; i< len; i++){ if(filesUpload[i].name == file.name) { return "文件已存在"; } } return null; } /** * 添加文件时,回调的函数 * @param object file 文件对象 * @param int type 0 是swf 上传的,1 是html5上传的 */ function fileQueuedPC(file, type) { var size=0 ,fid=file.id, name=""; type = type || 0; if(file != undefined ) { //计算文件大小 单位MB size = countFileSize(file.size); name = substr_string(file.name); //如果没有找到这个节点,先创建 if ($('#drag tbody tr:last-child').prev().attr('data-status') != 'init' ){ var HTML = '' + '' + '' + '' + '' + ''; $("#drag tbody tr:last-child").before(HTML); } var i = $('#drag [data-status=init]').eq(0).index('#drag [data-js=item]'); $('#drag [data-js=item]').eq(i).children().eq(0).find('span').html(i+1); $('#drag [data-js=item]').eq(i).children().eq(1).find('span').html(name); $('#drag [data-js=item]').eq(i).children().eq(2).find('span').html(size); $('#drag [data-js=item]').eq(i).children().eq(3).find('span i').addClass('red').html('0%'); $('#drag [data-js=item]').eq(i).attr('data-status', 'ed'); fileMap[file.name] = $('#drag [data-js=item]').eq(i).children().eq(3).find('span i'); } } //上传时返回的状态 function uploadProgress(file, bytesLoaded, bytesTotal) { fileMap[file.name].html(parseInt((bytesLoaded/bytesTotal)*100)+"%"); } //上传成功 function uploadSuccess(file, serverData, res) { fileMap[file.name].removeClass('red').addClass('op_right').html(''); } /** * 查找在数组中的位置 */ function findObjectKey (object, fid){ var len = object.length; for(var i=0; i -1) files.splice(filesUploadKey, 1); return files; } ================================================ FILE: app/src/main/assets/web/uploadBook/js/html5_fun.js ================================================ /** * 处理拖拽上传 */ var isDragOver = false;//拖拽触发点 var fileNumber = -1; //上传文件编号 var fileNumberPex = "zyFileUpload_"; //编号前缀 var currUploadfile = {}; //当前上传的文件对象 var uploadQueue = [];//上传队列集合 var isUploading = false;//是否正在上传 var XHR; try{ XHR = new XMLHttpRequest(); }catch(e){} (function(isSupportFileUpload){ //不支持拖拽上传,或者 不支持FormData ,显示WiFi表示 if(!isSupportFileUpload){ $("#drag tbody tr:last-child td span").html('您的浏览器不支持拖拽上传'); return; //更换样式 }else{ $("#drag tbody tr:last-child td span").html('请将图书或字体拖拽至此即可上传'); } addEvent(); /** * 添加事件 */ function addEvent(){ var click = $('#click')[0]; var dropArea = $('#drag')[0]; click.addEventListener('change', handleDrop, false); dropArea.addEventListener('dragover', handleDragOver, false); dropArea.addEventListener('dragleave', handleDragLeave, false); dropArea.addEventListener('drop', handleDrop, false); } /** * 松开拖拽文件的处理,进行上传 */ function handleDrop(evt){ evt.stopPropagation(); evt.preventDefault(); $('#drag').removeClass('active'); isDragOver = false; var file={}; var errorMsgs = []; var len = 0; if(typeof (this.files) == 'object'){ len = this.files.length; }else{ len = evt.dataTransfer.files.length; } for(var i=0; i < len; i++){ fileNumber ++ ; if(typeof(this.files) == 'object'){ file = this.files[i]; }else{ file = evt.dataTransfer.files[i]; } //检测文件 msg = checkFile(file); //文件可以通过 if(!msg){ file.id = fileNumberPex+fileNumber; //添加全局 filesUpload.push(file); //添加上传队列 uploadQueue.push(file); //在页面进行展示 fileQueuedPC(file, 1); }else{ errorMsgs.push(msg) } } if(errorMsgs.length>0){ //只选择做一个进行上传 if(len==1){ alert(errorMsgs[0]); }else{ alert("你选择了"+len+"个文件,只能上传"+(len - errorMsgs.length)+"个文件。\n请选择可支持文件格式且文件名不能重复。"); } } //拿出第一个,进行上传 if(!isUploading && uploadQueue.length>0) uploadFiles(uploadQueue.shift()); //清空input内容,防止两次上传文件一样,change事件不触发 document.getElementById('click').value = ''; } function handleDragOver(evt){ evt.stopPropagation(); evt.preventDefault(); //防止多次DOM操作 if (!isDragOver) { $('#drag').addClass('active'); isDragOver = true; } } function handleDragLeave(evt){ evt.stopPropagation(); evt.preventDefault(); isDragOver = false; $('#drag').removeClass('active'); } function uploadFiles(file){ console.log(file); //正在上传 isUploading = true; //设置上传的数据 // var reader = new FileReader(); // reader.readAsDataURL(file); // reader.onload = function (e) { // var data = e.target.result; var fd = new FormData(); fd.append("fileName", file.name); fd.append("fileData", file); //设置当前的上传对象 currUploadfile = file; if(XHR.readyState>0){ XHR = new XMLHttpRequest(); } XHR.upload.addEventListener("progress", progress, false); XHR.upload.addEventListener("load", requestLoad, false); XHR.upload.addEventListener("error", error, false); XHR.upload.addEventListener("abort", abort, false); XHR.upload.addEventListener("loadend", loadend, false); XHR.upload.addEventListener("loadstart", loadstart, false); XHR.open("POST", config.url); // XHR.setRequestHeader("Content-Type","application/octet-stream"); XHR.send(fd); XHR.onreadystatechange = function() { //只要上传完成不管成功失败 if (XHR.readyState == 4 ){ if(XHR.status == 200){ uploadSuccess(currUploadfile, {}, XHR.status) }else{ uploadError() } //进行下一个上传 nextUpload() } }; // }; } //请求完成,无论失败或成功 function loadend(evt){ // console.log("loadend",+new Date(),evt); } //请求开始 function loadstart(evt){ // console.log("loadstart",evt); } //在请求发送或接收数据期间,在服务器指定的时间间隔触发。 function progress(evt){ uploadProgress(currUploadfile, evt.loaded || evt.position , evt.total) } //在请求被取消时触发,例如,在调用 abort() 方法时。 function abort(evt){ // console.log("abort",evt); } //在请求失败时触发。 function error(evt){ //终止ajax请求 XHR.abort(); uploadError(); nextUpload(); } //在请求成功完成时触发。 function requestLoad(evt){ // console.log("requestLoad", +new Date(),evt); } //进行下一个上传 function nextUpload(){ isUploading = false; if(uploadQueue.length>0){ uploadFiles(uploadQueue.shift()); }else{ //米有正在上传的了 currUploadfile = {} } } //上传出错误了,比如断网, function uploadError(){ //移除全局变量中的,上传出错的 removeFileFromFilesUpload(filesUpload, currUploadfile.id); var file = currUploadfile; fileMap[file.name].removeClass('red').addClass('op_wrong').html(''); } //对外部注册的函数 var HTML5Funs = { /** * 取消上传 * @param string fid 文件的Id */ cancelUpload : function(fid){ var filesUploadKey = -1; var uploadQueueKey = -1; //从全局中删除文件 removeFileFromFilesUpload(filesUpload, fid) //如果是正在上传的,AJAX取消 if(currUploadfile.id == fid){ XHR.abort(); }else{ //从上传队列中移除 removeFileFromFilesUpload(uploadQueue, fid) } } }; window.HTML5Funs = HTML5Funs; })("FormData" in window && "ondrop" in document.body); ================================================ FILE: app/src/main/assets/web/vue/assets/BookChapter-BsiFtdIw.css ================================================ @charset "UTF-8";.title[data-v-d8efefe3]{margin-bottom:57px;font:24px/32px PingFangSC-Regular,HelveticaNeue-Light,Helvetica Neue Light,Microsoft YaHei,sans-serif}p[data-v-d8efefe3]{display:block;word-wrap:break-word;letter-spacing:calc(var(--v3af0ffa9) * 1em);line-height:calc(1 + var(--v316cdf92));margin:calc(var(--v3ab99b0b) * 1em) 0}p[data-v-d8efefe3] img{height:1em}.full[data-v-d8efefe3]{display:block;width:100%}@font-face{font-family:FZZCYSK;src:local("☺"),url(./popfont-WaOB0hHG.ttf);font-style:normal;font-weight:400}@font-face{font-family:iconfont;src:url(./iconfont-PstzbNMW.woff) format("woff")}[data-v-dd7cfcb2] .iconfont,[data-v-dd7cfcb2] .moon-icon{font-family:iconfont;font-style:normal}.settings-wrapper[data-v-dd7cfcb2]{-webkit-user-select:none;user-select:none;margin:-13px;text-align:left;padding:40px 0 40px 24px;background:#ede7da url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyBAMAAADsEZWCAAAAD1BMVEX48dr48Nf58tv379X17NJtIBxUAAACFUlEQVQ4y1XRUZakMAgF0Af2AiDWApDZgHZqAV1nZv9rGh7Rj7Y8McUFEg1wvcMESMNVD/neU8Xcaz7nYYkYlYO6Ti82PBI4BvIEg1aj3wKwRvIMgZsUy5LdhCawPFh1sZs4SrlyN9fQKpv8s5dgZ2eLyqqJiu+WkCmUEybXkm3INS01WAiv0PapJ0CZc0SJQUzcWnZYbOOY20iFD8Bk+/j2A3wNxH7GdShFYS5ff237kXh9I9zSkQmIAhOsOSVfJ6DIXTMDaPnzkRJ92S1BQQmXl5LdirgRLLDdcYqcGPwe3QN4xCBiGNbrqq9wpW1XCecChwaQdVOsRDpPCpeoolPdxeXp3WNB9PHVzWBHlygy4NJCCrFHREv6bDt0VGwJZASkpONmm1UseGeFKAQexgaAkrfYWl3AGxWOLL2AIMBNbCXpktmS3k3vHeYjGCPBa43wJTurO3ZFVpQSJdAZGLoHTyk1upkjxMEaIxum3iIARcCa5kSkFAW5fi1mUlL9eyOsaanFmOMruwvEdE3ZYzsRSzo5ewRLXyVPPEvknt8ij4DvCg2O7xOgBCUprEzV4z1WekSpUgI8DT2mrnSOXKRfQavwuKA1F+tFnMKdJSUpMA7wQAifWRkMgjUKKZE4lBl6MCM4B1pq1P4uIjDE6Pq6rL0FnW1nIFmta5vrSvq/Ch4tpqG/ZNyyWa5jZPktq81eYv8Bt5s4iFITOp4AAAAASUVORK5CYII=) repeat}.settings-wrapper .settings-title[data-v-dd7cfcb2]{font-size:18px;line-height:22px;margin-bottom:28px;font-family:FZZCYSK;font-weight:400}.settings-wrapper .setting-list[data-v-dd7cfcb2]{max-height:calc(70vh - 50px);overflow:auto}.settings-wrapper .setting-list ul[data-v-dd7cfcb2]{list-style:none outside none;margin:0;padding:0}.settings-wrapper .setting-list ul li[data-v-dd7cfcb2]{list-style:none outside none}.settings-wrapper .setting-list ul li i[data-v-dd7cfcb2]{font:12px/16px PingFangSC-Regular,-apple-system,Simsun;display:inline-block;min-width:48px;margin-right:16px;vertical-align:middle;color:#666}.settings-wrapper .setting-list ul li .theme-item[data-v-dd7cfcb2]{line-height:32px;width:34px;height:34px;margin-right:16px;margin-top:5px;border-radius:100%;display:inline-block;cursor:pointer;text-align:center;vertical-align:middle}.settings-wrapper .setting-list ul li .theme-item .iconfont[data-v-dd7cfcb2]{display:none}.settings-wrapper .setting-list ul li .selected[data-v-dd7cfcb2]{color:#ed4259}.settings-wrapper .setting-list ul li .selected .iconfont[data-v-dd7cfcb2]{display:inline}.settings-wrapper .setting-list ul .font-list[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .infinite-loading[data-v-dd7cfcb2]{margin-top:28px}.settings-wrapper .setting-list ul .font-list .font-item[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .font-list .infinite-loading-item[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .infinite-loading .font-item[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .infinite-loading .infinite-loading-item[data-v-dd7cfcb2]{width:78px;height:34px;cursor:pointer;margin-right:16px;border-radius:2px;text-align:center;vertical-align:middle;display:inline-block;font:14px/34px PingFangSC-Regular,HelveticaNeue-Light,Helvetica Neue Light,Microsoft YaHei,sans-serif}.settings-wrapper .setting-list ul .font-list .font-item-input[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .infinite-loading .font-item-input[data-v-dd7cfcb2]{width:168px;color:#000}.settings-wrapper .setting-list ul .font-list .selected[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .infinite-loading .selected[data-v-dd7cfcb2]{color:#ed4259;border:1px solid #ed4259}.settings-wrapper .setting-list ul .font-list .font-item[data-v-dd7cfcb2]:hover,.settings-wrapper .setting-list ul .font-list .infinite-loading-item[data-v-dd7cfcb2]:hover,.settings-wrapper .setting-list ul .infinite-loading .font-item[data-v-dd7cfcb2]:hover,.settings-wrapper .setting-list ul .infinite-loading .infinite-loading-item[data-v-dd7cfcb2]:hover{border:1px solid #ed4259;color:#ed4259}.settings-wrapper .setting-list ul .font-size[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .read-width[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .letter-spacing[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .line-spacing[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .paragraph-spacing[data-v-dd7cfcb2]{margin-top:28px}.settings-wrapper .setting-list ul .font-size .resize[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .read-width .resize[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .letter-spacing .resize[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .line-spacing .resize[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .paragraph-spacing .resize[data-v-dd7cfcb2]{display:inline-block;width:274px;height:34px;vertical-align:middle;border-radius:2px}.settings-wrapper .setting-list ul .font-size .resize span[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .read-width .resize span[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .letter-spacing .resize span[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .line-spacing .resize span[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .paragraph-spacing .resize span[data-v-dd7cfcb2]{width:89px;height:34px;line-height:34px;display:inline-block;cursor:pointer;text-align:center;vertical-align:middle}.settings-wrapper .setting-list ul .font-size .resize span em[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .read-width .resize span em[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .letter-spacing .resize span em[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .line-spacing .resize span em[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .paragraph-spacing .resize span em[data-v-dd7cfcb2]{font-style:normal}.settings-wrapper .setting-list ul .font-size .resize .less[data-v-dd7cfcb2]:hover,.settings-wrapper .setting-list ul .font-size .resize .more[data-v-dd7cfcb2]:hover,.settings-wrapper .setting-list ul .read-width .resize .less[data-v-dd7cfcb2]:hover,.settings-wrapper .setting-list ul .read-width .resize .more[data-v-dd7cfcb2]:hover,.settings-wrapper .setting-list ul .letter-spacing .resize .less[data-v-dd7cfcb2]:hover,.settings-wrapper .setting-list ul .letter-spacing .resize .more[data-v-dd7cfcb2]:hover,.settings-wrapper .setting-list ul .line-spacing .resize .less[data-v-dd7cfcb2]:hover,.settings-wrapper .setting-list ul .line-spacing .resize .more[data-v-dd7cfcb2]:hover,.settings-wrapper .setting-list ul .paragraph-spacing .resize .less[data-v-dd7cfcb2]:hover,.settings-wrapper .setting-list ul .paragraph-spacing .resize .more[data-v-dd7cfcb2]:hover{color:#ed4259}.settings-wrapper .setting-list ul .font-size .resize .lang[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .read-width .resize .lang[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .letter-spacing .resize .lang[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .line-spacing .resize .lang[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .paragraph-spacing .resize .lang[data-v-dd7cfcb2]{color:#a6a6a6;font-weight:400;font-family:FZZCYSK}.settings-wrapper .setting-list ul .font-size .resize b[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .read-width .resize b[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .letter-spacing .resize b[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .line-spacing .resize b[data-v-dd7cfcb2],.settings-wrapper .setting-list ul .paragraph-spacing .resize b[data-v-dd7cfcb2]{display:inline-block;height:20px;vertical-align:middle}.night[data-v-dd7cfcb2] .theme-item,.night[data-v-dd7cfcb2] .selected{border:1px solid #666}.night[data-v-dd7cfcb2] .moon-icon{color:#ed4259}.night[data-v-dd7cfcb2] .font-list .font-item,.night[data-v-dd7cfcb2] .font-list .infinite-loading-item,.night .infinite-loading .font-item[data-v-dd7cfcb2],.night .infinite-loading .infinite-loading-item[data-v-dd7cfcb2],.night[data-v-dd7cfcb2] .resize{border:1px solid #666;background:#2d2d2d80}.night[data-v-dd7cfcb2] .resize b{border-right:1px solid #666}.day[data-v-dd7cfcb2] .theme-item{border:1px solid #e5e5e5}.day[data-v-dd7cfcb2] .selected{border:1px solid #ed4259}.day[data-v-dd7cfcb2] .moon-icon{display:inline;color:#fff3}.day[data-v-dd7cfcb2] .font-list .font-item,.day[data-v-dd7cfcb2] .font-list .infinite-loading-item,.day .infinite-loading .font-item[data-v-dd7cfcb2],.day .infinite-loading .infinite-loading-item[data-v-dd7cfcb2]{background:#ffffff80;border:1px solid rgba(0,0,0,.1)}.day[data-v-dd7cfcb2] .resize{border:1px solid #e5e5e5;background:#ffffff80}.day[data-v-dd7cfcb2] .resize b{border-right:1px solid #e5e5e5}@media screen and (max-width: 500px){.settings-wrapper i[data-v-dd7cfcb2]{display:flex!important;flex-wrap:wrap;padding-bottom:5px!important}}.selected[data-v-a892cd6d]{color:#eb4259}.wrapper[data-v-a892cd6d]{display:flex}.wrapper .cata-text[data-v-a892cd6d]{width:100%;margin-right:26px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.cata-wrapper[data-v-6cab38af]{margin:-16px;padding:18px 0 24px 25px}.cata-wrapper .title[data-v-6cab38af]{font-size:18px;font-weight:400;font-family:FZZCYSK;margin:0 0 20px;color:#ed4259;width:fit-content;border-bottom:1px solid #ed4259}.cata-wrapper[data-v-6cab38af] .data-wrapper .cata{height:40px;cursor:pointer;font:16px/40px PingFangSC-Regular,HelveticaNeue-Light,Helvetica Neue Light,Microsoft YaHei,sans-serif}.cata-wrapper .night[data-v-6cab38af] .cata{border-bottom:1px solid #666}.cata-wrapper .day[data-v-6cab38af] .cata{border-bottom:1px solid #f2f2f2}[data-v-fff9fad7] .pop-setting{margin-left:68px;top:0}[data-v-fff9fad7] .pop-cata{margin-left:10px}.chapter-wrapper[data-v-fff9fad7]{padding:0 4%;overflow-x:hidden}.chapter-wrapper[data-v-fff9fad7] .no-point{pointer-events:none}.chapter-wrapper .tool-bar[data-v-fff9fad7]{position:fixed;top:0;left:50%;z-index:100}.chapter-wrapper .tool-bar .tools[data-v-fff9fad7]{display:flex;flex-direction:column}.chapter-wrapper .tool-bar .tools .tool-icon[data-v-fff9fad7]{font-size:18px;width:58px;height:48px;text-align:center;padding-top:12px;cursor:pointer;outline:none}.chapter-wrapper .tool-bar .tools .tool-icon .iconfont[data-v-fff9fad7]{font-family:iconfont;width:16px;height:16px;font-size:16px;margin:0 auto 6px}.chapter-wrapper .tool-bar .tools .tool-icon .icon-text[data-v-fff9fad7]{font-size:12px}.chapter-wrapper .read-bar[data-v-fff9fad7]{position:fixed;bottom:0;right:50%;z-index:100}.chapter-wrapper .read-bar .tools[data-v-fff9fad7]{display:flex;flex-direction:column}.chapter-wrapper .read-bar .tools .tool-icon[data-v-fff9fad7]{font-size:18px;width:42px;height:31px;padding-top:12px;text-align:center;align-items:center;cursor:pointer;outline:none;margin-top:-1px}.chapter-wrapper .read-bar .tools .tool-icon .iconfont[data-v-fff9fad7]{font-family:iconfont;width:16px;height:16px;font-size:16px;margin:0 auto 6px}.chapter-wrapper .chapter[data-v-fff9fad7]{font-family:Microsoft YaHei,PingFangSC-Regular,HelveticaNeue-Light,Helvetica Neue Light,sans-serif;text-align:left;padding:0 65px;min-height:100vh;width:670px;margin:0 auto}.chapter-wrapper .chapter .content[data-v-fff9fad7]{font-size:18px;line-height:1.8;font-family:Microsoft YaHei,PingFangSC-Regular,HelveticaNeue-Light,Helvetica Neue Light,sans-serif}.chapter-wrapper .chapter .content .bottom-bar[data-v-fff9fad7],.chapter-wrapper .chapter .content .top-bar[data-v-fff9fad7]{height:64px}.day[data-v-fff9fad7] .popup{box-shadow:0 2px 4px #0000001f,0 0 6px #0000000a}.day[data-v-fff9fad7] .tool-icon{border:1px solid rgba(0,0,0,.1);margin-top:-1px;color:#000}.day[data-v-fff9fad7] .tool-icon .icon-text{color:#0006}.day[data-v-fff9fad7] .chapter{border:1px solid #d8d8d8;color:#262626}.night[data-v-fff9fad7] .popup{box-shadow:0 2px 4px #0000007a,0 0 6px #00000029}.night[data-v-fff9fad7] .tool-icon{border:1px solid #444;margin-top:-1px;color:#666}.night[data-v-fff9fad7] .tool-icon .icon-text{color:#666}.night[data-v-fff9fad7] .chapter{border:1px solid #444;color:#666}.night[data-v-fff9fad7] .popper__arrow{background:#666}@media screen and (max-width: 776px){.chapter-wrapper[data-v-fff9fad7]{padding:0}.chapter-wrapper .tool-bar[data-v-fff9fad7]{left:0;width:100vw;margin-left:0!important}.chapter-wrapper .tool-bar .tools[data-v-fff9fad7]{flex-direction:row;justify-content:space-between}.chapter-wrapper .tool-bar .tools .tool-icon[data-v-fff9fad7]{border:none}.chapter-wrapper .read-bar[data-v-fff9fad7]{right:0;width:100vw;margin-right:0!important}.chapter-wrapper .read-bar .tools[data-v-fff9fad7]{flex-direction:row;justify-content:space-between;padding:0 15px}.chapter-wrapper .read-bar .tools .tool-icon[data-v-fff9fad7]{border:none;width:auto}.chapter-wrapper .read-bar .tools .tool-icon .iconfont[data-v-fff9fad7]{display:inline-block}.chapter-wrapper .chapter[data-v-fff9fad7]{width:100vw!important;padding:0 20px;box-sizing:border-box}} ================================================ FILE: app/src/main/assets/web/vue/assets/BookChapter-Cs3stH93.js ================================================ import{d as ce,a4 as Je,U as Fe,a9 as Pe,o as g,e as p,F as se,h as t,z as K,R as le,u as a,aa as Z,C as l,M as D,ab as Me,N as be,y as Q,f as H,ac as Ge,w as z,ad as qe,ae as Ze,H as fe,af as Le,A as Ye,g as q,P as pe,a8 as Re,n as R,ag as je,a6 as Ke,ah as Xe,V as _e,Q as De,a7 as $e,ai as et,aj as tt,c as ot}from"./vendor-KSDcS24u.js";import{u as me,i as Qe,A as ne,_ as Ae,c as Ve}from"./index-Wr40-hHf.js";import{u as nt}from"./loading-C4J6hIxs.js";const st=(c,s,A,f)=>(c/=f/2,c<1?A/2*c*c+s:(c--,-A/2*(c*(c-2)-1)+s)),at=()=>{let c,s,A,f,n,w,m,y,h,F,P,x,S;function V(){let u=c.scrollTop||c.scrollY||c.pageYOffset;return u=typeof u>"u"?0:u,u}function U(u){const i=u.getBoundingClientRect().top,d=c.getBoundingClientRect?c.getBoundingClientRect().top:0;return i-d+A}function C(u){c.scrollTo?c.scrollTo(0,u):c.scrollTop=u}function I(u){F||(F=u),P=u-F,x=w(P,A,y,h),C(x),P({v3af0ffa9:y.spacing.letter,v316cdf92:y.spacing.line,v3ab99b0b:y.spacing.paragraph}));const f=me(),n=l(()=>f.config.readWidth),w=l(()=>f.config.fontSize),m=l(()=>f.readingBook.bookUrl),y=c,h=/]*src=['"]([^'"]*(?:['"][^>]+\})?)['"][^>]*>/g,F=i=>i.replace(h,(d,B)=>{if(Qe(B)){const W=ne.getProxyImageUrl(m.value,B,w.value*2);return d.replace(B,W)}return d}),P=i=>{const d=/]*src=['"]([^'"]*(?:['"][^>]+\})?)['"][^>]*>/,B=i.match(d)[1];return Qe(B)?ne.getProxyImageUrl(m.value,B,n.value):B},x=i=>{var B;const d=(B=i.target)==null?void 0:B.getAttribute("src");d!=null&&d.length>0&&(i.target.src=ne.getProxyImageUrl(m.value,d,n.value))},S=i=>{var d;((d=i.target)==null?void 0:d.tagName)==="IMG"&&x(i)},V=i=>i.replace(h," ").length,U=l(()=>{let i=-1;return Array.from(y.contents,d=>(i+=V(d)+1,i))}),C=D(),I=D();s({scrollToReadedLength:i=>{if(i===0)return;const d=U.value.findIndex(B=>B>=i);d!==-1&&Me(()=>{te(I.value[d],{duration:0})})}});let v=null;const u=A;return Fe(()=>{v=new IntersectionObserver(i=>{for(const{target:d,isIntersecting:B}of i)B&&u("readedLengthChange",y.chapterIndex,parseInt(d.dataset.chapterpos))},{rootMargin:`0px 0px -${window.innerHeight-24}px 0px`}),v.observe(C.value),I.value.forEach(i=>{v.observe(i)})}),Pe(()=>{v==null||v.disconnect(),v=null}),(i,d)=>(g(),p(se,null,[t("div",{class:"title","data-chapterpos":"0",ref_key:"titleRef",ref:C},K(c.title),513),(g(!0),p(se,null,le(c.contents,(B,W)=>(g(),p("div",{key:W,ref_for:!0,ref_key:"paragraphRef",ref:I,"data-chapterpos":a(U)[W]},[/^\s*]*src[^>]+>$/.test(String(B))?(g(),p("img",{key:0,class:"full",src:P(B),onErrorOnce:x,loading:"lazy"},null,40,rt)):(g(),p("p",{key:1,style:Z({fontFamily:c.fontFamily,fontSize:a(w)}),innerHTML:F(B),onErrorCapture:S},null,44,lt))],8,it))),128))],64))}}),At=Ae(ct,[["__scopeId","data-v-d8efefe3"]]),dt="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyAgMAAABjUWAiAAAADFBMVEXr5djn4dTp49bt59rT6LKxAAACnElEQVQozw3NUUwScRzA8d8R6MF8YMIx8uk47hDSJbj14IPzOGc7jPLvwTGg5uAYDbe2tt56cLtznvEnS6yDqCcEaWi91DvrbLJZz7b1aFtz1aO+2OZWvn+/+4CHeB6BMYaqBLfjPNRY6RFT2JJYby+uAk4WUTrtlmJ4hgPYb2q1XGDQjaK8pgJHvqNaAX+KyuIkDXpgQinb46nOulnn4b5laUHTxLfseeArAoNOeJlOIjdoal0n1FA7tKFv5roK+YaHOqP3P0XyKHPHY+MhTRe5uCZnKhtJKw2eSrSoBDPLtpZuNcFNJcFyiCMxOaaHIfXz1e8HQbWLySrBQ4x0x1qlhnHlnz2HQEC6TNb0gTHXa7IKhcaHqkE015hk9whA0YeWiLIXf7Fa2CZo3DjqjB4tTuF8jIcbfcEx5z/w4sXpQhXW+ju0cqh7icTFmRMaG+v6CIvTjcSpHcH8JEsF3EPh3fRthYdVLLgI2fWXm85/pGFE4l046s70L+yKCcirGFR+jbpy3kMmiCGHrSezVONsn1RBixncyk2PcVWk7DlgxHo8iZwDyq5uAUD854dZhdIFYzKoQig2haUKi1lVufz2RZUZPZ41n/hrOQB6h0Hhg8I367FNoEHgeM/KY7szSeQwD8q2WE3HM35ZLl0K1MJiOtHIkBclRQUwZnyOWcNsRQQgVLj1PSqkjF9DsoOSaSg3iinKzvfmgsNFFfpP/2T3GLGvL4fHEfwIX1sVvXcPqLztehWGcfn9nI2U9nTfCgJPe/jFPLZwgVEzimBgAm0VIyK2tt1cE/AzQdLK+SxLSQ4aDCZnnId94OG2S1XwvnTbNk/ZnhyRCQT+sZM6z9g6LXL1BOBe+zJySiFkHAINCtnQokbCJ/apCv0foqPiZVfhpywAAAAASUVORK5CYII=",ut="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyAgMAAABjUWAiAAAACVBMVEX28ef48+n69esoK7jYAAAB4UlEQVQozw2OsW4bQQxEhwLXkDrysGdEqRRgVShfQQq8wOr2jD0jSpXCLvwXbtKfADlFqgSwC/9ljqweZgYzQFnb/QGepYhA9jzmTc1WaSEtQpbFgjWATI00ZZtIckXx8q2Oe5yEByBy+RHOTcM+VVTadULsvxvRC/q8WTwgcWGD+Mnaqa0oy2gw2pKFzK+PzEsus5hP9AHojKslVynLlioVTBEN8cjDNnZoR1uMGTiZAAN47HxMtEkGUE9b8HWzkqNX5Lpk0yVziAJOs46rK1pG/xNuXLjz95fSDoJE5IqG23MAYPtWoeWPvfVtIV/Ng9oH3W0gGMPIOqd4MK4QZ55dV61gOb8Zxp7I9qayaGxp6Q91cmC0ZRdBwEQVHWzSAanlZwVWc9yljeTCeaHjBVvlPSLeyeBUT2rPdJegQI103jVS3uYkyIx1il6mslMDedZuOkwzolsagvPuQAfp7cYg7k9V1NOxfq64PNSvMdwONV4VYEmqlbpZy5OAakRKkjPnL4CBv5/OZRgoWHBmNbxB0LgB1I4vXFj93UoF2/0TPEsWwV9EhbIiTPqYoTHYoMn3enTDjmrFeDTIzaL1bUC/PBIMuF+vSSYSaxoVt90EO3Gu1zrMuMRGUk7Ffv3L+A931Gsb/yBoIgAAAABJRU5ErkJggg==",gt="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyAgMAAABjUWAiAAAADFBMVEX6+fP8+/X+/ff///kbczPAAAACeElEQVQozxXHQUgUUQAG4P8936yzs6VvZNZmN9QxVxiF9OLBoOjtOC6rQq6ygXjI2fCQBdXBg4egtzFGdqkoI+zgBFbqkm3hQSxhFYLotOcubeKhOnVYoqQy+m4f5g5TvpX0xHLbLY9j8SMhJp+Jk4LfAUS2kVRIjILmnwGBTX42PhCVlDJQkIiy2nWAvaJ1h+oFIpJ0hMSYVbyyrgDWshcMpMyL1brPDQKWmduO+KTJ6XeXAMK9Yc3FpD7atyNwg6kt5XgFpLPhjUTFSYVn2abDiugGShwD8JTVRJVo/2ecuKtRb/qc4BK+9TboFfokog4T2Fn6Oqdnsjk90NMS76Rji6E0NmwkPBAZ4Xbkw8KoDAkAbEhkc78e9omxxgxg6qa5HvMv+UZbCV0qmHnSHKl5TxeA2XTCGWekR581mwC5crBH81PznASqB9va3TbkYAjJPLfg5uBfXaJgIgIBv9eessRIhxe7PA7kj6uUMeMaQ/OEQOYRaaHlqH2Gxwsl6E/pwVY5FH7uCypBZPKvDQyVziYBrAkMURe2MOOOxG/eQpp5PF+bFzUV5HtPj9GeiVSNZDELleifYTp9NAjsoiXg4cW+4ZORkdSMB/B74aAdjhsVakhgkugsbmqcDSLEoWp8zRjrux3tli6Q5uM3E+maT99Wy0RiP7tboiuRZle2c6CYeL2kcUc1KvPtQKucogMadKVTQOJYCeyCYlhQQ/Q7Etfd/vBygy9iqy+LyHeF46saCYvW6ingsbA9RBWtdi8GgUXW+oQx9/wP6bAAX1TWeV+CbShZDlQ9xT6SoSxZmKRAkmXb60kzEzkRF+Ccb94BGspGJoN/UzmyR4wjXHAAAAAASUVORK5CYII=",pt="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAMAAAAp4XiDAAAATlBMVEXdzaHh0KPgz6LdzKDezqLczJ7ezZ/fz6Dcy5zi0aXdzZ3fz6Tfz57h0KDg0aLcyZrg0KXi0qPfzZ3j06bh0qbdyJbfzJrhz5/cxpLZwo0vDconAAAFn0lEQVRIxxyPW5LjMAwDAT5FybLl2JnM3P+i6+wXWVC1GoQGaD0h4XM3Q5o4T0HgABHBi6pZ4CDXXcUOFd6VhqC3Kch4EI8w9oMXwvU6m5LOOvcxKMOhuu8i5+5cMjcgb0t4F2uvOoeI3/MlT4IqsbtM9UG2AGSXUOsxzPevnXzK1CSHytZLvx7VdQmUcJsJCxJh2nmHW12Qod1qPjt8pih47uQ9aGpoNWF+yElCt60oH7vdIU/MnlRPSBLC/VwqxcKR8PFqnADN9ih5ufqnTlG9KwCofvs7kKYqOPHTNMQ93j9qNImFw9vjHPZ0F1m8hUUVB/Q/TrRYDMXr9++APMFARAt6sPh6wVAXzxUGhZsFUwCNfPZ8/72TAHebAhvuOuT3gO1Vn5d9Jd5sBRkg0p2seL9B7ulkjFJFIt9HPpLzdSzzMP3UcodAfMqC6pBuET2heHK1itZf1GZ1bi0BwOSxiCS8f/JBHMPMM4XCu3Mt1uz9lJbDJRqsKDZuikzkvskQEz6hanfDfO494azY5JpqPqOF1RhxD9XYEdaNxiqWqakKgmPfmrsta8KAiwF4HBxGVUJAgeSqQaiRRZJ7D2jedhw5t1CIAKxag0CBA60BpoBE6DcUi8O5AuM4pLfN0kHLmeu2B4e6HofqbgxsTWUw3PAODqa1oDtyzgXBlusi1KFdclMPE8O3jvLJ8RNi5/RxDQVzVmXA233XQ4KummunfxvLOZo+iH37964YjP06995CTdu9hsvErqJNzmf4wTrZ5DL7+qW9EoLnadrx67b8dUtrJnBXaT1N1uvPaYRKpWkq52xNsMN7vv4Sdryt/f4MhQoMCKnvVxikai1CQ6ZsnwJDc8+3Y/z8HcfvYQNq66pnAu1Hwa+3KNSwbNu8h3nDPqTl9fl7tx8fBhFfdS0o0F3JUKEZtZG9b/LZEM95lzaR30OnWPzroMxyZYdBIMoMnpN0J+m7/40+/P4soFSUjgzE7yY5zrMJuoZv0CmpVguYx1pprfb5HOviRVhHUVi/352shxCYrYBZxGtVaxiAz/MsaGSIsB7R1t4zJXH//n7RTTQQwxqcGEqEvklFHUgiO2GvJV+jAIPR+N29usWDoiSOVrN3XuqT1egQJAAU9EwslVJC8u0rGcy+WPqktJhjfMpatIG6CDAb0v5H34MGKqiVRue7GGLZ9Otxtt4JIrAhxBDwDuqI9JavcO0A7GlqFt219tH/bln9jBXzaKWAEqJV0CBxs5TwM8EvUPHaa8S86vN303MVWOsl3goDBHPWSoQ9c0kQmCKljfsKNH1+ofEOHW8a9a7glZGS8fPieL/SRSs0LAhI4FDTnXs1QYtubv2+IXPZpHB4bhivRexBkYKsSrYXNjvMUbVXpVJ+N6haV72c1k2zrnv5IYBMJBYTSZx0KTkoM3vY93rU/qs7zHplc/3d2ACadhFWByrn9LUk2IWb5JywvawTQc3F0iz+lgsBmInAIemBJtft2plKIlAFOgcroigrG2XlDsAzywQECNyaI8yr2ogoh7D4qJOYmZBzQgoZAM1PAcB8sDrr1uE5CDMR+nWSSVUGUCHAs8Vd21HOE0FzNj37pX0sLp9p3K8k++xxpkmzDxK64rmTSJnDUuIgTeslui6lg92jonZXI4jqNiUuzN4IagcKMjCniMGCODoo8T4tGDprn2hRww+NrnYiCwokd9iiWrkmbRfXYGLAoZrjO1lVQKExjUy5fIkgJURmz2uGFdASwwlWx5gDVTMK7hP6ISRVsFbYNmqtZL9MQtio285PaekyzDhZmtdexCYB0SZcTmBdhvdbmAEonk8hwcHQuZN1kVqrhyKoHHsnQhQAjF7SG533Da2S4LGjx1LoZqp7XeKQLDUBmYmydG0NQHpMeR5lRIRQc1PQ2ASMQflF4YBDMt0/GFlEHeRwCcEAAAAASUVORK5CYII=",ft="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyBAMAAADsEZWCAAAALVBMVEXx58b168ny6Mjz6sn06sf27Mvw5sTz6cbw5cLy58T37svv47/168v37s7t4Ltrv0//AAAEjUlEQVQ4yw2Ty2sTURxGf3dmOqmPxb0zmaStCnfmZpL6gpmbxIpUSMZGrSJkxsZiVZimNVaqMklrUnWTRq2KIDFWWx+IFrIRFxXEB4KIgqu6EBdu7M6FIPg32PW3+DhwDmBaYrK56KP4HGIsvg/uvOV0wK+qgBMlO9BujuH4DSJlOseqV5a/BEF97gt0ChyIPqBhXI9BtqtIB8vJB/LdCQ3OVjaLNX0g7+OmoI4e7nkemAqX6o8vg0yyQAyQS7IfgvFbI+6QyI3R4KELxw7kwM2ooQfyQigYnwY5MZbMlHI1DvnQVCoVcrt+R+bO7vPDif3ybNajwqAAe443dpfDsPt379VMWZzGRuqM79mQF+DUz9nt74bQ8J/O80MtVR51U02JKKmTCvTzLVf+vuxP/aHnPo9+2bW+zVsJ0Y630/CrfzX+b+UL+7O68Rczv+7lrMh5etfKXvhc2rk6KforxuoO2xB2tcxKfeXHt18rHOiHI/0RRjW/YGRDkHiwo3nzqL60o58C/bgRuaj7vk+QOwOhpnFNdjuWpKMCGP8Yapu9Ty5FTHKQLGSEFikjd9ADwP9ciaNNjc5qMH6w50AF/LKOsOYqsOG9GjKgc7ZXolqntm6fysJ6Ma6ll2CiqmOgE6O7x1wXExklbeqMYcwsmJmOoigt8SBg2WfilDSsAZJcBxDcrqtBXzFQJqZNHfscyIhoZlygAtyYAceah+elrFbI+46gEHDGiW878Kj7JpWyfhg6iyRMymV1MKBSeVpfgLHIohyTojI6sRyK1VpcqzVZeEBLOnA9unhGKUXPJDYtV9Dxuz4iA5xSkSWhCJdAiJR9PHlvfvbntbrR14FDqUNRAYDJmSnv3oKxuz5+7fiblgVJyYLTbgUM05P7LESkoXvyWNfb0aUU6FZizgQIa25VqKQZqFrk6v6BsqqIHlQmkQ9KrBhkC20/DrFsAFEEYLjM+lj2wYHXCwnNvZQR42XJ2iVK+UBXnI+OBE6oXpUUHiQ1yg0MhA03iwGbnOdQYc1CMiPIPQrCQJFH4L4BMFktAtKd9PN5gnU2Gra4KuK+V+mjtBRpAGIqDVe4wnSnajiFGO5d7smvhVQEMEYwqshrENIEaY7YeblJYtsb3QhAHWZCEKK67swwPMKw0If1Ta+6DgHmlgPzcUTSbi3rrv1Y64/BYEMPQ5SDHUOR022B4QRF6xLUPAaPX/V4IDI5N2BMwx4LqO1uO4j6uW7NvM7lATqGAxY/ZHVgoGZbu7SvkNR75x6qGSB23FdouENVwN7sCbewTdsXGrrnQ5ZZKOCOFtMTIzxlPu6eYmtL+nMFmoK7OeXajn86r9sqWbfmvHC4IagE5qfCPGZvLSq5F55hHIxJFa4/vRxHBlz0og4TojU1l/MOHJX17lybdF0mQhFO44JYUNt3UA473IXw/iPfDWtKG5oFSXIF5iU/VnyDSjxxeDk3jAXRyVyGTNB9FxH9qcFDNJpVbt2y9LytUXkK7Py6+z1RezHQqnoY8XcLimmd8dCnBhQCuaGpJCq3SoIlmYvLz8UkWhJw7T8k+Db/DYEKwgAAAABJRU5ErkJggg==",mt="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyBAMAAADsEZWCAAAAD1BMVEX48dr48Nf58tv379X17NJtIBxUAAACFUlEQVQ4y1XRUZakMAgF0Af2AiDWApDZgHZqAV1nZv9rGh7Rj7Y8McUFEg1wvcMESMNVD/neU8Xcaz7nYYkYlYO6Ti82PBI4BvIEg1aj3wKwRvIMgZsUy5LdhCawPFh1sZs4SrlyN9fQKpv8s5dgZ2eLyqqJiu+WkCmUEybXkm3INS01WAiv0PapJ0CZc0SJQUzcWnZYbOOY20iFD8Bk+/j2A3wNxH7GdShFYS5ff237kXh9I9zSkQmIAhOsOSVfJ6DIXTMDaPnzkRJ92S1BQQmXl5LdirgRLLDdcYqcGPwe3QN4xCBiGNbrqq9wpW1XCecChwaQdVOsRDpPCpeoolPdxeXp3WNB9PHVzWBHlygy4NJCCrFHREv6bDt0VGwJZASkpONmm1UseGeFKAQexgaAkrfYWl3AGxWOLL2AIMBNbCXpktmS3k3vHeYjGCPBa43wJTurO3ZFVpQSJdAZGLoHTyk1upkjxMEaIxum3iIARcCa5kSkFAW5fi1mUlL9eyOsaanFmOMruwvEdE3ZYzsRSzo5ewRLXyVPPEvknt8ij4DvCg2O7xOgBCUprEzV4z1WekSpUgI8DT2mrnSOXKRfQavwuKA1F+tFnMKdJSUpMA7wQAifWRkMgjUKKZE4lBl6MCM4B1pq1P4uIjDE6Pq6rL0FnW1nIFmta5vrSvq/Ch4tpqG/ZNyyWa5jZPktq81eYv8Bt5s4iFITOp4AAAAASUVORK5CYII=",vt="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyAgMAAABjUWAiAAAADFBMVEXN383Q4tDP4c/R5NEInCCXAAACVElEQVQozw3Hv2sTYRwH4M/79pJ7bZL2bXqtERJ97zjUpbZDhg6pfC8qibi8hLR0EaJ0EFxaCSWDxjfpj1zrYBcRBKE6SAfBJWsx9i8IQfdQxDlKtA6t2OnhQfN3lbG7ytYRywF8rVoPCNO0X2sQOKDpAnSDK2VwkHgmh5yLGT8qASt+2KofnNt2Xg1gf1UF8AoM6052cRMNaloLZb7RKQGrKKji2OefsZF+VqIvos5ZLVIZCX61JcwUdk56wASVkgQvzPfvmT2twTSwyYaC/Pl/UhAHorFhBgZtL6XdAZRp1tkPwC1NLa9CWs5prLhI85NBQsLdXvjDymG3/EbYfQhVNYqc3TtktQhWLY3ko0QsdMbSEp+64v0NfxyqLbIGdh6M2xHHlLBGqKTyQo4E/nebBgBfe1GpdeywYXc8CT7D3cKXuMXkBy4xN6o5OuKamYp3DVI6uccO9lxgd2CAlJgI2BGgaAgIJV/TYwKqu3WFccjbMuA+bVkWgS2bfnlRbD1Eb1sDyWMmjKYIBgGAWbqKRicfvzBkBIz3V5AKnguWdglQEysQsSuVzOg6ALy1pitA5ykGCsc857BRYcgCSZyFOdvoOigSGoPc5Ta73mgxshIcQE5sHMHd9D7yqITw7JO+GHVMxjhzYLcKPSEgmz3fU+BRy3iYNtiXLaBssCW8KguReqkQOTb3MStV0Ugt4U1eIs1RZWRII6Ww8xeNNItyGGQI4ZMlpg/3lQtkl2JFnBp1imRyFe0kK2Id3PCslMgiQNMS77gvFeDhG3cSkYvheeg/e7ClIh5oh+IAAAAASUVORK5CYII=",Bt="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyAgMAAABjUWAiAAAADFBMVEXh7eHl8eXj7+Pn8+eTbH1KAAACPElEQVQozxWPQWrbQABF/0xn3JFKQRTZOIuUsbCCbOgdRoYEOauxkYPcTRyTlPQWIxEltrsRwQ6hK9nEQek6F+gNTE/Q3qLLusv34cN7SH3mFicdYW4gNIhJWXPBRVXzjcFD0IqeU4o4PRbAIVjyico0vJpIifqPfL80QN9DAQY5ucRHE/hpHxBldXe9GilaHKcKMlj6pho2zXgkNdBl0oJ8kiF1DSiJF1ZHBJkQr0Dbux/5I42Zp4cFahJDFGeW6/QjBwmFY/Q7vZ2SnoOdW2parv/Cnm81+m0xrEfiVXQ3W4nOXIqVYi3l6AAQBwMFkViVBANMto4enXHPNTkHBB0oVj4r5vHzCWayrgBvxtygDlDB2CNDjd80ZInY69aKVYZcfJ8DW+fWuc+syEODALx+ojqoafHsthTI+ZW27PGpIeo/cR6YKcbqIuIFhHmBrzAovzIOOJk1ucvcDzrMRYGVBH2yvcAOf0KiKwfRovBI3tm/kW1eemtfNWwIIXE2mJNhvoszfmMBfRCv0OPwd2321uDW3nx2q/BDxFVeoN1g7a6Im8yRnoawa8kbdXnU0cHeTMxKfZGlJgvLb3sKsxgglQnDdAfvj9LUnqWRDo0GiUmPwyU7TAsD7wHeIW3Nfy1qVGKoE9NgJCdYCAexNRob9yCn4DAQmXtQuUtera6bEmTTXhZy6h856xi4mnEl6BI9mfISkLbtJyZIMJIAUd5ZOBEu88KRAk71yxfItj/hpIB0Errv4gO1os4/UICf+o3kkqwAAAAASUVORK5CYII=",kt="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyBAMAAADsEZWCAAAAD1BMVEX0/PTx+fH2/vbz+/P4//htSO9OAAAC5UlEQVQ4yyWT0QGjMAxDZTsDWKQDmJQBYrgBUsr+M517x0+LRWw9CyA+pC1YzndrMgHaNXVKQ+di13Of1qbur48nWhuRjj8i6ON8e7pNm7zyag/DBTfS9Z4Hup1fUuXMKY4HEE8QOHCByXkIkl7lDT239RtL9quO4JItmmhOAHXg45QuYKrQFLyGJcRvaTw6kQqZy6mkR6JAPFH/XqsQjEDRmUOA+MNLHGyMUT7AHApoAhjgjIJmCxy6XHdf648AWCdGe57IUDazCeTImQOY4/z+eVYVX2IjOw9RydeAeJwl79iGi4HpgQgHEchWraUZLtayu8scq0lHHHUKMY3Ml8hB7CS1jOckDLG9ccgNeX3124phOcjL9fPnWJhTXpLHeG9DRmHnTxHEaHakS2J51lwAJcUraNbuU7q4gMTDQj3Eripc/x+qFM5VEKAB1roQfAkX5/PxqnS2QpOrxfK1Zft0/omV5T+xCSBUAIbEIwUQgvAfxFE1O8dnk233+1UZiqJ1mAbsue6Yt8tF+yOrxC/YrUhzC4qPlE3EbR5hGKhhHdlrg7J9WunV7L7BcYQwAeE59u2tnN1c6gfVYrQiLSZ9OxZdWDXQq0+r0Pbarh3UqGCwauVvbiXuDsNxCtLDdW9rTF8oQYN4EoXXdfmwNguQP26n/tRjDeo+F2W7PjWtfSr6Bn/z+cXOLp4NnMV4RytvSW4B68m+XN9XfZTFGhO/S+cHTuTqZDC21ccA0N7QsePALaDQC3D1f94U9CWo+aq6BjB3v0rxIimBM12296M3aKPHjXLQE9KQKH4By8RHraJ3AgVto2r4xdFqlaPaiAHLl1ZF4P2pI6cYc+K8UZdcmxy7lqGc1IoPxLmIFuIeEZ6j2sQT88muEg1zwrEDTIX5U/ZmcsqfgVlBumiBLF4sAyhf9BFlXOPKLZ4H0iFb3VoHrGhtHTldKrOvP2/reu2zfV8CXMPqzRdlgd0a5eI7WwB/AYcgavcqxXWEAAAAAElFTkSuQmCC",ht="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyAgMAAABjUWAiAAAADFBMVEXM2t7O3ODQ3uLR4OTDp25yAAACdUlEQVQozw3P70sTcQDH8c/3/M7NG+j35mnHwjwh4hRy/QFK3zvPNbeIG1koPZmxfj2IDAwihL53zj0JYisfmEHcZJZOiBUG60lZiI8T/ANusuftgQ+kCPIPeMP7hS5mUrV9c1g6MQCAEZ8tDLHwofImAGRlX+SZK3Vu9rRRPuO4PK6/9nA4GIATsxlODS+rdCMhkAZivpYV0LWoQHSLSA4NfUg+6mY+7BKL2++F9LvnrBDYm6JO9i/YO3i/HJTGQ4pdIV82TbEDFG6vGYCd4wZchgK5J2CrKTLE+Tx0v+YGlIbdWJFcQl4ptBN8fUJQN1MCJLcZLYwUVVo+famGGty8EXJF5ofOEDzcodT3/Fb0I5sHmc1ZG7CcSl8COgxlXx09jT05OafjCZLIHJhGIaU6wDZHsuMQ41wbdjmQXbhKnMq1zlXSYrjCnyZblqexA7fC8RxS74tq2P3OxSQwTuJSApH8OZLzBBp1pOe0i3rdyDUA47GySZ31YmC4EQYSXvFSvieORGBxXF9aeVtUWKGS9WMC4Z9Y2uXnJ2nCUXVMbPOYqNYNmGWWQ7Evr+BWC+a0JAMTImcq/S4Z5INdQMeuOqDIMa9beilxfA60iC6sP1INcPDpmHBW8drZHNmqwyddJtVje9q8WGUgWAOzmbU4FCQBFi8B2Wk6pickBnYhJMenmJGuRmtt2IoKq9NuFGbNFR99sHnvrnLsLysKANDIsxbp6RNMAsoDSKuRpMwZbAAzI68QatIjmZ0aImyM3O8/4e2MNlOHZomFsa/fLDsysliHS+nlYLQJMnynxrH8QO4PaAV2Li8B/+52UgeGIVNFYf8B1XG/kFSmLcUAAAAASUVORK5CYII=",Ct="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyAgMAAABjUWAiAAAADFBMVEXh7vLf7PDj8PTm8/ecW+lZAAACZElEQVQozw2RsU8TUQCHfz3fw7MS87jeI7DdmSMpDEoHE+P0HqGkvRR8vb5XC4NpN2RQZqcK9xJkwtriekcggerC4OZADDiT+A+goxv/gfwB3zd8H/T6vYF/pTZkCSmDNd3CBEtmZJP4N+CvvhecDvmntKsvwB17rpbIRTLOEoYkj9KZzRUuJsuBQFwgptyJ3Y7EL4V+ud5LO1UnMeQSSObqisiISZkbQBlliP3qWSk3GPQXjxv6VF2BTDO4ySx1zhuJXbA2wBNJF4t5vH9keg6wu5NvUpLtXrZ3OHC9ZsgVcZdOl38PM1y/L6m8GRiErj4AqezUjHGatGGIgs5NJDHh8Ua1IuB4035haVT6SaYWMoQ0eJ3rB/Gpnr3fB49YAy1Wa21YKqAHOmAveVw6CCMGMZh5bGtVI7jnZaiQNbta1Z+285oSoKoRbta1KZ/1bBdKH/RIxv2pRVpkoCmvpr097RWoo0CpMlTWllIenSjECU8mV43mHx2fIRfH/pncrJm3+58BWdbSqCS07/yiQnvHiCG4ZPGRFeAtfreoOubyctzHvLNHhjNvIhukxQzjU5O6QdOEzUp1Ef4d98Pxz+IPYX0bcpnT52dbedfz8y7C4R89RV+MjJkuCCx7mWDt4eyK/62lQB55xXGJK7p8u6bgRv4hVHylelYGGFs64W94tng8sAIVqSRJBpqRA9rFvAysS+9ak8s7557pz5HR4qhCRmWgplpTRJ+bhYfSAMO8/YBucWPuSdmFFtOnuWqvV2NbF6CJnbhNDzEZ/T0XSDrUydzkZCG1z/oIEyUFYxW/KPXNfwopuHDcO04UAAAAAElFTkSuQmCC",yt="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyAgMAAABjUWAiAAAADFBMVEXm9PXq+Pno9vfs+vttWKBGAAACPElEQVQozw3RQWrbQACF4TfCMjPqZgIj4RRaxsZKE0PuMBZ2cLKaCI9RDAXFmJJknUWWI1O1UlamOMHJSjGkuFn3AD2Cr9CepDrAg+/xIxK4QwIqHHQkUhQ/WuphInVIFBojl8QXc012Tgq4RTtVHWVLZVFh1tEoI91uiN4joCqde8Ukn/zGM1B2W4ari2PtTwyw55Ld+Wways54qhGPyS6FzbIT3lIY8WwWdCq56Yolx6KmSKzoqrsCB5heAp4TGNQWJ1Pc6XlE5jQD5OlIX9I47A9uiUQcPQxcury/ToyxWJG/za6ki88crxKPocKS59Sl3EtBG7C89fCGflpfqoSzCeC4crioJA7F0V5+8MaSIk4qSCdwzpogmbqzEirVpGiS2dOVJvUuuqFEmhHao06KEpq+8lvHI14NJk3Qrmi9vBuRLwAz0qZB4hsDXQFXgtnlpDX3C6ug9BquSw/CYtwAzuTz5vuQNdr/YibhR68378ehZH30FSpjh71LpQkrsj+Q062h5WwZ5wlRoD6uQJy1DqvSYuCUapMBqT5YA4ZFw4KlWapxoUGlKWrx0eDQvmigu4WMYt97ruru98fYL8/0lG6CTOFcFWBhFK5gKw19h2JN808nh7xhkU6sWKLXdtkqBL6h+lULK5k19wFB/FldnGYf3LDeuf6IC2/MzJOSOP0qPxLqzaGIqtBcFIItrstkazONOkrc1D1czjuwEGESB4JJnjgSMN7PXAu7fZQpl1C236C+9mM4Af8P98Ch4R2TRl8AAAAASUVORK5CYII=",It="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyAgMAAABjUWAiAAAADFBMVEXPz8/R0dHT09PU1NToNyAhAAACdElEQVQozw3NP0xTQQDH8d9d7sFrG+QeKVgQ4aoFCwFkYERyLY//0UB8GNGg1WAC0RBGJrzW4mCXQmpgvCYOwEAYiulSpYtza2KiW7s5FgNJFSV2/CzfL7RwpoJ20iadmgA8owOyaxmusKE44scBeb4vIv00dqYgmf6jzWcr7W6INbDQeZbQL9ytXeYgtFfzmW1Fek5msxJlwhyt6qDDxOLQzpVPompYrMPnEnhvLm7M5BxY5nowAj3zkydAkpC0FIG6g7AK+Ub25ybyNWVYwtpseP2rfrQwiGRpfqrnMuPeuvr2dA0p2YsHF2XghkrXKtZ8tLBjR7S2qIaYbKmyLd/QP+EogLjqqwNw5Lq1pDlMLkM5+gNoSvdq+Pxmz9/61EFq6GYM6GqaGvlN95zy3gsmEWI8K3k8OP9OmRLEPO6DP3Wv3g42COinJTZ33dcIvs4ESp6opMTjDs6mcYTEbFeUifuxh989yZrIx4lkpuixxz0nHLCekKbE17suKhYkMGhoYhTZtVBvg4bfq/1L1Im0AGMVpBFwumM0zwyuKiCMi5dqR4Flx47AGyF2xTbxqUdTwCH94BT3DozpLV5WuAL/N8rGtHKjotBOOuOtCJ9E21uqsyBoLOzaXbHPrK5PQBP+fBfeidvJAeMIAmzVt5IkJJ9DBWaZDAepYUhlQqHt0h72SJ3j8TZHom64f516xx9T5evgMPgwG82jZdJaJIDyWp6LAjOCclVyzNA3iTKzIULlBQEPaTXlPHok5gISclmyaWZlqY2aTHdRHpJOwTdDEQ3ZfKtbpclcNhyVClagmY+fIfyKukntPqBgnx5QvZHk/D/MK8JMClrSigAAAABJRU5ErkJggg==",bt="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyAgMAAABjUWAiAAAADFBMVEXe3t7a2trc3Nzg4OCXP9lCAAACoklEQVQozwXBzU/TYBwA4N+QEr4CNbSFwcFuowSqMRvEAwShHWAYNsu7dS0dLnGUSWT4kZB4lGzE4VtcwgIDJqcOWLJxcv4BOoQZuCPxSNSD4WSWLJGL8XmAIiyo2RgJ4A1pxQQlOxRAszLTdnPu2oQGb05RC5slJld7ZAIfo4O44Bn1ud59F0BcjnYOa17Jhwc6EdiKettncsXjT1f8KUBZUW41pK0Jc1Az4dEV3rkkPBtDSZ83Blyt0kSf2PRjzIykoBwINisPbPPtljdVE9iAXRfUPkXLVIgYrCccp5g687NdZbcJ+xa5VE/HhTtT23IKsN5jj/pcUd0dTZNAqCVw72n4gOwnTOC0vvHfaauT8d9zAoRRfPpISZRVyUiw8ELzOG1b2DZpFzkSrHLhq52twDEdyZHwvp2j4uv/bjvOf23/AcEtTuJbY5Cp4YcAer1IGkUzOo2rn8LQOKjFJw3NTw24nprQXY5aF4wxcqcSdbFQ00H4xFl8Drx4X4CikvAM1tuR8bKIBCBoLnKN10KJG4zKAsc7c9WEB9gnCi6BhVjqoco6t20ILAJuVctvaEZK732cRHDRmGfuihOam0o2CHByUZ/epCcVlRs2wmCnMqsd6aSim3ibBJtm1LGyXW3Bb7tJCPlFtUG+SvPdeEUAB60lNdo+VQbLcwRNVtT68FsLcr1+NotgNihlpExS1V2SFgNbeC8bEhgm8sM17wSi6Us2gxVWJU/5GKBpandvfyYbU1yHCLpCgWGbbPXn40rehEsUXKIJr9DMKgICfjc4bl1YfvUhE/YIECGRqjCxSM9hrybAIkND5OeWfFZsXkxB+qDzb7pUQ3EfQ3Ml6EChEt3D+iS01VqC7EQ/Z/DuPQcz4yChoFQJce2Qr+NNAv0HxofmpXGqgHkAAAAASUVORK5CYII=",wt="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyBAMAAADsEZWCAAAAD1BMVEXm5ubo6Ojp6enr6+vt7e1FnZagAAACrklEQVQ4yx1SixUbMQgT3AKAFwDcAfzpBN1/qMrJS5w7bCQhC6IGSUGYQJd6Ox9ZPXi1AGJBavhUTT0JjYPGAab9WcDYIxsmlnxkayX8mhxCmKHA75az5cfRbWybEExiu08xDSgGym0mwuf3j4SvHeQxDJJzh2zp4iOlrD8iOb4SXyC1wiOLRTcnrje+nGamFeXVKWkmzbFIPChkmJ6Fg7mBpV8n+JGOVCd4jv1thThkjeQGNeafpeV3rsEWLfyWc8tC9jOv6FQ8rRzHOOVB+jCYEUAJpDvh8xHNFm/Tm5p5lw94Pp3NhtKEfQsGvnXhowdZE73hPwxKvjDd4i4PCdd0fe3W5fO8ktAsUAacLgstpUw60JCiPLg2XpkgiqPIYYXJd9ksGIT3q+LlevypzItvO+s0F1dBzVr2QDMUkYmuyGcrIS44mVJ7JVKwQXjYuBYp0Uetecbswzsikzu3gUR8bJC/C8Gd/NAzI/xdUGOYQQHDZ8X2d5XuzGRUiXAi9si5CRgoiToRZPtzLJkd0FUHRHZwJf0BHT1sE7gcnh0jmKKlSSF4/GBirGk5+K9NKlGDCfc9JtPhg78JdabH0YQRKNZnJ8tFnPfXHJb4xum1TTCeEmyEdbyEJLjznMLHuFD2Y9NEkSleIBs7SiCbblhgctVi9ch++kDYnn1C9DA5TvdPsToXM55wI6k+8eKT1blwPTqWb5CFJ+7dTBmab+KHy+xwNtItXhZNSpHD2fxnynrxG3ZBKRe8KBpXk11AnadlccEhr9w1nBBvBylNkv7A8eqpGBCDqhitmWQXBjjdS6idr/QjXWLDeMzMbVDoJuM8zN7WenMZWXgZ2vX3F01J3jHZbwk1LRP+DWEvDJtOUoh/AIaBUz5VpWyhuyx4QtgL/NmgC6kM/JvNe+R/C/5aL7BKIbYAAAAASUVORK5CYII=",St="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyBAMAAADsEZWCAAAAElBMVEUQERMODxESFBYWGBkaHB0eICLm6ozJAAACkUlEQVQ4yyWTUdLbMAiEASfvoOkBkBy/O5keIE0v8E/uf5h+68qZWALELgu2MG9PP9qyvCzTVhrrsPGOCjvTfXQZvtp/W3Gy6LCITqs4q/DZ+KYl76zKzHVYpY2wNY27nqN1sbLGcrLH3/ENH4oWlGctsDu8AO+HzTLlsYdh8MzP1m6YDMz0ACfcimvakBj+mwO/+5Uta5teOD379sxK1fUxmUhv8MU3jUT5gs26PMephFznkLcpQZ6/dPL9C/GWHcCxDN6oZhD5xBm5qoYBPA+PFE/H1tXDWcWl8TW7rS+4dUzAVy0BIrvC4/HcqW2TkG1HO8q9dC23INAg7NA4AFRFkDTM2lfELPyFzi1VddcpX2z0KjHBUDmdLNJ6dDps4ytrX+FPsZwE31wSL+6OWfHOAJ3+Y0Rk/MiKfmWNPg7oVP/U3Ck9FoCkC2gBpALOiqbMNTkOe8P4FWkTD2Y9Q3+5VmV0uLUJBl68U5uAK2Kl6QDXvLxbwweOL2sixW78uU8p0ysfc7cWrF1j6B1sPJ4WgclYSnJN1bzozrhEcFHmRzBkbJWqqdG+EYJXRFmT5jnLXPUNF6WBdoFbTxYsmDXVLU/WA7MExNc93sJS5hIXDeLxzMScHzdhKvEkibr6cQXYPrmtmTA7JcInISrTzRDvShTdka0uVGrsJAAR6tSn1sKziZtfKVjAxPrJsYgZO0bye+vKTZ/DgoAoLGNO6jYHimZYTL/3pLJHawquJukjBpfz8WOGVSVIWx9ywUfS5iENutidRM4NzkAmxgUSQ68xgNOU+ZLalr4TS2V+D2xqukZig+Z9DilR7Nouzwp1cp/3E5q6Rdlf08obKvAM4qZ6pMr+w3PmQALSSBfjyZn5DwrNRVbywBQiAAAAAElFTkSuQmCC",Et="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyAgMAAABjUWAiAAAADFBMVEUWGBkYGhsdHyAfISI1t/v6AAAB5ElEQVQozxXQsYoTURSA4f/EeycZsDgDdySDjihk38Hy3GWi2J2BCaziQhaiaB+tt9AFu1kwvYUPsIXNPoB9BAUfwAfwEUzKv/v4odGrroyp9/rUaC6rZ5skv5F8qPsfYYP+yKUMymmAEEeW55oUR4o8jr05KNzJ07yvB7w0KKfLwcQUSjfmMU0PJfPHFoEVU+ohNrcKMEzMQ23FDnVSI2dqtYWI7KlLu6vE4UnyvKc3SJuL7lBbeEEl42ItpGLjzIT8PRJCmkRjVpVpsbJFVN0687okJNZiHAr5Z7MV0BnGIDc+THM1zlbieBc1Fq+tH5BH+OpnbWkj40hSqC8Lw2TvFuF0SUFJCk2IytXbjeqcRAt6NHpnrUkUU4KRzZs8RCK8N/Akn2W04LwxMU/V7XK0bDyN2RxfDyx7I4h5vjZby72V8UnOWumZL3qtYc+8DTE0siSBMXGhywx2dMYPnQHbxdFZ7deiNGxCCtD/QWnbwDoGhRYPDzUdUA3krjpnkvdAgDN4ddLkEQSov9qjd42HaDjI34gEqS9TUueAk+sc4qg5ws407KQYKs8G1jv4xBlqBVk6cb4dISZIwVi1Jzu4+HLk6lyfUxkXvwy+1Q+4WVdHIhwfybZ6CWVhxMEhShOgsP/HOW0MvZJeFwAAAABJRU5ErkJggg==",xt="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyAgMAAABjUWAiAAAADFBMVEUWGBkYGhsdHyAfISI1t/v6AAAB5ElEQVQozxXQsYoTURSA4f/EeycZsDgDdySDjihk38Hy3GWi2J2BCaziQhaiaB+tt9AFu1kwvYUPsIXNPoB9BAUfwAfwEUzKv/v4odGrroyp9/rUaC6rZ5skv5F8qPsfYYP+yKUMymmAEEeW55oUR4o8jr05KNzJ07yvB7w0KKfLwcQUSjfmMU0PJfPHFoEVU+ohNrcKMEzMQ23FDnVSI2dqtYWI7KlLu6vE4UnyvKc3SJuL7lBbeEEl42ItpGLjzIT8PRJCmkRjVpVpsbJFVN0687okJNZiHAr5Z7MV0BnGIDc+THM1zlbieBc1Fq+tH5BH+OpnbWkj40hSqC8Lw2TvFuF0SUFJCk2IytXbjeqcRAt6NHpnrUkUU4KRzZs8RCK8N/Akn2W04LwxMU/V7XK0bDyN2RxfDyx7I4h5vjZby72V8UnOWumZL3qtYc+8DTE0siSBMXGhywx2dMYPnQHbxdFZ7deiNGxCCtD/QWnbwDoGhRYPDzUdUA3krjpnkvdAgDN4ddLkEQSov9qjd42HaDjI34gEqS9TUueAk+sc4qg5ws407KQYKs8G1jv4xBlqBVk6cb4dISZIwVi1Jzu4+HLk6lyfUxkXvwy+1Q+4WVdHIhwfybZ6CWVhxMEhShOgsP/HOW0MvZJeFwAAAABJRU5ErkJggg==",oe={themes:[{body:"#ede7da url("+dt+") repeat",content:"#ede7da url("+ut+") repeat",popup:"#ede7da url("+gt+") repeat"},{body:"#ede7da url("+pt+") repeat",content:"#ede7da url("+ft+") repeat",popup:"#ede7da url("+mt+") repeat"},{body:"#ede7da url("+vt+") repeat",content:"#ede7da url("+Bt+") repeat",popup:"#ede7da url("+kt+") repeat"},{body:"#ede7da url("+ht+") repeat",content:"#ede7da url("+Ct+") repeat",popup:"#ede7da url("+yt+") repeat"},{body:"#ebcece repeat",content:"#f5e4e4 repeat",popup:"#faeceb repeat"},{body:"#ede7da url("+It+") repeat",content:"#ede7da url("+bt+") repeat",popup:"#ede7da url("+wt+") repeat"},{body:"#ede7da url("+St+") repeat",content:"#ede7da url("+Et+") repeat",popup:"#ede7da url("+xt+") repeat"}],fonts:["Microsoft YaHei, PingFangSC-Regular, HelveticaNeue-Light, Helvetica Neue Light, sans-serif","PingFangSC-Regular, -apple-system, Simsun","Kaiti"]},Ut={class:"setting-list"},Dt={class:"theme-list"},Qt=["onClick"],Vt={key:0,class:"iconfont"},Ft={key:1,class:"moon-icon"},Pt={class:"font-list"},Mt=["onClick"],Lt={class:"font-list"},Rt={style:{"text-align":"right",margin:"0"}},Kt={class:"font-size"},zt={class:"resize"},Ht={class:"lang"},Ot={class:"letter-spacing"},Wt={class:"resize"},Nt={class:"lang"},Tt={class:"line-spacing"},Jt={class:"resize"},Gt={class:"lang"},qt={class:"paragraph-spacing"},Zt={class:"resize"},Yt={class:"resize"},jt={class:"lang"},Xt={key:0,class:"read-width"},_t={class:"resize"},$t={class:"lang"},eo={class:"paragraph-spacing"},to={class:"resize"},oo={class:"resize"},no={class:"lang"},so={class:"infinite-loading"},ao=ce({__name:"ReadSettings",setup(c){const s=me(),A=je(()=>ne.saveReadConfig(s.config),500);be(()=>s.config,()=>{A()},{deep:2});const f=l(()=>s.theme),n=l(()=>s.isNight),w=l(()=>f.value==6?"":""),m=[{background:"rgba(250, 245, 235, 0.8)"},{background:"rgba(245, 234, 204, 0.8)"},{background:"rgba(230, 242, 230, 0.8)"},{background:"rgba(228, 241, 245, 0.8)"},{background:"rgba(245, 228, 228, 0.8)"},{background:"rgba(224, 224, 224, 0.8)"},{background:"rgba(0, 0, 0, 0.5)"}],y=l(()=>({background:oe.themes[f.value].popup})),h=M=>{s.config.theme=M},F=D(["雅黑","宋体","楷书"]),P=M=>{s.config.font=M},x=l(()=>s.config.font),S=D(s.config.customFontName),V=D(!1),U=()=>{V.value=!1,s.config.font=-1,s.config.customFontName=S.value},C=()=>{V.value=!1,Re.prompt("请输入 字体网络链接","提示",{confirmButtonText:"确定",cancelButtonText:"取消",inputPattern:/^https?:.+$/,inputErrorMessage:"url 形式不正确",beforeClose:(M,e,N)=>{if(M==="confirm"){e.confirmButtonLoading=!0,e.confirmButtonText="下载中……";const Y=e.inputValue;if(typeof FontFace!="function")return R.error("浏览器不支持FontFace"),N();const _=new FontFace(S.value,`url("${Y}")`);document.fonts.add(_),_.load().then(function(){e.confirmButtonLoading=!1,R.info("字体加载成功!"),U(),N()}).catch(function(E){throw e.confirmButtonLoading=!1,e.confirmButtonText="确定",R.error("下载失败,请检查您输入的 url"),E})}else N()}})},I=l(()=>s.config.fontSize),b=()=>{s.config.fontSize<48&&(s.config.fontSize+=2)},v=()=>{s.config.fontSize>12&&(s.config.fontSize-=2)},u=l(()=>s.config.spacing),i=()=>{s.config.spacing.letter-=.01},d=()=>{s.config.spacing.letter+=.01},B=()=>{s.config.spacing.line-=.1},W=()=>{s.config.spacing.line+=.1},ve=()=>{s.config.spacing.paragraph-=.1},de=()=>{s.config.spacing.paragraph+=.1},Be=l(()=>s.config.readWidth),ue=()=>{s.config.readWidth+160+2*68>window.innerWidth||(s.config.readWidth+=160)},ke=()=>{s.config.readWidth>640&&(s.config.readWidth-=160)},he=l(()=>s.config.jumpDuration),j=()=>{s.config.jumpDuration+=100},Ce=()=>{s.config.jumpDuration!==0&&(s.config.jumpDuration-=100)},ge=l(()=>s.config.infiniteLoading),X=M=>{s.config.infiniteLoading=M};return(M,e)=>{const N=Ge,Y=Ye,_=Le;return g(),p("div",{class:Q(["settings-wrapper",{night:a(n),day:!a(n)}]),style:Z(a(y))},[e[51]||(e[51]=t("div",{class:"settings-title"},"设置",-1)),t("div",Ut,[t("ul",null,[t("li",Dt,[e[7]||(e[7]=t("i",null,"阅读主题",-1)),(g(),p(se,null,le(m,(E,L)=>t("span",{class:Q(["theme-item",{selected:a(f)==L}]),key:L,style:Z(E),ref_for:!0,ref:"themes",onClick:T=>h(L)},[L<6?(g(),p("em",Vt,"")):(g(),p("em",Ft,K(a(w)),1))],14,Qt)),64))]),t("li",Pt,[e[8]||(e[8]=t("i",null,"正文字体",-1)),(g(!0),p(se,null,le(a(F),(E,L)=>(g(),p("span",{class:Q(["font-item",{selected:a(x)==L}]),key:L,onClick:T=>P(L)},K(E),11,Mt))),128))]),t("li",Lt,[e[14]||(e[14]=t("i",null,"自定字体",-1)),H(N,{effect:"dark",content:"自定义的字体名称",placement:"top"},{default:z(()=>[qe(t("input",{type:"text",class:"font-item font-item-input","onUpdate:modelValue":e[0]||(e[0]=E=>fe(S)?S.value=E:null),placeholder:"请输入自定义的字体名称"},null,512),[[Ze,a(S)]])]),_:1}),H(_,{placement:"top",width:"270",trigger:"click",visible:a(V),"onUpdate:visible":e[4]||(e[4]=E=>fe(V)?V.value=E:null)},{reference:z(()=>[...e[12]||(e[12]=[t("span",{type:"text",class:"font-item"},"保存",-1)])]),default:z(()=>[e[13]||(e[13]=t("p",null," 已经安装在您的设备上的字体请确认输入的字体名称完整无误,或者从网络下载字体。 ",-1)),t("div",Rt,[H(Y,{size:"small",plain:"",onClick:e[1]||(e[1]=E=>V.value=!1)},{default:z(()=>[...e[9]||(e[9]=[q("取消",-1)])]),_:1}),H(Y,{type:"primary",size:"small",onClick:e[2]||(e[2]=E=>U())},{default:z(()=>[...e[10]||(e[10]=[q("确定",-1)])]),_:1}),H(Y,{type:"primary",size:"small",onClick:e[3]||(e[3]=E=>C())},{default:z(()=>[...e[11]||(e[11]=[q("网络下载",-1)])]),_:1})])]),_:1},8,["visible"])]),t("li",Kt,[e[20]||(e[20]=t("i",null,"字体大小",-1)),t("div",zt,[t("span",{class:"less",onClick:v},[...e[15]||(e[15]=[t("em",{class:"iconfont"},"",-1)])]),e[17]||(e[17]=t("b",null,null,-1)),e[18]||(e[18]=q()),t("span",Ht,K(a(I)),1),e[19]||(e[19]=t("b",null,null,-1)),t("span",{class:"more",onClick:b},[...e[16]||(e[16]=[t("em",{class:"iconfont"},"",-1)])])])]),t("li",Ot,[e[26]||(e[26]=t("i",null,"字距",-1)),t("div",Wt,[t("span",{class:"less",onClick:i},[...e[21]||(e[21]=[t("em",{class:"iconfont"},"",-1)])]),e[23]||(e[23]=t("b",null,null,-1)),e[24]||(e[24]=q()),t("span",Nt,K(a(u).letter.toFixed(2)),1),e[25]||(e[25]=t("b",null,null,-1)),t("span",{class:"more",onClick:d},[...e[22]||(e[22]=[t("em",{class:"iconfont"},"",-1)])])])]),t("li",Tt,[e[32]||(e[32]=t("i",null,"行距",-1)),t("div",Jt,[t("span",{class:"less",onClick:B},[...e[27]||(e[27]=[t("em",{class:"iconfont"},"",-1)])]),e[29]||(e[29]=t("b",null,null,-1)),e[30]||(e[30]=q()),t("span",Gt,K(a(u).line.toFixed(1)),1),e[31]||(e[31]=t("b",null,null,-1)),t("span",{class:"more",onClick:W},[...e[28]||(e[28]=[t("em",{class:"iconfont"},"",-1)])])])]),t("li",qt,[e[37]||(e[37]=t("i",null,"段距",-1)),t("div",Zt,[t("div",Yt,[t("span",{class:"less",onClick:ve},[...e[33]||(e[33]=[t("em",{class:"iconfont"},"",-1)])]),e[35]||(e[35]=t("b",null,null,-1)),t("span",jt,K(a(u).paragraph.toFixed(1)),1),e[36]||(e[36]=t("b",null,null,-1)),t("span",{class:"more",onClick:de},[...e[34]||(e[34]=[t("em",{class:"iconfont"},"",-1)])])])])]),a(s).miniInterface?pe("",!0):(g(),p("li",Xt,[e[43]||(e[43]=t("i",null,"页面宽度",-1)),t("div",_t,[t("span",{class:"less",onClick:ke},[...e[38]||(e[38]=[t("em",{class:"iconfont"},"",-1)])]),e[40]||(e[40]=t("b",null,null,-1)),e[41]||(e[41]=q()),t("span",$t,K(a(Be)),1),e[42]||(e[42]=t("b",null,null,-1)),t("span",{class:"more",onClick:ue},[...e[39]||(e[39]=[t("em",{class:"iconfont"},"",-1)])])])])),t("li",eo,[e[49]||(e[49]=t("i",null,"翻页速度",-1)),t("div",to,[t("div",oo,[t("span",{class:"less",onClick:Ce},[...e[44]||(e[44]=[t("em",{class:"iconfont"},"",-1)])]),e[46]||(e[46]=t("b",null,null,-1)),e[47]||(e[47]=q()),t("span",no,K(a(he)),1),e[48]||(e[48]=t("b",null,null,-1)),t("span",{class:"more",onClick:j},[...e[45]||(e[45]=[t("em",{class:"iconfont"},"",-1)])])])])]),t("li",so,[e[50]||(e[50]=t("i",null,"无限加载",-1)),(g(),p("span",{class:Q(["infinite-loading-item",{selected:a(ge)==!1}]),key:0,onClick:e[5]||(e[5]=E=>X(!1))},"关闭",2)),(g(),p("span",{class:Q(["infinite-loading-item",{selected:a(ge)==!0}]),key:1,onClick:e[6]||(e[6]=E=>X(!0))},"开启",2))])])])],6)}}}),io=Ae(ao,[["__scopeId","data-v-dd7cfcb2"]]),ro={class:"wrapper"},lo=["onClick"],co=ce({__name:"CatalogItem",props:{index:{},source:{},gotoChapter:{type:Function},currentChapterIndex:{}},setup(c){const s=c,A=n=>n==s.currentChapterIndex,f=l(()=>{const n=s.source;return"catas"in n?n.catas:[s.source]});return(n,w)=>(g(),p("div",ro,[(g(!0),p(se,null,le(a(f),m=>(g(),p("div",{class:Q(["cata-text",{selected:A(m.index)}]),key:m.url,onClick:y=>c.gotoChapter(m)},K(m.title),11,lo))),128))]))}}),Ao=Ae(co,[["__scopeId","data-v-a892cd6d"]]),uo=ce({__name:"PopCatalog",emits:["getContent"],setup(c,{emit:s}){const A=me(),{catalog:f,popCataVisible:n,miniInterface:w}=Ke(A),m=l(()=>A.theme),y=l(()=>A.theme),h=l(()=>({background:oe.themes[y.value].popup})),F=l(()=>{const C=f.value;if(w.value)return C;const I=Math.ceil(C.length/2),b=new Array(I);let v=0;for(;vA.readingBook.chapterIndex,set:C=>A.readingBook.chapterIndex=C}),S=l(()=>{const C=x.value;return w.value?C:Math.floor(C/2)});Xe(()=>{n.value&&P.value.scrollToIndex(S.value)});const V=s,U=C=>{const I=f.value.indexOf(C);x.value=I,A.setPopCataVisible(!1),A.setContentLoading(!0),A.saveBookProgress(),V("getContent",I)};return(C,I)=>(g(),p("div",{class:Q({"cata-wrapper":!0,visible:a(n)}),style:Z(a(h))},[I[0]||(I[0]=t("div",{class:"title"},"目录",-1)),H(a(_e),{style:{height:"300px",overflow:"auto"},class:Q({night:a(m),day:!a(m)}),ref_key:"virtualListRef",ref:P,"data-key":"index","wrap-class":"data-wrapper","item-class":"cata","data-sources":a(F),"data-component":Ao,"estimate-size":40,"extra-props":{gotoChapter:U,currentChapterIndex:a(x)}},null,8,["class","data-sources","extra-props"])],6))}}),go=Ae(uo,[["__scopeId","data-v-6cab38af"]]),po={class:"tools"},fo={class:"tools"},mo={key:0},vo={key:0},Bo={class:"content"},ko=["chapterIndex"],ho=ce({__name:"BookChapter",setup(c){const s=D(),{isLoading:A,loadingWrapper:f}=nt(s,"正在获取信息"),n=me(),{catalog:w,popCataVisible:m,readSettingsVisible:y,miniInterface:h,showContent:F,bookProgress:P,theme:x,isNight:S}=Ke(n),V=l({get:()=>n.readingBook.chapterPos,set:o=>n.readingBook.chapterPos=o}),U=l({get:()=>n.readingBook.chapterIndex,set:o=>n.readingBook.chapterIndex=o}),C=l({get:()=>n.readingBook.isSeachBook,set:o=>n.readingBook.isSeachBook=o});be(()=>n.readingBook,o=>{localStorage.setItem("readingRecent",JSON.stringify(o)),sessionStorage.setItem("chapterIndex",o.chapterIndex.toString()),sessionStorage.setItem("chapterPos",o.chapterPos.toString())},{deep:1});const I=l(()=>n.config.infiniteLoading);let b;const v=D();De(()=>{I.value?b==null||b.observe(v.value):b==null||b.disconnect()});const u=()=>{const o=T.value.slice(-1)[0].index;w.value.length-1>o&&(ae(o+1,!1),n.saveBookProgress())},i=o=>{if(!A.value)for(const{isIntersecting:r}of o){if(!r)return;u()}},d=l(()=>n.config.font>=0?oe.fonts[n.config.font]:n.config.customFontName),B=l(()=>n.config.fontSize+"px"),W=l(()=>oe.themes[x.value].body),ve=l(()=>oe.themes[x.value].content),de=l(()=>oe.themes[x.value].popup),Be=l(()=>h.value?window.innerWidth+"px":n.config.readWidth-130+"px"),ue=l(()=>h.value?window.innerWidth-33:n.config.readWidth-33),ke=l(()=>({background:W.value})),he=l(()=>({background:ve.value,width:Be.value})),j=D(!1),Ce=l(()=>({background:de.value,marginLeft:h.value?0:-(n.config.readWidth/2+68)+"px",display:h.value&&!j.value?"none":"block"})),ge=l(()=>({background:de.value,marginRight:h.value?0:-(n.config.readWidth/2+52)+"px",display:h.value&&!j.value?"none":"block"})),X=()=>{n.setMiniInterface(window.innerWidth<776);const o=n.config.readWidth;M(o)},M=o=>{n.miniInterface||(o<640&&(n.config.readWidth=640),o+2*68>window.innerWidth&&(n.config.readWidth-=160))};be(()=>n.config.readWidth,o=>M(o));const e=D(),N=D(),Y=()=>{te(e.value)},_=()=>{te(N.value)},E=$e(),L=()=>{E.push("/")},T=D([]),$=D(!0),ae=(o,r=!0,O=0)=>{r&&(n.setShowContent(!1),te(e.value,{duration:0}),we(o,O),T.value=[]);const J=n.readingBook.bookUrl,{title:G,index:re}=w.value[o];f(ne.getBookContent(J,re).then(k=>{if(k.data.isSuccess){const Te=k.data.data.split(/\n+/);T.value.push({index:o,content:Te,title:G}),r&&He(O)}else{R({message:k.data.errorMsg,type:"error"});const ee=[k.data.errorMsg];T.value.push({index:o,content:ee,title:G})}if(n.setContentLoading(!0),$.value=!1,n.setShowContent(!0),!k.data.isSuccess)throw k.data},k=>{const ee=["获取章节内容失败!"];throw T.value.push({index:o,content:ee,title:G}),n.setShowContent(!0),k}))},ze=D(),ye=D(),He=o=>{Me(()=>{ye.value.length===1&&ye.value[0].scrollToReadedLength(o)})},Oe=et(()=>n.saveBookProgress(),6e4),We=(o,r)=>{we(o,r),Oe()};De(()=>{var o;document.title=((o=w.value[U.value])==null?void 0:o.title)||document.title});const we=(o,r)=>{U.value=o,V.value=r},Se=()=>{const o=P.value;document.visibilityState=="hidden"&&o&&n.saveBookProgress()},Ee=()=>{n.setContentLoading(!0);const o=U.value+1;typeof w.value[o]<"u"?(R({message:"下一章",type:"info"}),ae(o),n.saveBookProgress()):R({message:"本章是最后一章",type:"error"})},xe=()=>{n.setContentLoading(!0);const o=U.value-1;typeof w.value[o]<"u"?(R({message:"上一章",type:"info"}),ae(o),n.saveBookProgress()):R({message:"本章是第一章",type:"error"})};let ie=!0;const Ie=o=>{if(ie)switch(o.key){case"ArrowLeft":o.stopPropagation(),o.preventDefault(),xe();break;case"ArrowRight":o.stopPropagation(),o.preventDefault(),Ee();break;case"ArrowUp":o.stopPropagation(),o.preventDefault(),document.documentElement.scrollTop===0?R.warning("已到达页面顶部"):(ie=!1,te(0-document.documentElement.clientHeight+100,{duration:n.config.jumpDuration,callback:()=>ie=!0}));break;case"ArrowDown":o.stopPropagation(),o.preventDefault(),document.documentElement.clientHeight+document.documentElement.scrollTop===document.documentElement.scrollHeight?R.warning("已到达页面底部"):(ie=!1,te(document.documentElement.clientHeight-100,{duration:n.config.jumpDuration,callback:()=>ie=!0}));break}},Ue=o=>{(o.key==="ArrowUp"||o.key==="ArrowDown")&&(o.preventDefault(),o.stopPropagation())};Fe(async()=>{await n.loadWebConfig();const o=sessionStorage.getItem("bookUrl"),r=sessionStorage.getItem("bookName"),O=sessionStorage.getItem("bookAuthor"),J=Number(sessionStorage.getItem("chapterIndex")||0),G=Number(sessionStorage.getItem("chapterPos")||0),re=sessionStorage.getItem("isSeachBook")==="true";if(Ve(o)||Ve(r)||O===null)return R.warning("书籍信息为空,即将自动返回书架页面..."),setTimeout(L,500);const k={bookUrl:o,name:r,author:O,chapterIndex:J,chapterPos:G,isSeachBook:re};X(),window.addEventListener("resize",X),f(n.loadWebCatalog(k).then(ee=>{n.setReadingBook(k),ae(J,!0,G),window.addEventListener("keyup",Ie),window.addEventListener("keydown",Ue),document.addEventListener("visibilitychange",Se),b=new IntersectionObserver(i,{rootMargin:"-100% 0% 20% 0%"}),I.value===!0&&b.observe(v.value),document.title="...",document.title=r+" | "+ee[J].title}))}),Pe(()=>{window.removeEventListener("keyup",Ie),window.removeEventListener("keydown",Ue),window.removeEventListener("resize",X),document.removeEventListener("visibilitychange",Se),y.value=!1,m.value=!1,b==null||b.disconnect(),b=null});const Ne=async()=>{const o=n.readingBook;o.isSeachBook===!0&&await Re.confirm(`是否将《${o.name}》放入书架?`,"放入书架",{confirmButtonText:"确认",cancelButtonText:"否",type:"info",closeOnHashChange:!1}).then(()=>{C.value=!1}).catch(async()=>{await ne.deleteBook(o)}).finally(()=>sessionStorage.removeItem("isSeachBook"))};return tt(async(o,r,O)=>{window.removeEventListener("keyup",Ie),await Ne(),O()}),(o,r)=>{const O=go,J=Le,G=io,re=At;return g(),p("div",{class:Q(["chapter-wrapper",{night:a(S),day:!a(S)}]),style:Z(a(ke)),onClick:r[2]||(r[2]=k=>j.value=!a(j))},[t("div",{class:"tool-bar",style:Z(a(Ce))},[t("div",po,[H(J,{placement:"right",width:a(ue),trigger:"click","show-arrow":!1,visible:a(m),"onUpdate:visible":r[0]||(r[0]=k=>fe(m)?m.value=k:null),"popper-class":"pop-cata"},{reference:z(()=>[t("div",{class:Q(["tool-icon",{"no-point":!1}])},[...r[3]||(r[3]=[t("div",{class:"iconfont"},"",-1),t("div",{class:"icon-text"},"目录",-1)])])]),default:z(()=>[H(O,{onGetContent:ae,class:"popup"})]),_:1},8,["width","visible"]),H(J,{placement:"right",width:a(ue),trigger:"click","show-arrow":!1,visible:a(y),"onUpdate:visible":r[1]||(r[1]=k=>fe(y)?y.value=k:null),"popper-class":"pop-setting"},{reference:z(()=>[t("div",{class:Q(["tool-icon",{"no-point":a($)}])},[...r[4]||(r[4]=[t("div",{class:"iconfont"},"",-1),t("div",{class:"icon-text"},"设置",-1)])],2)]),default:z(()=>[H(G,{class:"popup"})]),_:1},8,["width","visible"]),t("div",{class:"tool-icon",onClick:L},[...r[5]||(r[5]=[t("div",{class:"iconfont"},"",-1),t("div",{class:"icon-text"},"书架",-1)])]),t("div",{class:Q(["tool-icon",{"no-point":a($)}]),onClick:Y},[...r[6]||(r[6]=[t("div",{class:"iconfont"},"",-1),t("div",{class:"icon-text"},"顶部",-1)])],2),t("div",{class:Q(["tool-icon",{"no-point":a($)}]),onClick:_},[...r[7]||(r[7]=[t("div",{class:"iconfont"},"",-1),t("div",{class:"icon-text"},"底部",-1)])],2)])],4),t("div",{class:"read-bar",style:Z(a(ge))},[t("div",fo,[t("div",{class:Q(["tool-icon",{"no-point":a($)}]),onClick:xe},[r[8]||(r[8]=t("div",{class:"iconfont"},"",-1)),a(h)?(g(),p("span",mo,"上一章")):pe("",!0)],2),t("div",{class:Q(["tool-icon",{"no-point":a($)}]),onClick:Ee},[a(h)?(g(),p("span",vo,"下一章")):pe("",!0),r[9]||(r[9]=t("div",{class:"iconfont"},"",-1))],2)])],4),r[10]||(r[10]=t("div",{class:"chapter-bar"},null,-1)),t("div",{class:"chapter",ref_key:"content",ref:s,style:Z(a(he))},[t("div",Bo,[t("div",{class:"top-bar",ref_key:"top",ref:e},null,512),(g(!0),p(se,null,le(a(T),k=>(g(),p("div",{key:k.index,chapterIndex:k.index,ref_for:!0,ref_key:"chapter",ref:ze},[a(F)?(g(),ot(re,{key:0,ref_for:!0,ref_key:"chapterRef",ref:ye,chapterIndex:k.index,contents:k.content,title:k.title,spacing:a(n).config.spacing,fontSize:a(B),fontFamily:a(d),onReadedLengthChange:We},null,8,["chapterIndex","contents","title","spacing","fontSize","fontFamily"])):pe("",!0)],8,ko))),128)),t("div",{class:"loading",ref_key:"loading",ref:v},null,512),t("div",{class:"bottom-bar",ref_key:"bottom",ref:N},null,512)])],4)],6)}}}),bo=Ae(ho,[["__scopeId","data-v-fff9fad7"]]);export{bo as default}; ================================================ FILE: app/src/main/assets/web/vue/assets/BookShelf-00b2QCsd.css ================================================ @charset "UTF-8";.books-wrapper[data-v-0f5f0160]{overflow:auto}.books-wrapper .wrapper[data-v-0f5f0160]{display:grid;grid-template-columns:repeat(auto-fill,380px);justify-content:space-around;grid-gap:10px}.books-wrapper .wrapper .book[data-v-0f5f0160]{-webkit-user-select:none;user-select:none;display:flex;cursor:pointer;margin-bottom:18px;padding:24px;width:360px;flex-direction:row;justify-content:space-around}.books-wrapper .wrapper .book .cover-img[data-v-0f5f0160],.books-wrapper .wrapper .book .cover-img .cover[data-v-0f5f0160]{width:84px;height:112px}.books-wrapper .wrapper .book .info[data-v-0f5f0160]{display:flex;flex-direction:column;justify-content:space-around;align-items:left;height:112px;margin-left:20px;flex:1;overflow:hidden}.books-wrapper .wrapper .book .info .name[data-v-0f5f0160]{width:fit-content;font-size:16px;font-weight:700;color:#33373d}.books-wrapper .wrapper .book .info .sub[data-v-0f5f0160]{display:flex;flex-direction:row;align-items:baseline;justify-content:var(--v2a51eeb0);font-size:12px;font-weight:600;color:#6b6b6b}.books-wrapper .wrapper .book .info .sub .tags[data-v-0f5f0160] .el-tag{margin-right:.5em}.books-wrapper .wrapper .book .info .sub .update-info[data-v-0f5f0160]{display:flex}.books-wrapper .wrapper .book .info .sub .update-info .dot[data-v-0f5f0160]{margin:0 7px}.books-wrapper .wrapper .book .info .intro[data-v-0f5f0160],.books-wrapper .wrapper .book .info .dur-chapter[data-v-0f5f0160],.books-wrapper .wrapper .book .info .last-chapter[data-v-0f5f0160]{color:#969ba3;font-size:13px;margin-top:3px;font-weight:500;word-wrap:break-word;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:1;line-clamp:1;text-align:left}.books-wrapper .wrapper .book[data-v-0f5f0160]:hover{background:#0000001a;transition-duration:.5s}.books-wrapper .wrapper[data-v-0f5f0160]:last-child{margin-right:auto}.books-wrapper[data-v-0f5f0160]::-webkit-scrollbar{width:0!important}@media screen and (max-width: 750px){.books-wrapper .wrapper[data-v-0f5f0160]{display:flex;flex-direction:column}.books-wrapper .wrapper .book[data-v-0f5f0160]{box-sizing:border-box;width:100%;margin-bottom:0;padding:10px 20px}}body{padding:0;margin:0;height:100vh}#app{font-family:Avenir,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;color:#2c3e50;margin:0;height:100%}@font-face{font-family:FZZCYSK;src:local("☺"),url(./shelffont-D-W4UqG-.ttf);font-style:normal;font-weight:400}.index-wrapper[data-v-5061e9c0]{height:100%;width:100%;display:flex;flex-direction:row}.index-wrapper .navigation-wrapper[data-v-5061e9c0]{width:260px;min-width:260px;padding:48px 36px;background-color:#f7f7f7}.index-wrapper .navigation-wrapper .navigation-title[data-v-5061e9c0]{font-size:24px;font-weight:500;font-family:FZZCYSK}.index-wrapper .navigation-wrapper .navigation-sub-title[data-v-5061e9c0]{font-size:16px;font-weight:300;font-family:FZZCYSK;margin-top:16px;color:#b1b1b1}.index-wrapper .navigation-wrapper .search-wrapper .search-input[data-v-5061e9c0]{border-radius:50%;margin-top:24px}.index-wrapper .navigation-wrapper .search-wrapper .search-input[data-v-5061e9c0] .el-input__wrapper{border-radius:50px;border-color:#e3e3e3}.index-wrapper .navigation-wrapper .bottom-wrapper[data-v-5061e9c0]{display:flex;flex-direction:column}.index-wrapper .navigation-wrapper .recent-wrapper[data-v-5061e9c0]{margin-top:36px}.index-wrapper .navigation-wrapper .recent-wrapper .recent-title[data-v-5061e9c0]{font-size:14px;color:#b1b1b1;font-family:FZZCYSK}.index-wrapper .navigation-wrapper .recent-wrapper .reading-recent[data-v-5061e9c0]{margin:18px 0}.index-wrapper .navigation-wrapper .recent-wrapper .reading-recent .recent-book[data-v-5061e9c0]{font-size:10px;cursor:pointer}.index-wrapper .navigation-wrapper .setting-wrapper[data-v-5061e9c0]{margin-top:36px}.index-wrapper .navigation-wrapper .setting-wrapper .setting-title[data-v-5061e9c0]{font-size:14px;color:#b1b1b1;font-family:FZZCYSK}.index-wrapper .navigation-wrapper .setting-wrapper .no-point[data-v-5061e9c0]{pointer-events:none}.index-wrapper .navigation-wrapper .setting-wrapper .setting-connect[data-v-5061e9c0]{font-size:8px;margin-top:16px;cursor:pointer}.index-wrapper .navigation-wrapper .bottom-icons[data-v-5061e9c0]{position:fixed;bottom:0;height:120px;width:260px;align-items:center;display:flex;flex-direction:row}.index-wrapper .shelf-wrapper[data-v-5061e9c0]{padding:48px;width:100%;display:flex;flex-direction:column;box-sizing:border-box;overflow:hidden}@media screen and (max-width: 750px){.index-wrapper[data-v-5061e9c0]{overflow-x:hidden;flex-direction:column}.index-wrapper .navigation-wrapper[data-v-5061e9c0]{padding:20px 24px;box-sizing:border-box;width:100%}.index-wrapper .navigation-wrapper .navigation-title-wrapper[data-v-5061e9c0]{white-space:nowrap;display:flex;justify-content:space-between;align-items:flex-end}.index-wrapper .navigation-wrapper .bottom-wrapper[data-v-5061e9c0]{flex-direction:row}.index-wrapper .navigation-wrapper .bottom-wrapper[data-v-5061e9c0]>*{flex-grow:1;margin-top:18px}.index-wrapper .navigation-wrapper .bottom-wrapper>* .reading-recent[data-v-5061e9c0],.index-wrapper .navigation-wrapper .bottom-wrapper>* .setting-item[data-v-5061e9c0]{margin-bottom:0}.index-wrapper .navigation-wrapper .bottom-icons[data-v-5061e9c0]{display:none}.index-wrapper .shelf-wrapper[data-v-5061e9c0]{padding:0;flex-grow:1}.index-wrapper .shelf-wrapper[data-v-5061e9c0] .el-loading-spinner{display:none}}.night .navigation-wrapper[data-v-5061e9c0]{background-color:#454545}.night .navigation-wrapper .navigation-title[data-v-5061e9c0]{color:#aeaeae}.night .navigation-wrapper .search-wrapper .search-input .el-input__wrapper[data-v-5061e9c0]{background-color:#454545}.night .navigation-wrapper .search-wrapper .search-input .el-input__inner[data-v-5061e9c0]{color:#b1b1b1}.night[data-v-5061e9c0] .shelf-wrapper{background-color:#161819} ================================================ FILE: app/src/main/assets/web/vue/assets/BookShelf-DIQtBULC.js ================================================ import{d as H,a4 as j,o as d,e as h,h as e,F as N,R as W,z as p,c as $,w as M,g as P,a5 as J,P as x,u as o,C as V,Q as ee,a6 as te,a7 as se,U as oe,f as I,O as ae,G as ne,H as re,D as ie,y as b,M as C,s as le,n as z,a8 as ce}from"./vendor-KSDcS24u.js";import{d as de,A as B,i as ue,_ as O,u as he,a as pe,l as ge,s as ve,p as me,b as D,v as fe}from"./index-Wr40-hHf.js";import{u as _e}from"./loading-C4J6hIxs.js";const we={class:"books-wrapper"},Be={class:"wrapper"},ke=["onClick"],ye={class:"cover-img"},Ae=["src"],Se={class:"info"},xe={class:"name"},Ie={class:"sub"},Ce={class:"author"},Re={key:0,class:"tags"},Ee={key:1,class:"update-info"},Le={class:"size"},be={class:"date"},ze={key:0,class:"intro"},Me={key:1,class:"dur-chapter"},Pe={class:"last-chapter"},Ve=H({__name:"BookItems",props:{books:{},isSearch:{type:Boolean}},emits:["bookClick"],setup(_,{emit:i}){j(g=>({v2a51eeb0:o(A)}));const k=_,R=i,a=g=>R("bookClick",g),y=({bookUrl:g,coverUrl:r})=>r===void 0?B.getProxyCoverUrl(g):ue(r)?B.getProxyCoverUrl(r):r,E=g=>{const r=g.target;r.src=B.getProxyCoverUrl(r.src)},A=V(()=>k.isSearch?"space-between":"flex-start");return(g,r)=>{const v=J;return d(),h("div",we,[e("div",Be,[(d(!0),h(N,null,W(_.books,n=>{var l;return d(),h("div",{class:"book",key:n.bookUrl,onClick:f=>a(n)},[e("div",ye,[(d(),h("img",{class:"cover",src:y(n),key:n.coverUrl,onErrorOnce:E,alt:"",loading:"lazy"},null,40,Ae))]),e("div",Se,[e("div",xe,p(n.name),1),e("div",Ie,[e("div",Ce,p(n.author),1),_.isSearch?(d(),h("div",Re,[(d(!0),h(N,null,W((l=n.kind)==null?void 0:l.split(",").slice(0,2),f=>(d(),$(v,{key:f},{default:M(()=>[P(p(f),1)]),_:2},1024))),128))])):x("",!0),_.isSearch?x("",!0):(d(),h("div",Ee,[r[0]||(r[0]=e("div",{class:"dot"},"•",-1)),e("div",Le,"共"+p(n.totalChapterNum)+"章",1),r[1]||(r[1]=e("div",{class:"dot"},"•",-1)),e("div",be,p(o(de)(n.lastCheckTime)),1)]))]),_.isSearch?(d(),h("div",ze,p(n.intro),1)):x("",!0),_.isSearch?x("",!0):(d(),h("div",Me," 已读:"+p(n.durChapterTitle),1)),e("div",Pe,"最新:"+p(n.latestChapterTitle),1)])],8,ke)}),128))])])}}}),Te=O(Ve,[["__scopeId","data-v-0f5f0160"]]),Ue="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAECUlEQVRYR7WXTYhcRRDHq3pY9yKrYBQ8KBsjgvHgwRhiQBTjYZm4Xe8NusawhwS/o9GLoKhgBGPAgJd1NdGIXwtZTbRf9Rqzl6gHTVyDeIkIgnEOghAM6oKHzTJd0sO8Zaa338zb7NjwmJn++Ndv+lVVVyOsoM3Ozl69sLBAiHiDc26NUuoKv9w5d14p9aeI/DI4OMgjIyN/lJXFMhOttQ8BgBaR0TLzEXEGAKzW+lCv+V0BmLmGiLtF5M5eQrFxRPxaRCaI6LOi9YUAzPwGADxxMYYjayaJ6MkoZKyTmU8AwF19Mp7LfElEW0LNZTvAzIcBYFufjedy00T0QLt2B4AxZo9S6qX/yXhT1jn3cpqme3IbSwDM/DgAvNlu3Dm3Uyl1HAA2IOJ2EdleEu5Io9H4EBHPVCqVLSISRsMuInrLazUBpqamhoaGhr4TkRsDgLVpmtbzPmPMLQBwOwD4vvzxw8P5IyJztVrtVL4my7L1iPhTx7Yj/jw/P79pfHx8vgmQZdkLiPhK+O8GBgauqVarv5f819FpxpjLlVJ/hYMi8mKSJHubAMz8KwBcF1EYI6IjqwRIlFImonGWiNZhlmVVRDxWYGTVAMx8HwB8EtMXka1orT0gIo9GJrxNRLH+FW8IMx8EgEeW5QDEgx5gTkQ2Bk7yr9b60hVb6rKAmc8BwJWBne+x4P3XiWhtPwGstV9FzpSzHuBvALgsMHaaiDp2ZbUwWZZNIuKuQOcfD7AAAJeEcaq1Xr9ao+3rmdknnscCzQse4LdWEukYazQaa2q12vl+QTDztwCwOdCr+zA8iYi3RQwREdl+ADDz9QDwIwB0OLaInPJRcEhEHoyEyAmt9d39ALDW2lg1hYjv+lfgC4WJgkTxcJIkPcuqbpC+qgKATwvm7PYAGwDgdBeRZ4notYvZCWPMDqXUe13W3to8C6y10yJyv//u6zj/2R6ziPiRiBwt6xPMrBExFZEdRcYR8WOt9bb8MNoKAJ+3Jvtwed05d4dSKtz+c4h4VGsdrRWttZMici8AXFVix+4homNLBUmWZQcQMc/9x4mommXZ84i4t11MKbV5dHR06bxvH5uZmbnZOfdN6O0RmMNE1CxulgCstdeKyBcAcFPrVTyltZ4wxiSVSuXplkhda72zh9P1rClFZFOSJHMdAP5Hq3rxR6eH+IGIvIOuqFlr94nIc10WdRzxy6riAMJnr2nn3JlcME3TppMWNWvtfhF5pmB8WX0RvZgEEEtaYUUbM2KtfUdE/FUubNHipvBmZIxZp5TaDwBprlQGIHLqzSHiPq01x4B7Xk6Z2d8TfDwPlwFozfd1f90598Hi4uKrY2NjFwrzQVkP81nNi/byAWOMv8gOp2n6fhnt/wDqJrRWLmhIrwAAAABJRU5ErkJggg==",Ne={class:"navigation-wrapper"},We={class:"search-wrapper"},De={class:"bottom-wrapper"},He={class:"recent-wrapper"},Je={class:"reading-recent"},Oe={class:"setting-wrapper"},Ze={class:"setting-item"},Fe={class:"bottom-icons"},Ke={href:"https://github.com/gedoor/legado_web_bookshelf",target:"_blank"},Ye={class:"bottom-icon"},Qe=["src"],qe=H({__name:"BookShelf",setup(_){const i=he(),k=V(()=>i.isNight),R=s=>{try{s!==void 0&&i.setConfig(s)}catch{z.info("阅读界面配置解析错误")}},a=C({name:"尚无阅读记录",author:"",bookUrl:"",chapterIndex:0,chapterPos:0,isSeachBook:!1}),y=C(),{showLoading:E,closeLoading:A,loadingWrapper:g,isLoading:r}=_e(y,"正在获取书籍信息"),v=le([]),n=V(()=>i.shelf),l=C(""),f=C(!1);ee(()=>{if(!(f.value&&l.value!="")){if(f.value=!1,v.value=[],l.value==""){v.value=n.value;return}v.value=n.value.filter(s=>s.name.includes(l.value)||s.author.includes(l.value))}});const T=()=>{l.value!=""&&(v.value=[],i.clearSearchBooks(),E(),f.value=!0,B.search(l.value,s=>{r&&A();try{i.setSearchBooks(s),v.value=i.searchBooks}catch(t){throw z.error("后端数据错误"),t}},()=>{A(),v.value.length==0&&z.info("搜索结果为空")}))},S=pe(),{connectStatus:Z,connectType:F,newConnect:K}=te(S),Y=()=>{ce.prompt("请输入 后端地址 ( 如:http://127.0.0.1:9527 或者通过内网穿透的地址)","提示",{confirmButtonText:"确定",cancelButtonText:"取消",inputPlaceholder:ge,inputValidator:s=>fe(s),inputErrorMessage:"输入的格式不对",beforeClose:(s,t,m)=>{if(s==="confirm"){S.setNewConnect(!0),t.confirmButtonLoading=!0,t.confirmButtonText="校验中……";const c=new URL(t.inputValue).toString();B.getReadConfig(c).then(function(u){S.setNewConnect(!1),R(u),t.confirmButtonLoading=!1,i.clearSearchBooks(),ve(...me(c)),c===location.origin?localStorage.removeItem(D):localStorage.setItem(D,c),i.loadBookShelf(),m()}).catch(function(u){throw S.setNewConnect(!1),t.confirmButtonLoading=!1,t.confirmButtonText="确定",u})}else m()}})},Q=se(),q=async s=>{const t="respondTime"in s;t&&await B.saveBook(s);const{bookUrl:m,name:c,author:u,durChapterIndex:w=0,durChapterPos:L=0}=s;U(m,c,u,w,L,t)},U=(s,t,m,c,u,w=!1,L=!1)=>{if(t!=="尚无阅读记录"){if(L&&n.value.every(X=>X.bookUrl!==s)){l.value=t,T();return}sessionStorage.setItem("bookUrl",s),sessionStorage.setItem("bookName",t),sessionStorage.setItem("bookAuthor",m),sessionStorage.setItem("chapterIndex",String(c)),sessionStorage.setItem("chapterPos",String(u)),sessionStorage.setItem("isSeachBook",String(w)),a.value={name:t,author:m,bookUrl:s,chapterIndex:c,chapterPos:u,isSeachBook:w},localStorage.setItem("readingRecent",JSON.stringify(a.value)),Q.push({path:"/chapter"})}},G=async()=>{await i.loadWebConfig(),await i.saveBookProgress(),await i.loadBookShelf()};return oe(()=>{const s=localStorage.getItem("readingRecent");s!=null&&(a.value=JSON.parse(s),typeof a.value.chapterIndex>"u"&&(a.value.chapterIndex=0)),g(G())}),(s,t)=>{const m=ie,c=J,u=Te;return d(),h("div",{class:b({"index-wrapper":!0,night:o(k),day:!o(k)})},[e("div",Ne,[t[4]||(t[4]=e("div",{class:"navigation-title-wrapper"},[e("div",{class:"navigation-title"},"阅读"),e("div",{class:"navigation-sub-title"},"清风不识字,何故乱翻书")],-1)),e("div",We,[I(m,{placeholder:"搜索书籍,在线书籍自动加入书架",modelValue:o(l),"onUpdate:modelValue":t[0]||(t[0]=w=>re(l)?l.value=w:null),class:"search-input","prefix-icon":o(ne),onKeyup:ae(T,["enter"])},null,8,["modelValue","prefix-icon"])]),e("div",De,[e("div",He,[t[2]||(t[2]=e("div",{class:"recent-title"},"最近阅读",-1)),e("div",Je,[I(c,{type:o(a).name=="尚无阅读记录"?"warning":"primary",class:b(["recent-book",{"no-point":o(a).bookUrl==""}]),size:"large",onClick:t[1]||(t[1]=w=>U(o(a).bookUrl,o(a).name,o(a).author,o(a).chapterIndex,o(a).chapterPos,o(a).isSeachBook,!0))},{default:M(()=>[P(p(o(a).name),1)]),_:1},8,["type","class"])])]),e("div",Oe,[t[3]||(t[3]=e("div",{class:"setting-title"},"基本设定",-1)),e("div",Ze,[I(c,{type:o(F),size:"large",class:b(["setting-connect",{"no-point":o(K)}]),onClick:Y},{default:M(()=>[P(p(o(Z)),1)]),_:1},8,["type","class"])])])]),e("div",Fe,[e("a",Ke,[e("div",Ye,[e("img",{src:o(Ue),alt:""},null,8,Qe)])])])]),e("div",{class:"shelf-wrapper",ref_key:"shelfWrapper",ref:y},[I(u,{books:o(v),onBookClick:q,isSearch:o(f)},null,8,["books","isSearch"])],512)],2)}}}),$e=O(qe,[["__scopeId","data-v-5061e9c0"]]);export{$e as default}; ================================================ FILE: app/src/main/assets/web/vue/assets/index-CrxHVQK7.css ================================================ .el-link[data-v-085627fb]{padding:4px}.el-text[data-v-085627fb]{padding-top:20px}[data-v-d8dae8d3] .el-checkbox__label{flex:1;display:flex;justify-content:space-between;align-items:center}.error[data-v-d8dae8d3]{border-color:var(--el-color-error)!important;color:var(--el-color-error)!important;--el-checkbox-checked-text-color: var(--el-color-error);--el-checkbox-checked-bg-color: var(--el-color-error);--el-checkbox-checked-input-border-color: var(--el-color-error)}.edit[data-v-d8dae8d3]{border-color:var(--el-color-dark)!important}.tool[data-v-258cd99b]{display:flex;margin:4px 0;justify-content:center}#source-list[data-v-258cd99b]{margin-top:6px;height:calc(100vh - 119px)}#source-list[data-v-258cd99b] .el-checkbox{margin-bottom:4px;width:100%}[data-v-3ac68c8a] #debug-text{height:calc(100vh - 86px)}[data-v-f62d9369] .el-input{width:100%}[data-v-f62d9369] #source-json{height:calc(100vh - 50px)}[data-v-fd81540f] .el-tabs__header{margin-bottom:5px}.flex-space-between[data-v-096adf6d]{display:flex;justify-content:space-between;align-items:baseline}.flex-column-center[data-v-096adf6d]{display:flex;flex-direction:column;justify-content:center}.menu>.el-button[data-v-096adf6d]{margin:4px;padding:1em;width:6em}.hotkeys-item .title[data-v-096adf6d]{width:5em;display:flex;justify-content:flex-end;margin-right:1em}.hotkeys-item .hotkeys-item__content[data-v-096adf6d]{display:flex;flex-wrap:wrap;flex:1}.hotkeys-item .hotkeys-item__content div[data-v-096adf6d]{margin-bottom:1em}.hotkeys-item .hotkeys-item__content span[data-v-096adf6d]{margin:.5em}[data-v-c07c5146] .el-tab-pane{height:calc(100vh - 55px);padding-top:15px;padding-right:5px;overflow-y:auto}[data-v-c07c5146] .el-tabs__header{margin:0}kbd{align-items:center;background:#7d7d7d1a;border-radius:3px;border:0;padding:4px 5px;font-weight:700;box-shadow:inset 0 -2px #cdcde6,inset 0 0 1px 1px #fff,0 1px 2px 1px #1e235a66}code{border-radius:4px;padding:.15rem .5rem;background-color:var(--el-fill-color-light);transition:color .25s,background-color .5s;font-size:14px}body{padding:0;margin:0}.el-tabs__header{position:sticky;top:0;z-index:2}.editor[data-v-f2c47af3]{display:flex;height:100vh;overflow:hidden}.editor .left[data-v-f2c47af3]{flex:1;margin-left:20px}.editor .right[data-v-f2c47af3]{flex:1;width:360px;margin-right:20px} ================================================ FILE: app/src/main/assets/web/vue/assets/index-Wr40-hHf.js ================================================ const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./BookShelf-DIQtBULC.js","./vendor-KSDcS24u.js","./vendor-CXe1BRiH.css","./loading-C4J6hIxs.js","./loading-DkQYEuap.css","./BookShelf-00b2QCsd.css","./BookChapter-Cs3stH93.js","./BookChapter-BsiFtdIw.css"])))=>i.map(i=>d[i]); import{r as Ke,o as g,c as C,a as le,b as ae,d as K,e as E,F as N,f as h,E as We,u as i,l as V,w as p,g as y,h as f,i as Ce,j as He,k as Me,m as ce,t as Ue,n as k,p as me,s as Se,q as Fe,v as Be,x as qe,y as Ee,z as ee,A as ue,B as xe,C as A,D as te,G as Le,H as M,I as ze,J as Ge,K as fe,L as Ye,V as Qe,M as J,N as de,O as Xe,P as I,Q as Ze,R as W,S as Re,T as Ie,U as et,W as tt,X as ot,Y as z,Z as nt,_ as rt,$ as st,a0 as it,a1 as lt,a2 as at,a3 as ct}from"./vendor-KSDcS24u.js";(function(){const o=document.createElement("link").relList;if(o&&o.supports&&o.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))n(r);new MutationObserver(r=>{for(const s of r)if(s.type==="childList")for(const c of s.addedNodes)c.tagName==="LINK"&&c.rel==="modulepreload"&&n(c)}).observe(document,{childList:!0,subtree:!0});function t(r){const s={};return r.integrity&&(s.integrity=r.integrity),r.referrerPolicy&&(s.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?s.credentials="include":r.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function n(r){if(r.ep)return;r.ep=!0;const s=t(r);fetch(r.href,s)}})();const j=(e,o)=>{const t=e.__vccOpts||e;for(const[n,r]of o)t[n]=r;return t},ut={};function dt(e,o){const t=Ke("router-view");return g(),C(t)}const Te=j(ut,[["render",dt]]),pt="modulepreload",gt=function(e,o){return new URL(e,o).href},ye={},be=function(o,t,n){let r=Promise.resolve();if(t&&t.length>0){const c=document.getElementsByTagName("link"),a=document.querySelector("meta[property=csp-nonce]"),_=(a==null?void 0:a.nonce)||(a==null?void 0:a.getAttribute("nonce"));r=Promise.allSettled(t.map(w=>{if(w=gt(w,n),w in ye)return;ye[w]=!0;const U=w.endsWith(".css"),x=U?'[rel="stylesheet"]':"";if(!!n)for(let b=c.length-1;b>=0;b--){const v=c[b];if(v.href===w&&(!U||v.rel==="stylesheet"))return}else if(document.querySelector(`link[href="${w}"]${x}`))return;const l=document.createElement("link");if(l.rel=U?"stylesheet":pt,U||(l.as="script"),l.crossOrigin="",l.href=w,_&&l.setAttribute("nonce",_),document.head.appendChild(l),U)return new Promise((b,v)=>{l.addEventListener("load",b),l.addEventListener("error",()=>v(new Error(`Unable to preload CSS for ${w}`)))})}))}function s(c){const a=new Event("vite:preloadError",{cancelable:!0});if(a.payload=c,window.dispatchEvent(a),!a.defaultPrevented)throw c}return r.then(c=>{for(const a of c||[])a.status==="rejected"&&s(a.reason);return o().catch(s)})},Ve=[{path:"/",name:"shelf",component:()=>be(()=>import("./BookShelf-DIQtBULC.js"),__vite__mapDeps([0,1,2,3,4,5]),import.meta.url)},{path:"/chapter",name:"chapter",component:()=>be(()=>import("./BookChapter-Cs3stH93.js"),__vite__mapDeps([6,1,2,3,4,7]),import.meta.url)}];le({history:ae(),routes:Ve});const ht={style:{"margin-top":"20px"}},mt=K({__name:"SourceHelp",setup(e){return(o,t)=>{const n=We,r=Ce;return g(),E(N,null,[h(n,{icon:i(V),href:"/help/#appHelp",target:"_blank"},{default:p(()=>[...t[0]||(t[0]=[y("APP帮助文档",-1)])]),_:1},8,["icon"]),t[19]||(t[19]=f("br",null,null,-1)),h(n,{icon:i(V),href:"/help/#ruleHelp",target:"_blank"},{default:p(()=>[...t[1]||(t[1]=[y("书源制作教程",-1)])]),_:1},8,["icon"]),t[20]||(t[20]=f("br",null,null,-1)),h(n,{icon:i(V),href:"/help/#jsHelp",target:"_blank"},{default:p(()=>[...t[2]||(t[2]=[y("js变量和函数",-1)])]),_:1},8,["icon"]),t[21]||(t[21]=f("br",null,null,-1)),h(n,{icon:i(V),href:"/help/#xpathHelp",target:"_blank"},{default:p(()=>[...t[3]||(t[3]=[y("xpath语法教程",-1)])]),_:1},8,["icon"]),t[22]||(t[22]=f("br",null,null,-1)),h(n,{icon:i(V),href:"/help/#regexHelp",target:"_blank"},{default:p(()=>[...t[4]||(t[4]=[y("正则表达式教程",-1)])]),_:1},8,["icon"]),t[23]||(t[23]=f("br",null,null,-1)),h(n,{icon:i(V),href:"/help/#txtTocRuleHelp",target:"_blank"},{default:p(()=>[...t[5]||(t[5]=[y("txt目录正则说明",-1)])]),_:1},8,["icon"]),t[24]||(t[24]=f("br",null,null,-1)),h(n,{icon:i(V),href:"/help/#debugHelp",target:"_blank"},{default:p(()=>[...t[6]||(t[6]=[y("书源调试说明",-1)])]),_:1},8,["icon"]),t[25]||(t[25]=f("br",null,null,-1)),h(n,{icon:i(V),href:"/help/#httpTTSHelp",target:"_blank"},{default:p(()=>[...t[7]||(t[7]=[y("在线朗读规则",-1)])]),_:1},8,["icon"]),t[26]||(t[26]=f("br",null,null,-1)),h(n,{icon:i(V),href:"/help/#webDavBookHelp",target:"_blank"},{default:p(()=>[...t[8]||(t[8]=[y(" WebDav书籍简明使用教程",-1)])]),_:1},8,["icon"]),t[27]||(t[27]=f("br",null,null,-1)),h(n,{icon:i(V),href:"/help/#webDavHelp",target:"_blank"},{default:p(()=>[...t[9]||(t[9]=[y(" WebDav备份教程",-1)])]),_:1},8,["icon"]),t[28]||(t[28]=f("br",null,null,-1)),h(n,{icon:i(V),href:"https://regexr-cn.com/",target:"_blank"},{default:p(()=>[...t[10]||(t[10]=[y("正则表达式在线验证工具",-1)])]),_:1},8,["icon"]),t[29]||(t[29]=f("br",null,null,-1)),f("div",ht,[f("span",null,[h(r,null,{default:p(()=>[...t[11]||(t[11]=[f("code",null,"^$()[]{}.?+*|",-1),y(" 这些是Java正则特殊符号,匹配需转义",-1)])]),_:1})]),t[15]||(t[15]=f("br",null,null,-1)),f("span",null,[h(r,null,{default:p(()=>[...t[12]||(t[12]=[f("code",null,"(?s)",-1),y(" 前缀表示跨行解析",-1)])]),_:1})]),t[16]||(t[16]=f("br",null,null,-1)),f("span",null,[h(r,null,{default:p(()=>[...t[13]||(t[13]=[f("code",null,"(?m)",-1),y(" 前缀表示逐行匹配",-1)])]),_:1})]),t[17]||(t[17]=f("br",null,null,-1)),f("span",null,[h(r,null,{default:p(()=>[...t[14]||(t[14]=[f("code",null,"(?i)",-1),y(" 前缀表示忽略大小写",-1)])]),_:1})]),t[18]||(t[18]=f("br",null,null,-1))])],64)}}}),St=j(mt,[["__scopeId","data-v-085627fb"]]),ft="remoteUrl",yt=1e3,B=He.create({baseURL:localStorage.getItem(ft)||location.origin,timeout:120*yt});let $="",pe="",ge=()=>{},H=()=>{};const bt=e=>H=e,_t=e=>{ge=e},kt=(e,o)=>{$=new URL(e).toString(),pe=new URL(o).toString(),B.defaults.baseURL=$},vt=async(e=$)=>{const{data:o}=await B.get("getReadConfig",{baseURL:e.toString(),timeout:3e3});if(o.isSuccess)try{return JSON.parse(o.data)}catch{}},wt=e=>B.post("saveReadConfig",e),Ct=e=>B.post("saveBookProgress",e),Ut=e=>{e&&navigator.sendBeacon(new URL("saveBookProgress",$),JSON.stringify(e))},Bt=()=>B.get("getBookshelf"),Et=e=>B.get("getChapterList?url="+encodeURIComponent(e)),xt=(e,o)=>B.get("getBookContent?url="+encodeURIComponent(e)+"&index="+o),Lt=(e,o,t)=>{const n=new WebSocket(new URL("searchBook",pe));n.onerror=ge,n.onopen=()=>{n.send(`{"key":"${e}"}`)},n.onmessage=r=>{try{o(JSON.parse(r.data)),H==null||H.call(n,r)}catch{t()}},n.onclose=()=>{t()}},Rt=e=>B.post("saveBook",e),It=e=>B.post("deleteBook",e),Z=/bookSource/i.test(location.href),Tt=()=>Z?B.get("getBookSources"):B.get("getRssSources"),Vt=e=>Z?B.post("saveBookSource",e):B.post("saveRssSource",e),Nt=e=>Z?B.post("saveBookSources",e):B.post("saveRssSources",e),Ot=e=>Z?B.post("deleteBookSources",e):B.post("deleteRssSources",e),Dt=(e,o,t,n)=>{const r=new URL(`${Z?"bookSource":"rssSource"}Debug`,pe),s=new WebSocket(r);s.onerror=ge,s.onopen=()=>{s.send(JSON.stringify({tag:e,key:o}))},s.onmessage=c=>{t(c.data),H==null||H.call(s,c)},s.onclose=()=>{n()}},Jt=e=>e.startsWith($)?e:new URL("cover?path="+encodeURIComponent(e),$).toString(),Pt=(e,o,t)=>o.startsWith($)?o:new URL("image?path="+encodeURIComponent(o)+"&url="+encodeURIComponent(e)+"&width="+t,$).toString(),P={getReadConfig:vt,saveReadConfig:wt,saveBookProgress:Ct,saveBookProgressWithBeacon:Ut,getBookShelf:Bt,getChapterList:Et,getBookContent:xt,search:Lt,saveBook:Rt,deleteBook:It,getSources:Tt,saveSources:Nt,saveSource:Vt,deleteSource:Ot,debug:Dt,getProxyCoverUrl:Jt,getProxyImageUrl:Pt},X=e=>e==null||e.length===0||/^\s+$/.test(e),Uo=e=>/,\s*\{/.test(e)||!(e.startsWith("http")||e.startsWith("data:")||e.startsWith("blob:")),$t=(e,o=["https:","http:"])=>{try{const t=new URL(e),{protocol:n}=t;if(!o.includes(n))throw new Error(`Expected protocol ${o.join("/")}, but ${n}`);return!0}catch{return!1}},Bo=e=>{const o=new Date().getTime(),t=Math.floor((o-e)/1e3);let n="";return t<=30?n="刚刚":t<60?n=t+"秒前":t<3600?n=Math.floor(t/60)+"分钟前":t<86400?n=Math.floor(t/3600)+"小时前":t<2592e3?n=Math.floor(t/86400)+"天前":n=Me(new Date(e),"YYYY-MM-DD"),n},jt={theme:0,font:0,fontSize:18,readWidth:800,infiniteLoading:!1,customFontName:"",jumpDuration:1e3,spacing:{paragraph:1,line:.8,letter:0}};let _e;const At=ce("book",{state:()=>({searchBooks:[],shelf:[],catalog:[],readingBook:{chapterPos:0,chapterIndex:0},popCataVisible:!1,contentLoading:!0,showContent:!1,config:jt,miniInterface:!1,readSettingsVisible:!1}),getters:{bookProgress:e=>{var c;if(e.catalog.length==0)return;const{chapterIndex:o,chapterPos:t,name:n,author:r}=e.readingBook,s=(c=e.catalog[o])==null?void 0:c.title;if(s)return{name:n,author:r,durChapterIndex:o,durChapterPos:t,durChapterTime:new Date().getTime(),durChapterTitle:s}},theme:e=>e.config.theme,isNight:e=>e.config.theme==6},actions:{async loadBookShelf(){const e=P.getBookShelf().then(o=>{const{isSuccess:t,data:n,errorMsg:r}=o.data;if(t===!0)this.shelf.length!==n.length&&this.shelf.length>0&&n.length>0&&k.info("书架数据已更新"),this.shelf=n.sort((s,c)=>{const a=s.durChapterTime||0;return(c.durChapterTime||0)-a});else{if(r.includes("还没有添加小说")&&this.shelf.length>0)return k.info("当前书架上的书籍已经被删除"),this.shelf=[];k.error(r??"后端返回格式错误!")}return this.shelf});return this.shelf.length>0?this.shelf:await e},async loadWebCatalog(e){const{bookUrl:o,name:t,chapterIndex:n}=e,r=P.getChapterList(o).then(s=>{const{isSuccess:c,data:a,errorMsg:_}=s.data;if(c===!1)throw k.error(_),new Error;return o===this.readingBook.bookUrl&&a.length!==this.catalog.length&&a.length>0&&this.catalog.length>0&&k.info(`书籍${t}: 章节目录已更新`),this.catalog=a,this.catalog});return o===this.readingBook.bookUrl&&this.catalog.length>0&&this.catalog.length-1>=n?this.catalog:await r},setPopCataVisible(e){this.popCataVisible=e},setContentLoading(e){this.contentLoading=e},setReadingBook(e){this.readingBook=e},async loadWebConfig(){if(_e===void 0){const e=await P.getReadConfig();return _e=new Date,this.setConfig(e)}},setConfig(e){this.config=Object.assign({},this.config,e)},setReadSettingsVisible(e){this.readSettingsVisible=e},setShowContent(e){this.showContent=e},setMiniInterface(e){this.miniInterface=e},async setSearchBooks(e){e.forEach(o=>{this.shelf.every(n=>n.bookUrl!==o.bookUrl)===!0&&this.searchBooks.push(o)})},clearSearchBooks(){this.searchBooks=[]},async saveBookProgress(){if(!this.bookProgress)return Promise.resolve();const{bookUrl:e}=this.readingBook,o=Ue(this.shelf),t=o.findIndex(n=>n.bookUrl===e);return t>-1&&(this.shelf[t]=Object.assign({},o[t],this.bookProgress)),P.saveBookProgressWithBeacon(this.bookProgress)}}}),oe=e=>"bookSourceName"in e,Kt=e=>oe(e)?!X(e.bookSourceName)&&!X(e.bookSourceUrl)&&!X(e.bookSourceType):!X(e.sourceName)&&!X(e.sourceUrl),ne=e=>oe(e)?e.bookSourceUrl:e.sourceUrl,he=e=>oe(e)?e.bookSourceName:e.sourceName,Wt=(e,o)=>{var t,n,r,s;return oe(e)?(e.bookSourceName.includes(o)||e.bookSourceUrl.includes(o)||((t=e.bookSourceGroup)==null?void 0:t.includes(o))||((n=e.bookSourceComment)==null?void 0:n.includes(o)))??!1:(e.sourceName.includes(o)||e.sourceUrl.includes(o)||((r=e.sourceGroup)==null?void 0:r.includes(o))||((s=e.sourceComment)==null?void 0:s.includes(o)))??!1},ie=e=>{const o=new Map;return e.forEach(t=>o.set(ne(t),t)),o},Ne=e=>{for(const o in e){const t=e[o];t===""||t===null||typeof t=="string"&&!t.trim()?delete e[o]:t instanceof Object&&Ne(t)}},Ht={ruleSearch:{},ruleBookInfo:{},ruleToc:{},ruleContent:{},ruleExplore:{}},Mt={},G=/bookSource/i.test(location.href),ke=G?Ht:Mt,F=ce("source",{state:()=>({bookSources:Se([]),rssSources:Se([]),savedSources:[],currentSource:JSON.parse(JSON.stringify(ke)),currentTab:localStorage.getItem("tabName")||"editTab",editTabSource:{},isDebuging:!1}),getters:{sources:e=>G?e.bookSources:e.rssSources,sourcesMap:function(){return ie(this.sources)},savedSourcesMap:e=>ie(e.savedSources),currentSourceUrl:e=>G?e.currentSource.bookSourceUrl:e.currentSource.sourceUrl,searchKey:e=>{var o,t;return G?((t=(o=e.currentSource)==null?void 0:o.ruleSearch)==null?void 0:t.checkKeyWord)||"我的":""}},actions:{startDebug(){this.currentTab="editDebug",this.isDebuging=!0},debugFinish(){this.isDebuging=!1},saveSources(e){G?this.bookSources=me(e):this.rssSources=me(e)},setPushReturnSources(e){this.savedSources=e},deleteSources(e){const o=G?this.bookSources:this.rssSources;e.forEach(t=>{const n=o.indexOf(t);n>-1&&o.splice(n,1)})},saveCurrentSource(){const e=this.currentSource,o=this.sourcesMap;o.set(ne(e),JSON.parse(JSON.stringify(e))),this.saveSources(Array.from(o.values()))},changeCurrentSource(e){this.currentSource=JSON.parse(JSON.stringify(e))},changeTabName(e){this.currentTab=e,localStorage.setItem("tabName",e)},changeEditTabSource(e){this.editTabSource=JSON.parse(JSON.stringify(e))},editHistory(e){let o;if(localStorage.getItem("history"))o=JSON.parse(localStorage.getItem("history")),o.new.push(e),o.new.length>50&&o.new.shift(),o.old.length>50&&o.old.shift(),localStorage.setItem("history",JSON.stringify(o));else{const t={new:[e],old:[]};localStorage.setItem("history",JSON.stringify(t))}},editHistoryUndo(){if(localStorage.getItem("history")){const e=JSON.parse(localStorage.getItem("history"));e.old.push(this.currentSource),e.new.length&&(this.currentSource=e.new.pop()),localStorage.setItem("history",JSON.stringify(e))}},clearAllHistory(){localStorage.setItem("history",JSON.stringify({new:[],old:[]}))},clearEdit(){this.editTabSource={},this.currentSource=JSON.parse(JSON.stringify(ke))},clearAllSource(){this.bookSources=[],this.rssSources=[],this.savedSources=[]}}}),Ft=ce("connection",{state:()=>({connectStatus:"正在连接后端服务器……",connectType:"primary",newConnect:!1}),actions:{setConnectStatus(e){this.newConnect!==!0&&(this.connectStatus=e)},setConnectType(e){this.newConnect!==!0&&(this.connectType=e)},setNewConnect(e){this.newConnect=e}}}),Oe=Fe();Be(Te).use(Oe);const Y=Ft(),ve=Array.of("isSuccess","errorMsg"),De=k,qt=e=>{let o=!0;try{const t=e.data;for(const n of ve)n in t||(o=!1,ve.length=0);t.isSuccess===!0&&("data"in t||(o=!1))}catch{o=!1}if(o===!1)throw De.warning({message:"后端返回内容格式错误",grouping:!0}),new Error;return Y.setConnectType("primary"),Y.setConnectStatus("已连接 "+$),e},Je=e=>{throw De.error({message:"后端连接失败,请检查阅读WEB服务或者设置其它可用链接",grouping:!0}),Y.setConnectType("danger"),Y.setConnectStatus("连接异常"),e};B.interceptors.response.use(qt,Je);_t(Je);bt(()=>{Y.setConnectType("primary"),Y.setConnectStatus("已连接 "+$)});const zt=e=>{let o=new URL(location.origin);$t(e)&&(o=new URL(e));const{protocol:t,port:n}=o;let r;n!==""?r=String(Number(n)+1):r=t.startsWith("https:")?"444":"81";const s=t.startsWith("https:")?"wss://":"ws://",c=o.toString();o.protocol=s,o.port=r;const a=o.toString();return[c,a]};kt(...zt(B.defaults.baseURL));const Gt=K({__name:"SourceItem",props:{source:{}},setup(e){const o=e,t=F(),n=A(()=>t.currentSourceUrl),r=A(()=>ne(o.source)),s=a=>{t.changeCurrentSource(a)},c=A(()=>{const a=t.savedSourcesMap;return a.size==0?!1:!a.has(r.value)});return(a,_)=>{const w=ue,U=qe;return g(),C(U,{size:"large",border:"",value:i(r),class:Ee({error:i(c),edit:i(r)==i(n)})},{default:p(()=>[y(ee(i(he)(e.source))+" ",1),h(w,{text:"",icon:i(xe),onClick:_[0]||(_[0]=x=>s(e.source))},null,8,["icon"])]),_:1},8,["value","class"])}}}),Yt=j(Gt,[["__scopeId","data-v-d8dae8d3"]]),Qt={class:"tool"},Xt=K({__name:"SourceList",setup(e){const o=F(),t=J([]),n=J(""),r=A(()=>o.sources),s=A(()=>{const d=n.value;return d===""?r.value:r.value.filter(l=>Wt(l,d))}),c=A(()=>{const d=t.value;if(d.length==0)return[];const l=n.value==""?o.sourcesMap:ie(s.value);return d.reduce((b,v)=>{const L=l.get(v);return L&&b.push(L),b},[])}),a=()=>{const d=c.value;P.deleteSource(d).then(({data:l})=>{if(!l.isSuccess)return k.error(l.errorMsg);o.deleteSources(d);const b=Ue(t.value);d.forEach(v=>{const L=b.indexOf(ne(v));L>-1&&b.splice(L,1)}),t.value=b})},_=()=>{o.clearAllSource(),t.value=[]},w=()=>{const d=document.createElement("input");d.type="file",d.accept=".json,.txt",d.addEventListener("change",()=>{const l=d.files;if(l===null)return k.info("未选择文件");const b=new FileReader;b.readAsText(l[0]),b.onload=()=>{try{const v=JSON.parse(b.result);o.saveSources(v)}catch(v){k.error("上传的源格式错误: "+v.message)}}}),d.click()},U=/bookSource/i.test(window.location.href),x=()=>{const d=document.createElement("a"),l=t.value.length===0?s.value:c.value,b=U?"BookSource":"RssSource";d.download=`${b}_${Date().replace(/.*?\s(\d+)\s(\d+)\s(\d+:\d+:\d+).*/,"$2$1$3").replace(/:/g,"")}.json`;const v=new Blob([JSON.stringify(l,null,4)],{type:"application/json"});d.href=window.URL.createObjectURL(v),d.click(),window.URL.revokeObjectURL(d.href)};return(d,l)=>{const b=te,v=ue,L=Ye;return g(),E(N,null,[h(b,{modelValue:i(n),"onUpdate:modelValue":l[0]||(l[0]=O=>M(n)?n.value=O:null),class:"search","prefix-icon":i(Le),placeholder:"筛选源"},null,8,["modelValue","prefix-icon"]),f("div",Qt,[h(v,{onClick:w,icon:i(ze)},{default:p(()=>[...l[2]||(l[2]=[y("打开",-1)])]),_:1},8,["icon"]),h(v,{disabled:i(s).length===0,onClick:x,icon:i(Ge)},{default:p(()=>[...l[3]||(l[3]=[y(" 导出",-1)])]),_:1},8,["disabled","icon"]),h(v,{type:"danger",icon:i(fe),onClick:a,disabled:i(c).length===0},{default:p(()=>[...l[4]||(l[4]=[y("删除",-1)])]),_:1},8,["icon","disabled"]),h(v,{type:"danger",icon:i(fe),onClick:_,disabled:i(r).length===0},{default:p(()=>[...l[5]||(l[5]=[y("清空",-1)])]),_:1},8,["icon","disabled"])]),h(L,{id:"source-list",modelValue:i(t),"onUpdate:modelValue":l[1]||(l[1]=O=>M(t)?t.value=O:null)},{default:p(()=>[h(i(Qe),{style:{height:"100%","overflow-y":"auto","overflow-x":"hidden"},"data-key":O=>i(he)(O),"data-sources":i(s),"data-component":Yt,"estimate-size":45},null,8,["data-key","data-sources"])]),_:1},8,["modelValue"])],64)}}}),Zt=j(Xt,[["__scopeId","data-v-258cd99b"]]),eo=K({__name:"SourceDebug",setup(e){const o=F(),t=J(""),n=J("");de(()=>o.isDebuging,()=>{o.isDebuging&&s()});const r=a=>{const _=document.querySelector("#debug-text");_.scrollTop=_.scrollHeight,t.value+=a+` `},s=async()=>{t.value="";try{await P.saveSource(o.currentSource)}catch(a){throw o.debugFinish(),a}P.debug(o.currentSourceUrl,n.value||o.searchKey,r,o.debugFinish)},c=A(()=>/bookSource/i.test(window.location.href));return(a,_)=>{const w=te;return g(),E(N,null,[i(c)?(g(),C(w,{key:0,id:"debug-key",modelValue:i(n),"onUpdate:modelValue":_[0]||(_[0]=U=>M(n)?n.value=U:null),placeholder:"搜索书名、作者","prefix-icon":i(Le),style:{"padding-bottom":"4px"},onKeydown:Xe(s,["enter"])},null,8,["modelValue","prefix-icon"])):I("",!0),h(w,{id:"debug-text",modelValue:i(t),"onUpdate:modelValue":_[1]||(_[1]=U=>M(t)?t.value=U:null),type:"textarea",readonly:"",rows:29,placeholder:"这里用于输出调试信息"},null,8,["modelValue"])],64)}}}),to=j(eo,[["__scopeId","data-v-3ac68c8a"]]),oo=K({__name:"SourceJson",setup(e){const o=F(),t=J(""),n=async r=>{try{o.changeEditTabSource(JSON.parse(r))}catch{k({message:"粘贴的源格式错误",type:"error"})}};return Ze(async()=>{const r=o.editTabSource;Object.keys(r).length>0?t.value=JSON.stringify(r,null,4):t.value=""}),(r,s)=>{const c=te;return g(),C(c,{id:"source-json",modelValue:i(t),"onUpdate:modelValue":s[0]||(s[0]=a=>M(t)?t.value=a:null),type:"textarea",placeholder:"这里输出序列化的JSON数据,可直接导入'阅读'APP",rows:30,onChange:n,style:{"margin-bottom":"4px"}},null,8,["modelValue"])}}}),no=j(oo,[["__scopeId","data-v-f62d9369"]]),ro=K({__name:"SourceTabTools",setup(e){const o=F(),t=A({get:()=>o.currentTab,set:r=>o.currentTab=r}),n=J([["editTab","编辑源"],["editDebug","调试源"],["editList","源列表"],["editHelp","帮助信息"]]);return(r,s)=>{const c=no,a=to,_=Zt,w=St,U=Re,x=Ie;return g(),C(x,{modelValue:i(t),"onUpdate:modelValue":s[0]||(s[0]=d=>M(t)?t.value=d:null)},{default:p(()=>[(g(!0),E(N,null,W(i(n),(d,l)=>(g(),C(U,{key:d[0],name:d[0],label:d[1]},{default:p(()=>[l==0?(g(),C(c,{key:0})):I("",!0),l==1?(g(),C(a,{key:1})):I("",!0),l==2?(g(),C(_,{key:2})):I("",!0),l==3?(g(),C(w,{key:3})):I("",!0)]),_:2},1032,["name","label"]))),128))]),_:1},8,["modelValue"])}}}),so=j(ro,[["__scopeId","data-v-fd81540f"]]),io={class:"menu flex-column-center"},lo={class:"hotkeys-header flex-space-between"},ao=["id"],co={key:0},uo={class:"hotkeys-settings flex-column-center"},po={class:"title"},go={class:"hotkeys-item__content"},ho={key:0},mo={key:0},So=K({__name:"ToolBar",setup(e){const o=F(),t=()=>{const S=k({message:"加载中……",showClose:!0,duration:0});P.getSources().then(({data:u})=>{u.isSuccess?(o.changeTabName("editList"),o.saveSources(u.data),k({message:`成功拉取${u.data.length}条源`,type:"success"})):k({message:u.errorMsg??"后端错误",type:"error"})}).finally(()=>S.close())},n=()=>{const S=o.sources;if(o.changeTabName("editList"),S.length===0)return k({message:"空空如也",type:"info"});k({message:"正在推送中",type:"info"}),P.saveSources(S).then(({data:u})=>{if(u.isSuccess){const m=u.data;if(Array.isArray(m)){let D="";S.length>m.length&&(D=` 推送失败的源将用红色字体标注!`,o.setPushReturnSources(m)),k({message:`批量推送源到「阅读3.0APP」 共计: ${S.length} 条 成功: ${m.length} 条 失败: ${S.length-m.length} 条${D}`,type:"success"})}}else k({message:`批量推送源失败! ErrorMsg: ${u.errorMsg}`,type:"error"})})},r=()=>{o.changeTabName("editTab"),o.changeEditTabSource(o.currentSource)},s=()=>{o.changeCurrentSource(o.editTabSource)},c=()=>{o.editHistoryUndo()},a=()=>{o.clearEdit(),k({message:"已清除",type:"success"})},_=()=>{o.clearEdit(),o.clearAllHistory(),k({message:"已清除所有历史记录",type:"success"})},w=()=>{const S=o.currentSource;Kt(S)?(Ne(S),P.saveSource(S).then(({data:u})=>{const m=he(S);u.isSuccess?(k({message:`源《${m}》已成功保存到「阅读3.0APP」`,type:"success"}),o.saveCurrentSource()):k({message:`源《${m}》保存失败! ErrorMsg: ${u.errorMsg}`,type:"error"})})):k({message:"请检查<必填>项是否全部填写",type:"error"})},U=()=>{o.startDebug()},x=J(Array.of({name:"⇈推送源",hotKeys:[],action:n},{name:"⇊拉取源",hotKeys:[],action:t},{name:"⋙生成源",hotKeys:[],action:r},{name:"⋘编辑源",hotKeys:[],action:s},{name:"✗清空表单",hotKeys:[],action:a},{name:"↶撤销操作",hotKeys:[],action:c},{name:"↷重做操作",hotKeys:[],action:_},{name:"⇏调试源",hotKeys:[],action:U},{name:"✓保存源",hotKeys:[],action:w})),d=J(!0),l=J(!1),b=J(-1),v=()=>{l.value||(d.value=!1),l.value=!1};de(d,S=>{if(!S){z.unbind("*"),Q(),q();return}Q(),z.unbind(),z("*",u=>{u.preventDefault();const m=z.getPressedKeyString();m.length==1&&m[0]=="esc"||l.value&&b.value>-1&&(x.value[b.value].hotKeys=m)})},{immediate:!0});const L=S=>{l.value=!0,k({message:"按ESC键或者点击空白处结束录入",type:"info"}),x.value[S].hotKeys=[],b.value=S},O=()=>{const S=[];x.value.forEach(({hotKeys:u})=>{S.push(u)}),T(S),d.value=!1},q=()=>{z.filter=()=>!0,x.value.forEach(({hotKeys:S,action:u})=>{S.length!=0&&z(S.join("+"),m=>{m.preventDefault(),u.call(null)})})},T=S=>{localStorage.setItem("legado_web_hotkeys",JSON.stringify(S))};function Q(){try{const S=localStorage.getItem("legado_web_hotkeys");if(S===null)return!1;const u=JSON.parse(S);return!Array.isArray(u)||u.length==0?!1:(x.value.forEach((m,D)=>m.hotKeys=u[D]),!0)}catch{k({message:"快捷键配置错误",type:"error"}),localStorage.removeItem("legado_web_hotkeys")}return!1}return et(()=>{Q()&&(d.value=!1)}),(S,u)=>{const m=ue,D=Ce,je=tt;return g(),E(N,null,[f("div",io,[(g(!0),E(N,null,W(i(x),R=>(g(),C(m,{size:"large",key:R.name,onClick:R.action},{default:p(()=>[y(ee(R.name),1)]),_:2},1032,["onClick"]))),128)),h(m,{size:"large",onClick:u[0]||(u[0]=()=>d.value=!0)},{default:p(()=>[...u[2]||(u[2]=[y("快捷键",-1)])]),_:1})]),h(je,{modelValue:i(d),"onUpdate:modelValue":u[1]||(u[1]=R=>M(d)?d.value=R:null),"show-close":!1,"before-close":v},{header:p(({titleClass:R,titleId:re})=>[f("div",lo,[f("div",{id:re,class:Ee(R)},[u[4]||(u[4]=y(" 快捷键设置 ",-1)),i(l)?(g(),E("span",co,[h(D,null,{default:p(()=>[...u[3]||(u[3]=[y(" / 录入中 ",-1)])]),_:1})])):I("",!0)],10,ao),h(m,{disabled:i(l),onClick:O,icon:i(ot)},{default:p(()=>[...u[5]||(u[5]=[y("保存",-1)])]),_:1},8,["disabled","icon"])])]),default:p(()=>[f("div",uo,[(g(!0),E(N,null,W(i(x),(R,re)=>(g(),E("div",{key:R.name,class:"hotkeys-item flex-space-between"},[f("span",po,[h(D,null,{default:p(()=>[y(ee(R.name),1)]),_:2},1024)]),f("div",go,[(g(!0),E(N,null,W(R.hotKeys,(se,Ae)=>(g(),E("div",{key:se},[f("kbd",null,ee(se),1),Ae+1[...u[6]||(u[6]=[y("+",-1)])]),_:1})])):I("",!0)]))),128)),R.hotKeys.length==0?(g(),E("span",mo,"未设置")):I("",!0)]),h(m,{disabled:i(l),text:"",icon:i(xe),onClick:se=>L(re)},{default:p(()=>[...u[7]||(u[7]=[y("编辑",-1)])]),_:1},8,["disabled","icon","onClick"])]))),128))])]),_:1},8,["modelValue"])],64)}}}),fo=j(So,[["__scopeId","data-v-096adf6d"]]),yo=K({__name:"SourceTabForm",props:{config:{}},setup(e){const o=F(),t=A(()=>o.currentSource);return(n,r)=>{const s=te,c=st,a=it,_=at,w=lt,U=rt,x=nt,d=Re,l=Ie;return g(),C(l,{id:"source-edit"},{default:p(()=>[(g(!0),E(N,null,W(Object.values(e.config),({name:b,children:v})=>(g(),C(d,{label:b,key:b},{default:p(()=>[h(x,{"label-position":"right","label-width":"auto"},{default:p(()=>[(g(!0),E(N,null,W(v,({type:L,title:O,namespace:q,id:T,array:Q,hint:S,required:u=!1})=>(g(),C(U,{label:O,key:O,required:u},{default:p(()=>[L=="String"&&typeof q>"u"?(g(),C(s,{key:0,type:"textarea",modelValue:i(t)[T],"onUpdate:modelValue":m=>i(t)[T]=m,placeholder:S,autosize:""},null,8,["modelValue","onUpdate:modelValue","placeholder"])):I("",!0),L=="String"&&typeof q<"u"?(g(),C(s,{key:1,type:"textarea",modelValue:i(t)[q][T],"onUpdate:modelValue":m=>i(t)[q][T]=m,placeholder:S,autosize:""},null,8,["modelValue","onUpdate:modelValue","placeholder"])):I("",!0),L==="Boolean"?(g(),C(c,{key:2,modelValue:i(t)[T],"onUpdate:modelValue":m=>i(t)[T]=m},null,8,["modelValue","onUpdate:modelValue"])):I("",!0),L==="Number"?(g(),C(a,{key:3,modelValue:i(t)[T],"onUpdate:modelValue":m=>i(t)[T]=m,min:0},null,8,["modelValue","onUpdate:modelValue"])):I("",!0),L==="Array"?(g(),C(w,{key:4,modelValue:i(t)[T],"onUpdate:modelValue":m=>i(t)[T]=m},{default:p(()=>[(g(!0),E(N,null,W(Q,(m,D)=>(g(),C(_,{value:D,key:m,label:m},null,8,["value","label"]))),128))]),_:2},1032,["modelValue","onUpdate:modelValue"])):I("",!0)]),_:2},1032,["label","required"]))),128))]),_:2},1024)]),_:2},1032,["label"]))),128))]),_:1})}}}),bo=j(yo,[["__scopeId","data-v-c07c5146"]]),_o={base:{name:"基础",children:[{title:"源类型",id:"bookSourceType",type:"Array",array:["文本","音频","图片","文件"],required:!0},{title:"源域名",id:"bookSourceUrl",type:"String",hint:"通常填写网站主页,例: https://www.qidian.com",required:!0},{title:"源名称",id:"bookSourceName",type:"String",hint:"会显示在源列表",required:!0},{title:"源分组",id:"bookSourceGroup",type:"String",hint:"描述源的特征信息"},{title:"源注释",id:"bookSourceComment",type:"String",hint:"描述源作者和状态"},{title:"登录地址",id:"loginUrl",type:"String",hint:"填写网站登录网址,仅在需要登录的源有用"},{title:"登录界面",id:"loginUi",type:"String",hint:"自定义登录界面"},{title:"登录检测",id:"loginCheckJs",type:"String",hint:"登录检测js"},{title:"封面解密",id:"coverDecodeJs",type:"String",hint:"封面解密js"},{title:"链接验证",id:"bookUrlPattern",type:"String",hint:"书籍URL正则,当详情页URL与源URL的域名不一致时有效,用于添加网址"},{title:"请求头",id:"header",type:"String",hint:"客户端标识"},{title:"变量说明",id:"variableComment",type:"String",hint:"书源变量说明"},{title:"并发率",id:"concurrentRate",type:"String",hint:"并发率,如1000(访问间隔1000ms)或者1/1000(1000ms内访问1次)"},{title:"js库",id:"jsLib",type:"String",hint:"js库, 可填写js或者key-value object获取在线js文件"}]},search:{name:"搜索",children:[{title:"搜索地址",id:"searchUrl",type:"String",hint:"[域名可省略]/search.php@kw={{key}}"},{title:"校验文字",namespace:"ruleSearch",id:"checkKeyWord",type:"String",hint:"校验关键字,强烈建议填写"},{title:"列表规则",namespace:"ruleSearch",id:"bookList",type:"String",hint:"选择书籍节点 (规则结果为List)"},{title:"书名规则",namespace:"ruleSearch",id:"name",type:"String",hint:"选择节点书名 (规则结果为String)"},{title:"作者规则",namespace:"ruleSearch",id:"author",type:"String",hint:"选择节点作者 (规则结果为String)"},{title:"分类规则",namespace:"ruleSearch",id:"kind",type:"String",hint:"选择节点分类信息 (规则结果为String)"},{title:"字数规则",namespace:"ruleSearch",id:"wordCount",type:"String",hint:"选择节点字数信息 (规则结果为String)"},{title:"最新章节",namespace:"ruleSearch",id:"lastChapter",type:"String",hint:"选择节点最新章节 (规则结果为String)"},{title:"简介规则",namespace:"ruleSearch",id:"intro",type:"String",hint:"选择节点书籍简介 (规则结果为String)"},{title:"封面规则",namespace:"ruleSearch",id:"coverUrl",type:"String",hint:"选择节点书籍封面 (规则结果为String类型的url)"},{title:"详情地址",namespace:"ruleSearch",id:"bookUrl",type:"String",hint:"选择书籍详情页网址 (规则结果为String类型的url)"}]},find:{name:"发现",children:[{title:"发现地址",id:"exploreUrl",type:"String",hint:"单个发现格式::或者{url:,title:,style:...};前者用换行符或者&&连接,后者放在数组内;可用js动态生成"},{title:"列表规则",namespace:"ruleExplore",id:"bookList",type:"String",hint:"选择书籍节点 (规则结果为List)"},{title:"书名规则",namespace:"ruleExplore",id:"name",type:"String",hint:"选择节点书名 (规则结果为String)"},{title:"作者规则",namespace:"ruleExplore",id:"author",type:"String",hint:"选择节点作者 (规则结果为String)"},{title:"分类规则",namespace:"ruleExplore",id:"kind",type:"String",hint:"选择节点分类信息 (规则结果为String)"},{title:"字数规则",namespace:"ruleExplore",id:"wordCount",type:"String",hint:"选择节点字数信息 (规则结果为String)"},{title:"最新章节",namespace:"ruleExplore",id:"lastChapter",type:"String",hint:"选择节点最新章节 (规则结果为String)"},{title:"简介规则",namespace:"ruleExplore",id:"intro",type:"String",hint:"选择节点书籍简介 (规则结果为String)"},{title:"封面规则",namespace:"ruleExplore",id:"coverUrl",type:"String",hint:"选择节点书籍封面 (规则结果为String类型的url)"},{title:"详情地址",namespace:"ruleExplore",id:"bookUrl",type:"String",hint:"选择书籍详情页网址 (规则结果为String类型的url)"}]},detail:{name:"详情",children:[{title:"预处理",namespace:"ruleBookInfo",id:"init",type:"String",hint:"用于加速详情信息检索,只支持AllInOne规则"},{title:"书名规则",namespace:"ruleBookInfo",id:"name",type:"String",hint:"选择节点书名 (规则结果为String)"},{title:"作者规则",namespace:"ruleBookInfo",id:"author",type:"String",hint:"选择节点作者 (规则结果为String)"},{title:"分类规则",namespace:"ruleBookInfo",id:"kind",type:"String",hint:"选择节点分类信息 (规则结果为String)"},{title:"字数规则",namespace:"ruleBookInfo",id:"wordCount",type:"String",hint:"选择节点字数信息 (规则结果为String)"},{title:"最新章节",namespace:"ruleBookInfo",id:"lastChapter",type:"String",hint:"选择节点最新章节 (规则结果为String)"},{title:"简介规则",namespace:"ruleBookInfo",id:"intro",type:"String",hint:"选择节点书籍简介 (规则结果为String)"},{title:"封面规则",namespace:"ruleBookInfo",id:"coverUrl",type:"String",hint:"选择节点书籍封面 (规则结果为String类型的url)"},{title:"目录地址",namespace:"ruleBookInfo",id:"tocUrl",type:"String",hint:"选择书籍详情页网址 (规则结果为String类型的url, 与详情页相同时可省略)"},{title:"修改书籍",namespace:"ruleBookInfo",id:"canReName",type:"String",hint:"允许修改书名作者(规则结果为String类型, 默认不允许)"},{title:"下载URL",namespace:"ruleBookInfo",id:"downloadUrls",type:"String",hint:"文件类书源下载地址 (规则结果为String类型的url, 多个链接返回数组)"}]},directory:{name:"目录",children:[{title:"更新前JS",namespace:"ruleToc",id:"preUpdateJs",type:"String",hint:"更新目录前调用JS 动态更新目录链接"},{title:"列表规则",namespace:"ruleToc",id:"chapterList",type:"String",hint:"选择目录列表的章节节点 (规则结果为List)"},{title:"章节名称",namespace:"ruleToc",id:"chapterName",type:"String",hint:"选择章节名称 (规则结果为String)"},{title:"章节地址",namespace:"ruleToc",id:"chapterUrl",type:"String",hint:"选择章节链接 (规则结果为String类型的Url)"},{title:"标题处理",namespace:"ruleToc",id:"formatJs",type:"String",hint:"遍历去重后的章节列表的回调,提供index(章节序号从1开始)、title(章节标题)变量,额外提供gInt(初始值0),返回值作为新的标题"},{title:"卷名标识",namespace:"ruleToc",id:"isVolume",type:"String",hint:"章节名称是否是卷名 (规则结果为Bool)"},{title:"章节信息",namespace:"ruleToc",id:"updateTime",type:"String",hint:"选择章节信息(如更新时间) (规则结果为String)"},{title:"收费标识",namespace:"ruleToc",id:"isVip",type:"String",hint:"章节是否为VIP章节 (规则结果为Bool)"},{title:"购买标识",namespace:"ruleToc",id:"isPay",type:"String",hint:"章节是否为已购买 (规则结果为Bool)"},{title:"翻页规则",namespace:"ruleToc",id:"nextTocUrl",type:"String",hint:"选择目录下一页链接 (规则结果为List或String)"}]},content:{name:"正文",children:[{title:"正文规则",namespace:"ruleContent",id:"content",type:"String",hint:"选择正文内容 (规则结果为String)"},{title:"标题规则",namespace:"ruleContent",id:"title",type:"String",hint:"获取结果将会覆盖章节标题 (规则结果为String)"},{title:"翻页规则",namespace:"ruleContent",id:"nextContentUrl",type:"String",hint:"选择下一分页(不是下一章)链接 (规则结果为String类型的Url)"},{title:"脚本注入",namespace:"ruleContent",id:"webJs",type:"String",hint:"注入javascript,用于模拟鼠标点击等,必须有返回值,一般为String类型"},{title:"资源正则",namespace:"ruleContent",id:"sourceRegex",type:"String",hint:"匹配资源的url特征,用于嗅探"},{title:"替换规则",namespace:"ruleContent",id:"replaceRegex",type:"String",hint:"多页内容合并后替换,用于正文净化"},{title:"图片样式",namespace:"ruleContent",id:"imageStyle",type:"String",hint:"FULL:铺满 不填:默认样式"},{title:"图片解密",namespace:"ruleContent",id:"imageDecode",type:"String",hint:"填写JavaScript 返回解密图片的bytes "},{title:"购买操作",namespace:"ruleContent",id:"payAction",type:"String",hint:"填写JavaScript 返回购买链接或者调用购买接口"}]},other:{name:"其他",children:[{title:"启用搜索",id:"enabled",type:"Boolean"},{title:"启用发现",id:"enabledExplore",type:"Boolean"},{title:"CookieJar",id:"enabledCookieJar",type:"Boolean"},{title:"搜索权重",id:"weight",type:"Number"},{title:"排序编号",id:"customOrder",type:"Number"}]}},ko={base:{name:"基础",children:[{title:"源域名",id:"sourceUrl",type:"String",hint:"通常填写网站主页,例: https://www.qidian.com",required:!0},{title:"图标",id:"sourceIcon",type:"String",hint:"填写图片网络链接"},{title:"源名称",id:"sourceName",type:"String",hint:"会显示在源列表",required:!0},{title:"源分组",id:"sourceGroup",type:"String",hint:"描述源的特征信息"},{title:"源注释",id:"sourceComment",type:"String",hint:"描述源作者和状态"},{title:"分类地址",id:"sortUrl",type:"String",hint:`名称1::链接1 名称2::链接2`},{title:"登录地址",id:"loginUrl",type:"String",hint:"填写网站登录网址,仅在需要登录的源有用"},{title:"登录界面",id:"loginUi",type:"String",hint:"自定义登录界面"},{title:"登录检测",id:"loginCheckJs",type:"String",hint:"登录检测js"},{title:"封面解密",id:"coverDecodeJs",type:"String",hint:"封面解密js"},{title:"请求头",id:"header",type:"String",hint:"客户端标识"},{title:"变量说明",id:"variableComment",type:"String",hint:"源变量说明"},{title:"并发率",id:"concurrentRate",type:"String",hint:"并发率"},{title:"js库",id:"jsLib",type:"String",hint:"js库, 可填写js或者key-value object获取在线js文件"}]},list:{name:"列表",children:[{title:"列表规则",id:"ruleArticles",type:"String",hint:"规则结果为List"},{title:"翻页规则",id:"ruleNextPage",type:"String",hint:"下一页链接 规则结果为List或String"},{title:"标题规则",id:"ruleTitle",type:"String",hint:"文章标题 规则结果为String"},{title:"时间规则",id:"rulePubDate",type:"String",hint:"文章发布时间 规则结果为String"},{title:"描述规则",id:"ruleDescription",type:"String",hint:"文章简要描述 规则结果为String"},{title:"图片规则",id:"ruleImage",type:"String",hint:"文章图片链接 规则结果为String"},{title:"链接规则",id:"ruleLink",type:"String",hint:"文章链接 规则结果为String"}]},webView:{name:"WebView",children:[{title:"内容规则",id:"ruleContent",type:"String",hint:"文章正文"},{title:"样式规则",id:"style",type:"String",hint:"文章正文样式 填写css"},{title:"注入规则",id:"injectJs",type:"String",hint:"注入网页的JavaScript"},{title:"黑名单",id:"contentBlacklist",type:"String",hint:"webView链接加载黑名单,英文逗号隔开"},{title:"白名单",id:"contentWhitelist",type:"String",hint:"webView链接加载白名单,英文逗号隔开"},{title:"链接拦截",id:"shouldOverrideUrlLoading",type:"String",hint:"填写js,变量url为当前资源链接,返回true拦截"}]},other:{name:"其他",children:[{title:"列表样式",id:"articleStyle",type:"Array",array:["默认","大图","双列"]},{title:"加载地址",id:"loadWithBaseUrl",type:"Boolean"},{title:"启用JS",id:"enableJs",type:"Boolean"},{title:"启用",id:"enabled",type:"Boolean"},{title:"Cookie",id:"enabledCookieJar",type:"Boolean"},{title:"单URL",id:"singleUrl",type:"Boolean"},{title:"排序编号",id:"customOrder",type:"Number"}]}},vo={class:"editor"},wo=K({__name:"SourceEditor",setup(e){ct();let o;return/bookSource/i.test(location.href)?(o=_o,document.title="书源管理"):(o=ko,document.title="订阅源管理"),(t,n)=>{const r=bo,s=fo,c=so;return g(),E("div",vo,[h(r,{class:"left",config:i(o)},null,8,["config"]),h(s),h(c,{class:"right"})])}}}),we=j(wo,[["__scopeId","data-v-f2c47af3"]]),Pe=[{path:"/bookSource",name:"book-home",component:we},{path:"/rssSource",name:"rss-home",component:we}];le({history:ae(),routes:Pe});const $e=le({history:ae(),routes:[Ve,Pe].flat()});$e.afterEach(e=>{e.name=="shelf"&&(document.title="书架")});Be(Te).use(Oe).use($e).mount("#app");de(()=>At().isNight,e=>{e?document.documentElement.classList.add("dark"):document.documentElement.classList.remove("dark")});window.addEventListener("vite:preloadError",e=>{e.preventDefault()});export{P as A,j as _,Ft as a,ft as b,X as c,Bo as d,Uo as i,$ as l,zt as p,kt as s,At as u,$t as v}; ================================================ FILE: app/src/main/assets/web/vue/assets/loading-C4J6hIxs.js ================================================ import{ak as i,N as g,a9 as c,M as d,al as f,u as m}from"./vendor-KSDcS24u.js";const L=(s,t,l=i)=>{const a=d(!1);let r=null;const o=()=>a.value=!1,n=()=>a.value=!0;g(a,e=>{if(!e)return r==null?void 0:r.close();r=f.service({target:m(s),spinner:l,text:t,lock:!0,background:"rgba(0, 0, 0, 0)"})});const u=e=>{if(!(e instanceof Promise))throw TypeError("loadingWrapper argument must be Promise");return n(),e.finally(o)};return c(()=>{o()}),{isLoading:a,showLoading:n,closeLoading:o,loadingWrapper:u}};export{L as u}; ================================================ FILE: app/src/main/assets/web/vue/assets/loading-DkQYEuap.css ================================================ .el-loading-spinner{font-size:36px;color:#b5b5b5}.el-loading-text{font-weight:500;color:#b5b5b5!important} ================================================ FILE: app/src/main/assets/web/vue/assets/vendor-CXe1BRiH.css ================================================ @charset "UTF-8";:root{--el-color-white:#ffffff;--el-color-black:#000000;--el-color-primary-rgb:64,158,255;--el-color-success-rgb:103,194,58;--el-color-warning-rgb:230,162,60;--el-color-danger-rgb:245,108,108;--el-color-error-rgb:245,108,108;--el-color-info-rgb:144,147,153;--el-font-size-extra-large:20px;--el-font-size-large:18px;--el-font-size-medium:16px;--el-font-size-base:14px;--el-font-size-small:13px;--el-font-size-extra-small:12px;--el-font-family:"Helvetica Neue",Helvetica,"PingFang SC","Hiragino Sans GB","Microsoft YaHei","微软雅黑",Arial,sans-serif;--el-font-weight-primary:500;--el-font-line-height-primary:24px;--el-index-normal:1;--el-index-top:1000;--el-index-popper:2000;--el-border-radius-base:4px;--el-border-radius-small:2px;--el-border-radius-round:20px;--el-border-radius-circle:100%;--el-transition-duration:.3s;--el-transition-duration-fast:.2s;--el-transition-function-ease-in-out-bezier:cubic-bezier(.645,.045,.355,1);--el-transition-function-fast-bezier:cubic-bezier(.23,1,.32,1);--el-transition-all:all var(--el-transition-duration) var(--el-transition-function-ease-in-out-bezier);--el-transition-fade:opacity var(--el-transition-duration) var(--el-transition-function-fast-bezier);--el-transition-md-fade:transform var(--el-transition-duration) var(--el-transition-function-fast-bezier),opacity var(--el-transition-duration) var(--el-transition-function-fast-bezier);--el-transition-fade-linear:opacity var(--el-transition-duration-fast) linear;--el-transition-border:border-color var(--el-transition-duration-fast) var(--el-transition-function-ease-in-out-bezier);--el-transition-box-shadow:box-shadow var(--el-transition-duration-fast) var(--el-transition-function-ease-in-out-bezier);--el-transition-color:color var(--el-transition-duration-fast) var(--el-transition-function-ease-in-out-bezier);--el-component-size-large:40px;--el-component-size:32px;--el-component-size-small:24px;color-scheme:light;--el-color-primary:#409eff;--el-color-primary-light-3:rgb(121.3,187.1,255);--el-color-primary-light-5:rgb(159.5,206.5,255);--el-color-primary-light-7:rgb(197.7,225.9,255);--el-color-primary-light-8:rgb(216.8,235.6,255);--el-color-primary-light-9:rgb(235.9,245.3,255);--el-color-primary-dark-2:rgb(51.2,126.4,204);--el-color-success:#67c23a;--el-color-success-light-3:rgb(148.6,212.3,117.1);--el-color-success-light-5:rgb(179,224.5,156.5);--el-color-success-light-7:rgb(209.4,236.7,195.9);--el-color-success-light-8:rgb(224.6,242.8,215.6);--el-color-success-light-9:rgb(239.8,248.9,235.3);--el-color-success-dark-2:rgb(82.4,155.2,46.4);--el-color-warning:#e6a23c;--el-color-warning-light-3:rgb(237.5,189.9,118.5);--el-color-warning-light-5:rgb(242.5,208.5,157.5);--el-color-warning-light-7:rgb(247.5,227.1,196.5);--el-color-warning-light-8:rgb(250,236.4,216);--el-color-warning-light-9:rgb(252.5,245.7,235.5);--el-color-warning-dark-2:rgb(184,129.6,48);--el-color-danger:#f56c6c;--el-color-danger-light-3:rgb(248,152.1,152.1);--el-color-danger-light-5:rgb(250,181.5,181.5);--el-color-danger-light-7:rgb(252,210.9,210.9);--el-color-danger-light-8:rgb(253,225.6,225.6);--el-color-danger-light-9:rgb(254,240.3,240.3);--el-color-danger-dark-2:rgb(196,86.4,86.4);--el-color-error:#f56c6c;--el-color-error-light-3:rgb(248,152.1,152.1);--el-color-error-light-5:rgb(250,181.5,181.5);--el-color-error-light-7:rgb(252,210.9,210.9);--el-color-error-light-8:rgb(253,225.6,225.6);--el-color-error-light-9:rgb(254,240.3,240.3);--el-color-error-dark-2:rgb(196,86.4,86.4);--el-color-info:#909399;--el-color-info-light-3:rgb(177.3,179.4,183.6);--el-color-info-light-5:rgb(199.5,201,204);--el-color-info-light-7:rgb(221.7,222.6,224.4);--el-color-info-light-8:rgb(232.8,233.4,234.6);--el-color-info-light-9:rgb(243.9,244.2,244.8);--el-color-info-dark-2:rgb(115.2,117.6,122.4);--el-bg-color:#ffffff;--el-bg-color-page:#f2f3f5;--el-bg-color-overlay:#ffffff;--el-text-color-primary:#303133;--el-text-color-regular:#606266;--el-text-color-secondary:#909399;--el-text-color-placeholder:#a8abb2;--el-text-color-disabled:#c0c4cc;--el-border-color:#dcdfe6;--el-border-color-light:#e4e7ed;--el-border-color-lighter:#ebeef5;--el-border-color-extra-light:#f2f6fc;--el-border-color-dark:#d4d7de;--el-border-color-darker:#cdd0d6;--el-fill-color:#f0f2f5;--el-fill-color-light:#f5f7fa;--el-fill-color-lighter:#fafafa;--el-fill-color-extra-light:#fafcff;--el-fill-color-dark:#ebedf0;--el-fill-color-darker:#e6e8eb;--el-fill-color-blank:#ffffff;--el-box-shadow:0px 12px 32px 4px rgba(0,0,0,.04),0px 8px 20px rgba(0,0,0,.08);--el-box-shadow-light:0px 0px 12px rgba(0,0,0,.12);--el-box-shadow-lighter:0px 0px 6px rgba(0,0,0,.12);--el-box-shadow-dark:0px 16px 48px 16px rgba(0,0,0,.08),0px 12px 32px rgba(0,0,0,.12),0px 8px 16px -8px rgba(0,0,0,.16);--el-disabled-bg-color:var(--el-fill-color-light);--el-disabled-text-color:var(--el-text-color-placeholder);--el-disabled-border-color:var(--el-border-color-light);--el-overlay-color:rgba(0,0,0,.8);--el-overlay-color-light:rgba(0,0,0,.7);--el-overlay-color-lighter:rgba(0,0,0,.5);--el-mask-color:rgba(255,255,255,.9);--el-mask-color-extra-light:rgba(255,255,255,.3);--el-border-width:1px;--el-border-style:solid;--el-border-color-hover:var(--el-text-color-disabled);--el-border:var(--el-border-width) var(--el-border-style) var(--el-border-color);--el-svg-monochrome-grey:var(--el-border-color)}.fade-in-linear-enter-active,.fade-in-linear-leave-active{transition:var(--el-transition-fade-linear)}.fade-in-linear-enter-from,.fade-in-linear-leave-to{opacity:0}.el-fade-in-linear-enter-active,.el-fade-in-linear-leave-active{transition:var(--el-transition-fade-linear)}.el-fade-in-linear-enter-from,.el-fade-in-linear-leave-to{opacity:0}.el-fade-in-enter-active,.el-fade-in-leave-active{transition:all var(--el-transition-duration) cubic-bezier(.55,0,.1,1)}.el-fade-in-enter-from,.el-fade-in-leave-active{opacity:0}.el-zoom-in-center-enter-active,.el-zoom-in-center-leave-active{transition:all var(--el-transition-duration) cubic-bezier(.55,0,.1,1)}.el-zoom-in-center-enter-from,.el-zoom-in-center-leave-active{opacity:0;transform:scaleX(0)}.el-zoom-in-top-enter-active,.el-zoom-in-top-leave-active{opacity:1;transform:scaleY(1);transform-origin:center top;transition:var(--el-transition-md-fade)}.el-zoom-in-top-enter-active[data-popper-placement^=top],.el-zoom-in-top-leave-active[data-popper-placement^=top]{transform-origin:center bottom}.el-zoom-in-top-enter-from,.el-zoom-in-top-leave-active{opacity:0;transform:scaleY(0)}.el-zoom-in-bottom-enter-active,.el-zoom-in-bottom-leave-active{opacity:1;transform:scaleY(1);transform-origin:center bottom;transition:var(--el-transition-md-fade)}.el-zoom-in-bottom-enter-from,.el-zoom-in-bottom-leave-active{opacity:0;transform:scaleY(0)}.el-zoom-in-left-enter-active,.el-zoom-in-left-leave-active{opacity:1;transform:scale(1);transform-origin:top left;transition:var(--el-transition-md-fade)}.el-zoom-in-left-enter-from,.el-zoom-in-left-leave-active{opacity:0;transform:scale(.45)}.collapse-transition{transition:var(--el-transition-duration) height ease-in-out,var(--el-transition-duration) padding-top ease-in-out,var(--el-transition-duration) padding-bottom ease-in-out}.el-collapse-transition-enter-active,.el-collapse-transition-leave-active{transition:var(--el-transition-duration) max-height ease-in-out,var(--el-transition-duration) padding-top ease-in-out,var(--el-transition-duration) padding-bottom ease-in-out}.horizontal-collapse-transition{transition:var(--el-transition-duration) width ease-in-out,var(--el-transition-duration) padding-left ease-in-out,var(--el-transition-duration) padding-right ease-in-out}.el-list-enter-active,.el-list-leave-active{transition:all 1s}.el-list-enter-from,.el-list-leave-to{opacity:0;transform:translateY(-30px)}.el-list-leave-active{position:absolute!important}.el-opacity-transition{transition:opacity var(--el-transition-duration) cubic-bezier(.55,0,.1,1)}.el-icon-loading{animation:rotating 2s linear infinite}.el-icon--right{margin-left:5px}.el-icon--left{margin-right:5px}@keyframes rotating{0%{transform:rotate(0)}to{transform:rotate(1turn)}}.el-icon{--color:inherit;align-items:center;display:inline-flex;height:1em;justify-content:center;line-height:1em;position:relative;width:1em;fill:currentColor;color:var(--color);font-size:inherit}.el-icon.is-loading{animation:rotating 2s linear infinite}.el-icon svg{height:1em;width:1em}.el-tabs{--el-tabs-header-height:40px;display:flex}.el-tabs__header{align-items:center;display:flex;justify-content:space-between;margin:0 0 15px;padding:0;position:relative}.el-tabs__header-vertical{flex-direction:column}.el-tabs__active-bar{background-color:var(--el-color-primary);bottom:0;height:2px;left:0;list-style:none;position:absolute;transition:width var(--el-transition-duration) var(--el-transition-function-ease-in-out-bezier),transform var(--el-transition-duration) var(--el-transition-function-ease-in-out-bezier);z-index:1}.el-tabs__new-tab{align-items:center;border:1px solid var(--el-border-color);border-radius:3px;color:var(--el-text-color-primary);cursor:pointer;display:flex;font-size:12px;height:20px;justify-content:center;line-height:20px;margin:10px 0 10px 10px;text-align:center;transition:all .15s;width:20px}.el-tabs__new-tab .is-icon-plus{height:inherit;transform:scale(.8);width:inherit}.el-tabs__new-tab .is-icon-plus svg{vertical-align:middle}.el-tabs__new-tab:hover{color:var(--el-color-primary)}.el-tabs__new-tab-vertical{margin-left:0}.el-tabs__nav-wrap{flex:1 auto;margin-bottom:-1px;overflow:hidden;position:relative}.el-tabs__nav-wrap:after{background-color:var(--el-border-color-light);bottom:0;content:"";height:2px;left:0;position:absolute;width:100%;z-index:var(--el-index-normal)}.el-tabs__nav-wrap.is-scrollable{box-sizing:border-box;padding:0 20px}.el-tabs__nav-scroll{overflow:hidden}.el-tabs__nav-next,.el-tabs__nav-prev{color:var(--el-text-color-secondary);cursor:pointer;font-size:12px;line-height:44px;position:absolute;text-align:center;width:20px}.el-tabs__nav-next{right:0}.el-tabs__nav-prev{left:0}.el-tabs__nav{display:flex;float:left;position:relative;transition:transform var(--el-transition-duration);white-space:nowrap;z-index:calc(var(--el-index-normal) + 1)}.el-tabs__nav.is-stretch{display:flex;min-width:100%}.el-tabs__nav.is-stretch>*{flex:1;text-align:center}.el-tabs__item{align-items:center;box-sizing:border-box;color:var(--el-text-color-primary);display:flex;font-size:var(--el-font-size-base);font-weight:500;height:var(--el-tabs-header-height);justify-content:center;list-style:none;padding:0 20px;position:relative}.el-tabs__item:focus,.el-tabs__item:focus:active{outline:none}.el-tabs__item:focus-visible{border-radius:3px;box-shadow:0 0 2px 2px var(--el-color-primary) inset}.el-tabs__item .is-icon-close{border-radius:50%;margin-left:5px;text-align:center;transition:all var(--el-transition-duration) var(--el-transition-function-ease-in-out-bezier)}.el-tabs__item .is-icon-close:before{display:inline-block;transform:scale(.9)}.el-tabs__item .is-icon-close:hover{background-color:var(--el-text-color-placeholder);color:#fff}.el-tabs__item.is-active,.el-tabs__item:hover{color:var(--el-color-primary)}.el-tabs__item:hover{cursor:pointer}.el-tabs__item.is-disabled{color:var(--el-disabled-text-color);cursor:not-allowed}.el-tabs__content{flex-grow:1;overflow:hidden;position:relative}.el-tabs--bottom>.el-tabs__header .el-tabs__item:nth-child(2),.el-tabs--top>.el-tabs__header .el-tabs__item:nth-child(2){padding-left:0}.el-tabs--bottom>.el-tabs__header .el-tabs__item:last-child,.el-tabs--top>.el-tabs__header .el-tabs__item:last-child{padding-right:0}.el-tabs--bottom.el-tabs--border-card>.el-tabs__header .el-tabs__item:nth-child(2),.el-tabs--bottom.el-tabs--card>.el-tabs__header .el-tabs__item:nth-child(2),.el-tabs--top.el-tabs--border-card>.el-tabs__header .el-tabs__item:nth-child(2),.el-tabs--top.el-tabs--card>.el-tabs__header .el-tabs__item:nth-child(2){padding-left:20px}.el-tabs--bottom.el-tabs--border-card>.el-tabs__header .el-tabs__item:last-child,.el-tabs--bottom.el-tabs--card>.el-tabs__header .el-tabs__item:last-child,.el-tabs--top.el-tabs--border-card>.el-tabs__header .el-tabs__item:last-child,.el-tabs--top.el-tabs--card>.el-tabs__header .el-tabs__item:last-child{padding-right:20px}.el-tabs--card>.el-tabs__header{border-bottom:1px solid var(--el-border-color-light);height:var(--el-tabs-header-height)}.el-tabs--card>.el-tabs__header .el-tabs__nav-wrap:after{content:none}.el-tabs--card>.el-tabs__header .el-tabs__nav{border:1px solid var(--el-border-color-light);border-bottom:none;border-radius:4px 4px 0 0;box-sizing:border-box}.el-tabs--card>.el-tabs__header .el-tabs__active-bar{display:none}.el-tabs--card>.el-tabs__header .el-tabs__item .is-icon-close{font-size:12px;height:14px;overflow:hidden;position:relative;right:-2px;transform-origin:100% 50%;width:0}.el-tabs--card>.el-tabs__header .el-tabs__item{border-bottom:1px solid transparent;border-left:1px solid var(--el-border-color-light);transition:color var(--el-transition-duration) var(--el-transition-function-ease-in-out-bezier),padding var(--el-transition-duration) var(--el-transition-function-ease-in-out-bezier)}.el-tabs--card>.el-tabs__header .el-tabs__item:first-child{border-left:none}.el-tabs--card>.el-tabs__header .el-tabs__item.is-closable:hover{padding-left:13px;padding-right:13px}.el-tabs--card>.el-tabs__header .el-tabs__item.is-closable:hover .is-icon-close{width:14px}.el-tabs--card>.el-tabs__header .el-tabs__item.is-active{border-bottom-color:var(--el-bg-color)}.el-tabs--card>.el-tabs__header .el-tabs__item.is-active.is-closable{padding-left:20px;padding-right:20px}.el-tabs--card>.el-tabs__header .el-tabs__item.is-active.is-closable .is-icon-close{width:14px}.el-tabs--border-card{background:var(--el-bg-color-overlay);border:1px solid var(--el-border-color)}.el-tabs--border-card>.el-tabs__content{padding:15px}.el-tabs--border-card>.el-tabs__header{background-color:var(--el-fill-color-light);border-bottom:1px solid var(--el-border-color-light);margin:0}.el-tabs--border-card>.el-tabs__header .el-tabs__nav-wrap:after{content:none}.el-tabs--border-card>.el-tabs__header .el-tabs__item{border:1px solid transparent;color:var(--el-text-color-secondary);margin-top:-1px;transition:all var(--el-transition-duration) var(--el-transition-function-ease-in-out-bezier)}.el-tabs--border-card>.el-tabs__header .el-tabs__item+.el-tabs__item,.el-tabs--border-card>.el-tabs__header .el-tabs__item:first-child{margin-left:-1px}.el-tabs--border-card>.el-tabs__header .el-tabs__item.is-active{background-color:var(--el-bg-color-overlay);border-left-color:var(--el-border-color);border-right-color:var(--el-border-color);color:var(--el-color-primary)}.el-tabs--border-card>.el-tabs__header .el-tabs__item:not(.is-disabled):hover{color:var(--el-color-primary)}.el-tabs--border-card>.el-tabs__header .el-tabs__item.is-disabled{color:var(--el-disabled-text-color)}.el-tabs--border-card>.el-tabs__header .is-scrollable .el-tabs__item:first-child{margin-left:0}.el-tabs--bottom{flex-direction:column}.el-tabs--bottom .el-tabs__header.is-bottom{margin-bottom:0;margin-top:10px}.el-tabs--bottom.el-tabs--border-card .el-tabs__header.is-bottom{border-bottom:0;border-top:1px solid var(--el-border-color)}.el-tabs--bottom.el-tabs--border-card .el-tabs__nav-wrap.is-bottom{margin-bottom:0;margin-top:-1px}.el-tabs--bottom.el-tabs--border-card .el-tabs__item.is-bottom:not(.is-active){border:1px solid transparent}.el-tabs--bottom.el-tabs--border-card .el-tabs__item.is-bottom{margin:0 -1px -1px}.el-tabs--left,.el-tabs--right{overflow:hidden}.el-tabs--left .el-tabs__header.is-left,.el-tabs--left .el-tabs__header.is-right,.el-tabs--left .el-tabs__nav-scroll,.el-tabs--left .el-tabs__nav-wrap.is-left,.el-tabs--left .el-tabs__nav-wrap.is-right,.el-tabs--right .el-tabs__header.is-left,.el-tabs--right .el-tabs__header.is-right,.el-tabs--right .el-tabs__nav-scroll,.el-tabs--right .el-tabs__nav-wrap.is-left,.el-tabs--right .el-tabs__nav-wrap.is-right{height:100%}.el-tabs--left .el-tabs__active-bar.is-left,.el-tabs--left .el-tabs__active-bar.is-right,.el-tabs--right .el-tabs__active-bar.is-left,.el-tabs--right .el-tabs__active-bar.is-right{bottom:auto;height:auto;top:0;width:2px}.el-tabs--left .el-tabs__nav-wrap.is-left,.el-tabs--left .el-tabs__nav-wrap.is-right,.el-tabs--right .el-tabs__nav-wrap.is-left,.el-tabs--right .el-tabs__nav-wrap.is-right{margin-bottom:0}.el-tabs--left .el-tabs__nav-wrap.is-left>.el-tabs__nav-next,.el-tabs--left .el-tabs__nav-wrap.is-left>.el-tabs__nav-prev,.el-tabs--left .el-tabs__nav-wrap.is-right>.el-tabs__nav-next,.el-tabs--left .el-tabs__nav-wrap.is-right>.el-tabs__nav-prev,.el-tabs--right .el-tabs__nav-wrap.is-left>.el-tabs__nav-next,.el-tabs--right .el-tabs__nav-wrap.is-left>.el-tabs__nav-prev,.el-tabs--right .el-tabs__nav-wrap.is-right>.el-tabs__nav-next,.el-tabs--right .el-tabs__nav-wrap.is-right>.el-tabs__nav-prev{cursor:pointer;height:30px;line-height:30px;text-align:center;width:100%}.el-tabs--left .el-tabs__nav-wrap.is-left>.el-tabs__nav-next i,.el-tabs--left .el-tabs__nav-wrap.is-left>.el-tabs__nav-prev i,.el-tabs--left .el-tabs__nav-wrap.is-right>.el-tabs__nav-next i,.el-tabs--left .el-tabs__nav-wrap.is-right>.el-tabs__nav-prev i,.el-tabs--right .el-tabs__nav-wrap.is-left>.el-tabs__nav-next i,.el-tabs--right .el-tabs__nav-wrap.is-left>.el-tabs__nav-prev i,.el-tabs--right .el-tabs__nav-wrap.is-right>.el-tabs__nav-next i,.el-tabs--right .el-tabs__nav-wrap.is-right>.el-tabs__nav-prev i{transform:rotate(90deg)}.el-tabs--left .el-tabs__nav-wrap.is-left>.el-tabs__nav-prev,.el-tabs--left .el-tabs__nav-wrap.is-right>.el-tabs__nav-prev,.el-tabs--right .el-tabs__nav-wrap.is-left>.el-tabs__nav-prev,.el-tabs--right .el-tabs__nav-wrap.is-right>.el-tabs__nav-prev{left:auto;top:0}.el-tabs--left .el-tabs__nav-wrap.is-left>.el-tabs__nav-next,.el-tabs--left .el-tabs__nav-wrap.is-right>.el-tabs__nav-next,.el-tabs--right .el-tabs__nav-wrap.is-left>.el-tabs__nav-next,.el-tabs--right .el-tabs__nav-wrap.is-right>.el-tabs__nav-next{bottom:0;right:auto}.el-tabs--left .el-tabs__nav-wrap.is-left.is-scrollable,.el-tabs--left .el-tabs__nav-wrap.is-right.is-scrollable,.el-tabs--right .el-tabs__nav-wrap.is-left.is-scrollable,.el-tabs--right .el-tabs__nav-wrap.is-right.is-scrollable{padding:30px 0}.el-tabs--left .el-tabs__nav-wrap.is-left:after,.el-tabs--left .el-tabs__nav-wrap.is-right:after,.el-tabs--right .el-tabs__nav-wrap.is-left:after,.el-tabs--right .el-tabs__nav-wrap.is-right:after{bottom:auto;height:100%;top:0;width:2px}.el-tabs--left .el-tabs__nav.is-left,.el-tabs--left .el-tabs__nav.is-right,.el-tabs--right .el-tabs__nav.is-left,.el-tabs--right .el-tabs__nav.is-right{flex-direction:column}.el-tabs--left .el-tabs__item.is-left,.el-tabs--right .el-tabs__item.is-left{justify-content:flex-end}.el-tabs--left .el-tabs__item.is-right,.el-tabs--right .el-tabs__item.is-right{justify-content:flex-start}.el-tabs--left{flex-direction:row-reverse}.el-tabs--left .el-tabs__header.is-left{margin-bottom:0;margin-right:10px}.el-tabs--left .el-tabs__nav-wrap.is-left{margin-right:-1px}.el-tabs--left .el-tabs__active-bar.is-left,.el-tabs--left .el-tabs__nav-wrap.is-left:after{left:auto;right:0}.el-tabs--left .el-tabs__item.is-left{text-align:right}.el-tabs--left.el-tabs--card .el-tabs__active-bar.is-left{display:none}.el-tabs--left.el-tabs--card .el-tabs__item.is-left{border-bottom:none;border-left:none;border-right:1px solid var(--el-border-color-light);border-top:1px solid var(--el-border-color-light);text-align:left}.el-tabs--left.el-tabs--card .el-tabs__item.is-left:first-child{border-right:1px solid var(--el-border-color-light);border-top:none}.el-tabs--left.el-tabs--card .el-tabs__item.is-left.is-active{border:1px solid var(--el-border-color-light);border-bottom:none;border-left:none;border-right:1px solid #fff}.el-tabs--left.el-tabs--card .el-tabs__item.is-left.is-active:first-child{border-top:none}.el-tabs--left.el-tabs--card .el-tabs__item.is-left.is-active:last-child{border-bottom:none}.el-tabs--left.el-tabs--card .el-tabs__nav{border-bottom:1px solid var(--el-border-color-light);border-radius:4px 0 0 4px;border-right:none}.el-tabs--left.el-tabs--card .el-tabs__new-tab{float:none}.el-tabs--left.el-tabs--border-card .el-tabs__header.is-left{border-right:1px solid var(--el-border-color)}.el-tabs--left.el-tabs--border-card .el-tabs__item.is-left{border:1px solid transparent;margin:-1px 0 -1px -1px}.el-tabs--left.el-tabs--border-card .el-tabs__item.is-left.is-active{border-color:rgb(209,219,229) transparent}.el-tabs--right .el-tabs__header.is-right{margin-bottom:0;margin-left:10px}.el-tabs--right .el-tabs__nav-wrap.is-right{margin-left:-1px}.el-tabs--right .el-tabs__nav-wrap.is-right:after{left:0;right:auto}.el-tabs--right .el-tabs__active-bar.is-right{left:0}.el-tabs--right.el-tabs--card .el-tabs__active-bar.is-right{display:none}.el-tabs--right.el-tabs--card .el-tabs__item.is-right{border-bottom:none;border-top:1px solid var(--el-border-color-light)}.el-tabs--right.el-tabs--card .el-tabs__item.is-right:first-child{border-left:1px solid var(--el-border-color-light);border-top:none}.el-tabs--right.el-tabs--card .el-tabs__item.is-right.is-active{border:1px solid var(--el-border-color-light);border-bottom:none;border-left:1px solid #fff;border-right:none}.el-tabs--right.el-tabs--card .el-tabs__item.is-right.is-active:first-child{border-top:none}.el-tabs--right.el-tabs--card .el-tabs__item.is-right.is-active:last-child{border-bottom:none}.el-tabs--right.el-tabs--card .el-tabs__nav{border-bottom:1px solid var(--el-border-color-light);border-left:none;border-radius:0 4px 4px 0}.el-tabs--right.el-tabs--border-card .el-tabs__header.is-right{border-left:1px solid var(--el-border-color)}.el-tabs--right.el-tabs--border-card .el-tabs__item.is-right{border:1px solid transparent;margin:-1px -1px -1px 0}.el-tabs--right.el-tabs--border-card .el-tabs__item.is-right.is-active{border-color:rgb(209,219,229) transparent}.el-tabs--top{flex-direction:column-reverse}.slideInLeft-transition,.slideInRight-transition{display:inline-block}.slideInRight-enter{animation:slideInRight-enter var(--el-transition-duration)}.slideInRight-leave{animation:slideInRight-leave var(--el-transition-duration);left:0;position:absolute;right:0}.slideInLeft-enter{animation:slideInLeft-enter var(--el-transition-duration)}.slideInLeft-leave{animation:slideInLeft-leave var(--el-transition-duration);left:0;position:absolute;right:0}@keyframes slideInRight-enter{0%{opacity:0;transform:translate(100%);transform-origin:0 0}to{opacity:1;transform:translate(0);transform-origin:0 0}}@keyframes slideInRight-leave{0%{opacity:1;transform:translate(0);transform-origin:0 0}to{opacity:0;transform:translate(100%);transform-origin:0 0}}@keyframes slideInLeft-enter{0%{opacity:0;transform:translate(-100%);transform-origin:0 0}to{opacity:1;transform:translate(0);transform-origin:0 0}}@keyframes slideInLeft-leave{0%{opacity:1;transform:translate(0);transform-origin:0 0}to{opacity:0;transform:translate(-100%);transform-origin:0 0}}.el-text{--el-text-font-size:var(--el-font-size-base);--el-text-color:var(--el-text-color-regular);align-self:center;color:var(--el-text-color);font-size:var(--el-text-font-size);margin:0;overflow-wrap:break-word;padding:0}.el-text.is-truncated{display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.el-text.is-line-clamp{display:-webkit-inline-box;-webkit-box-orient:vertical;overflow:hidden}.el-text--large{--el-text-font-size:var(--el-font-size-medium)}.el-text--default{--el-text-font-size:var(--el-font-size-base)}.el-text--small{--el-text-font-size:var(--el-font-size-extra-small)}.el-text.el-text--primary{--el-text-color:var(--el-color-primary)}.el-text.el-text--success{--el-text-color:var(--el-color-success)}.el-text.el-text--warning{--el-text-color:var(--el-color-warning)}.el-text.el-text--danger{--el-text-color:var(--el-color-danger)}.el-text.el-text--error{--el-text-color:var(--el-color-error)}.el-text.el-text--info{--el-text-color:var(--el-color-info)}.el-text>.el-icon{vertical-align:-2px}.el-link{--el-link-font-size:var(--el-font-size-base);--el-link-font-weight:var(--el-font-weight-primary);--el-link-text-color:var(--el-text-color-regular);--el-link-hover-text-color:var(--el-color-primary);--el-link-disabled-text-color:var(--el-text-color-placeholder);align-items:center;color:var(--el-link-text-color);cursor:pointer;display:inline-flex;flex-direction:row;font-size:var(--el-link-font-size);font-weight:var(--el-link-font-weight);justify-content:center;outline:none;padding:0;position:relative;text-decoration:none;vertical-align:middle}.el-link:hover{color:var(--el-link-hover-text-color)}.el-link.is-underline:hover:after{border-bottom:1px solid var(--el-link-hover-text-color);bottom:0;content:"";height:0;left:0;position:absolute;right:0}.el-link.is-disabled{color:var(--el-link-disabled-text-color);cursor:not-allowed}.el-link [class*=el-icon-]+span{margin-left:5px}.el-link.el-link--default:after{border-color:var(--el-link-hover-text-color)}.el-link__inner{align-items:center;display:inline-flex;justify-content:center}.el-link.el-link--primary{--el-link-text-color:var(--el-color-primary);--el-link-hover-text-color:var(--el-color-primary-light-3);--el-link-disabled-text-color:var(--el-color-primary-light-5)}.el-link.el-link--primary.is-underline:hover:after,.el-link.el-link--primary:after{border-color:var(--el-link-text-color)}.el-link.el-link--success{--el-link-text-color:var(--el-color-success);--el-link-hover-text-color:var(--el-color-success-light-3);--el-link-disabled-text-color:var(--el-color-success-light-5)}.el-link.el-link--success.is-underline:hover:after,.el-link.el-link--success:after{border-color:var(--el-link-text-color)}.el-link.el-link--warning{--el-link-text-color:var(--el-color-warning);--el-link-hover-text-color:var(--el-color-warning-light-3);--el-link-disabled-text-color:var(--el-color-warning-light-5)}.el-link.el-link--warning.is-underline:hover:after,.el-link.el-link--warning:after{border-color:var(--el-link-text-color)}.el-link.el-link--danger{--el-link-text-color:var(--el-color-danger);--el-link-hover-text-color:var(--el-color-danger-light-3);--el-link-disabled-text-color:var(--el-color-danger-light-5)}.el-link.el-link--danger.is-underline:hover:after,.el-link.el-link--danger:after{border-color:var(--el-link-text-color)}.el-link.el-link--error{--el-link-text-color:var(--el-color-error);--el-link-hover-text-color:var(--el-color-error-light-3);--el-link-disabled-text-color:var(--el-color-error-light-5)}.el-link.el-link--error.is-underline:hover:after,.el-link.el-link--error:after{border-color:var(--el-link-text-color)}.el-link.el-link--info{--el-link-text-color:var(--el-color-info);--el-link-hover-text-color:var(--el-color-info-light-3);--el-link-disabled-text-color:var(--el-color-info-light-5)}.el-link.el-link--info.is-underline:hover:after,.el-link.el-link--info:after{border-color:var(--el-link-text-color)}.el-checkbox-group{font-size:0;line-height:0}.el-button{--el-button-font-weight:var(--el-font-weight-primary);--el-button-border-color:var(--el-border-color);--el-button-bg-color:var(--el-fill-color-blank);--el-button-text-color:var(--el-text-color-regular);--el-button-disabled-text-color:var(--el-disabled-text-color);--el-button-disabled-bg-color:var(--el-fill-color-blank);--el-button-disabled-border-color:var(--el-border-color-light);--el-button-divide-border-color:rgba(255,255,255,.5);--el-button-hover-text-color:var(--el-color-primary);--el-button-hover-bg-color:var(--el-color-primary-light-9);--el-button-hover-border-color:var(--el-color-primary-light-7);--el-button-active-text-color:var(--el-button-hover-text-color);--el-button-active-border-color:var(--el-color-primary);--el-button-active-bg-color:var(--el-button-hover-bg-color);--el-button-outline-color:var(--el-color-primary-light-5);--el-button-hover-link-text-color:var(--el-color-info);--el-button-active-color:var(--el-text-color-primary);align-items:center;-webkit-appearance:none;background-color:var(--el-button-bg-color);border:var(--el-border);border-color:var(--el-button-border-color);box-sizing:border-box;color:var(--el-button-text-color);cursor:pointer;display:inline-flex;font-weight:var(--el-button-font-weight);height:32px;justify-content:center;line-height:1;outline:none;text-align:center;transition:.1s;-webkit-user-select:none;-moz-user-select:none;user-select:none;vertical-align:middle;white-space:nowrap}.el-button:hover{background-color:var(--el-button-hover-bg-color);border-color:var(--el-button-hover-border-color);color:var(--el-button-hover-text-color);outline:none}.el-button:active{background-color:var(--el-button-active-bg-color);border-color:var(--el-button-active-border-color);color:var(--el-button-active-text-color);outline:none}.el-button:focus-visible{outline:2px solid var(--el-button-outline-color);outline-offset:1px;transition:outline-offset 0s,outline 0s}.el-button>span{align-items:center;display:inline-flex}.el-button+.el-button{margin-left:12px}.el-button{border-radius:var(--el-border-radius-base);font-size:var(--el-font-size-base)}.el-button,.el-button.is-round{padding:8px 15px}.el-button::-moz-focus-inner{border:0}.el-button [class*=el-icon]+span{margin-left:6px}.el-button [class*=el-icon] svg{vertical-align:bottom}.el-button.is-plain{--el-button-hover-text-color:var(--el-color-primary);--el-button-hover-bg-color:var(--el-fill-color-blank);--el-button-hover-border-color:var(--el-color-primary)}.el-button.is-active{background-color:var(--el-button-active-bg-color);border-color:var(--el-button-active-border-color);color:var(--el-button-active-text-color);outline:none}.el-button.is-disabled,.el-button.is-disabled:hover{background-color:var(--el-button-disabled-bg-color);background-image:none;border-color:var(--el-button-disabled-border-color);color:var(--el-button-disabled-text-color);cursor:not-allowed}.el-button.is-loading{pointer-events:none;position:relative}.el-button.is-loading:before{background-color:var(--el-mask-color-extra-light);border-radius:inherit;bottom:-1px;content:"";left:-1px;pointer-events:none;position:absolute;right:-1px;top:-1px;z-index:1}.el-button.is-round{border-radius:var(--el-border-radius-round)}.el-button.is-circle{border-radius:50%;padding:8px;width:32px}.el-button.is-text{background-color:transparent;border:0 solid transparent;color:var(--el-button-text-color)}.el-button.is-text.is-disabled{background-color:transparent!important;color:var(--el-button-disabled-text-color)}.el-button.is-text:not(.is-disabled):hover{background-color:var(--el-fill-color-light)}.el-button.is-text:not(.is-disabled):focus-visible{outline:2px solid var(--el-button-outline-color);outline-offset:1px;transition:outline-offset 0s,outline 0s}.el-button.is-text:not(.is-disabled):active{background-color:var(--el-fill-color)}.el-button.is-text:not(.is-disabled).is-has-bg{background-color:var(--el-fill-color-light)}.el-button.is-text:not(.is-disabled).is-has-bg:hover{background-color:var(--el-fill-color)}.el-button.is-text:not(.is-disabled).is-has-bg:active{background-color:var(--el-fill-color-dark)}.el-button__text--expand{letter-spacing:.3em;margin-right:-.3em}.el-button.is-link{background:transparent;border-color:transparent;color:var(--el-button-text-color);height:auto;padding:2px}.el-button.is-link:hover{color:var(--el-button-hover-link-text-color)}.el-button.is-link.is-disabled{background-color:transparent!important;border-color:transparent!important;color:var(--el-button-disabled-text-color)}.el-button.is-link:not(.is-disabled):active,.el-button.is-link:not(.is-disabled):hover{background-color:transparent;border-color:transparent}.el-button.is-link:not(.is-disabled):active{color:var(--el-button-active-color)}.el-button--text{background:transparent;border-color:transparent;color:var(--el-color-primary);padding-left:0;padding-right:0}.el-button--text.is-disabled{background-color:transparent!important;border-color:transparent!important;color:var(--el-button-disabled-text-color)}.el-button--text:not(.is-disabled):hover{background-color:transparent;border-color:transparent;color:var(--el-color-primary-light-3)}.el-button--text:not(.is-disabled):active{background-color:transparent;border-color:transparent;color:var(--el-color-primary-dark-2)}.el-button__link--expand{letter-spacing:.3em;margin-right:-.3em}.el-button--primary{--el-button-text-color:var(--el-color-white);--el-button-bg-color:var(--el-color-primary);--el-button-border-color:var(--el-color-primary);--el-button-outline-color:var(--el-color-primary-light-5);--el-button-active-color:var(--el-color-primary-dark-2);--el-button-hover-text-color:var(--el-color-white);--el-button-hover-link-text-color:var(--el-color-primary-light-5);--el-button-hover-bg-color:var(--el-color-primary-light-3);--el-button-hover-border-color:var(--el-color-primary-light-3);--el-button-active-bg-color:var(--el-color-primary-dark-2);--el-button-active-border-color:var(--el-color-primary-dark-2);--el-button-disabled-text-color:var(--el-color-white);--el-button-disabled-bg-color:var(--el-color-primary-light-5);--el-button-disabled-border-color:var(--el-color-primary-light-5)}.el-button--primary.is-link,.el-button--primary.is-plain,.el-button--primary.is-text{--el-button-text-color:var(--el-color-primary);--el-button-bg-color:var(--el-color-primary-light-9);--el-button-border-color:var(--el-color-primary-light-5);--el-button-hover-text-color:var(--el-color-white);--el-button-hover-bg-color:var(--el-color-primary);--el-button-hover-border-color:var(--el-color-primary);--el-button-active-text-color:var(--el-color-white)}.el-button--primary.is-link.is-disabled,.el-button--primary.is-link.is-disabled:active,.el-button--primary.is-link.is-disabled:focus,.el-button--primary.is-link.is-disabled:hover,.el-button--primary.is-plain.is-disabled,.el-button--primary.is-plain.is-disabled:active,.el-button--primary.is-plain.is-disabled:focus,.el-button--primary.is-plain.is-disabled:hover,.el-button--primary.is-text.is-disabled,.el-button--primary.is-text.is-disabled:active,.el-button--primary.is-text.is-disabled:focus,.el-button--primary.is-text.is-disabled:hover{background-color:var(--el-color-primary-light-9);border-color:var(--el-color-primary-light-8);color:var(--el-color-primary-light-5)}.el-button--success{--el-button-text-color:var(--el-color-white);--el-button-bg-color:var(--el-color-success);--el-button-border-color:var(--el-color-success);--el-button-outline-color:var(--el-color-success-light-5);--el-button-active-color:var(--el-color-success-dark-2);--el-button-hover-text-color:var(--el-color-white);--el-button-hover-link-text-color:var(--el-color-success-light-5);--el-button-hover-bg-color:var(--el-color-success-light-3);--el-button-hover-border-color:var(--el-color-success-light-3);--el-button-active-bg-color:var(--el-color-success-dark-2);--el-button-active-border-color:var(--el-color-success-dark-2);--el-button-disabled-text-color:var(--el-color-white);--el-button-disabled-bg-color:var(--el-color-success-light-5);--el-button-disabled-border-color:var(--el-color-success-light-5)}.el-button--success.is-link,.el-button--success.is-plain,.el-button--success.is-text{--el-button-text-color:var(--el-color-success);--el-button-bg-color:var(--el-color-success-light-9);--el-button-border-color:var(--el-color-success-light-5);--el-button-hover-text-color:var(--el-color-white);--el-button-hover-bg-color:var(--el-color-success);--el-button-hover-border-color:var(--el-color-success);--el-button-active-text-color:var(--el-color-white)}.el-button--success.is-link.is-disabled,.el-button--success.is-link.is-disabled:active,.el-button--success.is-link.is-disabled:focus,.el-button--success.is-link.is-disabled:hover,.el-button--success.is-plain.is-disabled,.el-button--success.is-plain.is-disabled:active,.el-button--success.is-plain.is-disabled:focus,.el-button--success.is-plain.is-disabled:hover,.el-button--success.is-text.is-disabled,.el-button--success.is-text.is-disabled:active,.el-button--success.is-text.is-disabled:focus,.el-button--success.is-text.is-disabled:hover{background-color:var(--el-color-success-light-9);border-color:var(--el-color-success-light-8);color:var(--el-color-success-light-5)}.el-button--warning{--el-button-text-color:var(--el-color-white);--el-button-bg-color:var(--el-color-warning);--el-button-border-color:var(--el-color-warning);--el-button-outline-color:var(--el-color-warning-light-5);--el-button-active-color:var(--el-color-warning-dark-2);--el-button-hover-text-color:var(--el-color-white);--el-button-hover-link-text-color:var(--el-color-warning-light-5);--el-button-hover-bg-color:var(--el-color-warning-light-3);--el-button-hover-border-color:var(--el-color-warning-light-3);--el-button-active-bg-color:var(--el-color-warning-dark-2);--el-button-active-border-color:var(--el-color-warning-dark-2);--el-button-disabled-text-color:var(--el-color-white);--el-button-disabled-bg-color:var(--el-color-warning-light-5);--el-button-disabled-border-color:var(--el-color-warning-light-5)}.el-button--warning.is-link,.el-button--warning.is-plain,.el-button--warning.is-text{--el-button-text-color:var(--el-color-warning);--el-button-bg-color:var(--el-color-warning-light-9);--el-button-border-color:var(--el-color-warning-light-5);--el-button-hover-text-color:var(--el-color-white);--el-button-hover-bg-color:var(--el-color-warning);--el-button-hover-border-color:var(--el-color-warning);--el-button-active-text-color:var(--el-color-white)}.el-button--warning.is-link.is-disabled,.el-button--warning.is-link.is-disabled:active,.el-button--warning.is-link.is-disabled:focus,.el-button--warning.is-link.is-disabled:hover,.el-button--warning.is-plain.is-disabled,.el-button--warning.is-plain.is-disabled:active,.el-button--warning.is-plain.is-disabled:focus,.el-button--warning.is-plain.is-disabled:hover,.el-button--warning.is-text.is-disabled,.el-button--warning.is-text.is-disabled:active,.el-button--warning.is-text.is-disabled:focus,.el-button--warning.is-text.is-disabled:hover{background-color:var(--el-color-warning-light-9);border-color:var(--el-color-warning-light-8);color:var(--el-color-warning-light-5)}.el-button--danger{--el-button-text-color:var(--el-color-white);--el-button-bg-color:var(--el-color-danger);--el-button-border-color:var(--el-color-danger);--el-button-outline-color:var(--el-color-danger-light-5);--el-button-active-color:var(--el-color-danger-dark-2);--el-button-hover-text-color:var(--el-color-white);--el-button-hover-link-text-color:var(--el-color-danger-light-5);--el-button-hover-bg-color:var(--el-color-danger-light-3);--el-button-hover-border-color:var(--el-color-danger-light-3);--el-button-active-bg-color:var(--el-color-danger-dark-2);--el-button-active-border-color:var(--el-color-danger-dark-2);--el-button-disabled-text-color:var(--el-color-white);--el-button-disabled-bg-color:var(--el-color-danger-light-5);--el-button-disabled-border-color:var(--el-color-danger-light-5)}.el-button--danger.is-link,.el-button--danger.is-plain,.el-button--danger.is-text{--el-button-text-color:var(--el-color-danger);--el-button-bg-color:var(--el-color-danger-light-9);--el-button-border-color:var(--el-color-danger-light-5);--el-button-hover-text-color:var(--el-color-white);--el-button-hover-bg-color:var(--el-color-danger);--el-button-hover-border-color:var(--el-color-danger);--el-button-active-text-color:var(--el-color-white)}.el-button--danger.is-link.is-disabled,.el-button--danger.is-link.is-disabled:active,.el-button--danger.is-link.is-disabled:focus,.el-button--danger.is-link.is-disabled:hover,.el-button--danger.is-plain.is-disabled,.el-button--danger.is-plain.is-disabled:active,.el-button--danger.is-plain.is-disabled:focus,.el-button--danger.is-plain.is-disabled:hover,.el-button--danger.is-text.is-disabled,.el-button--danger.is-text.is-disabled:active,.el-button--danger.is-text.is-disabled:focus,.el-button--danger.is-text.is-disabled:hover{background-color:var(--el-color-danger-light-9);border-color:var(--el-color-danger-light-8);color:var(--el-color-danger-light-5)}.el-button--info{--el-button-text-color:var(--el-color-white);--el-button-bg-color:var(--el-color-info);--el-button-border-color:var(--el-color-info);--el-button-outline-color:var(--el-color-info-light-5);--el-button-active-color:var(--el-color-info-dark-2);--el-button-hover-text-color:var(--el-color-white);--el-button-hover-link-text-color:var(--el-color-info-light-5);--el-button-hover-bg-color:var(--el-color-info-light-3);--el-button-hover-border-color:var(--el-color-info-light-3);--el-button-active-bg-color:var(--el-color-info-dark-2);--el-button-active-border-color:var(--el-color-info-dark-2);--el-button-disabled-text-color:var(--el-color-white);--el-button-disabled-bg-color:var(--el-color-info-light-5);--el-button-disabled-border-color:var(--el-color-info-light-5)}.el-button--info.is-link,.el-button--info.is-plain,.el-button--info.is-text{--el-button-text-color:var(--el-color-info);--el-button-bg-color:var(--el-color-info-light-9);--el-button-border-color:var(--el-color-info-light-5);--el-button-hover-text-color:var(--el-color-white);--el-button-hover-bg-color:var(--el-color-info);--el-button-hover-border-color:var(--el-color-info);--el-button-active-text-color:var(--el-color-white)}.el-button--info.is-link.is-disabled,.el-button--info.is-link.is-disabled:active,.el-button--info.is-link.is-disabled:focus,.el-button--info.is-link.is-disabled:hover,.el-button--info.is-plain.is-disabled,.el-button--info.is-plain.is-disabled:active,.el-button--info.is-plain.is-disabled:focus,.el-button--info.is-plain.is-disabled:hover,.el-button--info.is-text.is-disabled,.el-button--info.is-text.is-disabled:active,.el-button--info.is-text.is-disabled:focus,.el-button--info.is-text.is-disabled:hover{background-color:var(--el-color-info-light-9);border-color:var(--el-color-info-light-8);color:var(--el-color-info-light-5)}.el-button--large{--el-button-size:40px;height:var(--el-button-size)}.el-button--large [class*=el-icon]+span{margin-left:8px}.el-button--large{border-radius:var(--el-border-radius-base);font-size:var(--el-font-size-base);padding:12px 19px}.el-button--large.is-round{padding:12px 19px}.el-button--large.is-circle{padding:12px;width:var(--el-button-size)}.el-button--small{--el-button-size:24px;height:var(--el-button-size)}.el-button--small [class*=el-icon]+span{margin-left:4px}.el-button--small{border-radius:calc(var(--el-border-radius-base) - 1px);font-size:12px;padding:5px 11px}.el-button--small.is-round{padding:5px 11px}.el-button--small.is-circle{padding:5px;width:var(--el-button-size)}.el-textarea{--el-input-text-color:var(--el-text-color-regular);--el-input-border:var(--el-border);--el-input-hover-border:var(--el-border-color-hover);--el-input-focus-border:var(--el-color-primary);--el-input-transparent-border:0 0 0 1px transparent inset;--el-input-border-color:var(--el-border-color);--el-input-border-radius:var(--el-border-radius-base);--el-input-bg-color:var(--el-fill-color-blank);--el-input-icon-color:var(--el-text-color-placeholder);--el-input-placeholder-color:var(--el-text-color-placeholder);--el-input-hover-border-color:var(--el-border-color-hover);--el-input-clear-hover-color:var(--el-text-color-secondary);--el-input-focus-border-color:var(--el-color-primary);--el-input-width:100%;display:inline-block;font-size:var(--el-font-size-base);position:relative;vertical-align:bottom;width:100%}.el-textarea__inner{-webkit-appearance:none;background-color:var(--el-input-bg-color,var(--el-fill-color-blank));background-image:none;border:none;border-radius:var(--el-input-border-radius,var(--el-border-radius-base));box-shadow:0 0 0 1px var(--el-input-border-color,var(--el-border-color)) inset;box-sizing:border-box;color:var(--el-input-text-color,var(--el-text-color-regular));display:block;font-family:inherit;font-size:inherit;line-height:1.5;padding:5px 11px;position:relative;resize:vertical;transition:var(--el-transition-box-shadow);width:100%}.el-textarea__inner::-moz-placeholder{color:var(--el-input-placeholder-color,var(--el-text-color-placeholder))}.el-textarea__inner::placeholder{color:var(--el-input-placeholder-color,var(--el-text-color-placeholder))}.el-textarea__inner:hover{box-shadow:0 0 0 1px var(--el-input-hover-border-color) inset}.el-textarea__inner:focus{box-shadow:0 0 0 1px var(--el-input-focus-border-color) inset;outline:none}.el-textarea .el-input__count{background:var(--el-fill-color-blank);bottom:5px;color:var(--el-color-info);font-size:12px;line-height:14px;position:absolute;right:10px}.el-textarea.is-disabled .el-textarea__inner{background-color:var(--el-disabled-bg-color);box-shadow:0 0 0 1px var(--el-disabled-border-color) inset;color:var(--el-disabled-text-color);cursor:not-allowed}.el-textarea.is-disabled .el-textarea__inner::-moz-placeholder{color:var(--el-text-color-placeholder)}.el-textarea.is-disabled .el-textarea__inner::placeholder{color:var(--el-text-color-placeholder)}.el-textarea.is-exceed .el-textarea__inner{box-shadow:0 0 0 1px var(--el-color-danger) inset}.el-textarea.is-exceed .el-input__count{color:var(--el-color-danger)}.el-input{--el-input-text-color:var(--el-text-color-regular);--el-input-border:var(--el-border);--el-input-hover-border:var(--el-border-color-hover);--el-input-focus-border:var(--el-color-primary);--el-input-transparent-border:0 0 0 1px transparent inset;--el-input-border-color:var(--el-border-color);--el-input-border-radius:var(--el-border-radius-base);--el-input-bg-color:var(--el-fill-color-blank);--el-input-icon-color:var(--el-text-color-placeholder);--el-input-placeholder-color:var(--el-text-color-placeholder);--el-input-hover-border-color:var(--el-border-color-hover);--el-input-clear-hover-color:var(--el-text-color-secondary);--el-input-focus-border-color:var(--el-color-primary);--el-input-width:100%;--el-input-height:var(--el-component-size);box-sizing:border-box;display:inline-flex;font-size:var(--el-font-size-base);line-height:var(--el-input-height);position:relative;vertical-align:middle;width:var(--el-input-width)}.el-input::-webkit-scrollbar{width:6px;z-index:11}.el-input::-webkit-scrollbar:horizontal{height:6px}.el-input::-webkit-scrollbar-thumb{background:var(--el-text-color-disabled);border-radius:5px;width:6px}.el-input::-webkit-scrollbar-corner,.el-input::-webkit-scrollbar-track{background:var(--el-fill-color-blank)}.el-input::-webkit-scrollbar-track-piece{background:var(--el-fill-color-blank);width:6px}.el-input .el-input__clear,.el-input .el-input__password{color:var(--el-input-icon-color);cursor:pointer;font-size:14px}.el-input .el-input__clear:hover,.el-input .el-input__password:hover{color:var(--el-input-clear-hover-color)}.el-input .el-input__count{align-items:center;color:var(--el-color-info);display:inline-flex;font-size:12px;height:100%}.el-input .el-input__count .el-input__count-inner{background:var(--el-fill-color-blank);display:inline-block;line-height:normal;padding-left:8px}.el-input__wrapper{align-items:center;background-color:var(--el-input-bg-color,var(--el-fill-color-blank));background-image:none;border-radius:var(--el-input-border-radius,var(--el-border-radius-base));box-shadow:0 0 0 1px var(--el-input-border-color,var(--el-border-color)) inset;cursor:text;display:inline-flex;flex-grow:1;justify-content:center;padding:1px 11px;transform:translateZ(0);transition:var(--el-transition-box-shadow)}.el-input__wrapper:hover{box-shadow:0 0 0 1px var(--el-input-hover-border-color) inset}.el-input__wrapper.is-focus{box-shadow:0 0 0 1px var(--el-input-focus-border-color) inset}.el-input__inner{--el-input-inner-height:calc(var(--el-input-height, 32px) - 2px);-webkit-appearance:none;background:none;border:none;box-sizing:border-box;color:var(--el-input-text-color,var(--el-text-color-regular));flex-grow:1;font-size:inherit;height:var(--el-input-inner-height);line-height:var(--el-input-inner-height);outline:none;padding:0;width:100%}.el-input__inner:focus{outline:none}.el-input__inner::-moz-placeholder{color:var(--el-input-placeholder-color,var(--el-text-color-placeholder))}.el-input__inner::placeholder{color:var(--el-input-placeholder-color,var(--el-text-color-placeholder))}.el-input__inner[type=password]::-ms-reveal{display:none}.el-input__inner[type=number]{line-height:1}.el-input__prefix{color:var(--el-input-icon-color,var(--el-text-color-placeholder));display:inline-flex;flex-shrink:0;flex-wrap:nowrap;height:100%;pointer-events:none;text-align:center;transition:all var(--el-transition-duration);white-space:nowrap}.el-input__prefix-inner{align-items:center;display:inline-flex;justify-content:center;pointer-events:all}.el-input__prefix-inner>:last-child{margin-right:8px}.el-input__prefix-inner>:first-child,.el-input__prefix-inner>:first-child.el-input__icon{margin-left:0}.el-input__suffix{color:var(--el-input-icon-color,var(--el-text-color-placeholder));display:inline-flex;flex-shrink:0;flex-wrap:nowrap;height:100%;pointer-events:none;text-align:center;transition:all var(--el-transition-duration);white-space:nowrap}.el-input__suffix-inner{align-items:center;display:inline-flex;justify-content:center;pointer-events:all}.el-input__suffix-inner>:first-child{margin-left:8px}.el-input .el-input__icon{align-items:center;display:flex;height:inherit;justify-content:center;line-height:inherit;margin-left:8px;transition:all var(--el-transition-duration)}.el-input__validateIcon{pointer-events:none}.el-input.is-active .el-input__wrapper{box-shadow:0 0 0 1px var(--el-input-focus-color, ) inset}.el-input.is-disabled{cursor:not-allowed}.el-input.is-disabled .el-input__wrapper{background-color:var(--el-disabled-bg-color);box-shadow:0 0 0 1px var(--el-disabled-border-color) inset}.el-input.is-disabled .el-input__inner{color:var(--el-disabled-text-color);-webkit-text-fill-color:var(--el-disabled-text-color);cursor:not-allowed}.el-input.is-disabled .el-input__inner::-moz-placeholder{color:var(--el-text-color-placeholder)}.el-input.is-disabled .el-input__inner::placeholder{color:var(--el-text-color-placeholder)}.el-input.is-disabled .el-input__icon{cursor:not-allowed}.el-input.is-exceed .el-input__wrapper{box-shadow:0 0 0 1px var(--el-color-danger) inset}.el-input.is-exceed .el-input__suffix .el-input__count{color:var(--el-color-danger)}.el-input--large{--el-input-height:var(--el-component-size-large);font-size:14px}.el-input--large .el-input__wrapper{padding:1px 15px}.el-input--large .el-input__inner{--el-input-inner-height:calc(var(--el-input-height, 40px) - 2px)}.el-input--small{--el-input-height:var(--el-component-size-small);font-size:12px}.el-input--small .el-input__wrapper{padding:1px 7px}.el-input--small .el-input__inner{--el-input-inner-height:calc(var(--el-input-height, 24px) - 2px)}.el-input-group{align-items:stretch;display:inline-flex;width:100%}.el-input-group__append,.el-input-group__prepend{align-items:center;background-color:var(--el-fill-color-light);border-radius:var(--el-input-border-radius);color:var(--el-color-info);display:inline-flex;justify-content:center;min-height:100%;padding:0 20px;position:relative;white-space:nowrap}.el-input-group__append:focus,.el-input-group__prepend:focus{outline:none}.el-input-group__append .el-button,.el-input-group__append .el-select,.el-input-group__prepend .el-button,.el-input-group__prepend .el-select{display:inline-block;margin:0 -20px}.el-input-group__append button.el-button,.el-input-group__append button.el-button:hover,.el-input-group__append div.el-select .el-select__wrapper,.el-input-group__append div.el-select:hover .el-select__wrapper,.el-input-group__prepend button.el-button,.el-input-group__prepend button.el-button:hover,.el-input-group__prepend div.el-select .el-select__wrapper,.el-input-group__prepend div.el-select:hover .el-select__wrapper{background-color:transparent;border-color:transparent;color:inherit}.el-input-group__append .el-button,.el-input-group__append .el-input,.el-input-group__prepend .el-button,.el-input-group__prepend .el-input{font-size:inherit}.el-input-group__prepend{border-bottom-right-radius:0;border-right:0;border-top-right-radius:0;box-shadow:1px 0 0 0 var(--el-input-border-color) inset,0 1px 0 0 var(--el-input-border-color) inset,0 -1px 0 0 var(--el-input-border-color) inset}.el-input-group__append{border-left:0;box-shadow:0 1px 0 0 var(--el-input-border-color) inset,0 -1px 0 0 var(--el-input-border-color) inset,-1px 0 0 0 var(--el-input-border-color) inset}.el-input-group--prepend>.el-input__wrapper,.el-input-group__append{border-bottom-left-radius:0;border-top-left-radius:0}.el-input-group--prepend .el-input-group__prepend .el-select .el-select__wrapper{border-bottom-right-radius:0;border-top-right-radius:0;box-shadow:1px 0 0 0 var(--el-input-border-color) inset,0 1px 0 0 var(--el-input-border-color) inset,0 -1px 0 0 var(--el-input-border-color) inset}.el-input-group--append>.el-input__wrapper{border-bottom-right-radius:0;border-top-right-radius:0}.el-input-group--append .el-input-group__append .el-select .el-select__wrapper{border-bottom-left-radius:0;border-top-left-radius:0;box-shadow:0 1px 0 0 var(--el-input-border-color) inset,0 -1px 0 0 var(--el-input-border-color) inset,-1px 0 0 0 var(--el-input-border-color) inset}.el-input-hidden{display:none!important}.el-badge{--el-badge-bg-color:var(--el-color-danger);--el-badge-radius:10px;--el-badge-font-size:12px;--el-badge-padding:6px;--el-badge-size:18px;display:inline-block;position:relative;vertical-align:middle;width:-moz-fit-content;width:fit-content}.el-badge__content{align-items:center;background-color:var(--el-badge-bg-color);border:1px solid var(--el-bg-color);border-radius:var(--el-badge-radius);color:var(--el-color-white);display:inline-flex;font-size:var(--el-badge-font-size);height:var(--el-badge-size);justify-content:center;padding:0 var(--el-badge-padding);white-space:nowrap}.el-badge__content.is-fixed{position:absolute;right:calc(1px + var(--el-badge-size)/2);top:0;transform:translateY(-50%) translate(100%);z-index:var(--el-index-normal)}.el-badge__content.is-fixed.is-dot{right:5px}.el-badge__content.is-dot{border-radius:50%;height:8px;padding:0;right:0;width:8px}.el-badge__content.is-hide-zero{display:none}.el-badge__content--primary{background-color:var(--el-color-primary)}.el-badge__content--success{background-color:var(--el-color-success)}.el-badge__content--warning{background-color:var(--el-color-warning)}.el-badge__content--info{background-color:var(--el-color-info)}.el-badge__content--danger{background-color:var(--el-color-danger)}.el-message{--el-message-bg-color:var(--el-color-info-light-9);--el-message-border-color:var(--el-border-color-lighter);--el-message-padding:11px 15px;--el-message-close-size:16px;--el-message-close-icon-color:var(--el-text-color-placeholder);--el-message-close-hover-color:var(--el-text-color-secondary);align-items:center;background-color:var(--el-message-bg-color);border-color:var(--el-message-border-color);border-radius:var(--el-border-radius-base);border-style:var(--el-border-style);border-width:var(--el-border-width);box-sizing:border-box;display:flex;gap:8px;left:50%;max-width:calc(100% - 32px);padding:var(--el-message-padding);position:fixed;top:20px;transform:translate(-50%);transition:opacity var(--el-transition-duration),transform .4s,top .4s;width:-moz-fit-content;width:fit-content}.el-message.is-center{justify-content:center}.el-message.is-plain{background-color:var(--el-bg-color-overlay);border-color:var(--el-bg-color-overlay);box-shadow:var(--el-box-shadow-light)}.el-message p{margin:0}.el-message--success{--el-message-bg-color:var(--el-color-success-light-9);--el-message-border-color:var(--el-color-success-light-8);--el-message-text-color:var(--el-color-success)}.el-message--success .el-message__content{color:var(--el-message-text-color);overflow-wrap:break-word}.el-message .el-message-icon--success{color:var(--el-message-text-color)}.el-message--info{--el-message-bg-color:var(--el-color-info-light-9);--el-message-border-color:var(--el-color-info-light-8);--el-message-text-color:var(--el-color-info)}.el-message--info .el-message__content{color:var(--el-message-text-color);overflow-wrap:break-word}.el-message .el-message-icon--info{color:var(--el-message-text-color)}.el-message--warning{--el-message-bg-color:var(--el-color-warning-light-9);--el-message-border-color:var(--el-color-warning-light-8);--el-message-text-color:var(--el-color-warning)}.el-message--warning .el-message__content{color:var(--el-message-text-color);overflow-wrap:break-word}.el-message .el-message-icon--warning{color:var(--el-message-text-color)}.el-message--error{--el-message-bg-color:var(--el-color-error-light-9);--el-message-border-color:var(--el-color-error-light-8);--el-message-text-color:var(--el-color-error)}.el-message--error .el-message__content{color:var(--el-message-text-color);overflow-wrap:break-word}.el-message .el-message-icon--error{color:var(--el-message-text-color)}.el-message .el-message__badge{position:absolute;right:-8px;top:-8px}.el-message__content{font-size:14px;line-height:1;padding:0}.el-message__content:focus{outline-width:0}.el-message .el-message__closeBtn{color:var(--el-message-close-icon-color);cursor:pointer;font-size:var(--el-message-close-size)}.el-message .el-message__closeBtn:focus{outline-width:0}.el-message .el-message__closeBtn:hover{color:var(--el-message-close-hover-color)}.el-message-fade-enter-from,.el-message-fade-leave-to{opacity:0;transform:translate(-50%,-100%)}.el-checkbox{--el-checkbox-font-size:14px;--el-checkbox-font-weight:var(--el-font-weight-primary);--el-checkbox-text-color:var(--el-text-color-regular);--el-checkbox-input-height:14px;--el-checkbox-input-width:14px;--el-checkbox-border-radius:var(--el-border-radius-small);--el-checkbox-bg-color:var(--el-fill-color-blank);--el-checkbox-input-border:var(--el-border);--el-checkbox-disabled-border-color:var(--el-border-color);--el-checkbox-disabled-input-fill:var(--el-fill-color-light);--el-checkbox-disabled-icon-color:var(--el-text-color-placeholder);--el-checkbox-disabled-checked-input-fill:var(--el-border-color-extra-light);--el-checkbox-disabled-checked-input-border-color:var(--el-border-color);--el-checkbox-disabled-checked-icon-color:var(--el-text-color-placeholder);--el-checkbox-checked-text-color:var(--el-color-primary);--el-checkbox-checked-input-border-color:var(--el-color-primary);--el-checkbox-checked-bg-color:var(--el-color-primary);--el-checkbox-checked-icon-color:var(--el-color-white);--el-checkbox-input-border-color-hover:var(--el-color-primary);align-items:center;color:var(--el-checkbox-text-color);cursor:pointer;display:inline-flex;font-size:var(--el-font-size-base);font-weight:var(--el-checkbox-font-weight);height:var(--el-checkbox-height,32px);margin-right:30px;position:relative;-webkit-user-select:none;-moz-user-select:none;user-select:none;white-space:nowrap}.el-checkbox.is-disabled{cursor:not-allowed}.el-checkbox.is-bordered{border:var(--el-border);border-radius:var(--el-border-radius-base);box-sizing:border-box;padding:0 15px 0 9px}.el-checkbox.is-bordered.is-checked{border-color:var(--el-color-primary)}.el-checkbox.is-bordered.is-disabled{border-color:var(--el-border-color-lighter)}.el-checkbox.is-bordered.el-checkbox--large{border-radius:var(--el-border-radius-base);padding:0 19px 0 11px}.el-checkbox.is-bordered.el-checkbox--large .el-checkbox__label{font-size:var(--el-font-size-base)}.el-checkbox.is-bordered.el-checkbox--large .el-checkbox__inner{height:14px;width:14px}.el-checkbox.is-bordered.el-checkbox--small{border-radius:calc(var(--el-border-radius-base) - 1px);padding:0 11px 0 7px}.el-checkbox.is-bordered.el-checkbox--small .el-checkbox__label{font-size:12px}.el-checkbox.is-bordered.el-checkbox--small .el-checkbox__inner{height:12px;width:12px}.el-checkbox.is-bordered.el-checkbox--small .el-checkbox__inner:after{height:6px;width:2px}.el-checkbox input:focus-visible+.el-checkbox__inner{border-radius:var(--el-checkbox-border-radius);outline:2px solid var(--el-checkbox-input-border-color-hover);outline-offset:1px}.el-checkbox__input{cursor:pointer;display:inline-flex;outline:none;position:relative;white-space:nowrap}.el-checkbox__input.is-disabled .el-checkbox__inner{background-color:var(--el-checkbox-disabled-input-fill);border-color:var(--el-checkbox-disabled-border-color);cursor:not-allowed}.el-checkbox__input.is-disabled .el-checkbox__inner:after{border-color:var(--el-checkbox-disabled-icon-color);cursor:not-allowed}.el-checkbox__input.is-disabled.is-checked .el-checkbox__inner{background-color:var(--el-checkbox-disabled-checked-input-fill);border-color:var(--el-checkbox-disabled-checked-input-border-color)}.el-checkbox__input.is-disabled.is-checked .el-checkbox__inner:after{border-color:var(--el-checkbox-disabled-checked-icon-color)}.el-checkbox__input.is-disabled.is-indeterminate .el-checkbox__inner{background-color:var(--el-checkbox-disabled-checked-input-fill);border-color:var(--el-checkbox-disabled-checked-input-border-color)}.el-checkbox__input.is-disabled.is-indeterminate .el-checkbox__inner:before{background-color:var(--el-checkbox-disabled-checked-icon-color);border-color:var(--el-checkbox-disabled-checked-icon-color)}.el-checkbox__input.is-disabled+span.el-checkbox__label{color:var(--el-disabled-text-color);cursor:not-allowed}.el-checkbox__input.is-checked .el-checkbox__inner{background-color:var(--el-checkbox-checked-bg-color);border-color:var(--el-checkbox-checked-input-border-color)}.el-checkbox__input.is-checked .el-checkbox__inner:after{border-color:var(--el-checkbox-checked-icon-color);transform:rotate(45deg) scaleY(1)}.el-checkbox__input.is-checked+.el-checkbox__label{color:var(--el-checkbox-checked-text-color)}.el-checkbox__input.is-focus:not(.is-checked) .el-checkbox__original:not(:focus-visible){border-color:var(--el-checkbox-input-border-color-hover)}.el-checkbox__input.is-indeterminate .el-checkbox__inner{background-color:var(--el-checkbox-checked-bg-color);border-color:var(--el-checkbox-checked-input-border-color)}.el-checkbox__input.is-indeterminate .el-checkbox__inner:before{background-color:var(--el-checkbox-checked-icon-color);content:"";display:block;height:2px;left:0;position:absolute;right:0;top:5px;transform:scale(.5)}.el-checkbox__input.is-indeterminate .el-checkbox__inner:after{display:none}.el-checkbox__inner{background-color:var(--el-checkbox-bg-color);border:var(--el-checkbox-input-border);border-radius:var(--el-checkbox-border-radius);box-sizing:border-box;display:inline-block;height:var(--el-checkbox-input-height);position:relative;transition:border-color .25s cubic-bezier(.71,-.46,.29,1.46),background-color .25s cubic-bezier(.71,-.46,.29,1.46),outline .25s cubic-bezier(.71,-.46,.29,1.46);width:var(--el-checkbox-input-width);z-index:var(--el-index-normal)}.el-checkbox__inner:hover{border-color:var(--el-checkbox-input-border-color-hover)}.el-checkbox__inner:after{border:1px solid transparent;border-left:0;border-top:0;box-sizing:content-box;content:"";height:7px;left:4px;position:absolute;top:1px;transform:rotate(45deg) scaleY(0);transform-origin:center;transition:transform .15s ease-in .05s;width:3px}.el-checkbox__original{height:0;margin:0;opacity:0;outline:none;position:absolute;width:0;z-index:-1}.el-checkbox__label{display:inline-block;font-size:var(--el-checkbox-font-size);line-height:1;padding-left:8px}.el-checkbox.el-checkbox--large{height:40px}.el-checkbox.el-checkbox--large .el-checkbox__label{font-size:14px}.el-checkbox.el-checkbox--large .el-checkbox__inner{height:14px;width:14px}.el-checkbox.el-checkbox--small{height:24px}.el-checkbox.el-checkbox--small .el-checkbox__label{font-size:12px}.el-checkbox.el-checkbox--small .el-checkbox__inner{height:12px;width:12px}.el-checkbox.el-checkbox--small .el-checkbox__input.is-indeterminate .el-checkbox__inner:before{top:4px}.el-checkbox.el-checkbox--small .el-checkbox__inner:after{height:6px;width:2px}.el-checkbox:last-of-type{margin-right:0}.el-dialog{--el-dialog-width:50%;--el-dialog-margin-top:15vh;--el-dialog-bg-color:var(--el-bg-color);--el-dialog-box-shadow:var(--el-box-shadow);--el-dialog-title-font-size:var(--el-font-size-large);--el-dialog-content-font-size:14px;--el-dialog-font-line-height:var(--el-font-line-height-primary);--el-dialog-padding-primary:16px;--el-dialog-border-radius:var(--el-border-radius-base);background:var(--el-dialog-bg-color);border-radius:var(--el-dialog-border-radius);box-shadow:var(--el-dialog-box-shadow);box-sizing:border-box;margin:var(--el-dialog-margin-top,15vh) auto 50px;overflow-wrap:break-word;padding:var(--el-dialog-padding-primary);position:relative;width:var(--el-dialog-width,50%)}.el-dialog:focus{outline:none!important}.el-dialog.is-align-center{margin:auto}.el-dialog.is-fullscreen{--el-dialog-width:100%;--el-dialog-margin-top:0;height:100%;margin-bottom:0;overflow:auto}.el-dialog__wrapper{bottom:0;left:0;margin:0;overflow:auto;position:fixed;right:0;top:0}.el-dialog.is-draggable .el-dialog__header{cursor:move;-webkit-user-select:none;-moz-user-select:none;user-select:none}.el-dialog__header{padding-bottom:var(--el-dialog-padding-primary)}.el-dialog__header.show-close{padding-right:calc(var(--el-dialog-padding-primary) + var(--el-message-close-size, 16px))}.el-dialog__headerbtn{background:transparent;border:none;cursor:pointer;font-size:var(--el-message-close-size,16px);height:48px;outline:none;padding:0;position:absolute;right:0;top:0;width:48px}.el-dialog__headerbtn .el-dialog__close{color:var(--el-color-info);font-size:inherit}.el-dialog__headerbtn:focus .el-dialog__close,.el-dialog__headerbtn:hover .el-dialog__close{color:var(--el-color-primary)}.el-dialog__title{color:var(--el-text-color-primary);font-size:var(--el-dialog-title-font-size);line-height:var(--el-dialog-font-line-height)}.el-dialog__body{color:var(--el-text-color-regular);font-size:var(--el-dialog-content-font-size)}.el-dialog__footer{box-sizing:border-box;padding-top:var(--el-dialog-padding-primary);text-align:right}.el-dialog--center{text-align:center}.el-dialog--center .el-dialog__body{text-align:initial}.el-dialog--center .el-dialog__footer{text-align:inherit}.el-overlay-dialog{bottom:0;left:0;overflow:auto;position:fixed;right:0;top:0}.dialog-fade-enter-active{animation:modal-fade-in var(--el-transition-duration)}.dialog-fade-enter-active .el-overlay-dialog{animation:dialog-fade-in var(--el-transition-duration)}.dialog-fade-leave-active{animation:modal-fade-out var(--el-transition-duration)}.dialog-fade-leave-active .el-overlay-dialog{animation:dialog-fade-out var(--el-transition-duration)}@keyframes dialog-fade-in{0%{opacity:0;transform:translate3d(0,-20px,0)}to{opacity:1;transform:translateZ(0)}}@keyframes dialog-fade-out{0%{opacity:1;transform:translateZ(0)}to{opacity:0;transform:translate3d(0,-20px,0)}}@keyframes modal-fade-in{0%{opacity:0}to{opacity:1}}@keyframes modal-fade-out{0%{opacity:1}to{opacity:0}}.el-overlay{background-color:var(--el-overlay-color-lighter);bottom:0;height:100%;left:0;overflow:auto;position:fixed;right:0;top:0;z-index:2000}.el-overlay .el-overlay-root{height:0}.el-form{--el-form-label-font-size:var(--el-font-size-base);--el-form-inline-content-width:220px}.el-form--inline .el-form-item{display:inline-flex;margin-right:32px;vertical-align:middle}.el-form--inline.el-form--label-top{display:flex;flex-wrap:wrap}.el-form--inline.el-form--label-top .el-form-item{display:block}.el-form-item{display:flex;--font-size:14px;margin-bottom:18px}.el-form-item .el-form-item{margin-bottom:0}.el-form-item .el-input__validateIcon{display:none}.el-form-item--large{--font-size:14px;--el-form-label-font-size:var(--font-size);margin-bottom:22px}.el-form-item--large .el-form-item__label{height:40px;line-height:40px}.el-form-item--large .el-form-item__content{line-height:40px}.el-form-item--large .el-form-item__error{padding-top:4px}.el-form-item--default{--font-size:14px;--el-form-label-font-size:var(--font-size);margin-bottom:18px}.el-form-item--default .el-form-item__label{height:32px;line-height:32px}.el-form-item--default .el-form-item__content{line-height:32px}.el-form-item--default .el-form-item__error{padding-top:2px}.el-form-item--small{--font-size:12px;--el-form-label-font-size:var(--font-size);margin-bottom:18px}.el-form-item--small .el-form-item__label{height:24px;line-height:24px}.el-form-item--small .el-form-item__content{line-height:24px}.el-form-item--small .el-form-item__error{padding-top:2px}.el-form-item--label-left .el-form-item__label{justify-content:flex-start}.el-form-item--label-top{display:block}.el-form-item--label-top .el-form-item__label{display:inline-block;height:auto;line-height:22px;margin-bottom:8px;text-align:left;vertical-align:middle}.el-form-item__label-wrap{display:flex}.el-form-item__label{align-items:flex-start;box-sizing:border-box;color:var(--el-text-color-regular);display:inline-flex;flex:0 0 auto;font-size:var(--el-form-label-font-size);height:32px;justify-content:flex-end;line-height:32px;padding:0 12px 0 0}.el-form-item__content{align-items:center;display:flex;flex:1;flex-wrap:wrap;font-size:var(--font-size);line-height:32px;min-width:0;position:relative}.el-form-item__content .el-input-group{vertical-align:top}.el-form-item__error{color:var(--el-color-danger);font-size:12px;left:0;line-height:1;padding-top:2px;position:absolute;top:100%}.el-form-item__error--inline{display:inline-block;left:auto;margin-left:10px;position:relative;top:auto}.el-form-item.is-required:not(.is-no-asterisk).asterisk-left>.el-form-item__label-wrap>.el-form-item__label:before,.el-form-item.is-required:not(.is-no-asterisk).asterisk-left>.el-form-item__label:before{color:var(--el-color-danger);content:"*";margin-right:4px}.el-form-item.is-required:not(.is-no-asterisk).asterisk-right>.el-form-item__label-wrap>.el-form-item__label:after,.el-form-item.is-required:not(.is-no-asterisk).asterisk-right>.el-form-item__label:after{color:var(--el-color-danger);content:"*";margin-left:4px}.el-form-item.is-error .el-input__wrapper,.el-form-item.is-error .el-input__wrapper.is-focus,.el-form-item.is-error .el-input__wrapper:focus,.el-form-item.is-error .el-input__wrapper:hover,.el-form-item.is-error .el-select__wrapper,.el-form-item.is-error .el-select__wrapper.is-focus,.el-form-item.is-error .el-select__wrapper:focus,.el-form-item.is-error .el-select__wrapper:hover,.el-form-item.is-error .el-textarea__inner,.el-form-item.is-error .el-textarea__inner.is-focus,.el-form-item.is-error .el-textarea__inner:focus,.el-form-item.is-error .el-textarea__inner:hover{box-shadow:0 0 0 1px var(--el-color-danger) inset}.el-form-item.is-error .el-input-group__append .el-input__wrapper,.el-form-item.is-error .el-input-group__prepend .el-input__wrapper{box-shadow:inset 0 0 0 1px transparent}.el-form-item.is-error .el-input-group__append .el-input__validateIcon,.el-form-item.is-error .el-input-group__prepend .el-input__validateIcon{display:none}.el-form-item.is-error .el-input__validateIcon{color:var(--el-color-danger)}.el-form-item--feedback .el-input__validateIcon{display:inline-flex}.el-tag{--el-tag-font-size:12px;--el-tag-border-radius:4px;--el-tag-border-radius-rounded:9999px;align-items:center;background-color:var(--el-tag-bg-color);border-color:var(--el-tag-border-color);border-radius:var(--el-tag-border-radius);border-style:solid;border-width:1px;box-sizing:border-box;color:var(--el-tag-text-color);display:inline-flex;font-size:var(--el-tag-font-size);height:24px;justify-content:center;line-height:1;padding:0 9px;vertical-align:middle;white-space:nowrap;--el-icon-size:14px}.el-tag,.el-tag.el-tag--primary{--el-tag-bg-color:var(--el-color-primary-light-9);--el-tag-border-color:var(--el-color-primary-light-8);--el-tag-hover-color:var(--el-color-primary)}.el-tag.el-tag--success{--el-tag-bg-color:var(--el-color-success-light-9);--el-tag-border-color:var(--el-color-success-light-8);--el-tag-hover-color:var(--el-color-success)}.el-tag.el-tag--warning{--el-tag-bg-color:var(--el-color-warning-light-9);--el-tag-border-color:var(--el-color-warning-light-8);--el-tag-hover-color:var(--el-color-warning)}.el-tag.el-tag--danger{--el-tag-bg-color:var(--el-color-danger-light-9);--el-tag-border-color:var(--el-color-danger-light-8);--el-tag-hover-color:var(--el-color-danger)}.el-tag.el-tag--error{--el-tag-bg-color:var(--el-color-error-light-9);--el-tag-border-color:var(--el-color-error-light-8);--el-tag-hover-color:var(--el-color-error)}.el-tag.el-tag--info{--el-tag-bg-color:var(--el-color-info-light-9);--el-tag-border-color:var(--el-color-info-light-8);--el-tag-hover-color:var(--el-color-info)}.el-tag.is-hit{border-color:var(--el-color-primary)}.el-tag.is-round{border-radius:var(--el-tag-border-radius-rounded)}.el-tag .el-tag__close{color:var(--el-tag-text-color);flex-shrink:0}.el-tag .el-tag__close:hover{background-color:var(--el-tag-hover-color);color:var(--el-color-white)}.el-tag.el-tag--primary{--el-tag-text-color:var(--el-color-primary)}.el-tag.el-tag--success{--el-tag-text-color:var(--el-color-success)}.el-tag.el-tag--warning{--el-tag-text-color:var(--el-color-warning)}.el-tag.el-tag--danger{--el-tag-text-color:var(--el-color-danger)}.el-tag.el-tag--error{--el-tag-text-color:var(--el-color-error)}.el-tag.el-tag--info{--el-tag-text-color:var(--el-color-info)}.el-tag .el-icon{border-radius:50%;cursor:pointer;font-size:calc(var(--el-icon-size) - 2px);height:var(--el-icon-size);width:var(--el-icon-size)}.el-tag .el-tag__close{margin-left:6px}.el-tag--dark{--el-tag-text-color:var(--el-color-white)}.el-tag--dark,.el-tag--dark.el-tag--primary{--el-tag-bg-color:var(--el-color-primary);--el-tag-border-color:var(--el-color-primary);--el-tag-hover-color:var(--el-color-primary-light-3)}.el-tag--dark.el-tag--success{--el-tag-bg-color:var(--el-color-success);--el-tag-border-color:var(--el-color-success);--el-tag-hover-color:var(--el-color-success-light-3)}.el-tag--dark.el-tag--warning{--el-tag-bg-color:var(--el-color-warning);--el-tag-border-color:var(--el-color-warning);--el-tag-hover-color:var(--el-color-warning-light-3)}.el-tag--dark.el-tag--danger{--el-tag-bg-color:var(--el-color-danger);--el-tag-border-color:var(--el-color-danger);--el-tag-hover-color:var(--el-color-danger-light-3)}.el-tag--dark.el-tag--error{--el-tag-bg-color:var(--el-color-error);--el-tag-border-color:var(--el-color-error);--el-tag-hover-color:var(--el-color-error-light-3)}.el-tag--dark.el-tag--info{--el-tag-bg-color:var(--el-color-info);--el-tag-border-color:var(--el-color-info);--el-tag-hover-color:var(--el-color-info-light-3)}.el-tag--dark.el-tag--danger,.el-tag--dark.el-tag--error,.el-tag--dark.el-tag--info,.el-tag--dark.el-tag--primary,.el-tag--dark.el-tag--success,.el-tag--dark.el-tag--warning{--el-tag-text-color:var(--el-color-white)}.el-tag--plain,.el-tag--plain.el-tag--primary{--el-tag-bg-color:var(--el-fill-color-blank);--el-tag-border-color:var(--el-color-primary-light-5);--el-tag-hover-color:var(--el-color-primary)}.el-tag--plain.el-tag--success{--el-tag-bg-color:var(--el-fill-color-blank);--el-tag-border-color:var(--el-color-success-light-5);--el-tag-hover-color:var(--el-color-success)}.el-tag--plain.el-tag--warning{--el-tag-bg-color:var(--el-fill-color-blank);--el-tag-border-color:var(--el-color-warning-light-5);--el-tag-hover-color:var(--el-color-warning)}.el-tag--plain.el-tag--danger{--el-tag-bg-color:var(--el-fill-color-blank);--el-tag-border-color:var(--el-color-danger-light-5);--el-tag-hover-color:var(--el-color-danger)}.el-tag--plain.el-tag--error{--el-tag-bg-color:var(--el-fill-color-blank);--el-tag-border-color:var(--el-color-error-light-5);--el-tag-hover-color:var(--el-color-error)}.el-tag--plain.el-tag--info{--el-tag-bg-color:var(--el-fill-color-blank);--el-tag-border-color:var(--el-color-info-light-5);--el-tag-hover-color:var(--el-color-info)}.el-tag.is-closable{padding-right:5px}.el-tag--large{height:32px;padding:0 11px;--el-icon-size:16px}.el-tag--large .el-tag__close{margin-left:8px}.el-tag--large.is-closable{padding-right:7px}.el-tag--small{height:20px;padding:0 7px;--el-icon-size:12px}.el-tag--small .el-tag__close{margin-left:4px}.el-tag--small.is-closable{padding-right:3px}.el-tag--small .el-icon-close{transform:scale(.8)}.el-tag.el-tag--primary.is-hit{border-color:var(--el-color-primary)}.el-tag.el-tag--success.is-hit{border-color:var(--el-color-success)}.el-tag.el-tag--warning.is-hit{border-color:var(--el-color-warning)}.el-tag.el-tag--danger.is-hit{border-color:var(--el-color-danger)}.el-tag.el-tag--error.is-hit{border-color:var(--el-color-error)}.el-tag.el-tag--info.is-hit{border-color:var(--el-color-info)}.el-select-dropdown.is-multiple .el-select-dropdown__item.is-selected:after{background-color:var(--el-color-primary);background-position:50%;background-repeat:no-repeat;border-right:none;border-top:none;content:"";height:12px;mask:url("data:image/svg+xml;utf8,%3Csvg class='icon' width='200' height='200' viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='currentColor' d='M406.656 706.944L195.84 496.256a32 32 0 10-45.248 45.248l256 256 512-512a32 32 0 00-45.248-45.248L406.592 706.944z'%3E%3C/path%3E%3C/svg%3E") no-repeat;mask-size:100% 100%;-webkit-mask:url("data:image/svg+xml;utf8,%3Csvg class='icon' width='200' height='200' viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='currentColor' d='M406.656 706.944L195.84 496.256a32 32 0 10-45.248 45.248l256 256 512-512a32 32 0 00-45.248-45.248L406.592 706.944z'%3E%3C/path%3E%3C/svg%3E") no-repeat;-webkit-mask-size:100% 100%;position:absolute;right:20px;top:50%;transform:translateY(-50%);width:12px}.el-scrollbar{--el-scrollbar-opacity:.3;--el-scrollbar-bg-color:var(--el-text-color-secondary);--el-scrollbar-hover-opacity:.5;--el-scrollbar-hover-bg-color:var(--el-text-color-secondary);height:100%;overflow:hidden;position:relative}.el-scrollbar__wrap{height:100%;overflow:auto}.el-scrollbar__wrap--hidden-default{scrollbar-width:none}.el-scrollbar__wrap--hidden-default::-webkit-scrollbar{display:none}.el-scrollbar__thumb{background-color:var(--el-scrollbar-bg-color,var(--el-text-color-secondary));border-radius:inherit;cursor:pointer;display:block;height:0;opacity:var(--el-scrollbar-opacity,.3);position:relative;transition:var(--el-transition-duration) background-color;width:0}.el-scrollbar__thumb:hover{background-color:var(--el-scrollbar-hover-bg-color,var(--el-text-color-secondary));opacity:var(--el-scrollbar-hover-opacity,.5)}.el-scrollbar__bar{border-radius:4px;bottom:2px;position:absolute;right:2px;z-index:1}.el-scrollbar__bar.is-vertical{top:2px;width:6px}.el-scrollbar__bar.is-vertical>div{width:100%}.el-scrollbar__bar.is-horizontal{height:6px;left:2px}.el-scrollbar__bar.is-horizontal>div{height:100%}.el-scrollbar-fade-enter-active{transition:opacity .34s ease-out}.el-scrollbar-fade-leave-active{transition:opacity .12s ease-out}.el-scrollbar-fade-enter-from,.el-scrollbar-fade-leave-active{opacity:0}.el-popper{--el-popper-border-radius:var(--el-popover-border-radius,4px);border-radius:var(--el-popper-border-radius);font-size:12px;line-height:20px;min-width:10px;overflow-wrap:break-word;padding:5px 11px;position:absolute;visibility:visible;z-index:2000}.el-popper.is-dark{color:var(--el-bg-color)}.el-popper.is-dark,.el-popper.is-dark>.el-popper__arrow:before{background:var(--el-text-color-primary);border:1px solid var(--el-text-color-primary)}.el-popper.is-dark>.el-popper__arrow:before{right:0}.el-popper.is-light,.el-popper.is-light>.el-popper__arrow:before{background:var(--el-bg-color-overlay);border:1px solid var(--el-border-color-light)}.el-popper.is-light>.el-popper__arrow:before{right:0}.el-popper.is-pure{padding:0}.el-popper__arrow,.el-popper__arrow:before{height:10px;position:absolute;width:10px;z-index:-1}.el-popper__arrow:before{background:var(--el-text-color-primary);box-sizing:border-box;content:" ";transform:rotate(45deg)}.el-popper[data-popper-placement^=top]>.el-popper__arrow{bottom:-5px}.el-popper[data-popper-placement^=top]>.el-popper__arrow:before{border-bottom-right-radius:2px}.el-popper[data-popper-placement^=bottom]>.el-popper__arrow{top:-5px}.el-popper[data-popper-placement^=bottom]>.el-popper__arrow:before{border-top-left-radius:2px}.el-popper[data-popper-placement^=left]>.el-popper__arrow{right:-5px}.el-popper[data-popper-placement^=left]>.el-popper__arrow:before{border-top-right-radius:2px}.el-popper[data-popper-placement^=right]>.el-popper__arrow{left:-5px}.el-popper[data-popper-placement^=right]>.el-popper__arrow:before{border-bottom-left-radius:2px}.el-popper[data-popper-placement^=top] .el-popper__arrow:before{border-left-color:transparent!important;border-top-color:transparent!important}.el-popper[data-popper-placement^=bottom] .el-popper__arrow:before{border-bottom-color:transparent!important;border-right-color:transparent!important}.el-popper[data-popper-placement^=left] .el-popper__arrow:before{border-bottom-color:transparent!important;border-left-color:transparent!important}.el-popper[data-popper-placement^=right] .el-popper__arrow:before{border-right-color:transparent!important;border-top-color:transparent!important}.el-select-dropdown{border-radius:var(--el-border-radius-base);box-sizing:border-box;z-index:calc(var(--el-index-top) + 1)}.el-select-dropdown .el-scrollbar.is-empty .el-select-dropdown__list{padding:0}.el-select-dropdown__empty,.el-select-dropdown__loading{color:var(--el-text-color-secondary);font-size:var(--el-select-font-size);margin:0;padding:10px 0;text-align:center}.el-select-dropdown__wrap{max-height:274px}.el-select-dropdown__list{box-sizing:border-box;list-style:none;margin:0;padding:6px 0}.el-select-dropdown__list.el-vl__window{margin:6px 0;padding:0}.el-select-dropdown__header{border-bottom:1px solid var(--el-border-color-light);padding:10px}.el-select-dropdown__footer{border-top:1px solid var(--el-border-color-light);padding:10px}.el-select-dropdown__item{box-sizing:border-box;color:var(--el-text-color-regular);cursor:pointer;font-size:var(--el-font-size-base);height:34px;line-height:34px;overflow:hidden;padding:0 32px 0 20px;position:relative;text-overflow:ellipsis;white-space:nowrap}.el-select-dropdown__item.is-hovering{background-color:var(--el-fill-color-light)}.el-select-dropdown__item.is-selected{color:var(--el-color-primary);font-weight:700}.el-select-dropdown__item.is-disabled{background-color:unset;color:var(--el-text-color-placeholder);cursor:not-allowed}.el-select-dropdown.is-multiple .el-select-dropdown__item.is-selected:after{background-color:var(--el-color-primary);background-position:50%;background-repeat:no-repeat;border-right:none;border-top:none;content:"";height:12px;mask:url("data:image/svg+xml;utf8,%3Csvg class='icon' width='200' height='200' viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='currentColor' d='M406.656 706.944L195.84 496.256a32 32 0 10-45.248 45.248l256 256 512-512a32 32 0 00-45.248-45.248L406.592 706.944z'%3E%3C/path%3E%3C/svg%3E") no-repeat;mask-size:100% 100%;-webkit-mask:url("data:image/svg+xml;utf8,%3Csvg class='icon' width='200' height='200' viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='currentColor' d='M406.656 706.944L195.84 496.256a32 32 0 10-45.248 45.248l256 256 512-512a32 32 0 00-45.248-45.248L406.592 706.944z'%3E%3C/path%3E%3C/svg%3E") no-repeat;-webkit-mask-size:100% 100%;position:absolute;right:20px;top:50%;transform:translateY(-50%);width:12px}.el-select-dropdown.is-multiple .el-select-dropdown__item.is-disabled:after{background-color:var(--el-text-color-placeholder)}.el-select-group{margin:0;padding:0}.el-select-group__wrap{list-style:none;margin:0;padding:0;position:relative}.el-select-group__title{color:var(--el-color-info);font-size:12px;line-height:34px;padding-left:20px}.el-select-group .el-select-dropdown__item{padding-left:20px}.el-select{--el-select-border-color-hover:var(--el-border-color-hover);--el-select-disabled-color:var(--el-disabled-text-color);--el-select-disabled-border:var(--el-disabled-border-color);--el-select-font-size:var(--el-font-size-base);--el-select-close-hover-color:var(--el-text-color-secondary);--el-select-input-color:var(--el-text-color-placeholder);--el-select-multiple-input-color:var(--el-text-color-regular);--el-select-input-focus-border-color:var(--el-color-primary);--el-select-input-font-size:14px;--el-select-width:100%;display:inline-block;position:relative;vertical-align:middle;width:var(--el-select-width)}.el-select__wrapper{align-items:center;background-color:var(--el-fill-color-blank);border-radius:var(--el-border-radius-base);box-shadow:0 0 0 1px var(--el-border-color) inset;box-sizing:border-box;cursor:pointer;display:flex;font-size:14px;gap:6px;line-height:24px;min-height:32px;padding:4px 12px;position:relative;text-align:left;transform:translateZ(0);transition:var(--el-transition-duration)}.el-select__wrapper.is-filterable{cursor:text}.el-select__wrapper.is-focused{box-shadow:0 0 0 1px var(--el-color-primary) inset}.el-select__wrapper.is-hovering:not(.is-focused){box-shadow:0 0 0 1px var(--el-border-color-hover) inset}.el-select__wrapper.is-disabled{background-color:var(--el-fill-color-light);color:var(--el-text-color-placeholder);cursor:not-allowed}.el-select__wrapper.is-disabled,.el-select__wrapper.is-disabled:hover{box-shadow:0 0 0 1px var(--el-select-disabled-border) inset}.el-select__wrapper.is-disabled.is-focus{box-shadow:0 0 0 1px var(--el-input-focus-border-color) inset}.el-select__wrapper.is-disabled .el-select__selected-item{color:var(--el-select-disabled-color)}.el-select__wrapper.is-disabled .el-select__caret,.el-select__wrapper.is-disabled .el-tag{cursor:not-allowed}.el-select__prefix,.el-select__suffix{align-items:center;color:var(--el-input-icon-color,var(--el-text-color-placeholder));display:flex;flex-shrink:0;gap:6px}.el-select__caret{color:var(--el-select-input-color);cursor:pointer;font-size:var(--el-select-input-font-size);transform:rotate(0);transition:var(--el-transition-duration)}.el-select__caret.is-reverse{transform:rotate(180deg)}.el-select__selection{align-items:center;display:flex;flex:1;flex-wrap:wrap;gap:6px;min-width:0;position:relative}.el-select__selection.is-near{margin-left:-8px}.el-select__selection .el-tag{border-color:transparent;cursor:pointer}.el-select__selection .el-tag.el-tag--plain{border-color:var(--el-tag-border-color)}.el-select__selection .el-tag .el-tag__content{min-width:0}.el-select__selected-item{display:flex;flex-wrap:wrap;-webkit-user-select:none;-moz-user-select:none;user-select:none}.el-select__tags-text{line-height:normal}.el-select__placeholder,.el-select__tags-text{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.el-select__placeholder{color:var(--el-input-text-color,var(--el-text-color-regular));position:absolute;top:50%;transform:translateY(-50%);width:100%}.el-select__placeholder.is-transparent{color:var(--el-text-color-placeholder);-webkit-user-select:none;-moz-user-select:none;user-select:none}.el-select__popper.el-popper{background:var(--el-bg-color-overlay);box-shadow:var(--el-box-shadow-light)}.el-select__popper.el-popper,.el-select__popper.el-popper .el-popper__arrow:before{border:1px solid var(--el-border-color-light)}.el-select__popper.el-popper[data-popper-placement^=top] .el-popper__arrow:before{border-left-color:transparent;border-top-color:transparent}.el-select__popper.el-popper[data-popper-placement^=bottom] .el-popper__arrow:before{border-bottom-color:transparent;border-right-color:transparent}.el-select__popper.el-popper[data-popper-placement^=left] .el-popper__arrow:before{border-bottom-color:transparent;border-left-color:transparent}.el-select__popper.el-popper[data-popper-placement^=right] .el-popper__arrow:before{border-right-color:transparent;border-top-color:transparent}.el-select__input-wrapper{max-width:100%}.el-select__input-wrapper.is-hidden{opacity:0;position:absolute}.el-select__input{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent;border:none;color:var(--el-select-multiple-input-color);font-family:inherit;font-size:inherit;height:24px;max-width:100%;outline:none;padding:0}.el-select__input.is-disabled{cursor:not-allowed}.el-select__input-calculator{left:0;max-width:100%;overflow:hidden;position:absolute;top:0;visibility:hidden;white-space:pre}.el-select--large .el-select__wrapper{font-size:14px;gap:6px;line-height:24px;min-height:40px;padding:8px 16px}.el-select--large .el-select__selection{gap:6px}.el-select--large .el-select__selection.is-near{margin-left:-8px}.el-select--large .el-select__prefix,.el-select--large .el-select__suffix{gap:6px}.el-select--large .el-select__input{height:24px}.el-select--small .el-select__wrapper{font-size:12px;gap:4px;line-height:20px;min-height:24px;padding:2px 8px}.el-select--small .el-select__selection{gap:4px}.el-select--small .el-select__selection.is-near{margin-left:-6px}.el-select--small .el-select__prefix,.el-select--small .el-select__suffix{gap:4px}.el-select--small .el-select__input{height:20px}.el-input-number{display:inline-flex;line-height:30px;position:relative;vertical-align:middle;width:150px}.el-input-number .el-input__wrapper{padding-left:42px;padding-right:42px}.el-input-number .el-input__inner{-webkit-appearance:none;-moz-appearance:textfield;line-height:1;text-align:center}.el-input-number .el-input__inner::-webkit-inner-spin-button,.el-input-number .el-input__inner::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}.el-input-number__decrease,.el-input-number__increase{align-items:center;background:var(--el-fill-color-light);bottom:1px;color:var(--el-text-color-regular);cursor:pointer;display:flex;font-size:13px;height:auto;justify-content:center;position:absolute;top:1px;-webkit-user-select:none;-moz-user-select:none;user-select:none;width:32px;z-index:1}.el-input-number__decrease:hover,.el-input-number__increase:hover{color:var(--el-color-primary)}.el-input-number__decrease:hover~.el-input:not(.is-disabled) .el-input__wrapper,.el-input-number__increase:hover~.el-input:not(.is-disabled) .el-input__wrapper{box-shadow:0 0 0 1px var(--el-input-focus-border-color,var(--el-color-primary)) inset}.el-input-number__decrease.is-disabled,.el-input-number__increase.is-disabled{color:var(--el-disabled-text-color);cursor:not-allowed}.el-input-number__increase{border-left:var(--el-border);border-radius:0 var(--el-border-radius-base) var(--el-border-radius-base) 0;right:1px}.el-input-number__decrease{border-radius:var(--el-border-radius-base) 0 0 var(--el-border-radius-base);border-right:var(--el-border);left:1px}.el-input-number.is-disabled .el-input-number__decrease,.el-input-number.is-disabled .el-input-number__increase{border-color:var(--el-disabled-border-color);color:var(--el-disabled-border-color)}.el-input-number.is-disabled .el-input-number__decrease:hover,.el-input-number.is-disabled .el-input-number__increase:hover{color:var(--el-disabled-border-color);cursor:not-allowed}.el-input-number--large{line-height:38px;width:180px}.el-input-number--large .el-input-number__decrease,.el-input-number--large .el-input-number__increase{font-size:14px;width:40px}.el-input-number--large .el-input--large .el-input__wrapper{padding-left:47px;padding-right:47px}.el-input-number--small{line-height:22px;width:120px}.el-input-number--small .el-input-number__decrease,.el-input-number--small .el-input-number__increase{font-size:12px;width:24px}.el-input-number--small .el-input--small .el-input__wrapper{padding-left:31px;padding-right:31px}.el-input-number--small .el-input-number__decrease [class*=el-icon],.el-input-number--small .el-input-number__increase [class*=el-icon]{transform:scale(.9)}.el-input-number.is-without-controls .el-input__wrapper{padding-left:15px;padding-right:15px}.el-input-number.is-controls-right .el-input__wrapper{padding-left:15px;padding-right:42px}.el-input-number.is-controls-right .el-input-number__decrease,.el-input-number.is-controls-right .el-input-number__increase{--el-input-number-controls-height:15px;height:var(--el-input-number-controls-height);line-height:var(--el-input-number-controls-height)}.el-input-number.is-controls-right .el-input-number__decrease [class*=el-icon],.el-input-number.is-controls-right .el-input-number__increase [class*=el-icon]{transform:scale(.8)}.el-input-number.is-controls-right .el-input-number__increase{border-bottom:var(--el-border);border-radius:0 var(--el-border-radius-base) 0 0;bottom:auto;left:auto}.el-input-number.is-controls-right .el-input-number__decrease{border-left:var(--el-border);border-radius:0 0 var(--el-border-radius-base) 0;border-right:none;left:auto;right:1px;top:auto}.el-input-number.is-controls-right[class*=large] [class*=decrease],.el-input-number.is-controls-right[class*=large] [class*=increase]{--el-input-number-controls-height:19px}.el-input-number.is-controls-right[class*=small] [class*=decrease],.el-input-number.is-controls-right[class*=small] [class*=increase]{--el-input-number-controls-height:11px}.el-switch{--el-switch-on-color:var(--el-color-primary);--el-switch-off-color:var(--el-border-color);align-items:center;display:inline-flex;font-size:14px;height:32px;line-height:20px;position:relative;vertical-align:middle}.el-switch.is-disabled .el-switch__core,.el-switch.is-disabled .el-switch__label{cursor:not-allowed}.el-switch__label{color:var(--el-text-color-primary);cursor:pointer;display:inline-block;font-size:14px;font-weight:500;height:20px;transition:var(--el-transition-duration-fast);vertical-align:middle}.el-switch__label.is-active{color:var(--el-color-primary)}.el-switch__label--left{margin-right:10px}.el-switch__label--right{margin-left:10px}.el-switch__label *{display:inline-block;font-size:14px;line-height:1}.el-switch__label .el-icon{height:inherit}.el-switch__label .el-icon svg{vertical-align:middle}.el-switch__input{height:0;margin:0;opacity:0;position:absolute;width:0}.el-switch__input:focus-visible~.el-switch__core{outline:2px solid var(--el-switch-on-color);outline-offset:1px}.el-switch__core{align-items:center;background:var(--el-switch-off-color);border:1px solid var(--el-switch-border-color,var(--el-switch-off-color));border-radius:10px;box-sizing:border-box;cursor:pointer;display:inline-flex;height:20px;min-width:40px;outline:none;position:relative;transition:border-color var(--el-transition-duration),background-color var(--el-transition-duration)}.el-switch__core .el-switch__inner{align-items:center;display:flex;height:16px;justify-content:center;overflow:hidden;padding:0 4px 0 18px;transition:all var(--el-transition-duration);width:100%}.el-switch__core .el-switch__inner .is-icon,.el-switch__core .el-switch__inner .is-text{color:var(--el-color-white);font-size:12px;overflow:hidden;text-overflow:ellipsis;-webkit-user-select:none;-moz-user-select:none;user-select:none;white-space:nowrap}.el-switch__core .el-switch__action{align-items:center;background-color:var(--el-color-white);border-radius:var(--el-border-radius-circle);color:var(--el-switch-off-color);display:flex;height:16px;justify-content:center;left:1px;position:absolute;transition:all var(--el-transition-duration);width:16px}.el-switch.is-checked .el-switch__core{background-color:var(--el-switch-on-color);border-color:var(--el-switch-border-color,var(--el-switch-on-color))}.el-switch.is-checked .el-switch__core .el-switch__action{color:var(--el-switch-on-color);left:calc(100% - 17px)}.el-switch.is-checked .el-switch__core .el-switch__inner{padding:0 18px 0 4px}.el-switch.is-disabled{opacity:.6}.el-switch--wide .el-switch__label.el-switch__label--left span{left:10px}.el-switch--wide .el-switch__label.el-switch__label--right span{right:10px}.el-switch .label-fade-enter-from,.el-switch .label-fade-leave-active{opacity:0}.el-switch--large{font-size:14px;height:40px;line-height:24px}.el-switch--large .el-switch__label{font-size:14px;height:24px}.el-switch--large .el-switch__label *{font-size:14px}.el-switch--large .el-switch__core{border-radius:12px;height:24px;min-width:50px}.el-switch--large .el-switch__core .el-switch__inner{height:20px;padding:0 6px 0 22px}.el-switch--large .el-switch__core .el-switch__action{height:20px;width:20px}.el-switch--large.is-checked .el-switch__core .el-switch__action{left:calc(100% - 21px)}.el-switch--large.is-checked .el-switch__core .el-switch__inner{padding:0 22px 0 6px}.el-switch--small{font-size:12px;height:24px;line-height:16px}.el-switch--small .el-switch__label{font-size:12px;height:16px}.el-switch--small .el-switch__label *{font-size:12px}.el-switch--small .el-switch__core{border-radius:8px;height:16px;min-width:30px}.el-switch--small .el-switch__core .el-switch__inner{height:12px;padding:0 2px 0 14px}.el-switch--small .el-switch__core .el-switch__action{height:12px;width:12px}.el-switch--small.is-checked .el-switch__core .el-switch__action{left:calc(100% - 13px)}.el-switch--small.is-checked .el-switch__core .el-switch__inner{padding:0 14px 0 2px}html.dark{color-scheme:dark;--el-color-primary:#409eff;--el-color-primary-light-3:rgb(50.8,116.6,184.5);--el-color-primary-light-5:rgb(42,89,137.5);--el-color-primary-light-7:rgb(33.2,61.4,90.5);--el-color-primary-light-8:rgb(28.8,47.6,67);--el-color-primary-light-9:rgb(24.4,33.8,43.5);--el-color-primary-dark-2:rgb(102.2,177.4,255);--el-color-success:#67c23a;--el-color-success-light-3:rgb(78.1,141.8,46.6);--el-color-success-light-5:rgb(61.5,107,39);--el-color-success-light-7:rgb(44.9,72.2,31.4);--el-color-success-light-8:rgb(36.6,54.8,27.6);--el-color-success-light-9:rgb(28.3,37.4,23.8);--el-color-success-dark-2:rgb(133.4,206.2,97.4);--el-color-warning:#e6a23c;--el-color-warning-light-3:rgb(167,119.4,48);--el-color-warning-light-5:#7d5b28;--el-color-warning-light-7:rgb(83,62.6,32);--el-color-warning-light-8:rgb(62,48.4,28);--el-color-warning-light-9:rgb(41,34.2,24);--el-color-warning-dark-2:rgb(235,180.6,99);--el-color-danger:#f56c6c;--el-color-danger-light-3:rgb(177.5,81.6,81.6);--el-color-danger-light-5:rgb(132.5,64,64);--el-color-danger-light-7:rgb(87.5,46.4,46.4);--el-color-danger-light-8:rgb(65,37.6,37.6);--el-color-danger-light-9:rgb(42.5,28.8,28.8);--el-color-danger-dark-2:rgb(247,137.4,137.4);--el-color-error:#f56c6c;--el-color-error-light-3:rgb(177.5,81.6,81.6);--el-color-error-light-5:rgb(132.5,64,64);--el-color-error-light-7:rgb(87.5,46.4,46.4);--el-color-error-light-8:rgb(65,37.6,37.6);--el-color-error-light-9:rgb(42.5,28.8,28.8);--el-color-error-dark-2:rgb(247,137.4,137.4);--el-color-info:#909399;--el-color-info-light-3:rgb(106.8,108.9,113.1);--el-color-info-light-5:rgb(82,83.5,86.5);--el-color-info-light-7:rgb(57.2,58.1,59.9);--el-color-info-light-8:rgb(44.8,45.4,46.6);--el-color-info-light-9:rgb(32.4,32.7,33.3);--el-color-info-dark-2:rgb(166.2,168.6,173.4);--el-box-shadow:0px 12px 32px 4px rgba(0,0,0,.36),0px 8px 20px rgba(0,0,0,.72);--el-box-shadow-light:0px 0px 12px rgba(0,0,0,.72);--el-box-shadow-lighter:0px 0px 6px rgba(0,0,0,.72);--el-box-shadow-dark:0px 16px 48px 16px rgba(0,0,0,.72),0px 12px 32px #000000,0px 8px 16px -8px #000000;--el-bg-color-page:#0a0a0a;--el-bg-color:#141414;--el-bg-color-overlay:#1d1e1f;--el-text-color-primary:#E5EAF3;--el-text-color-regular:#CFD3DC;--el-text-color-secondary:#A3A6AD;--el-text-color-placeholder:#8D9095;--el-text-color-disabled:#6C6E72;--el-border-color-darker:#636466;--el-border-color-dark:#58585B;--el-border-color:#4C4D4F;--el-border-color-light:#414243;--el-border-color-lighter:#363637;--el-border-color-extra-light:#2B2B2C;--el-fill-color-darker:#424243;--el-fill-color-dark:#39393A;--el-fill-color:#303030;--el-fill-color-light:#262727;--el-fill-color-lighter:#1D1D1D;--el-fill-color-extra-light:#191919;--el-fill-color-blank:transparent;--el-mask-color:rgba(0,0,0,.8);--el-mask-color-extra-light:rgba(0,0,0,.3)}html.dark .el-button{--el-button-disabled-text-color:rgba(255,255,255,.5)}html.dark .el-card{--el-card-bg-color:var(--el-bg-color-overlay)}html.dark .el-empty{--el-empty-fill-color-0:var(--el-color-black);--el-empty-fill-color-1:#4b4b52;--el-empty-fill-color-2:#36383d;--el-empty-fill-color-3:#1e1e20;--el-empty-fill-color-4:#262629;--el-empty-fill-color-5:#202124;--el-empty-fill-color-6:#212224;--el-empty-fill-color-7:#1b1c1f;--el-empty-fill-color-8:#1c1d1f;--el-empty-fill-color-9:#18181a}:root{--el-loading-spinner-size:42px;--el-loading-fullscreen-spinner-size:50px}.el-loading-parent--relative{position:relative!important}.el-loading-parent--hidden{overflow:hidden!important}.el-loading-mask{background-color:var(--el-mask-color);bottom:0;left:0;margin:0;position:absolute;right:0;top:0;transition:opacity var(--el-transition-duration);z-index:2000}.el-loading-mask.is-fullscreen{position:fixed}.el-loading-mask.is-fullscreen .el-loading-spinner{margin-top:calc((0px - var(--el-loading-fullscreen-spinner-size))/2)}.el-loading-mask.is-fullscreen .el-loading-spinner .circular{height:var(--el-loading-fullscreen-spinner-size);width:var(--el-loading-fullscreen-spinner-size)}.el-loading-spinner{margin-top:calc((0px - var(--el-loading-spinner-size))/2);position:absolute;text-align:center;top:50%;width:100%}.el-loading-spinner .el-loading-text{color:var(--el-color-primary);font-size:14px;margin:3px 0}.el-loading-spinner .circular{animation:loading-rotate 2s linear infinite;display:inline;height:var(--el-loading-spinner-size);width:var(--el-loading-spinner-size)}.el-loading-spinner .path{animation:loading-dash 1.5s ease-in-out infinite;stroke-dasharray:90,150;stroke-dashoffset:0;stroke-width:2;stroke:var(--el-color-primary);stroke-linecap:round}.el-loading-spinner i{color:var(--el-color-primary)}.el-loading-fade-enter-from,.el-loading-fade-leave-to{opacity:0}@keyframes loading-rotate{to{transform:rotate(1turn)}}@keyframes loading-dash{0%{stroke-dasharray:1,200;stroke-dashoffset:0}50%{stroke-dasharray:90,150;stroke-dashoffset:-40px}to{stroke-dasharray:90,150;stroke-dashoffset:-120px}}:root{--el-popup-modal-bg-color:var(--el-color-black);--el-popup-modal-opacity:.5}.v-modal-enter{animation:v-modal-in var(--el-transition-duration-fast) ease}.v-modal-leave{animation:v-modal-out var(--el-transition-duration-fast) ease forwards}@keyframes v-modal-in{0%{opacity:0}}@keyframes v-modal-out{to{opacity:0}}.v-modal{background:var(--el-popup-modal-bg-color);height:100%;left:0;opacity:var(--el-popup-modal-opacity);position:fixed;top:0;width:100%}.el-popup-parent--hidden{overflow:hidden}.el-message-box{--el-messagebox-title-color:var(--el-text-color-primary);--el-messagebox-width:420px;--el-messagebox-border-radius:4px;--el-messagebox-box-shadow:var(--el-box-shadow);--el-messagebox-font-size:var(--el-font-size-large);--el-messagebox-content-font-size:var(--el-font-size-base);--el-messagebox-content-color:var(--el-text-color-regular);--el-messagebox-error-font-size:12px;--el-messagebox-padding-primary:12px;--el-messagebox-font-line-height:var(--el-font-line-height-primary);backface-visibility:hidden;background-color:var(--el-bg-color);border-radius:var(--el-messagebox-border-radius);box-shadow:var(--el-messagebox-box-shadow);box-sizing:border-box;display:inline-block;font-size:var(--el-messagebox-font-size);max-width:var(--el-messagebox-width);overflow:hidden;overflow-wrap:break-word;padding:var(--el-messagebox-padding-primary);position:relative;text-align:left;vertical-align:middle;width:100%}.el-message-box:focus{outline:none!important}.el-overlay.is-message-box .el-overlay-message-box{bottom:0;left:0;overflow:auto;padding:16px;position:fixed;right:0;text-align:center;top:0}.el-overlay.is-message-box .el-overlay-message-box:after{content:"";display:inline-block;height:100%;vertical-align:middle;width:0}.el-message-box.is-draggable .el-message-box__header{cursor:move;-webkit-user-select:none;-moz-user-select:none;user-select:none}.el-message-box__header{padding-bottom:var(--el-messagebox-padding-primary)}.el-message-box__header.show-close{padding-right:calc(var(--el-messagebox-padding-primary) + var(--el-message-close-size, 16px))}.el-message-box__title{color:var(--el-messagebox-title-color);font-size:var(--el-messagebox-font-size);line-height:var(--el-messagebox-font-line-height)}.el-message-box__headerbtn{background:transparent;border:none;cursor:pointer;font-size:var(--el-message-close-size,16px);height:40px;outline:none;padding:0;position:absolute;right:0;top:0;width:40px}.el-message-box__headerbtn .el-message-box__close{color:var(--el-color-info);font-size:inherit}.el-message-box__headerbtn:focus .el-message-box__close,.el-message-box__headerbtn:hover .el-message-box__close{color:var(--el-color-primary)}.el-message-box__content{color:var(--el-messagebox-content-color);font-size:var(--el-messagebox-content-font-size)}.el-message-box__container{align-items:center;display:flex;gap:12px}.el-message-box__input{padding-top:12px}.el-message-box__input div.invalid>input,.el-message-box__input div.invalid>input:focus{border-color:var(--el-color-error)}.el-message-box__status{font-size:24px}.el-message-box__status.el-message-box-icon--success{--el-messagebox-color:var(--el-color-success);color:var(--el-messagebox-color)}.el-message-box__status.el-message-box-icon--info{--el-messagebox-color:var(--el-color-info);color:var(--el-messagebox-color)}.el-message-box__status.el-message-box-icon--warning{--el-messagebox-color:var(--el-color-warning);color:var(--el-messagebox-color)}.el-message-box__status.el-message-box-icon--error{--el-messagebox-color:var(--el-color-error);color:var(--el-messagebox-color)}.el-message-box__message{margin:0}.el-message-box__message p{line-height:var(--el-messagebox-font-line-height);margin:0}.el-message-box__errormsg{color:var(--el-color-error);font-size:var(--el-messagebox-error-font-size);line-height:var(--el-messagebox-font-line-height)}.el-message-box__btns{align-items:center;display:flex;flex-wrap:wrap;justify-content:flex-end;padding-top:var(--el-messagebox-padding-primary)}.el-message-box--center .el-message-box__title{align-items:center;display:flex;gap:6px;justify-content:center}.el-message-box--center .el-message-box__status{font-size:inherit}.el-message-box--center .el-message-box__btns,.el-message-box--center .el-message-box__container{justify-content:center}.fade-in-linear-enter-active .el-overlay-message-box{animation:msgbox-fade-in var(--el-transition-duration)}.fade-in-linear-leave-active .el-overlay-message-box{animation:msgbox-fade-in var(--el-transition-duration) reverse}@keyframes msgbox-fade-in{0%{opacity:0;transform:translate3d(0,-20px,0)}to{opacity:1;transform:translateZ(0)}}.el-popover{--el-popover-bg-color:var(--el-bg-color-overlay);--el-popover-font-size:var(--el-font-size-base);--el-popover-border-color:var(--el-border-color-lighter);--el-popover-padding:12px;--el-popover-padding-large:18px 20px;--el-popover-title-font-size:16px;--el-popover-title-text-color:var(--el-text-color-primary);--el-popover-border-radius:4px}.el-popover.el-popper{background:var(--el-popover-bg-color);border:1px solid var(--el-popover-border-color);border-radius:var(--el-popover-border-radius);box-shadow:var(--el-box-shadow-light);box-sizing:border-box;color:var(--el-text-color-regular);font-size:var(--el-popover-font-size);line-height:1.4;min-width:150px;overflow-wrap:break-word;padding:var(--el-popover-padding);z-index:var(--el-index-popper)}.el-popover.el-popper--plain{padding:var(--el-popover-padding-large)}.el-popover__title{color:var(--el-popover-title-text-color);font-size:var(--el-popover-title-font-size);line-height:1;margin-bottom:12px}.el-popover__reference:focus:hover,.el-popover__reference:focus:not(.focusing){outline-width:0}.el-popover.el-popper.is-dark{--el-popover-bg-color:var(--el-text-color-primary);--el-popover-border-color:var(--el-text-color-primary);--el-popover-title-text-color:var(--el-bg-color);color:var(--el-bg-color)}.el-popover.el-popper:focus,.el-popover.el-popper:focus:active{outline-width:0} ================================================ FILE: app/src/main/assets/web/vue/assets/vendor-KSDcS24u.js ================================================ /** * @vue/shared v3.5.30 * (c) 2018-present Yuxi (Evan) You and Vue contributors * @license MIT **/function Ku(e){const t=Object.create(null);for(const n of e.split(","))t[n]=1;return n=>n in t}const Ye={},Mo=[],ht=()=>{},mh=()=>!1,Na=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),qu=e=>e.startsWith("onUpdate:"),vt=Object.assign,Wu=(e,t)=>{const n=e.indexOf(t);n>-1&&e.splice(n,1)},Wb=Object.prototype.hasOwnProperty,Ve=(e,t)=>Wb.call(e,t),pe=Array.isArray,$o=e=>oi(e)==="[object Map]",La=e=>oi(e)==="[object Set]",nf=e=>oi(e)==="[object Date]",ve=e=>typeof e=="function",Ae=e=>typeof e=="string",Tn=e=>typeof e=="symbol",Oe=e=>e!==null&&typeof e=="object",ia=e=>(Oe(e)||ve(e))&&ve(e.then)&&ve(e.catch),gh=Object.prototype.toString,oi=e=>gh.call(e),Ui=e=>oi(e).slice(8,-1),bh=e=>oi(e)==="[object Object]",Ma=e=>Ae(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,ws=Ku(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),$a=e=>{const t=Object.create(null);return n=>t[n]||(t[n]=e(n))},Gb=/-\w/g,Mt=$a(e=>e.replace(Gb,t=>t.slice(1).toUpperCase())),Yb=/\B([A-Z])/g,hr=$a(e=>e.replace(Yb,"-$1").toLowerCase()),si=$a(e=>e.charAt(0).toUpperCase()+e.slice(1)),Ki=$a(e=>e?`on${si(e)}`:""),jn=(e,t)=>!Object.is(e,t),qi=(e,...t)=>{for(let n=0;n{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:r,value:n})},Gu=e=>{const t=parseFloat(e);return isNaN(t)?e:t},Jb=e=>{const t=Ae(e)?Number(e):NaN;return isNaN(t)?e:t};let rf;const ka=()=>rf||(rf=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function ot(e){if(pe(e)){const t={};for(let n=0;n{if(n){const r=n.split(Zb);r.length>1&&(t[r[0].trim()]=r[1].trim())}}),t}function U(e){let t="";if(Ae(e))t=e;else if(pe(e))for(let n=0;nii(n,t))}const Eh=e=>!!(e&&e.__v_isRef===!0),He=e=>Ae(e)?e:e==null?"":pe(e)||Oe(e)&&(e.toString===gh||!ve(e.toString))?Eh(e)?He(e.value):JSON.stringify(e,_h,2):String(e),_h=(e,t)=>Eh(t)?_h(e,t.value):$o(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((n,[r,o],s)=>(n[yl(r,s)+" =>"]=o,n),{})}:La(t)?{[`Set(${t.size})`]:[...t.values()].map(n=>yl(n))}:Tn(t)?yl(t):Oe(t)&&!pe(t)&&!bh(t)?String(t):t,yl=(e,t="")=>{var n;return Tn(e)?`Symbol(${(n=e.description)!=null?n:t})`:e};function oy(e){return e==null?"initial":typeof e=="string"?e===""?" ":e:String(e)}/** * @vue/reactivity v3.5.30 * (c) 2018-present Yuxi (Evan) You and Vue contributors * @license MIT **/let xt;class Ch{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this.__v_skip=!0,this.parent=xt,!t&&xt&&(this.index=(xt.scopes||(xt.scopes=[])).push(this)-1)}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,n;if(this.scopes)for(t=0,n=this.scopes.length;t0&&--this._on===0&&(xt=this.prevScope,this.prevScope=void 0)}stop(t){if(this._active){this._active=!1;let n,r;for(n=0,r=this.effects.length;n0)return;if(Es){let t=Es;for(Es=void 0;t;){const n=t.next;t.next=void 0,t.flags&=-9,t=n}}let e;for(;Ss;){let t=Ss;for(Ss=void 0;t;){const n=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(r){e||(e=r)}t=n}}if(e)throw e}function xh(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function Ih(e){let t,n=e.depsTail,r=n;for(;r;){const o=r.prevDep;r.version===-1?(r===n&&(n=o),Xu(r),sy(r)):t=r,r.dep.activeLink=r.prevActiveLink,r.prevActiveLink=void 0,r=o}e.deps=t,e.depsTail=n}function eu(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(Ph(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function Ph(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===Fs)||(e.globalVersion=Fs,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!eu(e))))return;e.flags|=2;const t=e.dep,n=et,r=Sn;et=e,Sn=!0;try{xh(e);const o=e.fn(e._value);(t.version===0||jn(o,e._value))&&(e.flags|=128,e._value=o,t.version++)}catch(o){throw t.version++,o}finally{et=n,Sn=r,Ih(e),e.flags&=-3}}function Xu(e,t=!1){const{dep:n,prevSub:r,nextSub:o}=e;if(r&&(r.nextSub=o,e.prevSub=void 0),o&&(o.prevSub=r,e.nextSub=void 0),n.subs===e&&(n.subs=r,!r&&n.computed)){n.computed.flags&=-5;for(let s=n.computed.deps;s;s=s.nextDep)Xu(s,!0)}!t&&!--n.sc&&n.map&&n.map.delete(n.key)}function sy(e){const{prevDep:t,nextDep:n}=e;t&&(t.nextDep=n,e.prevDep=void 0),n&&(n.prevDep=t,e.nextDep=void 0)}let Sn=!0;const Nh=[];function lr(){Nh.push(Sn),Sn=!1}function ur(){const e=Nh.pop();Sn=e===void 0?!0:e}function of(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const n=et;et=void 0;try{t()}finally{et=n}}}let Fs=0,iy=class{constructor(t,n){this.sub=t,this.dep=n,this.version=n.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}};class Da{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!et||!Sn||et===this.computed)return;let n=this.activeLink;if(n===void 0||n.sub!==et)n=this.activeLink=new iy(et,this),et.deps?(n.prevDep=et.depsTail,et.depsTail.nextDep=n,et.depsTail=n):et.deps=et.depsTail=n,Lh(n);else if(n.version===-1&&(n.version=this.version,n.nextDep)){const r=n.nextDep;r.prevDep=n.prevDep,n.prevDep&&(n.prevDep.nextDep=r),n.prevDep=et.depsTail,n.nextDep=void 0,et.depsTail.nextDep=n,et.depsTail=n,et.deps===n&&(et.deps=r)}return n}trigger(t){this.version++,Fs++,this.notify(t)}notify(t){Yu();try{for(let n=this.subs;n;n=n.prevSub)n.sub.notify()&&n.sub.dep.notify()}finally{Ju()}}}function Lh(e){if(e.dep.sc++,e.sub.flags&4){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let r=t.deps;r;r=r.nextDep)Lh(r)}const n=e.dep.subs;n!==e&&(e.prevSub=n,n&&(n.nextSub=e)),e.dep.subs=e}}const aa=new WeakMap,oo=Symbol(""),tu=Symbol(""),Bs=Symbol("");function It(e,t,n){if(Sn&&et){let r=aa.get(e);r||aa.set(e,r=new Map);let o=r.get(n);o||(r.set(n,o=new Da),o.map=r,o.key=n),o.track()}}function nr(e,t,n,r,o,s){const i=aa.get(e);if(!i){Fs++;return}const a=l=>{l&&l.trigger()};if(Yu(),t==="clear")i.forEach(a);else{const l=pe(e),u=l&&Ma(n);if(l&&n==="length"){const c=Number(r);i.forEach((f,d)=>{(d==="length"||d===Bs||!Tn(d)&&d>=c)&&a(f)})}else switch((n!==void 0||i.has(void 0))&&a(i.get(n)),u&&a(i.get(Bs)),t){case"add":l?u&&a(i.get("length")):(a(i.get(oo)),$o(e)&&a(i.get(tu)));break;case"delete":l||(a(i.get(oo)),$o(e)&&a(i.get(tu)));break;case"set":$o(e)&&a(i.get(oo));break}}Ju()}function ay(e,t){const n=aa.get(e);return n&&n.get(t)}function _o(e){const t=Me(e);return t===e?t:(It(t,"iterate",Bs),Qt(e)?t:t.map(On))}function Va(e){return It(e=Me(e),"iterate",Bs),e}function Dn(e,t){return cr(e)?Do(Hn(e)?On(t):t):On(t)}const ly={__proto__:null,[Symbol.iterator](){return Sl(this,Symbol.iterator,e=>Dn(this,e))},concat(...e){return _o(this).concat(...e.map(t=>pe(t)?_o(t):t))},entries(){return Sl(this,"entries",e=>(e[1]=Dn(this,e[1]),e))},every(e,t){return Yn(this,"every",e,t,void 0,arguments)},filter(e,t){return Yn(this,"filter",e,t,n=>n.map(r=>Dn(this,r)),arguments)},find(e,t){return Yn(this,"find",e,t,n=>Dn(this,n),arguments)},findIndex(e,t){return Yn(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return Yn(this,"findLast",e,t,n=>Dn(this,n),arguments)},findLastIndex(e,t){return Yn(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return Yn(this,"forEach",e,t,void 0,arguments)},includes(...e){return El(this,"includes",e)},indexOf(...e){return El(this,"indexOf",e)},join(e){return _o(this).join(e)},lastIndexOf(...e){return El(this,"lastIndexOf",e)},map(e,t){return Yn(this,"map",e,t,void 0,arguments)},pop(){return ls(this,"pop")},push(...e){return ls(this,"push",e)},reduce(e,...t){return sf(this,"reduce",e,t)},reduceRight(e,...t){return sf(this,"reduceRight",e,t)},shift(){return ls(this,"shift")},some(e,t){return Yn(this,"some",e,t,void 0,arguments)},splice(...e){return ls(this,"splice",e)},toReversed(){return _o(this).toReversed()},toSorted(e){return _o(this).toSorted(e)},toSpliced(...e){return _o(this).toSpliced(...e)},unshift(...e){return ls(this,"unshift",e)},values(){return Sl(this,"values",e=>Dn(this,e))}};function Sl(e,t,n){const r=Va(e),o=r[t]();return r!==e&&!Qt(e)&&(o._next=o.next,o.next=()=>{const s=o._next();return s.done||(s.value=n(s.value)),s}),o}const uy=Array.prototype;function Yn(e,t,n,r,o,s){const i=Va(e),a=i!==e&&!Qt(e),l=i[t];if(l!==uy[t]){const f=l.apply(e,s);return a?On(f):f}let u=n;i!==e&&(a?u=function(f,d){return n.call(this,Dn(e,f),d,e)}:n.length>2&&(u=function(f,d){return n.call(this,f,d,e)}));const c=l.call(i,u,r);return a&&o?o(c):c}function sf(e,t,n,r){const o=Va(e),s=o!==e&&!Qt(e);let i=n,a=!1;o!==e&&(s?(a=r.length===0,i=function(u,c,f){return a&&(a=!1,u=Dn(e,u)),n.call(this,u,Dn(e,c),f,e)}):n.length>3&&(i=function(u,c,f){return n.call(this,u,c,f,e)}));const l=o[t](i,...r);return a?Dn(e,l):l}function El(e,t,n){const r=Me(e);It(r,"iterate",Bs);const o=r[t](...n);return(o===-1||o===!1)&&ja(n[0])?(n[0]=Me(n[0]),r[t](...n)):o}function ls(e,t,n=[]){lr(),Yu();const r=Me(e)[t].apply(e,n);return Ju(),ur(),r}const cy=Ku("__proto__,__v_isRef,__isVue"),Mh=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter(Tn));function fy(e){Tn(e)||(e=String(e));const t=Me(this);return It(t,"has",e),t.hasOwnProperty(e)}class $h{constructor(t=!1,n=!1){this._isReadonly=t,this._isShallow=n}get(t,n,r){if(n==="__v_skip")return t.__v_skip;const o=this._isReadonly,s=this._isShallow;if(n==="__v_isReactive")return!o;if(n==="__v_isReadonly")return o;if(n==="__v_isShallow")return s;if(n==="__v_raw")return r===(o?s?Sy:Dh:s?Bh:Fh).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(r)?t:void 0;const i=pe(t);if(!o){let l;if(i&&(l=ly[n]))return l;if(n==="hasOwnProperty")return fy}const a=Reflect.get(t,n,Ue(t)?t:r);if((Tn(n)?Mh.has(n):cy(n))||(o||It(t,"get",n),s))return a;if(Ue(a)){const l=i&&Ma(n)?a:a.value;return o&&Oe(l)?Mr(l):l}return Oe(a)?o?Mr(a):wt(a):a}}class kh extends $h{constructor(t=!1){super(!1,t)}set(t,n,r,o){let s=t[n];const i=pe(t)&&Ma(n);if(!this._isShallow){const u=cr(s);if(!Qt(r)&&!cr(r)&&(s=Me(s),r=Me(r)),!i&&Ue(s)&&!Ue(r))return u||(s.value=r),!0}const a=i?Number(n)e,Ci=e=>Reflect.getPrototypeOf(e);function my(e,t,n){return function(...r){const o=this.__v_raw,s=Me(o),i=$o(s),a=e==="entries"||e===Symbol.iterator&&i,l=e==="keys"&&i,u=o[e](...r),c=n?nu:t?Do:On;return!t&&It(s,"iterate",l?tu:oo),vt(Object.create(u),{next(){const{value:f,done:d}=u.next();return d?{value:f,done:d}:{value:a?[c(f[0]),c(f[1])]:c(f),done:d}}})}}function Ti(e){return function(...t){return e==="delete"?!1:e==="clear"?void 0:this}}function gy(e,t){const n={get(o){const s=this.__v_raw,i=Me(s),a=Me(o);e||(jn(o,a)&&It(i,"get",o),It(i,"get",a));const{has:l}=Ci(i),u=t?nu:e?Do:On;if(l.call(i,o))return u(s.get(o));if(l.call(i,a))return u(s.get(a));s!==i&&s.get(o)},get size(){const o=this.__v_raw;return!e&&It(Me(o),"iterate",oo),o.size},has(o){const s=this.__v_raw,i=Me(s),a=Me(o);return e||(jn(o,a)&&It(i,"has",o),It(i,"has",a)),o===a?s.has(o):s.has(o)||s.has(a)},forEach(o,s){const i=this,a=i.__v_raw,l=Me(a),u=t?nu:e?Do:On;return!e&&It(l,"iterate",oo),a.forEach((c,f)=>o.call(s,u(c),u(f),i))}};return vt(n,e?{add:Ti("add"),set:Ti("set"),delete:Ti("delete"),clear:Ti("clear")}:{add(o){const s=Me(this),i=Ci(s),a=Me(o),l=!t&&!Qt(o)&&!cr(o)?a:o;return i.has.call(s,l)||jn(o,l)&&i.has.call(s,o)||jn(a,l)&&i.has.call(s,a)||(s.add(l),nr(s,"add",l,l)),this},set(o,s){!t&&!Qt(s)&&!cr(s)&&(s=Me(s));const i=Me(this),{has:a,get:l}=Ci(i);let u=a.call(i,o);u||(o=Me(o),u=a.call(i,o));const c=l.call(i,o);return i.set(o,s),u?jn(s,c)&&nr(i,"set",o,s):nr(i,"add",o,s),this},delete(o){const s=Me(this),{has:i,get:a}=Ci(s);let l=i.call(s,o);l||(o=Me(o),l=i.call(s,o)),a&&a.call(s,o);const u=s.delete(o);return l&&nr(s,"delete",o,void 0),u},clear(){const o=Me(this),s=o.size!==0,i=o.clear();return s&&nr(o,"clear",void 0,void 0),i}}),["keys","values","entries",Symbol.iterator].forEach(o=>{n[o]=my(o,e,t)}),n}function Zu(e,t){const n=gy(e,t);return(r,o,s)=>o==="__v_isReactive"?!e:o==="__v_isReadonly"?e:o==="__v_raw"?r:Reflect.get(Ve(n,o)&&o in r?n:r,o,s)}const by={get:Zu(!1,!1)},yy={get:Zu(!1,!0)},wy={get:Zu(!0,!1)};const Fh=new WeakMap,Bh=new WeakMap,Dh=new WeakMap,Sy=new WeakMap;function Ey(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function _y(e){return e.__v_skip||!Object.isExtensible(e)?0:Ey(Ui(e))}function wt(e){return cr(e)?e:ec(e,!1,py,by,Fh)}function Qu(e){return ec(e,!1,vy,yy,Bh)}function Mr(e){return ec(e,!0,hy,wy,Dh)}function ec(e,t,n,r,o){if(!Oe(e)||e.__v_raw&&!(t&&e.__v_isReactive))return e;const s=_y(e);if(s===0)return e;const i=o.get(e);if(i)return i;const a=new Proxy(e,s===2?r:n);return o.set(e,a),a}function Hn(e){return cr(e)?Hn(e.__v_raw):!!(e&&e.__v_isReactive)}function cr(e){return!!(e&&e.__v_isReadonly)}function Qt(e){return!!(e&&e.__v_isShallow)}function ja(e){return e?!!e.__v_raw:!1}function Me(e){const t=e&&e.__v_raw;return t?Me(t):e}function Ds(e){return!Ve(e,"__v_skip")&&Object.isExtensible(e)&&yh(e,"__v_skip",!0),e}const On=e=>Oe(e)?wt(e):e,Do=e=>Oe(e)?Mr(e):e;function Ue(e){return e?e.__v_isRef===!0:!1}function V(e){return Vh(e,!1)}function ir(e){return Vh(e,!0)}function Vh(e,t){return Ue(e)?e:new Cy(e,t)}class Cy{constructor(t,n){this.dep=new Da,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=n?t:Me(t),this._value=n?t:On(t),this.__v_isShallow=n}get value(){return this.dep.track(),this._value}set value(t){const n=this._rawValue,r=this.__v_isShallow||Qt(t)||cr(t);t=r?t:Me(t),jn(t,n)&&(this._rawValue=t,this._value=r?t:On(t),this.dep.trigger())}}function g(e){return Ue(e)?e.value:e}const Ty={get:(e,t,n)=>t==="__v_raw"?e:g(Reflect.get(e,t,n)),set:(e,t,n,r)=>{const o=e[t];return Ue(o)&&!Ue(n)?(o.value=n,!0):Reflect.set(e,t,n,r)}};function jh(e){return Hn(e)?e:new Proxy(e,Ty)}class Oy{constructor(t){this.__v_isRef=!0,this._value=void 0;const n=this.dep=new Da,{get:r,set:o}=t(n.track.bind(n),n.trigger.bind(n));this._get=r,this._set=o}get value(){return this._value=this._get()}set value(t){this._set(t)}}function Ay(e){return new Oy(e)}function vr(e){const t=pe(e)?new Array(e.length):{};for(const n in e)t[n]=zh(e,n);return t}class Ry{constructor(t,n,r){this._object=t,this._key=n,this._defaultValue=r,this.__v_isRef=!0,this._value=void 0,this._raw=Me(t);let o=!0,s=t;if(!pe(t)||!Ma(String(n)))do o=!ja(s)||Qt(s);while(o&&(s=s.__v_raw));this._shallow=o}get value(){let t=this._object[this._key];return this._shallow&&(t=g(t)),this._value=t===void 0?this._defaultValue:t}set value(t){if(this._shallow&&Ue(this._raw[this._key])){const n=this._object[this._key];if(Ue(n)){n.value=t;return}}this._object[this._key]=t}get dep(){return ay(this._raw,this._key)}}class xy{constructor(t){this._getter=t,this.__v_isRef=!0,this.__v_isReadonly=!0,this._value=void 0}get value(){return this._value=this._getter()}}function Jt(e,t,n){return Ue(e)?e:ve(e)?new xy(e):Oe(e)&&arguments.length>1?zh(e,t,n):V(e)}function zh(e,t,n){return new Ry(e,t,n)}class Iy{constructor(t,n,r){this.fn=t,this.setter=n,this._value=void 0,this.dep=new Da(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=Fs-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!n,this.isSSR=r}notify(){if(this.flags|=16,!(this.flags&8)&&et!==this)return Rh(this,!0),!0}get value(){const t=this.dep.track();return Ph(this),t&&(t.version=this.dep.version),this._value}set value(t){this.setter&&this.setter(t)}}function Py(e,t,n=!1){let r,o;return ve(e)?r=e:(r=e.get,o=e.set),new Iy(r,o,n)}const Oi={},la=new WeakMap;let Jr;function Ny(e,t=!1,n=Jr){if(n){let r=la.get(n);r||la.set(n,r=[]),r.push(e)}}function Ly(e,t,n=Ye){const{immediate:r,deep:o,once:s,scheduler:i,augmentJob:a,call:l}=n,u=w=>o?w:Qt(w)||o===!1||o===0?rr(w,1):rr(w);let c,f,d,v,p=!1,h=!1;if(Ue(e)?(f=()=>e.value,p=Qt(e)):Hn(e)?(f=()=>u(e),p=!0):pe(e)?(h=!0,p=e.some(w=>Hn(w)||Qt(w)),f=()=>e.map(w=>{if(Ue(w))return w.value;if(Hn(w))return u(w);if(ve(w))return l?l(w,2):w()})):ve(e)?t?f=l?()=>l(e,2):e:f=()=>{if(d){lr();try{d()}finally{ur()}}const w=Jr;Jr=c;try{return l?l(e,3,[v]):e(v)}finally{Jr=w}}:f=ht,t&&o){const w=f,y=o===!0?1/0:o;f=()=>rr(w(),y)}const b=Fa(),m=()=>{c.stop(),b&&b.active&&Wu(b.effects,c)};if(s&&t){const w=t;t=(...y)=>{w(...y),m()}}let S=h?new Array(e.length).fill(Oi):Oi;const _=w=>{if(!(!(c.flags&1)||!c.dirty&&!w))if(t){const y=c.run();if(o||p||(h?y.some((A,R)=>jn(A,S[R])):jn(y,S))){d&&d();const A=Jr;Jr=c;try{const R=[y,S===Oi?void 0:h&&S[0]===Oi?[]:S,v];S=y,l?l(t,3,R):t(...R)}finally{Jr=A}}}else c.run()};return a&&a(_),c=new Oh(f),c.scheduler=i?()=>i(_,!1):_,v=w=>Ny(w,!1,c),d=c.onStop=()=>{const w=la.get(c);if(w){if(l)l(w,4);else for(const y of w)y();la.delete(c)}},t?r?_(!0):S=c.run():i?i(_.bind(null,!0),!0):c.run(),m.pause=c.pause.bind(c),m.resume=c.resume.bind(c),m.stop=m,m}function rr(e,t=1/0,n){if(t<=0||!Oe(e)||e.__v_skip||(n=n||new Map,(n.get(e)||0)>=t))return e;if(n.set(e,t),t--,Ue(e))rr(e.value,t,n);else if(pe(e))for(let r=0;r{rr(r,t,n)});else if(bh(e)){for(const r in e)rr(e[r],t,n);for(const r of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,r)&&rr(e[r],t,n)}return e}/** * @vue/runtime-core v3.5.30 * (c) 2018-present Yuxi (Evan) You and Vue contributors * @license MIT **/function ai(e,t,n,r){try{return r?e(...r):e()}catch(o){za(o,t,n)}}function An(e,t,n,r){if(ve(e)){const o=ai(e,t,n,r);return o&&ia(o)&&o.catch(s=>{za(s,t,n)}),o}if(pe(e)){const o=[];for(let s=0;s>>1,o=Ft[r],s=Vs(o);s=Vs(n)?Ft.push(e):Ft.splice($y(t),0,e),e.flags|=1,Uh()}}function Uh(){ua||(ua=Hh.then(Wh))}function Kh(e){pe(e)?ko.push(...e):xr&&e.id===-1?xr.splice(xo+1,0,e):e.flags&1||(ko.push(e),e.flags|=1),Uh()}function af(e,t,n=kn+1){for(;nVs(n)-Vs(r));if(ko.length=0,xr){xr.push(...t);return}for(xr=t,xo=0;xoe.id==null?e.flags&2?-1:1/0:e.id;function Wh(e){try{for(kn=0;kn{r._d&&pa(-1);const s=ca(t);let i;try{i=e(...o)}finally{ca(s),r._d&&pa(1)}return i};return r._n=!0,r._c=!0,r._d=!0,r}function ct(e,t){if(Ct===null)return e;const n=Ga(Ct),r=e.dirs||(e.dirs=[]);for(let o=0;o1)return n&&ve(t)?t.call(r&&r.proxy):t}}function ky(){return!!(Ge()||so)}const Fy=Symbol.for("v-scx"),By=()=>Ee(Fy);function Ha(e,t){return nc(e,null,t)}function he(e,t,n){return nc(e,t,n)}function nc(e,t,n=Ye){const{immediate:r,deep:o,flush:s,once:i}=n,a=vt({},n),l=t&&r||!t&&s!=="post";let u;if(Hs){if(s==="sync"){const v=By();u=v.__watcherHandles||(v.__watcherHandles=[])}else if(!l){const v=()=>{};return v.stop=ht,v.resume=ht,v.pause=ht,v}}const c=Pt;a.call=(v,p,h)=>An(v,c,p,h);let f=!1;s==="post"?a.scheduler=v=>{Rt(v,c&&c.suspense)}:s!=="sync"&&(f=!0,a.scheduler=(v,p)=>{p?v():tc(v)}),a.augmentJob=v=>{t&&(v.flags|=4),f&&(v.flags|=2,c&&(v.id=c.uid,v.i=c))};const d=Ly(e,t,a);return Hs&&(u?u.push(d):l&&d()),d}function Dy(e,t,n){const r=this.proxy,o=Ae(e)?e.includes(".")?Yh(r,e):()=>r[e]:e.bind(r,r);let s;ve(t)?s=t:(s=t.handler,n=t);const i=li(this),a=nc(o,s.bind(r),n);return i(),a}function Yh(e,t){const n=t.split(".");return()=>{let r=e;for(let o=0;oe.__isTeleport,_s=e=>e&&(e.disabled||e.disabled===""),lf=e=>e&&(e.defer||e.defer===""),uf=e=>typeof SVGElement<"u"&&e instanceof SVGElement,cf=e=>typeof MathMLElement=="function"&&e instanceof MathMLElement,ru=(e,t)=>{const n=e&&e.to;return Ae(n)?t?t(n):null:n},Zh={name:"Teleport",__isTeleport:!0,process(e,t,n,r,o,s,i,a,l,u){const{mc:c,pc:f,pbc:d,o:{insert:v,querySelector:p,createText:h,createComment:b}}=u,m=_s(t.props);let{shapeFlag:S,children:_,dynamicChildren:w}=t;if(e==null){const y=t.el=h(""),A=t.anchor=h("");v(y,n,r),v(A,n,r);const R=(I,x)=>{S&16&&c(_,I,x,o,s,i,a,l)},N=()=>{const I=t.target=ru(t.props,p),x=ou(I,t,h,v);I&&(i!=="svg"&&uf(I)?i="svg":i!=="mathml"&&cf(I)&&(i="mathml"),o&&o.isCE&&(o.ce._teleportTargets||(o.ce._teleportTargets=new Set)).add(I),m||(R(I,x),Wi(t,!1)))};m&&(R(n,A),Wi(t,!0)),lf(t.props)?(t.el.__isMounted=!1,Rt(()=>{N(),delete t.el.__isMounted},s)):N()}else{if(lf(t.props)&&e.el.__isMounted===!1){Rt(()=>{Zh.process(e,t,n,r,o,s,i,a,l,u)},s);return}t.el=e.el,t.targetStart=e.targetStart;const y=t.anchor=e.anchor,A=t.target=e.target,R=t.targetAnchor=e.targetAnchor,N=_s(e.props),I=N?n:A,x=N?y:R;if(i==="svg"||uf(A)?i="svg":(i==="mathml"||cf(A))&&(i="mathml"),w?(d(e.dynamicChildren,w,I,o,s,i,a),cc(e,t,!0)):l||f(e,t,I,x,o,s,i,a,!1),m)N?t.props&&e.props&&t.props.to!==e.props.to&&(t.props.to=e.props.to):Ai(t,n,y,u,1);else if((t.props&&t.props.to)!==(e.props&&e.props.to)){const k=t.target=ru(t.props,p);k&&Ai(t,k,null,u,0)}else N&&Ai(t,A,R,u,1);Wi(t,m)}},remove(e,t,n,{um:r,o:{remove:o}},s){const{shapeFlag:i,children:a,anchor:l,targetStart:u,targetAnchor:c,target:f,props:d}=e;if(f&&(o(u),o(c)),s&&o(l),i&16){const v=s||!_s(d);for(let p=0;p{e.isMounted=!0}),St(()=>{e.isUnmounting=!0}),e}const sn=[Function,Array],ev={mode:String,appear:Boolean,persisted:Boolean,onBeforeEnter:sn,onEnter:sn,onAfterEnter:sn,onEnterCancelled:sn,onBeforeLeave:sn,onLeave:sn,onAfterLeave:sn,onLeaveCancelled:sn,onBeforeAppear:sn,onAppear:sn,onAfterAppear:sn,onAppearCancelled:sn},tv=e=>{const t=e.subTree;return t.component?tv(t.component):t},zy={name:"BaseTransition",props:ev,setup(e,{slots:t}){const n=Ge(),r=Qh();return()=>{const o=t.default&&rc(t.default(),!0);if(!o||!o.length)return;const s=nv(o),i=Me(e),{mode:a}=i;if(r.isLeaving)return _l(s);const l=ff(s);if(!l)return _l(s);let u=js(l,i,r,n,f=>u=f);l.type!==_t&&uo(l,u);let c=n.subTree&&ff(n.subTree);if(c&&c.type!==_t&&!Xr(c,l)&&tv(n).type!==_t){let f=js(c,i,r,n);if(uo(c,f),a==="out-in"&&l.type!==_t)return r.isLeaving=!0,f.afterLeave=()=>{r.isLeaving=!1,n.job.flags&8||n.update(),delete f.afterLeave,c=void 0},_l(s);a==="in-out"&&l.type!==_t?f.delayLeave=(d,v,p)=>{const h=rv(r,c);h[String(c.key)]=c,d[Bn]=()=>{v(),d[Bn]=void 0,delete u.delayedLeave,c=void 0},u.delayedLeave=()=>{p(),delete u.delayedLeave,c=void 0}}:c=void 0}else c&&(c=void 0);return s}}};function nv(e){let t=e[0];if(e.length>1){for(const n of e)if(n.type!==_t){t=n;break}}return t}const Hy=zy;function rv(e,t){const{leavingVNodes:n}=e;let r=n.get(t.type);return r||(r=Object.create(null),n.set(t.type,r)),r}function js(e,t,n,r,o){const{appear:s,mode:i,persisted:a=!1,onBeforeEnter:l,onEnter:u,onAfterEnter:c,onEnterCancelled:f,onBeforeLeave:d,onLeave:v,onAfterLeave:p,onLeaveCancelled:h,onBeforeAppear:b,onAppear:m,onAfterAppear:S,onAppearCancelled:_}=t,w=String(e.key),y=rv(n,e),A=(I,x)=>{I&&An(I,r,9,x)},R=(I,x)=>{const k=x[1];A(I,x),pe(I)?I.every($=>$.length<=1)&&k():I.length<=1&&k()},N={mode:i,persisted:a,beforeEnter(I){let x=l;if(!n.isMounted)if(s)x=b||l;else return;I[Bn]&&I[Bn](!0);const k=y[w];k&&Xr(e,k)&&k.el[Bn]&&k.el[Bn](),A(x,[I])},enter(I){if(y[w]===e)return;let x=u,k=c,$=f;if(!n.isMounted)if(s)x=m||u,k=S||c,$=_||f;else return;let F=!1;I[us]=P=>{F||(F=!0,P?A($,[I]):A(k,[I]),N.delayedLeave&&N.delayedLeave(),I[us]=void 0)};const Y=I[us].bind(null,!1);x?R(x,[I,Y]):Y()},leave(I,x){const k=String(e.key);if(I[us]&&I[us](!0),n.isUnmounting)return x();A(d,[I]);let $=!1;I[Bn]=Y=>{$||($=!0,x(),Y?A(h,[I]):A(p,[I]),I[Bn]=void 0,y[k]===e&&delete y[k])};const F=I[Bn].bind(null,!1);y[k]=e,v?R(v,[I,F]):F()},clone(I){const x=js(I,t,n,r,o);return o&&o(x),x}};return N}function _l(e){if(Ua(e))return e=fr(e),e.children=null,e}function ff(e){if(!Ua(e))return Xh(e.type)&&e.children?nv(e.children):e;if(e.component)return e.component.subTree;const{shapeFlag:t,children:n}=e;if(n){if(t&16)return n[0];if(t&32&&ve(n.default))return n.default()}}function uo(e,t){e.shapeFlag&6&&e.component?(e.transition=t,uo(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function rc(e,t=!1,n){let r=[],o=0;for(let s=0;s1)for(let s=0;sCs(h,t&&(pe(t)?t[b]:t),n,r,o));return}if(Fo(r)&&!o){r.shapeFlag&512&&r.type.__asyncResolved&&r.component.subTree.component&&Cs(e,t,n,r.component.subTree);return}const s=r.shapeFlag&4?Ga(r.component):r.el,i=o?null:s,{i:a,r:l}=e,u=t&&t.r,c=a.refs===Ye?a.refs={}:a.refs,f=a.setupState,d=Me(f),v=f===Ye?mh:h=>df(c,h)?!1:Ve(d,h),p=(h,b)=>!(b&&df(c,b));if(u!=null&&u!==l){if(pf(t),Ae(u))c[u]=null,v(u)&&(f[u]=null);else if(Ue(u)){const h=t;p(u,h.k)&&(u.value=null),h.k&&(c[h.k]=null)}}if(ve(l))ai(l,a,12,[i,c]);else{const h=Ae(l),b=Ue(l);if(h||b){const m=()=>{if(e.f){const S=h?v(l)?f[l]:c[l]:p()||!e.k?l.value:c[e.k];if(o)pe(S)&&Wu(S,s);else if(pe(S))S.includes(s)||S.push(s);else if(h)c[l]=[s],v(l)&&(f[l]=c[l]);else{const _=[s];p(l,e.k)&&(l.value=_),e.k&&(c[e.k]=_)}}else h?(c[l]=i,v(l)&&(f[l]=i)):b&&(p(l,e.k)&&(l.value=i),e.k&&(c[e.k]=i))};if(i){const S=()=>{m(),fa.delete(e)};S.id=-1,fa.set(e,S),Rt(S,n)}else pf(e),m()}}}function pf(e){const t=fa.get(e);t&&(t.flags|=8,fa.delete(e))}ka().requestIdleCallback;ka().cancelIdleCallback;const Fo=e=>!!e.type.__asyncLoader,Ua=e=>e.type.__isKeepAlive;function Ka(e,t){sv(e,"a",t)}function oc(e,t){sv(e,"da",t)}function sv(e,t,n=Pt){const r=e.__wdc||(e.__wdc=()=>{let o=n;for(;o;){if(o.isDeactivated)return;o=o.parent}return e()});if(qa(t,r,n),n){let o=n.parent;for(;o&&o.parent;)Ua(o.parent.vnode)&&Uy(r,t,n,o),o=o.parent}}function Uy(e,t,n,r){const o=qa(t,e,r,!0);kr(()=>{Wu(r[t],o)},n)}function qa(e,t,n=Pt,r=!1){if(n){const o=n[e]||(n[e]=[]),s=t.__weh||(t.__weh=(...i)=>{lr();const a=li(n),l=An(t,n,e,i);return a(),ur(),l});return r?o.unshift(s):o.push(s),s}}const mr=e=>(t,n=Pt)=>{(!Hs||e==="sp")&&qa(e,(...r)=>t(...r),n)},sc=mr("bm"),Ke=mr("m"),iv=mr("bu"),mo=mr("u"),St=mr("bum"),kr=mr("um"),Ky=mr("sp"),qy=mr("rtg"),Wy=mr("rtc");function Gy(e,t=Pt){qa("ec",e,t)}const ic="components",Yy="directives";function Yt(e,t){return ac(ic,e,!0,t)||e}const av=Symbol.for("v-ndc");function Xe(e){return Ae(e)?ac(ic,e,!1)||e:e||av}function Jy(e){return ac(Yy,e)}function ac(e,t,n=!0,r=!1){const o=Ct||Pt;if(o){const s=o.type;if(e===ic){const a=L0(s,!1);if(a&&(a===t||a===Mt(t)||a===si(Mt(t))))return s}const i=hf(o[e]||s[e],t)||hf(o.appContext[e],t);return!i&&r?s:i}}function hf(e,t){return e&&(e[t]||e[Mt(t)]||e[si(Mt(t))])}function vf(e,t,n,r){let o;const s=n,i=pe(e);if(i||Ae(e)){const a=i&&Hn(e);let l=!1,u=!1;a&&(l=!Qt(e),u=cr(e),e=Va(e)),o=new Array(e.length);for(let c=0,f=e.length;ct(a,l,void 0,s));else{const a=Object.keys(e);o=new Array(a.length);for(let l=0,u=a.length;l{const s=r.fn(...o);return s&&(s.key=r.key),s}:r.fn)}return e}function de(e,t,n={},r,o){if(Ct.ce||Ct.parent&&Fo(Ct.parent)&&Ct.parent.ce){const u=Object.keys(n).length>0;return t!=="default"&&(n.name=t),L(),fe(nt,null,[re("slot",n,r&&r())],u?-2:64)}let s=e[t];s&&s._c&&(s._d=!1),L();const i=s&&uv(s(n)),a=n.key||i&&i.key,l=fe(nt,{key:(a&&!Tn(a)?a:`_${t}`)+(!i&&r?"_fb":"")},i||(r?r():[]),i&&e._===1?64:-2);return l.scopeId&&(l.slotScopeIds=[l.scopeId+"-s"]),s&&s._c&&(s._d=!0),l}function uv(e){return e.some(t=>cn(t)?!(t.type===_t||t.type===nt&&!uv(t.children)):!0)?e:null}const su=e=>e?xv(e)?Ga(e):su(e.parent):null,Ts=vt(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>su(e.parent),$root:e=>su(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>dv(e),$forceUpdate:e=>e.f||(e.f=()=>{tc(e.update)}),$nextTick:e=>e.n||(e.n=Ie.bind(e.proxy)),$watch:e=>Dy.bind(e)}),Cl=(e,t)=>e!==Ye&&!e.__isScriptSetup&&Ve(e,t),Xy={get({_:e},t){if(t==="__v_skip")return!0;const{ctx:n,setupState:r,data:o,props:s,accessCache:i,type:a,appContext:l}=e;if(t[0]!=="$"){const d=i[t];if(d!==void 0)switch(d){case 1:return r[t];case 2:return o[t];case 4:return n[t];case 3:return s[t]}else{if(Cl(r,t))return i[t]=1,r[t];if(o!==Ye&&Ve(o,t))return i[t]=2,o[t];if(Ve(s,t))return i[t]=3,s[t];if(n!==Ye&&Ve(n,t))return i[t]=4,n[t];iu&&(i[t]=0)}}const u=Ts[t];let c,f;if(u)return t==="$attrs"&&It(e.attrs,"get",""),u(e);if((c=a.__cssModules)&&(c=c[t]))return c;if(n!==Ye&&Ve(n,t))return i[t]=4,n[t];if(f=l.config.globalProperties,Ve(f,t))return f[t]},set({_:e},t,n){const{data:r,setupState:o,ctx:s}=e;return Cl(o,t)?(o[t]=n,!0):r!==Ye&&Ve(r,t)?(r[t]=n,!0):Ve(e.props,t)||t[0]==="$"&&t.slice(1)in e?!1:(s[t]=n,!0)},has({_:{data:e,setupState:t,accessCache:n,ctx:r,appContext:o,props:s,type:i}},a){let l;return!!(n[a]||e!==Ye&&a[0]!=="$"&&Ve(e,a)||Cl(t,a)||Ve(s,a)||Ve(r,a)||Ve(Ts,a)||Ve(o.config.globalProperties,a)||(l=i.__cssModules)&&l[a])},defineProperty(e,t,n){return n.get!=null?e._.accessCache[t]=0:Ve(n,"value")&&this.set(e,t,n.value,null),Reflect.defineProperty(e,t,n)}};function go(){return cv().slots}function Zy(){return cv().attrs}function cv(e){const t=Ge();return t.setupContext||(t.setupContext=Pv(t))}function mf(e){return pe(e)?e.reduce((t,n)=>(t[n]=null,t),{}):e}let iu=!0;function Qy(e){const t=dv(e),n=e.proxy,r=e.ctx;iu=!1,t.beforeCreate&&gf(t.beforeCreate,e,"bc");const{data:o,computed:s,methods:i,watch:a,provide:l,inject:u,created:c,beforeMount:f,mounted:d,beforeUpdate:v,updated:p,activated:h,deactivated:b,beforeDestroy:m,beforeUnmount:S,destroyed:_,unmounted:w,render:y,renderTracked:A,renderTriggered:R,errorCaptured:N,serverPrefetch:I,expose:x,inheritAttrs:k,components:$,directives:F,filters:Y}=t;if(u&&e0(u,r,null),i)for(const j in i){const Q=i[j];ve(Q)&&(r[j]=Q.bind(n))}if(o){const j=o.call(n,n);Oe(j)&&(e.data=wt(j))}if(iu=!0,s)for(const j in s){const Q=s[j],me=ve(Q)?Q.bind(n,n):ve(Q.get)?Q.get.bind(n,n):ht,Pe=!ve(Q)&&ve(Q.set)?Q.set.bind(n):ht,Re=C({get:me,set:Pe});Object.defineProperty(r,j,{enumerable:!0,configurable:!0,get:()=>Re.value,set:Ce=>Re.value=Ce})}if(a)for(const j in a)fv(a[j],r,n,j);if(l){const j=ve(l)?l.call(n):l;Reflect.ownKeys(j).forEach(Q=>{ft(Q,j[Q])})}c&&gf(c,e,"c");function O(j,Q){pe(Q)?Q.forEach(me=>j(me.bind(n))):Q&&j(Q.bind(n))}if(O(sc,f),O(Ke,d),O(iv,v),O(mo,p),O(Ka,h),O(oc,b),O(Gy,N),O(Wy,A),O(qy,R),O(St,S),O(kr,w),O(Ky,I),pe(x))if(x.length){const j=e.exposed||(e.exposed={});x.forEach(Q=>{Object.defineProperty(j,Q,{get:()=>n[Q],set:me=>n[Q]=me,enumerable:!0})})}else e.exposed||(e.exposed={});y&&e.render===ht&&(e.render=y),k!=null&&(e.inheritAttrs=k),$&&(e.components=$),F&&(e.directives=F),I&&ov(e)}function e0(e,t,n=ht){pe(e)&&(e=au(e));for(const r in e){const o=e[r];let s;Oe(o)?"default"in o?s=Ee(o.from||r,o.default,!0):s=Ee(o.from||r):s=Ee(o),Ue(s)?Object.defineProperty(t,r,{enumerable:!0,configurable:!0,get:()=>s.value,set:i=>s.value=i}):t[r]=s}}function gf(e,t,n){An(pe(e)?e.map(r=>r.bind(t.proxy)):e.bind(t.proxy),t,n)}function fv(e,t,n,r){let o=r.includes(".")?Yh(n,r):()=>n[r];if(Ae(e)){const s=t[e];ve(s)&&he(o,s)}else if(ve(e))he(o,e.bind(n));else if(Oe(e))if(pe(e))e.forEach(s=>fv(s,t,n,r));else{const s=ve(e.handler)?e.handler.bind(n):t[e.handler];ve(s)&&he(o,s,e)}}function dv(e){const t=e.type,{mixins:n,extends:r}=t,{mixins:o,optionsCache:s,config:{optionMergeStrategies:i}}=e.appContext,a=s.get(t);let l;return a?l=a:!o.length&&!n&&!r?l=t:(l={},o.length&&o.forEach(u=>da(l,u,i,!0)),da(l,t,i)),Oe(t)&&s.set(t,l),l}function da(e,t,n,r=!1){const{mixins:o,extends:s}=t;s&&da(e,s,n,!0),o&&o.forEach(i=>da(e,i,n,!0));for(const i in t)if(!(r&&i==="expose")){const a=t0[i]||n&&n[i];e[i]=a?a(e[i],t[i]):t[i]}return e}const t0={data:bf,props:yf,emits:yf,methods:gs,computed:gs,beforeCreate:$t,created:$t,beforeMount:$t,mounted:$t,beforeUpdate:$t,updated:$t,beforeDestroy:$t,beforeUnmount:$t,destroyed:$t,unmounted:$t,activated:$t,deactivated:$t,errorCaptured:$t,serverPrefetch:$t,components:gs,directives:gs,watch:r0,provide:bf,inject:n0};function bf(e,t){return t?e?function(){return vt(ve(e)?e.call(this,this):e,ve(t)?t.call(this,this):t)}:t:e}function n0(e,t){return gs(au(e),au(t))}function au(e){if(pe(e)){const t={};for(let n=0;nt==="modelValue"||t==="model-value"?e.modelModifiers:e[`${t}Modifiers`]||e[`${Mt(t)}Modifiers`]||e[`${hr(t)}Modifiers`];function a0(e,t,...n){if(e.isUnmounted)return;const r=e.vnode.props||Ye;let o=n;const s=t.startsWith("update:"),i=s&&i0(r,t.slice(7));i&&(i.trim&&(o=n.map(c=>Ae(c)?c.trim():c)),i.number&&(o=n.map(Gu)));let a,l=r[a=Ki(t)]||r[a=Ki(Mt(t))];!l&&s&&(l=r[a=Ki(hr(t))]),l&&An(l,e,6,o);const u=r[a+"Once"];if(u){if(!e.emitted)e.emitted={};else if(e.emitted[a])return;e.emitted[a]=!0,An(u,e,6,o)}}const l0=new WeakMap;function hv(e,t,n=!1){const r=n?l0:t.emitsCache,o=r.get(e);if(o!==void 0)return o;const s=e.emits;let i={},a=!1;if(!ve(e)){const l=u=>{const c=hv(u,t,!0);c&&(a=!0,vt(i,c))};!n&&t.mixins.length&&t.mixins.forEach(l),e.extends&&l(e.extends),e.mixins&&e.mixins.forEach(l)}return!s&&!a?(Oe(e)&&r.set(e,null),null):(pe(s)?s.forEach(l=>i[l]=null):vt(i,s),Oe(e)&&r.set(e,i),i)}function Wa(e,t){return!e||!Na(t)?!1:(t=t.slice(2).replace(/Once$/,""),Ve(e,t[0].toLowerCase()+t.slice(1))||Ve(e,hr(t))||Ve(e,t))}function wf(e){const{type:t,vnode:n,proxy:r,withProxy:o,propsOptions:[s],slots:i,attrs:a,emit:l,render:u,renderCache:c,props:f,data:d,setupState:v,ctx:p,inheritAttrs:h}=e,b=ca(e);let m,S;try{if(n.shapeFlag&4){const w=o||r,y=w;m=Vn(u.call(y,w,c,f,v,d,p)),S=a}else{const w=t;m=Vn(w.length>1?w(f,{attrs:a,slots:i,emit:l}):w(f,null)),S=t.props?a:u0(a)}}catch(w){Os.length=0,za(w,e,1),m=re(_t)}let _=m;if(S&&h!==!1){const w=Object.keys(S),{shapeFlag:y}=_;w.length&&y&7&&(s&&w.some(qu)&&(S=c0(S,s)),_=fr(_,S,!1,!0))}return n.dirs&&(_=fr(_,null,!1,!0),_.dirs=_.dirs?_.dirs.concat(n.dirs):n.dirs),n.transition&&uo(_,n.transition),m=_,ca(b),m}const u0=e=>{let t;for(const n in e)(n==="class"||n==="style"||Na(n))&&((t||(t={}))[n]=e[n]);return t},c0=(e,t)=>{const n={};for(const r in e)(!qu(r)||!(r.slice(9)in t))&&(n[r]=e[r]);return n};function f0(e,t,n){const{props:r,children:o,component:s}=e,{props:i,children:a,patchFlag:l}=t,u=s.emitsOptions;if(t.dirs||t.transition)return!0;if(n&&l>=0){if(l&1024)return!0;if(l&16)return r?Sf(r,i,u):!!i;if(l&8){const c=t.dynamicProps;for(let f=0;fObject.create(mv),bv=e=>Object.getPrototypeOf(e)===mv;function p0(e,t,n,r=!1){const o={},s=gv();e.propsDefaults=Object.create(null),yv(e,t,o,s);for(const i in e.propsOptions[0])i in o||(o[i]=void 0);n?e.props=r?o:Qu(o):e.type.props?e.props=o:e.props=s,e.attrs=s}function h0(e,t,n,r){const{props:o,attrs:s,vnode:{patchFlag:i}}=e,a=Me(o),[l]=e.propsOptions;let u=!1;if((r||i>0)&&!(i&16)){if(i&8){const c=e.vnode.dynamicProps;for(let f=0;f{l=!0;const[d,v]=wv(f,t,!0);vt(i,d),v&&a.push(...v)};!n&&t.mixins.length&&t.mixins.forEach(c),e.extends&&c(e.extends),e.mixins&&e.mixins.forEach(c)}if(!s&&!l)return Oe(e)&&r.set(e,Mo),Mo;if(pe(s))for(let c=0;ce==="_"||e==="_ctx"||e==="$stable",uc=e=>pe(e)?e.map(Vn):[Vn(e)],m0=(e,t,n)=>{if(t._n)return t;const r=ce((...o)=>uc(t(...o)),n);return r._c=!1,r},Sv=(e,t,n)=>{const r=e._ctx;for(const o in e){if(lc(o))continue;const s=e[o];if(ve(s))t[o]=m0(o,s,r);else if(s!=null){const i=uc(s);t[o]=()=>i}}},Ev=(e,t)=>{const n=uc(t);e.slots.default=()=>n},_v=(e,t,n)=>{for(const r in t)(n||!lc(r))&&(e[r]=t[r])},g0=(e,t,n)=>{const r=e.slots=gv();if(e.vnode.shapeFlag&32){const o=t._;o?(_v(r,t,n),n&&yh(r,"_",o,!0)):Sv(t,r)}else t&&Ev(e,t)},b0=(e,t,n)=>{const{vnode:r,slots:o}=e;let s=!0,i=Ye;if(r.shapeFlag&32){const a=t._;a?n&&a===1?s=!1:_v(o,t,n):(s=!t.$stable,Sv(t,o)),i=t}else t&&(Ev(e,t),i={default:1});if(s)for(const a in o)!lc(a)&&i[a]==null&&delete o[a]},Rt=_0;function y0(e){return w0(e)}function w0(e,t){const n=ka();n.__VUE__=!0;const{insert:r,remove:o,patchProp:s,createElement:i,createText:a,createComment:l,setText:u,setElementText:c,parentNode:f,nextSibling:d,setScopeId:v=ht,insertStaticContent:p}=e,h=(E,T,M,W=null,G=null,q=null,se=void 0,ne=null,te=!!T.dynamicChildren)=>{if(E===T)return;E&&!Xr(E,T)&&(W=B(E),Ce(E,G,q,!0),E=null),T.patchFlag===-2&&(te=!1,T.dynamicChildren=null);const{type:X,ref:Se,shapeFlag:ue}=T;switch(X){case Zo:b(E,T,M,W);break;case _t:m(E,T,M,W);break;case Gi:E==null&&S(T,M,W,se);break;case nt:$(E,T,M,W,G,q,se,ne,te);break;default:ue&1?y(E,T,M,W,G,q,se,ne,te):ue&6?F(E,T,M,W,G,q,se,ne,te):(ue&64||ue&128)&&X.process(E,T,M,W,G,q,se,ne,te,oe)}Se!=null&&G?Cs(Se,E&&E.ref,q,T||E,!T):Se==null&&E&&E.ref!=null&&Cs(E.ref,null,q,E,!0)},b=(E,T,M,W)=>{if(E==null)r(T.el=a(T.children),M,W);else{const G=T.el=E.el;T.children!==E.children&&u(G,T.children)}},m=(E,T,M,W)=>{E==null?r(T.el=l(T.children||""),M,W):T.el=E.el},S=(E,T,M,W)=>{[E.el,E.anchor]=p(E.children,T,M,W,E.el,E.anchor)},_=({el:E,anchor:T},M,W)=>{let G;for(;E&&E!==T;)G=d(E),r(E,M,W),E=G;r(T,M,W)},w=({el:E,anchor:T})=>{let M;for(;E&&E!==T;)M=d(E),o(E),E=M;o(T)},y=(E,T,M,W,G,q,se,ne,te)=>{if(T.type==="svg"?se="svg":T.type==="math"&&(se="mathml"),E==null)A(T,M,W,G,q,se,ne,te);else{const X=E.el&&E.el._isVueCE?E.el:null;try{X&&X._beginPatch(),I(E,T,G,q,se,ne,te)}finally{X&&X._endPatch()}}},A=(E,T,M,W,G,q,se,ne)=>{let te,X;const{props:Se,shapeFlag:ue,transition:ye,dirs:z}=E;if(te=E.el=i(E.type,q,Se&&Se.is,Se),ue&8?c(te,E.children):ue&16&&N(E.children,te,null,W,G,Tl(E,q),se,ne),z&&Ur(E,null,W,"created"),R(te,E,E.scopeId,se,W),Se){for(const Le in Se)Le!=="value"&&!ws(Le)&&s(te,Le,null,Se[Le],q,W);"value"in Se&&s(te,"value",null,Se.value,q),(X=Se.onVnodeBeforeMount)&&Mn(X,W,E)}z&&Ur(E,null,W,"beforeMount");const be=S0(G,ye);be&&ye.beforeEnter(te),r(te,T,M),((X=Se&&Se.onVnodeMounted)||be||z)&&Rt(()=>{X&&Mn(X,W,E),be&&ye.enter(te),z&&Ur(E,null,W,"mounted")},G)},R=(E,T,M,W,G)=>{if(M&&v(E,M),W)for(let q=0;q{for(let X=te;X{const ne=T.el=E.el;let{patchFlag:te,dynamicChildren:X,dirs:Se}=T;te|=E.patchFlag&16;const ue=E.props||Ye,ye=T.props||Ye;let z;if(M&&Kr(M,!1),(z=ye.onVnodeBeforeUpdate)&&Mn(z,M,T,E),Se&&Ur(T,E,M,"beforeUpdate"),M&&Kr(M,!0),(ue.innerHTML&&ye.innerHTML==null||ue.textContent&&ye.textContent==null)&&c(ne,""),X?x(E.dynamicChildren,X,ne,M,W,Tl(T,G),q):se||Q(E,T,ne,null,M,W,Tl(T,G),q,!1),te>0){if(te&16)k(ne,ue,ye,M,G);else if(te&2&&ue.class!==ye.class&&s(ne,"class",null,ye.class,G),te&4&&s(ne,"style",ue.style,ye.style,G),te&8){const be=T.dynamicProps;for(let Le=0;Le{z&&Mn(z,M,T,E),Se&&Ur(T,E,M,"updated")},W)},x=(E,T,M,W,G,q,se)=>{for(let ne=0;ne{if(T!==M){if(T!==Ye)for(const q in T)!ws(q)&&!(q in M)&&s(E,q,T[q],null,G,W);for(const q in M){if(ws(q))continue;const se=M[q],ne=T[q];se!==ne&&q!=="value"&&s(E,q,ne,se,G,W)}"value"in M&&s(E,"value",T.value,M.value,G)}},$=(E,T,M,W,G,q,se,ne,te)=>{const X=T.el=E?E.el:a(""),Se=T.anchor=E?E.anchor:a("");let{patchFlag:ue,dynamicChildren:ye,slotScopeIds:z}=T;z&&(ne=ne?ne.concat(z):z),E==null?(r(X,M,W),r(Se,M,W),N(T.children||[],M,Se,G,q,se,ne,te)):ue>0&&ue&64&&ye&&E.dynamicChildren&&E.dynamicChildren.length===ye.length?(x(E.dynamicChildren,ye,M,G,q,se,ne),(T.key!=null||G&&T===G.subTree)&&cc(E,T,!0)):Q(E,T,M,Se,G,q,se,ne,te)},F=(E,T,M,W,G,q,se,ne,te)=>{T.slotScopeIds=ne,E==null?T.shapeFlag&512?G.ctx.activate(T,M,W,se,te):Y(T,M,W,G,q,se,te):P(E,T,te)},Y=(E,T,M,W,G,q,se)=>{const ne=E.component=x0(E,W,G);if(Ua(E)&&(ne.ctx.renderer=oe),I0(ne,!1,se),ne.asyncDep){if(G&&G.registerDep(ne,O,se),!E.el){const te=ne.subTree=re(_t);m(null,te,T,M),E.placeholder=te.el}}else O(ne,E,T,M,G,q,se)},P=(E,T,M)=>{const W=T.component=E.component;if(f0(E,T,M))if(W.asyncDep&&!W.asyncResolved){j(W,T,M);return}else W.next=T,W.update();else T.el=E.el,W.vnode=T},O=(E,T,M,W,G,q,se)=>{const ne=()=>{if(E.isMounted){let{next:ue,bu:ye,u:z,parent:be,vnode:Le}=E;{const on=Cv(E);if(on){ue&&(ue.el=Le.el,j(E,ue,se)),on.asyncDep.then(()=>{Rt(()=>{E.isUnmounted||X()},G)});return}}let ke=ue,dt;Kr(E,!1),ue?(ue.el=Le.el,j(E,ue,se)):ue=Le,ye&&qi(ye),(dt=ue.props&&ue.props.onVnodeBeforeUpdate)&&Mn(dt,be,ue,Le),Kr(E,!0);const pt=wf(E),rn=E.subTree;E.subTree=pt,h(rn,pt,f(rn.el),B(rn),E,G,q),ue.el=pt.el,ke===null&&d0(E,pt.el),z&&Rt(z,G),(dt=ue.props&&ue.props.onVnodeUpdated)&&Rt(()=>Mn(dt,be,ue,Le),G)}else{let ue;const{el:ye,props:z}=T,{bm:be,m:Le,parent:ke,root:dt,type:pt}=E,rn=Fo(T);Kr(E,!1),be&&qi(be),!rn&&(ue=z&&z.onVnodeBeforeMount)&&Mn(ue,ke,T),Kr(E,!0);{dt.ce&&dt.ce._hasShadowRoot()&&dt.ce._injectChildStyle(pt,E.parent?E.parent.type:void 0);const on=E.subTree=wf(E);h(null,on,M,W,E,G,q),T.el=on.el}if(Le&&Rt(Le,G),!rn&&(ue=z&&z.onVnodeMounted)){const on=T;Rt(()=>Mn(ue,ke,on),G)}(T.shapeFlag&256||ke&&Fo(ke.vnode)&&ke.vnode.shapeFlag&256)&&E.a&&Rt(E.a,G),E.isMounted=!0,T=M=W=null}};E.scope.on();const te=E.effect=new Oh(ne);E.scope.off();const X=E.update=te.run.bind(te),Se=E.job=te.runIfDirty.bind(te);Se.i=E,Se.id=E.uid,te.scheduler=()=>tc(Se),Kr(E,!0),X()},j=(E,T,M)=>{T.component=E;const W=E.vnode.props;E.vnode=T,E.next=null,h0(E,T.props,W,M),b0(E,T.children,M),lr(),af(E),ur()},Q=(E,T,M,W,G,q,se,ne,te=!1)=>{const X=E&&E.children,Se=E?E.shapeFlag:0,ue=T.children,{patchFlag:ye,shapeFlag:z}=T;if(ye>0){if(ye&128){Pe(X,ue,M,W,G,q,se,ne,te);return}else if(ye&256){me(X,ue,M,W,G,q,se,ne,te);return}}z&8?(Se&16&&De(X,G,q),ue!==X&&c(M,ue)):Se&16?z&16?Pe(X,ue,M,W,G,q,se,ne,te):De(X,G,q,!0):(Se&8&&c(M,""),z&16&&N(ue,M,W,G,q,se,ne,te))},me=(E,T,M,W,G,q,se,ne,te)=>{E=E||Mo,T=T||Mo;const X=E.length,Se=T.length,ue=Math.min(X,Se);let ye;for(ye=0;yeSe?De(E,G,q,!0,!1,ue):N(T,M,W,G,q,se,ne,te,ue)},Pe=(E,T,M,W,G,q,se,ne,te)=>{let X=0;const Se=T.length;let ue=E.length-1,ye=Se-1;for(;X<=ue&&X<=ye;){const z=E[X],be=T[X]=te?tr(T[X]):Vn(T[X]);if(Xr(z,be))h(z,be,M,null,G,q,se,ne,te);else break;X++}for(;X<=ue&&X<=ye;){const z=E[ue],be=T[ye]=te?tr(T[ye]):Vn(T[ye]);if(Xr(z,be))h(z,be,M,null,G,q,se,ne,te);else break;ue--,ye--}if(X>ue){if(X<=ye){const z=ye+1,be=zye)for(;X<=ue;)Ce(E[X],G,q,!0),X++;else{const z=X,be=X,Le=new Map;for(X=be;X<=ye;X++){const Ot=T[X]=te?tr(T[X]):Vn(T[X]);Ot.key!=null&&Le.set(Ot.key,X)}let ke,dt=0;const pt=ye-be+1;let rn=!1,on=0;const jr=new Array(pt);for(X=0;X=pt){Ce(Ot,G,q,!0);continue}let qt;if(Ot.key!=null)qt=Le.get(Ot.key);else for(ke=be;ke<=ye;ke++)if(jr[ke-be]===0&&Xr(Ot,T[ke])){qt=ke;break}qt===void 0?Ce(Ot,G,q,!0):(jr[qt-be]=X+1,qt>=on?on=qt:rn=!0,h(Ot,T[qt],M,null,G,q,se,ne,te),dt++)}const as=rn?E0(jr):Mo;for(ke=as.length-1,X=pt-1;X>=0;X--){const Ot=be+X,qt=T[Ot],zr=T[Ot+1],Si=Ot+1{const{el:q,type:se,transition:ne,children:te,shapeFlag:X}=E;if(X&6){Re(E.component.subTree,T,M,W);return}if(X&128){E.suspense.move(T,M,W);return}if(X&64){se.move(E,T,M,oe);return}if(se===nt){r(q,T,M);for(let ue=0;uene.enter(q),G);else{const{leave:ue,delayLeave:ye,afterLeave:z}=ne,be=()=>{E.ctx.isUnmounted?o(q):r(q,T,M)},Le=()=>{q._isLeaving&&q[Bn](!0),ue(q,()=>{be(),z&&z()})};ye?ye(q,be,Le):Le()}else r(q,T,M)},Ce=(E,T,M,W=!1,G=!1)=>{const{type:q,props:se,ref:ne,children:te,dynamicChildren:X,shapeFlag:Se,patchFlag:ue,dirs:ye,cacheIndex:z}=E;if(ue===-2&&(G=!1),ne!=null&&(lr(),Cs(ne,null,M,E,!0),ur()),z!=null&&(T.renderCache[z]=void 0),Se&256){T.ctx.deactivate(E);return}const be=Se&1&&ye,Le=!Fo(E);let ke;if(Le&&(ke=se&&se.onVnodeBeforeUnmount)&&Mn(ke,T,E),Se&6)ze(E.component,M,W);else{if(Se&128){E.suspense.unmount(M,W);return}be&&Ur(E,null,T,"beforeUnmount"),Se&64?E.type.remove(E,T,M,oe,W):X&&!X.hasOnce&&(q!==nt||ue>0&&ue&64)?De(X,T,M,!1,!0):(q===nt&&ue&384||!G&&Se&16)&&De(te,T,M),W&&_e(E)}(Le&&(ke=se&&se.onVnodeUnmounted)||be)&&Rt(()=>{ke&&Mn(ke,T,E),be&&Ur(E,null,T,"unmounted")},M)},_e=E=>{const{type:T,el:M,anchor:W,transition:G}=E;if(T===nt){qe(M,W);return}if(T===Gi){w(E);return}const q=()=>{o(M),G&&!G.persisted&&G.afterLeave&&G.afterLeave()};if(E.shapeFlag&1&&G&&!G.persisted){const{leave:se,delayLeave:ne}=G,te=()=>se(M,q);ne?ne(E.el,q,te):te()}else q()},qe=(E,T)=>{let M;for(;E!==T;)M=d(E),o(E),E=M;o(T)},ze=(E,T,M)=>{const{bum:W,scope:G,job:q,subTree:se,um:ne,m:te,a:X}=E;_f(te),_f(X),W&&qi(W),G.stop(),q&&(q.flags|=8,Ce(se,E,T,M)),ne&&Rt(ne,T),Rt(()=>{E.isUnmounted=!0},T)},De=(E,T,M,W=!1,G=!1,q=0)=>{for(let se=q;se{if(E.shapeFlag&6)return B(E.component.subTree);if(E.shapeFlag&128)return E.suspense.next();const T=d(E.anchor||E.el),M=T&&T[Jh];return M?d(M):T};let K=!1;const J=(E,T,M)=>{let W;E==null?T._vnode&&(Ce(T._vnode,null,null,!0),W=T._vnode.component):h(T._vnode||null,E,T,null,null,null,M),T._vnode=E,K||(K=!0,af(W),qh(),K=!1)},oe={p:h,um:Ce,m:Re,r:_e,mt:Y,mc:N,pc:Q,pbc:x,n:B,o:e};return{render:J,hydrate:void 0,createApp:s0(J)}}function Tl({type:e,props:t},n){return n==="svg"&&e==="foreignObject"||n==="mathml"&&e==="annotation-xml"&&t&&t.encoding&&t.encoding.includes("html")?void 0:n}function Kr({effect:e,job:t},n){n?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function S0(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function cc(e,t,n=!1){const r=e.children,o=t.children;if(pe(r)&&pe(o))for(let s=0;s>1,e[n[a]]0&&(t[r]=n[s-1]),n[s]=r)}}for(s=n.length,i=n[s-1];s-- >0;)n[s]=i,i=t[i];return n}function Cv(e){const t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:Cv(t)}function _f(e){if(e)for(let t=0;te.__isSuspense;function _0(e,t){t&&t.pendingBranch?pe(e)?t.effects.push(...e):t.effects.push(e):Kh(e)}const nt=Symbol.for("v-fgt"),Zo=Symbol.for("v-txt"),_t=Symbol.for("v-cmt"),Gi=Symbol.for("v-stc"),Os=[];let Xt=null;function L(e=!1){Os.push(Xt=e?null:[])}function C0(){Os.pop(),Xt=Os[Os.length-1]||null}let zs=1;function pa(e,t=!1){zs+=e,e<0&&Xt&&t&&(Xt.hasOnce=!0)}function Av(e){return e.dynamicChildren=zs>0?Xt||Mo:null,C0(),zs>0&&Xt&&Xt.push(e),e}function ee(e,t,n,r,o,s){return Av(le(e,t,n,r,o,s,!0))}function fe(e,t,n,r,o){return Av(re(e,t,n,r,o,!0))}function cn(e){return e?e.__v_isVNode===!0:!1}function Xr(e,t){return e.type===t.type&&e.key===t.key}const Rv=({key:e})=>e??null,Yi=({ref:e,ref_key:t,ref_for:n})=>(typeof e=="number"&&(e=""+e),e!=null?Ae(e)||Ue(e)||ve(e)?{i:Ct,r:e,k:t,f:!!n}:e:null);function le(e,t=null,n=null,r=0,o=null,s=e===nt?0:1,i=!1,a=!1){const l={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&Rv(t),ref:t&&Yi(t),scopeId:Gh,slotScopeIds:null,children:n,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:s,patchFlag:r,dynamicProps:o,dynamicChildren:null,appContext:null,ctx:Ct};return a?(fc(l,n),s&128&&e.normalize(l)):n&&(l.shapeFlag|=Ae(n)?8:16),zs>0&&!i&&Xt&&(l.patchFlag>0||s&6)&&l.patchFlag!==32&&Xt.push(l),l}const re=T0;function T0(e,t=null,n=null,r=0,o=null,s=!1){if((!e||e===av)&&(e=_t),cn(e)){const a=fr(e,t,!0);return n&&fc(a,n),zs>0&&!s&&Xt&&(a.shapeFlag&6?Xt[Xt.indexOf(e)]=a:Xt.push(a)),a.patchFlag=-2,a}if(M0(e)&&(e=e.__vccOpts),t){t=O0(t);let{class:a,style:l}=t;a&&!Ae(a)&&(t.class=U(a)),Oe(l)&&(ja(l)&&!pe(l)&&(l=vt({},l)),t.style=ot(l))}const i=Ae(e)?1:Ov(e)?128:Xh(e)?64:Oe(e)?4:ve(e)?2:0;return le(e,t,n,r,o,i,s,!0)}function O0(e){return e?ja(e)||bv(e)?vt({},e):e:null}function fr(e,t,n=!1,r=!1){const{props:o,ref:s,patchFlag:i,children:a,transition:l}=e,u=t?En(o||{},t):o,c={__v_isVNode:!0,__v_skip:!0,type:e.type,props:u,key:u&&Rv(u),ref:t&&t.ref?n&&s?pe(s)?s.concat(Yi(t)):[s,Yi(t)]:Yi(t):s,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:a,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==nt?i===-1?16:i|16:i,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:l,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&fr(e.ssContent),ssFallback:e.ssFallback&&fr(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return l&&r&&uo(c,l.clone(c)),c}function Un(e=" ",t=0){return re(Zo,null,e,t)}function ae(e="",t=!1){return t?(L(),fe(_t,null,e)):re(_t,null,e)}function Vn(e){return e==null||typeof e=="boolean"?re(_t):pe(e)?re(nt,null,e.slice()):cn(e)?tr(e):re(Zo,null,String(e))}function tr(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:fr(e)}function fc(e,t){let n=0;const{shapeFlag:r}=e;if(t==null)t=null;else if(pe(t))n=16;else if(typeof t=="object")if(r&65){const o=t.default;o&&(o._c&&(o._d=!1),fc(e,o()),o._c&&(o._d=!0));return}else{n=32;const o=t._;!o&&!bv(t)?t._ctx=Ct:o===3&&Ct&&(Ct.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else ve(t)?(t={default:t,_ctx:Ct},n=32):(t=String(t),r&64?(n=16,t=[Un(t)]):n=8);e.children=t,e.shapeFlag|=n}function En(...e){const t={};for(let n=0;nPt||Ct;let ha,uu;{const e=ka(),t=(n,r)=>{let o;return(o=e[n])||(o=e[n]=[]),o.push(r),s=>{o.length>1?o.forEach(i=>i(s)):o[0](s)}};ha=t("__VUE_INSTANCE_SETTERS__",n=>Pt=n),uu=t("__VUE_SSR_SETTERS__",n=>Hs=n)}const li=e=>{const t=Pt;return ha(e),e.scope.on(),()=>{e.scope.off(),ha(t)}},Cf=()=>{Pt&&Pt.scope.off(),ha(null)};function xv(e){return e.vnode.shapeFlag&4}let Hs=!1;function I0(e,t=!1,n=!1){t&&uu(t);const{props:r,children:o}=e.vnode,s=xv(e);p0(e,r,s,t),g0(e,o,n||t);const i=s?P0(e,t):void 0;return t&&uu(!1),i}function P0(e,t){const n=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,Xy);const{setup:r}=n;if(r){lr();const o=e.setupContext=r.length>1?Pv(e):null,s=li(e),i=ai(r,e,0,[e.props,o]),a=ia(i);if(ur(),s(),(a||e.sp)&&!Fo(e)&&ov(e),a){if(i.then(Cf,Cf),t)return i.then(l=>{Tf(e,l)}).catch(l=>{za(l,e,0)});e.asyncDep=i}else Tf(e,i)}else Iv(e)}function Tf(e,t,n){ve(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:Oe(t)&&(e.setupState=jh(t)),Iv(e)}function Iv(e,t,n){const r=e.type;e.render||(e.render=r.render||ht);{const o=li(e);lr();try{Qy(e)}finally{ur(),o()}}}const N0={get(e,t){return It(e,"get",""),e[t]}};function Pv(e){const t=n=>{e.exposed=n||{}};return{attrs:new Proxy(e.attrs,N0),slots:e.slots,emit:e.emit,expose:t}}function Ga(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy(jh(Ds(e.exposed)),{get(t,n){if(n in t)return t[n];if(n in Ts)return Ts[n](e)},has(t,n){return n in t||n in Ts}})):e.proxy}function L0(e,t=!0){return ve(e)?e.displayName||e.name:e.name||t&&e.__name}function M0(e){return ve(e)&&"__vccOpts"in e}const C=(e,t)=>Py(e,t,Hs);function or(e,t,n){try{pa(-1);const r=arguments.length;return r===2?Oe(t)&&!pe(t)?cn(t)?re(e,null,[t]):re(e,t):re(e,null,t):(r>3?n=Array.prototype.slice.call(arguments,2):r===3&&cn(n)&&(n=[n]),re(e,t,n))}finally{pa(1)}}const $0="3.5.30",k0=ht;/** * @vue/runtime-dom v3.5.30 * (c) 2018-present Yuxi (Evan) You and Vue contributors * @license MIT **/let cu;const Of=typeof window<"u"&&window.trustedTypes;if(Of)try{cu=Of.createPolicy("vue",{createHTML:e=>e})}catch{}const Nv=cu?e=>cu.createHTML(e):e=>e,F0="http://www.w3.org/2000/svg",B0="http://www.w3.org/1998/Math/MathML",Zn=typeof document<"u"?document:null,Af=Zn&&Zn.createElement("template"),D0={insert:(e,t,n)=>{t.insertBefore(e,n||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,n,r)=>{const o=t==="svg"?Zn.createElementNS(F0,e):t==="mathml"?Zn.createElementNS(B0,e):n?Zn.createElement(e,{is:n}):Zn.createElement(e);return e==="select"&&r&&r.multiple!=null&&o.setAttribute("multiple",r.multiple),o},createText:e=>Zn.createTextNode(e),createComment:e=>Zn.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>Zn.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,n,r,o,s){const i=n?n.previousSibling:t.lastChild;if(o&&(o===s||o.nextSibling))for(;t.insertBefore(o.cloneNode(!0),n),!(o===s||!(o=o.nextSibling)););else{Af.innerHTML=Nv(r==="svg"?`${e}`:r==="mathml"?`${e}`:e);const a=Af.content;if(r==="svg"||r==="mathml"){const l=a.firstChild;for(;l.firstChild;)a.appendChild(l.firstChild);a.removeChild(l)}t.insertBefore(a,n)}return[i?i.nextSibling:t.firstChild,n?n.previousSibling:t.lastChild]}},Sr="transition",cs="animation",Vo=Symbol("_vtc"),Lv={name:String,type:String,css:{type:Boolean,default:!0},duration:[String,Number,Object],enterFromClass:String,enterActiveClass:String,enterToClass:String,appearFromClass:String,appearActiveClass:String,appearToClass:String,leaveFromClass:String,leaveActiveClass:String,leaveToClass:String},Mv=vt({},ev,Lv),V0=e=>(e.displayName="Transition",e.props=Mv,e),Fr=V0((e,{slots:t})=>or(Hy,$v(e),t)),qr=(e,t=[])=>{pe(e)?e.forEach(n=>n(...t)):e&&e(...t)},Rf=e=>e?pe(e)?e.some(t=>t.length>1):e.length>1:!1;function $v(e){const t={};for(const $ in e)$ in Lv||(t[$]=e[$]);if(e.css===!1)return t;const{name:n="v",type:r,duration:o,enterFromClass:s=`${n}-enter-from`,enterActiveClass:i=`${n}-enter-active`,enterToClass:a=`${n}-enter-to`,appearFromClass:l=s,appearActiveClass:u=i,appearToClass:c=a,leaveFromClass:f=`${n}-leave-from`,leaveActiveClass:d=`${n}-leave-active`,leaveToClass:v=`${n}-leave-to`}=e,p=j0(o),h=p&&p[0],b=p&&p[1],{onBeforeEnter:m,onEnter:S,onEnterCancelled:_,onLeave:w,onLeaveCancelled:y,onBeforeAppear:A=m,onAppear:R=S,onAppearCancelled:N=_}=t,I=($,F,Y,P)=>{$._enterCancelled=P,Cr($,F?c:a),Cr($,F?u:i),Y&&Y()},x=($,F)=>{$._isLeaving=!1,Cr($,f),Cr($,v),Cr($,d),F&&F()},k=$=>(F,Y)=>{const P=$?R:S,O=()=>I(F,$,Y);qr(P,[F,O]),xf(()=>{Cr(F,$?l:s),$n(F,$?c:a),Rf(P)||If(F,r,h,O)})};return vt(t,{onBeforeEnter($){qr(m,[$]),$n($,s),$n($,i)},onBeforeAppear($){qr(A,[$]),$n($,l),$n($,u)},onEnter:k(!1),onAppear:k(!0),onLeave($,F){$._isLeaving=!0;const Y=()=>x($,F);$n($,f),$._enterCancelled?($n($,d),fu($)):(fu($),$n($,d)),xf(()=>{$._isLeaving&&(Cr($,f),$n($,v),Rf(w)||If($,r,b,Y))}),qr(w,[$,Y])},onEnterCancelled($){I($,!1,void 0,!0),qr(_,[$])},onAppearCancelled($){I($,!0,void 0,!0),qr(N,[$])},onLeaveCancelled($){x($),qr(y,[$])}})}function j0(e){if(e==null)return null;if(Oe(e))return[Ol(e.enter),Ol(e.leave)];{const t=Ol(e);return[t,t]}}function Ol(e){return Jb(e)}function $n(e,t){t.split(/\s+/).forEach(n=>n&&e.classList.add(n)),(e[Vo]||(e[Vo]=new Set)).add(t)}function Cr(e,t){t.split(/\s+/).forEach(r=>r&&e.classList.remove(r));const n=e[Vo];n&&(n.delete(t),n.size||(e[Vo]=void 0))}function xf(e){requestAnimationFrame(()=>{requestAnimationFrame(e)})}let z0=0;function If(e,t,n,r){const o=e._endId=++z0,s=()=>{o===e._endId&&r()};if(n!=null)return setTimeout(s,n);const{type:i,timeout:a,propCount:l}=kv(e,t);if(!i)return r();const u=i+"end";let c=0;const f=()=>{e.removeEventListener(u,d),s()},d=v=>{v.target===e&&++c>=l&&f()};setTimeout(()=>{c(n[p]||"").split(", "),o=r(`${Sr}Delay`),s=r(`${Sr}Duration`),i=Pf(o,s),a=r(`${cs}Delay`),l=r(`${cs}Duration`),u=Pf(a,l);let c=null,f=0,d=0;t===Sr?i>0&&(c=Sr,f=i,d=s.length):t===cs?u>0&&(c=cs,f=u,d=l.length):(f=Math.max(i,u),c=f>0?i>u?Sr:cs:null,d=c?c===Sr?s.length:l.length:0);const v=c===Sr&&/\b(?:transform|all)(?:,|$)/.test(r(`${Sr}Property`).toString());return{type:c,timeout:f,propCount:d,hasTransform:v}}function Pf(e,t){for(;e.lengthNf(n)+Nf(e[r])))}function Nf(e){return e==="auto"?0:Number(e.slice(0,-1).replace(",","."))*1e3}function fu(e){return(e?e.ownerDocument:document).body.offsetHeight}function H0(e,t,n){const r=e[Vo];r&&(t=(t?[t,...r]:[...r]).join(" ")),t==null?e.removeAttribute("class"):n?e.setAttribute("class",t):e.className=t}const va=Symbol("_vod"),Fv=Symbol("_vsh"),en={name:"show",beforeMount(e,{value:t},{transition:n}){e[va]=e.style.display==="none"?"":e.style.display,n&&t?n.beforeEnter(e):fs(e,t)},mounted(e,{value:t},{transition:n}){n&&t&&n.enter(e)},updated(e,{value:t,oldValue:n},{transition:r}){!t!=!n&&(r?t?(r.beforeEnter(e),fs(e,!0),r.enter(e)):r.leave(e,()=>{fs(e,!1)}):fs(e,t))},beforeUnmount(e,{value:t}){fs(e,t)}};function fs(e,t){e.style.display=t?e[va]:"none",e[Fv]=!t}const Bv=Symbol("");function bN(e){const t=Ge();if(!t)return;const n=t.ut=(o=e(t.proxy))=>{Array.from(document.querySelectorAll(`[data-v-owner="${t.uid}"]`)).forEach(s=>ma(s,o))},r=()=>{const o=e(t.proxy);t.ce?ma(t.ce,o):du(t.subTree,o),n(o)};iv(()=>{Kh(r)}),Ke(()=>{he(r,ht,{flush:"post"});const o=new MutationObserver(r);o.observe(t.subTree.el.parentNode,{childList:!0}),kr(()=>o.disconnect())})}function du(e,t){if(e.shapeFlag&128){const n=e.suspense;e=n.activeBranch,n.pendingBranch&&!n.isHydrating&&n.effects.push(()=>{du(n.activeBranch,t)})}for(;e.component;)e=e.component.subTree;if(e.shapeFlag&1&&e.el)ma(e.el,t);else if(e.type===nt)e.children.forEach(n=>du(n,t));else if(e.type===Gi){let{el:n,anchor:r}=e;for(;n&&(ma(n,t),n!==r);)n=n.nextSibling}}function ma(e,t){if(e.nodeType===1){const n=e.style;let r="";for(const o in t){const s=oy(t[o]);n.setProperty(`--${o}`,s),r+=`--${o}: ${s};`}n[Bv]=r}}const U0=/(?:^|;)\s*display\s*:/;function K0(e,t,n){const r=e.style,o=Ae(n);let s=!1;if(n&&!o){if(t)if(Ae(t))for(const i of t.split(";")){const a=i.slice(0,i.indexOf(":")).trim();n[a]==null&&Ji(r,a,"")}else for(const i in t)n[i]==null&&Ji(r,i,"");for(const i in n)i==="display"&&(s=!0),Ji(r,i,n[i])}else if(o){if(t!==n){const i=r[Bv];i&&(n+=";"+i),r.cssText=n,s=U0.test(n)}}else t&&e.removeAttribute("style");va in e&&(e[va]=s?r.display:"",e[Fv]&&(r.display="none"))}const Lf=/\s*!important$/;function Ji(e,t,n){if(pe(n))n.forEach(r=>Ji(e,t,r));else if(n==null&&(n=""),t.startsWith("--"))e.setProperty(t,n);else{const r=q0(e,t);Lf.test(n)?e.setProperty(hr(r),n.replace(Lf,""),"important"):e[r]=n}}const Mf=["Webkit","Moz","ms"],Al={};function q0(e,t){const n=Al[t];if(n)return n;let r=Mt(t);if(r!=="filter"&&r in e)return Al[t]=r;r=si(r);for(let o=0;oRl||(J0.then(()=>Rl=0),Rl=Date.now());function Z0(e,t){const n=r=>{if(!r._vts)r._vts=Date.now();else if(r._vts<=n.attached)return;An(Q0(r,n.value),t,5,[r])};return n.value=e,n.attached=X0(),n}function Q0(e,t){if(pe(t)){const n=e.stopImmediatePropagation;return e.stopImmediatePropagation=()=>{n.call(e),e._stopped=!0},t.map(r=>o=>!o._stopped&&r&&r(o))}else return t}const Vf=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,ew=(e,t,n,r,o,s)=>{const i=o==="svg";t==="class"?H0(e,r,i):t==="style"?K0(e,n,r):Na(t)?qu(t)||G0(e,t,n,r,s):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):tw(e,t,r,i))?(Ff(e,t,r),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&kf(e,t,r,i,s,t!=="value")):e._isVueCE&&(nw(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!Ae(r)))?Ff(e,Mt(t),r,s,t):(t==="true-value"?e._trueValue=r:t==="false-value"&&(e._falseValue=r),kf(e,t,r,i))};function tw(e,t,n,r){if(r)return!!(t==="innerHTML"||t==="textContent"||t in e&&Vf(t)&&ve(n));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="sandbox"&&e.tagName==="IFRAME"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const o=e.tagName;if(o==="IMG"||o==="VIDEO"||o==="CANVAS"||o==="SOURCE")return!1}return Vf(t)&&Ae(n)?!1:t in e}function nw(e,t){const n=e._def.props;if(!n)return!1;const r=Mt(t);return Array.isArray(n)?n.some(o=>Mt(o)===r):Object.keys(n).some(o=>Mt(o)===r)}const Dv=new WeakMap,Vv=new WeakMap,ga=Symbol("_moveCb"),jf=Symbol("_enterCb"),rw=e=>(delete e.props.mode,e),ow=rw({name:"TransitionGroup",props:vt({},Mv,{tag:String,moveClass:String}),setup(e,{slots:t}){const n=Ge(),r=Qh();let o,s;return mo(()=>{if(!o.length)return;const i=e.moveClass||`${e.name||"v"}-move`;if(!uw(o[0].el,n.vnode.el,i)){o=[];return}o.forEach(iw),o.forEach(aw);const a=o.filter(lw);fu(n.vnode.el),a.forEach(l=>{const u=l.el,c=u.style;$n(u,i),c.transform=c.webkitTransform=c.transitionDuration="";const f=u[ga]=d=>{d&&d.target!==u||(!d||d.propertyName.endsWith("transform"))&&(u.removeEventListener("transitionend",f),u[ga]=null,Cr(u,i))};u.addEventListener("transitionend",f)}),o=[]}),()=>{const i=Me(e),a=$v(i);let l=i.tag||nt;if(o=[],s)for(let u=0;u{a.split(/\s+/).forEach(l=>l&&r.classList.remove(l))}),n.split(/\s+/).forEach(a=>a&&r.classList.add(a)),r.style.display="none";const s=t.nodeType===1?t:t.parentNode;s.appendChild(r);const{hasTransform:i}=kv(r);return s.removeChild(r),i}const ba=e=>{const t=e.props["onUpdate:modelValue"]||!1;return pe(t)?n=>qi(t,n):t};function cw(e){e.target.composing=!0}function zf(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}const Bo=Symbol("_assign");function Hf(e,t,n){return t&&(e=e.trim()),n&&(e=Gu(e)),e}const fw={created(e,{modifiers:{lazy:t,trim:n,number:r}},o){e[Bo]=ba(o);const s=r||o.props&&o.props.type==="number";Zr(e,t?"change":"input",i=>{i.target.composing||e[Bo](Hf(e.value,n,s))}),(n||s)&&Zr(e,"change",()=>{e.value=Hf(e.value,n,s)}),t||(Zr(e,"compositionstart",cw),Zr(e,"compositionend",zf),Zr(e,"change",zf))},mounted(e,{value:t}){e.value=t??""},beforeUpdate(e,{value:t,oldValue:n,modifiers:{lazy:r,trim:o,number:s}},i){if(e[Bo]=ba(i),e.composing)return;const a=(s||e.type==="number")&&!/^0\d/.test(e.value)?Gu(e.value):e.value,l=t??"";a!==l&&(document.activeElement===e&&e.type!=="range"&&(r&&t===n||o&&e.value.trim()===l)||(e.value=l))}},ya={deep:!0,created(e,t,n){e[Bo]=ba(n),Zr(e,"change",()=>{const r=e._modelValue,o=dw(e),s=e.checked,i=e[Bo];if(pe(r)){const a=Sh(r,o),l=a!==-1;if(s&&!l)i(r.concat(o));else if(!s&&l){const u=[...r];u.splice(a,1),i(u)}}else if(La(r)){const a=new Set(r);s?a.add(o):a.delete(o),i(a)}else i(zv(e,s))})},mounted:Uf,beforeUpdate(e,t,n){e[Bo]=ba(n),Uf(e,t,n)}};function Uf(e,{value:t,oldValue:n},r){e._modelValue=t;let o;if(pe(t))o=Sh(t,r.props.value)>-1;else if(La(t))o=t.has(r.props.value);else{if(t===n)return;o=ii(t,zv(e,!0))}e.checked!==o&&(e.checked=o)}function dw(e){return"_value"in e?e._value:e.value}function zv(e,t){const n=t?"_trueValue":"_falseValue";return n in e?e[n]:t}const pw=["ctrl","shift","alt","meta"],hw={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>pw.some(n=>e[`${n}Key`]&&!t.includes(n))},tt=(e,t)=>{if(!e)return e;const n=e._withMods||(e._withMods={}),r=t.join(".");return n[r]||(n[r]=(o,...s)=>{for(let i=0;i{const n=e._withKeys||(e._withKeys={}),r=t.join(".");return n[r]||(n[r]=o=>{if(!("key"in o))return;const s=hr(o.key);if(t.some(i=>i===s||vw[i]===s))return e(o)})},mw=vt({patchProp:ew},D0);let Kf;function Hv(){return Kf||(Kf=y0(mw))}const wa=(...e)=>{Hv().render(...e)},gw=(...e)=>{const t=Hv().createApp(...e),{mount:n}=t;return t.mount=r=>{const o=yw(r);if(!o)return;const s=t._component;!ve(s)&&!s.render&&!s.template&&(s.template=o.innerHTML),o.nodeType===1&&(o.textContent="");const i=n(o,!1,bw(o));return o instanceof Element&&(o.removeAttribute("v-cloak"),o.setAttribute("data-v-app","")),i},t};function bw(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function yw(e){return Ae(e)?document.querySelector(e):e}/*! * vue-router v4.6.4 * (c) 2025 Eduardo San Martin Morote * @license MIT */const Io=typeof document<"u";function Uv(e){return typeof e=="object"||"displayName"in e||"props"in e||"__vccOpts"in e}function ww(e){return e.__esModule||e[Symbol.toStringTag]==="Module"||e.default&&Uv(e.default)}const We=Object.assign;function xl(e,t){const n={};for(const r in t){const o=t[r];n[r]=Rn(o)?o.map(e):e(o)}return n}const As=()=>{},Rn=Array.isArray;function qf(e,t){const n={};for(const r in e)n[r]=r in t?t[r]:e[r];return n}const Kv=/#/g,Sw=/&/g,Ew=/\//g,_w=/=/g,Cw=/\?/g,qv=/\+/g,Tw=/%5B/g,Ow=/%5D/g,Wv=/%5E/g,Aw=/%60/g,Gv=/%7B/g,Rw=/%7C/g,Yv=/%7D/g,xw=/%20/g;function dc(e){return e==null?"":encodeURI(""+e).replace(Rw,"|").replace(Tw,"[").replace(Ow,"]")}function Iw(e){return dc(e).replace(Gv,"{").replace(Yv,"}").replace(Wv,"^")}function pu(e){return dc(e).replace(qv,"%2B").replace(xw,"+").replace(Kv,"%23").replace(Sw,"%26").replace(Aw,"`").replace(Gv,"{").replace(Yv,"}").replace(Wv,"^")}function Pw(e){return pu(e).replace(_w,"%3D")}function Nw(e){return dc(e).replace(Kv,"%23").replace(Cw,"%3F")}function Lw(e){return Nw(e).replace(Ew,"%2F")}function Us(e){if(e==null)return null;try{return decodeURIComponent(""+e)}catch{}return""+e}const Mw=/\/$/,$w=e=>e.replace(Mw,"");function Il(e,t,n="/"){let r,o={},s="",i="";const a=t.indexOf("#");let l=t.indexOf("?");return l=a>=0&&l>a?-1:l,l>=0&&(r=t.slice(0,l),s=t.slice(l,a>0?a:t.length),o=e(s.slice(1))),a>=0&&(r=r||t.slice(0,a),i=t.slice(a,t.length)),r=Dw(r??t,n),{fullPath:r+s+i,path:r,query:o,hash:Us(i)}}function kw(e,t){const n=t.query?e(t.query):"";return t.path+(n&&"?")+n+(t.hash||"")}function Wf(e,t){return!t||!e.toLowerCase().startsWith(t.toLowerCase())?e:e.slice(t.length)||"/"}function Fw(e,t,n){const r=t.matched.length-1,o=n.matched.length-1;return r>-1&&r===o&&jo(t.matched[r],n.matched[o])&&Jv(t.params,n.params)&&e(t.query)===e(n.query)&&t.hash===n.hash}function jo(e,t){return(e.aliasOf||e)===(t.aliasOf||t)}function Jv(e,t){if(Object.keys(e).length!==Object.keys(t).length)return!1;for(var n in e)if(!Bw(e[n],t[n]))return!1;return!0}function Bw(e,t){return Rn(e)?Gf(e,t):Rn(t)?Gf(t,e):(e==null?void 0:e.valueOf())===(t==null?void 0:t.valueOf())}function Gf(e,t){return Rn(t)?e.length===t.length&&e.every((n,r)=>n===t[r]):e.length===1&&e[0]===t}function Dw(e,t){if(e.startsWith("/"))return e;if(!e)return t;const n=t.split("/"),r=e.split("/"),o=r[r.length-1];(o===".."||o===".")&&r.push("");let s=n.length-1,i,a;for(i=0;i1&&s--;else break;return n.slice(0,s).join("/")+"/"+r.slice(i).join("/")}const Er={path:"/",name:void 0,params:{},query:{},hash:"",fullPath:"/",matched:[],meta:{},redirectedFrom:void 0};let hu=function(e){return e.pop="pop",e.push="push",e}({}),Pl=function(e){return e.back="back",e.forward="forward",e.unknown="",e}({});function Vw(e){if(!e)if(Io){const t=document.querySelector("base");e=t&&t.getAttribute("href")||"/",e=e.replace(/^\w+:\/\/[^\/]+/,"")}else e="/";return e[0]!=="/"&&e[0]!=="#"&&(e="/"+e),$w(e)}const jw=/^[^#]+#/;function zw(e,t){return e.replace(jw,"#")+t}function Hw(e,t){const n=document.documentElement.getBoundingClientRect(),r=e.getBoundingClientRect();return{behavior:t.behavior,left:r.left-n.left-(t.left||0),top:r.top-n.top-(t.top||0)}}const Ya=()=>({left:window.scrollX,top:window.scrollY});function Uw(e){let t;if("el"in e){const n=e.el,r=typeof n=="string"&&n.startsWith("#"),o=typeof n=="string"?r?document.getElementById(n.slice(1)):document.querySelector(n):n;if(!o)return;t=Hw(o,e)}else t=e;"scrollBehavior"in document.documentElement.style?window.scrollTo(t):window.scrollTo(t.left!=null?t.left:window.scrollX,t.top!=null?t.top:window.scrollY)}function Yf(e,t){return(history.state?history.state.position-t:-1)+e}const vu=new Map;function Kw(e,t){vu.set(e,t)}function qw(e){const t=vu.get(e);return vu.delete(e),t}function Ww(e){return typeof e=="string"||e&&typeof e=="object"}function Xv(e){return typeof e=="string"||typeof e=="symbol"}let ut=function(e){return e[e.MATCHER_NOT_FOUND=1]="MATCHER_NOT_FOUND",e[e.NAVIGATION_GUARD_REDIRECT=2]="NAVIGATION_GUARD_REDIRECT",e[e.NAVIGATION_ABORTED=4]="NAVIGATION_ABORTED",e[e.NAVIGATION_CANCELLED=8]="NAVIGATION_CANCELLED",e[e.NAVIGATION_DUPLICATED=16]="NAVIGATION_DUPLICATED",e}({});const Zv=Symbol("");ut.MATCHER_NOT_FOUND+"",ut.NAVIGATION_GUARD_REDIRECT+"",ut.NAVIGATION_ABORTED+"",ut.NAVIGATION_CANCELLED+"",ut.NAVIGATION_DUPLICATED+"";function zo(e,t){return We(new Error,{type:e,[Zv]:!0},t)}function Jn(e,t){return e instanceof Error&&Zv in e&&(t==null||!!(e.type&t))}const Gw=["params","query","hash"];function Yw(e){if(typeof e=="string")return e;if(e.path!=null)return e.path;const t={};for(const n of Gw)n in e&&(t[n]=e[n]);return JSON.stringify(t,null,2)}function Jw(e){const t={};if(e===""||e==="?")return t;const n=(e[0]==="?"?e.slice(1):e).split("&");for(let r=0;ro&&pu(o)):[r&&pu(r)]).forEach(o=>{o!==void 0&&(t+=(t.length?"&":"")+n,o!=null&&(t+="="+o))})}return t}function Xw(e){const t={};for(const n in e){const r=e[n];r!==void 0&&(t[n]=Rn(r)?r.map(o=>o==null?null:""+o):r==null?r:""+r)}return t}const Qv=Symbol(""),Xf=Symbol(""),Ja=Symbol(""),em=Symbol(""),mu=Symbol("");function ds(){let e=[];function t(r){return e.push(r),()=>{const o=e.indexOf(r);o>-1&&e.splice(o,1)}}function n(){e=[]}return{add:t,list:()=>e.slice(),reset:n}}function Zw(e,t,n){const r=()=>{e[t].delete(n)};kr(r),oc(r),Ka(()=>{e[t].add(n)}),e[t].add(n)}function yN(e){const t=Ee(Qv,{}).value;t&&Zw(t,"leaveGuards",e)}function Ir(e,t,n,r,o,s=i=>i()){const i=r&&(r.enterCallbacks[o]=r.enterCallbacks[o]||[]);return()=>new Promise((a,l)=>{const u=d=>{d===!1?l(zo(ut.NAVIGATION_ABORTED,{from:n,to:t})):d instanceof Error?l(d):Ww(d)?l(zo(ut.NAVIGATION_GUARD_REDIRECT,{from:t,to:d})):(i&&r.enterCallbacks[o]===i&&typeof d=="function"&&i.push(d),a())},c=s(()=>e.call(r&&r.instances[o],t,n,u));let f=Promise.resolve(c);e.length<3&&(f=f.then(u)),f.catch(d=>l(d))})}function Nl(e,t,n,r,o=s=>s()){const s=[];for(const i of e)for(const a in i.components){let l=i.components[a];if(!(t!=="beforeRouteEnter"&&!i.instances[a]))if(Uv(l)){const u=(l.__vccOpts||l)[t];u&&s.push(Ir(u,n,r,i,a,o))}else{let u=l();s.push(()=>u.then(c=>{if(!c)throw new Error(`Couldn't resolve component "${a}" at "${i.path}"`);const f=ww(c)?c.default:c;i.mods[a]=c,i.components[a]=f;const d=(f.__vccOpts||f)[t];return d&&Ir(d,n,r,i,a,o)()}))}}return s}function Qw(e,t){const n=[],r=[],o=[],s=Math.max(t.matched.length,e.matched.length);for(let i=0;ijo(u,a))?r.push(a):n.push(a));const l=e.matched[i];l&&(t.matched.find(u=>jo(u,l))||o.push(l))}return[n,r,o]}/*! * vue-router v4.6.4 * (c) 2025 Eduardo San Martin Morote * @license MIT */let e1=()=>location.protocol+"//"+location.host;function tm(e,t){const{pathname:n,search:r,hash:o}=t,s=e.indexOf("#");if(s>-1){let i=o.includes(e.slice(s))?e.slice(s).length:1,a=o.slice(i);return a[0]!=="/"&&(a="/"+a),Wf(a,"")}return Wf(n,e)+r+o}function t1(e,t,n,r){let o=[],s=[],i=null;const a=({state:d})=>{const v=tm(e,location),p=n.value,h=t.value;let b=0;if(d){if(n.value=v,t.value=d,i&&i===p){i=null;return}b=h?d.position-h.position:0}else r(v);o.forEach(m=>{m(n.value,p,{delta:b,type:hu.pop,direction:b?b>0?Pl.forward:Pl.back:Pl.unknown})})};function l(){i=n.value}function u(d){o.push(d);const v=()=>{const p=o.indexOf(d);p>-1&&o.splice(p,1)};return s.push(v),v}function c(){if(document.visibilityState==="hidden"){const{history:d}=window;if(!d.state)return;d.replaceState(We({},d.state,{scroll:Ya()}),"")}}function f(){for(const d of s)d();s=[],window.removeEventListener("popstate",a),window.removeEventListener("pagehide",c),document.removeEventListener("visibilitychange",c)}return window.addEventListener("popstate",a),window.addEventListener("pagehide",c),document.addEventListener("visibilitychange",c),{pauseListeners:l,listen:u,destroy:f}}function Zf(e,t,n,r=!1,o=!1){return{back:e,current:t,forward:n,replaced:r,position:window.history.length,scroll:o?Ya():null}}function n1(e){const{history:t,location:n}=window,r={value:tm(e,n)},o={value:t.state};o.value||s(r.value,{back:null,current:r.value,forward:null,position:t.length-1,replaced:!0,scroll:null},!0);function s(l,u,c){const f=e.indexOf("#"),d=f>-1?(n.host&&document.querySelector("base")?e:e.slice(f))+l:e1()+e+l;try{t[c?"replaceState":"pushState"](u,"",d),o.value=u}catch{n[c?"replace":"assign"](d)}}function i(l,u){s(l,We({},t.state,Zf(o.value.back,l,o.value.forward,!0),u,{position:o.value.position}),!0),r.value=l}function a(l,u){const c=We({},o.value,t.state,{forward:l,scroll:Ya()});s(c.current,c,!0),s(l,We({},Zf(r.value,l,null),{position:c.position+1},u),!1),r.value=l}return{location:r,state:o,push:a,replace:i}}function r1(e){e=Vw(e);const t=n1(e),n=t1(e,t.state,t.location,t.replace);function r(s,i=!0){i||n.pauseListeners(),history.go(s)}const o=We({location:"",base:e,go:r,createHref:zw.bind(null,e)},t,n);return Object.defineProperty(o,"location",{enumerable:!0,get:()=>t.location.value}),Object.defineProperty(o,"state",{enumerable:!0,get:()=>t.state.value}),o}function wN(e){return e=location.host?e||location.pathname+location.search:"",e.includes("#")||(e+="#"),r1(e)}let Qr=function(e){return e[e.Static=0]="Static",e[e.Param=1]="Param",e[e.Group=2]="Group",e}({});var gt=function(e){return e[e.Static=0]="Static",e[e.Param=1]="Param",e[e.ParamRegExp=2]="ParamRegExp",e[e.ParamRegExpEnd=3]="ParamRegExpEnd",e[e.EscapeNext=4]="EscapeNext",e}(gt||{});const o1={type:Qr.Static,value:""},s1=/[a-zA-Z0-9_]/;function i1(e){if(!e)return[[]];if(e==="/")return[[o1]];if(!e.startsWith("/"))throw new Error(`Invalid path "${e}"`);function t(v){throw new Error(`ERR (${n})/"${u}": ${v}`)}let n=gt.Static,r=n;const o=[];let s;function i(){s&&o.push(s),s=[]}let a=0,l,u="",c="";function f(){u&&(n===gt.Static?s.push({type:Qr.Static,value:u}):n===gt.Param||n===gt.ParamRegExp||n===gt.ParamRegExpEnd?(s.length>1&&(l==="*"||l==="+")&&t(`A repeatable param (${u}) must be alone in its segment. eg: '/:ids+.`),s.push({type:Qr.Param,value:u,regexp:c,repeatable:l==="*"||l==="+",optional:l==="*"||l==="?"})):t("Invalid state to consume buffer"),u="")}function d(){u+=l}for(;at.length?t.length===1&&t[0]===kt.Static+kt.Segment?1:-1:0}function nm(e,t){let n=0;const r=e.score,o=t.score;for(;n0&&t[t.length-1]<0}const f1={strict:!1,end:!0,sensitive:!1};function d1(e,t,n){const r=u1(i1(e.path),n),o=We(r,{record:e,parent:t,children:[],alias:[]});return t&&!o.record.aliasOf==!t.record.aliasOf&&t.children.push(o),o}function p1(e,t){const n=[],r=new Map;t=qf(f1,t);function o(f){return r.get(f)}function s(f,d,v){const p=!v,h=nd(f);h.aliasOf=v&&v.record;const b=qf(t,f),m=[h];if("alias"in f){const w=typeof f.alias=="string"?[f.alias]:f.alias;for(const y of w)m.push(nd(We({},h,{components:v?v.record.components:h.components,path:y,aliasOf:v?v.record:h})))}let S,_;for(const w of m){const{path:y}=w;if(d&&y[0]!=="/"){const A=d.record.path,R=A[A.length-1]==="/"?"":"/";w.path=d.record.path+(y&&R+y)}if(S=d1(w,d,b),v?v.alias.push(S):(_=_||S,_!==S&&_.alias.push(S),p&&f.name&&!rd(S)&&i(f.name)),rm(S)&&l(S),h.children){const A=h.children;for(let R=0;R{i(_)}:As}function i(f){if(Xv(f)){const d=r.get(f);d&&(r.delete(f),n.splice(n.indexOf(d),1),d.children.forEach(i),d.alias.forEach(i))}else{const d=n.indexOf(f);d>-1&&(n.splice(d,1),f.record.name&&r.delete(f.record.name),f.children.forEach(i),f.alias.forEach(i))}}function a(){return n}function l(f){const d=m1(f,n);n.splice(d,0,f),f.record.name&&!rd(f)&&r.set(f.record.name,f)}function u(f,d){let v,p={},h,b;if("name"in f&&f.name){if(v=r.get(f.name),!v)throw zo(ut.MATCHER_NOT_FOUND,{location:f});b=v.record.name,p=We(td(d.params,v.keys.filter(_=>!_.optional).concat(v.parent?v.parent.keys.filter(_=>_.optional):[]).map(_=>_.name)),f.params&&td(f.params,v.keys.map(_=>_.name))),h=v.stringify(p)}else if(f.path!=null)h=f.path,v=n.find(_=>_.re.test(h)),v&&(p=v.parse(h),b=v.record.name);else{if(v=d.name?r.get(d.name):n.find(_=>_.re.test(d.path)),!v)throw zo(ut.MATCHER_NOT_FOUND,{location:f,currentLocation:d});b=v.record.name,p=We({},d.params,f.params),h=v.stringify(p)}const m=[];let S=v;for(;S;)m.unshift(S.record),S=S.parent;return{name:b,path:h,params:p,matched:m,meta:v1(m)}}e.forEach(f=>s(f));function c(){n.length=0,r.clear()}return{addRoute:s,resolve:u,removeRoute:i,clearRoutes:c,getRoutes:a,getRecordMatcher:o}}function td(e,t){const n={};for(const r of t)r in e&&(n[r]=e[r]);return n}function nd(e){const t={path:e.path,redirect:e.redirect,name:e.name,meta:e.meta||{},aliasOf:e.aliasOf,beforeEnter:e.beforeEnter,props:h1(e),children:e.children||[],instances:{},leaveGuards:new Set,updateGuards:new Set,enterCallbacks:{},components:"components"in e?e.components||null:e.component&&{default:e.component}};return Object.defineProperty(t,"mods",{value:{}}),t}function h1(e){const t={},n=e.props||!1;if("component"in e)t.default=n;else for(const r in e.components)t[r]=typeof n=="object"?n[r]:n;return t}function rd(e){for(;e;){if(e.record.aliasOf)return!0;e=e.parent}return!1}function v1(e){return e.reduce((t,n)=>We(t,n.meta),{})}function m1(e,t){let n=0,r=t.length;for(;n!==r;){const s=n+r>>1;nm(e,t[s])<0?r=s:n=s+1}const o=g1(e);return o&&(r=t.lastIndexOf(o,r-1)),r}function g1(e){let t=e;for(;t=t.parent;)if(rm(t)&&nm(e,t)===0)return t}function rm({record:e}){return!!(e.name||e.components&&Object.keys(e.components).length||e.redirect)}function od(e){const t=Ee(Ja),n=Ee(em),r=C(()=>{const l=g(e.to);return t.resolve(l)}),o=C(()=>{const{matched:l}=r.value,{length:u}=l,c=l[u-1],f=n.matched;if(!c||!f.length)return-1;const d=f.findIndex(jo.bind(null,c));if(d>-1)return d;const v=sd(l[u-2]);return u>1&&sd(c)===v&&f[f.length-1].path!==v?f.findIndex(jo.bind(null,l[u-2])):d}),s=C(()=>o.value>-1&&E1(n.params,r.value.params)),i=C(()=>o.value>-1&&o.value===n.matched.length-1&&Jv(n.params,r.value.params));function a(l={}){if(S1(l)){const u=t[g(e.replace)?"replace":"push"](g(e.to)).catch(As);return e.viewTransition&&typeof document<"u"&&"startViewTransition"in document&&document.startViewTransition(()=>u),u}return Promise.resolve()}return{route:r,href:C(()=>r.value.href),isActive:s,isExactActive:i,navigate:a}}function b1(e){return e.length===1?e[0]:e}const y1=Z({name:"RouterLink",compatConfig:{MODE:3},props:{to:{type:[String,Object],required:!0},replace:Boolean,activeClass:String,exactActiveClass:String,custom:Boolean,ariaCurrentValue:{type:String,default:"page"},viewTransition:Boolean},useLink:od,setup(e,{slots:t}){const n=wt(od(e)),{options:r}=Ee(Ja),o=C(()=>({[id(e.activeClass,r.linkActiveClass,"router-link-active")]:n.isActive,[id(e.exactActiveClass,r.linkExactActiveClass,"router-link-exact-active")]:n.isExactActive}));return()=>{const s=t.default&&b1(t.default(n));return e.custom?s:or("a",{"aria-current":n.isExactActive?e.ariaCurrentValue:null,href:n.href,onClick:n.navigate,class:o.value},s)}}}),w1=y1;function S1(e){if(!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)&&!e.defaultPrevented&&!(e.button!==void 0&&e.button!==0)){if(e.currentTarget&&e.currentTarget.getAttribute){const t=e.currentTarget.getAttribute("target");if(/\b_blank\b/i.test(t))return}return e.preventDefault&&e.preventDefault(),!0}}function E1(e,t){for(const n in t){const r=t[n],o=e[n];if(typeof r=="string"){if(r!==o)return!1}else if(!Rn(o)||o.length!==r.length||r.some((s,i)=>s.valueOf()!==o[i].valueOf()))return!1}return!0}function sd(e){return e?e.aliasOf?e.aliasOf.path:e.path:""}const id=(e,t,n)=>e??t??n,_1=Z({name:"RouterView",inheritAttrs:!1,props:{name:{type:String,default:"default"},route:Object},compatConfig:{MODE:3},setup(e,{attrs:t,slots:n}){const r=Ee(mu),o=C(()=>e.route||r.value),s=Ee(Xf,0),i=C(()=>{let u=g(s);const{matched:c}=o.value;let f;for(;(f=c[u])&&!f.components;)u++;return u}),a=C(()=>o.value.matched[i.value]);ft(Xf,C(()=>i.value+1)),ft(Qv,a),ft(mu,o);const l=V();return he(()=>[l.value,a.value,e.name],([u,c,f],[d,v,p])=>{c&&(c.instances[f]=u,v&&v!==c&&u&&u===d&&(c.leaveGuards.size||(c.leaveGuards=v.leaveGuards),c.updateGuards.size||(c.updateGuards=v.updateGuards))),u&&c&&(!v||!jo(c,v)||!d)&&(c.enterCallbacks[f]||[]).forEach(h=>h(u))},{flush:"post"}),()=>{const u=o.value,c=e.name,f=a.value,d=f&&f.components[c];if(!d)return ad(n.default,{Component:d,route:u});const v=f.props[c],p=v?v===!0?u.params:typeof v=="function"?v(u):v:null,b=or(d,We({},p,t,{onVnodeUnmounted:m=>{m.component.isUnmounted&&(f.instances[c]=null)},ref:l}));return ad(n.default,{Component:b,route:u})||b}}});function ad(e,t){if(!e)return null;const n=e(t);return n.length===1?n[0]:n}const C1=_1;function SN(e){const t=p1(e.routes,e),n=e.parseQuery||Jw,r=e.stringifyQuery||Jf,o=e.history,s=ds(),i=ds(),a=ds(),l=ir(Er);let u=Er;Io&&e.scrollBehavior&&"scrollRestoration"in history&&(history.scrollRestoration="manual");const c=xl.bind(null,B=>""+B),f=xl.bind(null,Lw),d=xl.bind(null,Us);function v(B,K){let J,oe;return Xv(B)?(J=t.getRecordMatcher(B),oe=K):oe=B,t.addRoute(oe,J)}function p(B){const K=t.getRecordMatcher(B);K&&t.removeRoute(K)}function h(){return t.getRoutes().map(B=>B.record)}function b(B){return!!t.getRecordMatcher(B)}function m(B,K){if(K=We({},K||l.value),typeof B=="string"){const M=Il(n,B,K.path),W=t.resolve({path:M.path},K),G=o.createHref(M.fullPath);return We(M,W,{params:d(W.params),hash:Us(M.hash),redirectedFrom:void 0,href:G})}let J;if(B.path!=null)J=We({},B,{path:Il(n,B.path,K.path).path});else{const M=We({},B.params);for(const W in M)M[W]==null&&delete M[W];J=We({},B,{params:f(M)}),K.params=f(K.params)}const oe=t.resolve(J,K),ge=B.hash||"";oe.params=c(d(oe.params));const E=kw(r,We({},B,{hash:Iw(ge),path:oe.path})),T=o.createHref(E);return We({fullPath:E,hash:ge,query:r===Jf?Xw(B.query):B.query||{}},oe,{redirectedFrom:void 0,href:T})}function S(B){return typeof B=="string"?Il(n,B,l.value.path):We({},B)}function _(B,K){if(u!==B)return zo(ut.NAVIGATION_CANCELLED,{from:K,to:B})}function w(B){return R(B)}function y(B){return w(We(S(B),{replace:!0}))}function A(B,K){const J=B.matched[B.matched.length-1];if(J&&J.redirect){const{redirect:oe}=J;let ge=typeof oe=="function"?oe(B,K):oe;return typeof ge=="string"&&(ge=ge.includes("?")||ge.includes("#")?ge=S(ge):{path:ge},ge.params={}),We({query:B.query,hash:B.hash,params:ge.path!=null?{}:B.params},ge)}}function R(B,K){const J=u=m(B),oe=l.value,ge=B.state,E=B.force,T=B.replace===!0,M=A(J,oe);if(M)return R(We(S(M),{state:typeof M=="object"?We({},ge,M.state):ge,force:E,replace:T}),K||J);const W=J;W.redirectedFrom=K;let G;return!E&&Fw(r,oe,J)&&(G=zo(ut.NAVIGATION_DUPLICATED,{to:W,from:oe}),Re(oe,oe,!0,!1)),(G?Promise.resolve(G):x(W,oe)).catch(q=>Jn(q)?Jn(q,ut.NAVIGATION_GUARD_REDIRECT)?q:Pe(q):Q(q,W,oe)).then(q=>{if(q){if(Jn(q,ut.NAVIGATION_GUARD_REDIRECT))return R(We({replace:T},S(q.to),{state:typeof q.to=="object"?We({},ge,q.to.state):ge,force:E}),K||W)}else q=$(W,oe,!0,T,ge);return k(W,oe,q),q})}function N(B,K){const J=_(B,K);return J?Promise.reject(J):Promise.resolve()}function I(B){const K=qe.values().next().value;return K&&typeof K.runWithContext=="function"?K.runWithContext(B):B()}function x(B,K){let J;const[oe,ge,E]=Qw(B,K);J=Nl(oe.reverse(),"beforeRouteLeave",B,K);for(const M of oe)M.leaveGuards.forEach(W=>{J.push(Ir(W,B,K))});const T=N.bind(null,B,K);return J.push(T),De(J).then(()=>{J=[];for(const M of s.list())J.push(Ir(M,B,K));return J.push(T),De(J)}).then(()=>{J=Nl(ge,"beforeRouteUpdate",B,K);for(const M of ge)M.updateGuards.forEach(W=>{J.push(Ir(W,B,K))});return J.push(T),De(J)}).then(()=>{J=[];for(const M of E)if(M.beforeEnter)if(Rn(M.beforeEnter))for(const W of M.beforeEnter)J.push(Ir(W,B,K));else J.push(Ir(M.beforeEnter,B,K));return J.push(T),De(J)}).then(()=>(B.matched.forEach(M=>M.enterCallbacks={}),J=Nl(E,"beforeRouteEnter",B,K,I),J.push(T),De(J))).then(()=>{J=[];for(const M of i.list())J.push(Ir(M,B,K));return J.push(T),De(J)}).catch(M=>Jn(M,ut.NAVIGATION_CANCELLED)?M:Promise.reject(M))}function k(B,K,J){a.list().forEach(oe=>I(()=>oe(B,K,J)))}function $(B,K,J,oe,ge){const E=_(B,K);if(E)return E;const T=K===Er,M=Io?history.state:{};J&&(oe||T?o.replace(B.fullPath,We({scroll:T&&M&&M.scroll},ge)):o.push(B.fullPath,ge)),l.value=B,Re(B,K,J,T),Pe()}let F;function Y(){F||(F=o.listen((B,K,J)=>{if(!ze.listening)return;const oe=m(B),ge=A(oe,ze.currentRoute.value);if(ge){R(We(ge,{replace:!0,force:!0}),oe).catch(As);return}u=oe;const E=l.value;Io&&Kw(Yf(E.fullPath,J.delta),Ya()),x(oe,E).catch(T=>Jn(T,ut.NAVIGATION_ABORTED|ut.NAVIGATION_CANCELLED)?T:Jn(T,ut.NAVIGATION_GUARD_REDIRECT)?(R(We(S(T.to),{force:!0}),oe).then(M=>{Jn(M,ut.NAVIGATION_ABORTED|ut.NAVIGATION_DUPLICATED)&&!J.delta&&J.type===hu.pop&&o.go(-1,!1)}).catch(As),Promise.reject()):(J.delta&&o.go(-J.delta,!1),Q(T,oe,E))).then(T=>{T=T||$(oe,E,!1),T&&(J.delta&&!Jn(T,ut.NAVIGATION_CANCELLED)?o.go(-J.delta,!1):J.type===hu.pop&&Jn(T,ut.NAVIGATION_ABORTED|ut.NAVIGATION_DUPLICATED)&&o.go(-1,!1)),k(oe,E,T)}).catch(As)}))}let P=ds(),O=ds(),j;function Q(B,K,J){Pe(B);const oe=O.list();return oe.length&&oe.forEach(ge=>ge(B,K,J)),Promise.reject(B)}function me(){return j&&l.value!==Er?Promise.resolve():new Promise((B,K)=>{P.add([B,K])})}function Pe(B){return j||(j=!B,Y(),P.list().forEach(([K,J])=>B?J(B):K()),P.reset()),B}function Re(B,K,J,oe){const{scrollBehavior:ge}=e;if(!Io||!ge)return Promise.resolve();const E=!J&&qw(Yf(B.fullPath,0))||(oe||!J)&&history.state&&history.state.scroll||null;return Ie().then(()=>ge(B,K,E)).then(T=>T&&Uw(T)).catch(T=>Q(T,B,K))}const Ce=B=>o.go(B);let _e;const qe=new Set,ze={currentRoute:l,listening:!0,addRoute:v,removeRoute:p,clearRoutes:t.clearRoutes,hasRoute:b,getRoutes:h,resolve:m,options:e,push:w,replace:y,go:Ce,back:()=>Ce(-1),forward:()=>Ce(1),beforeEach:s.add,beforeResolve:i.add,afterEach:a.add,onError:O.add,isReady:me,install(B){B.component("RouterLink",w1),B.component("RouterView",C1),B.config.globalProperties.$router=ze,Object.defineProperty(B.config.globalProperties,"$route",{enumerable:!0,get:()=>g(l)}),Io&&!_e&&l.value===Er&&(_e=!0,w(o.location).catch(oe=>{}));const K={};for(const oe in Er)Object.defineProperty(K,oe,{get:()=>l.value[oe],enumerable:!0});B.provide(Ja,ze),B.provide(em,Qu(K)),B.provide(mu,l);const J=B.unmount;qe.add(B),B.unmount=function(){qe.delete(B),qe.size<1&&(u=Er,F&&F(),F=null,l.value=Er,_e=!1,j=!1),J()}}};function De(B){return B.reduce((K,J)=>K.then(()=>I(J)),Promise.resolve())}return ze}function EN(){return Ee(Ja)}const T1='a[href],button:not([disabled]),button:not([hidden]),:not([tabindex="-1"]),input:not([disabled]),input:not([type="hidden"]),select:not([disabled]),textarea:not([disabled])',O1=e=>getComputedStyle(e).position==="fixed"?!1:e.offsetParent!==null,ld=e=>Array.from(e.querySelectorAll(T1)).filter(t=>A1(t)&&O1(t)),A1=e=>{if(e.tabIndex>0||e.tabIndex===0&&e.getAttribute("tabIndex")!==null)return!0;if(e.disabled)return!1;switch(e.nodeName){case"A":return!!e.href&&e.rel!=="ignore";case"INPUT":return!(e.type==="hidden"||e.type==="file");case"BUTTON":case"SELECT":case"TEXTAREA":return!0;default:return!1}},Qn=(e,t,{checkForDefaultPrevented:n=!0}={})=>o=>{const s=e==null?void 0:e(o);if(n===!1||!s)return t==null?void 0:t(o)};var R1=Object.defineProperty,x1=Object.defineProperties,I1=Object.getOwnPropertyDescriptors,ud=Object.getOwnPropertySymbols,P1=Object.prototype.hasOwnProperty,N1=Object.prototype.propertyIsEnumerable,cd=(e,t,n)=>t in e?R1(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n,L1=(e,t)=>{for(var n in t||(t={}))P1.call(t,n)&&cd(e,n,t[n]);if(ud)for(var n of ud(t))N1.call(t,n)&&cd(e,n,t[n]);return e},M1=(e,t)=>x1(e,I1(t));function fd(e,t){var n;const r=ir();return Ha(()=>{r.value=e()},M1(L1({},t),{flush:(n=void 0)!=null?n:"sync"})),Mr(r)}var dd;const st=typeof window<"u",$1=e=>typeof e=="string",Sa=()=>{},gu=st&&((dd=window==null?void 0:window.navigator)==null?void 0:dd.userAgent)&&/iP(ad|hone|od)/.test(window.navigator.userAgent);function Ks(e){return typeof e=="function"?e():g(e)}function k1(e,t){function n(...r){return new Promise((o,s)=>{Promise.resolve(e(()=>t.apply(this,r),{fn:t,thisArg:this,args:r})).then(o).catch(s)})}return n}function F1(e,t={}){let n,r,o=Sa;const s=a=>{clearTimeout(a),o(),o=Sa};return a=>{const l=Ks(e),u=Ks(t.maxWait);return n&&s(n),l<=0||u!==void 0&&u<=0?(r&&(s(r),r=null),Promise.resolve(a())):new Promise((c,f)=>{o=t.rejectOnCancel?f:c,u&&!r&&(r=setTimeout(()=>{n&&s(n),r=null,c(a())},u)),n=setTimeout(()=>{r&&s(r),r=null,c(a())},l)})}}function B1(e){return e}function ui(e){return Fa()?(Ba(e),!0):!1}function D1(e,t=200,n={}){return k1(F1(t,n),e)}function V1(e,t=200,n={}){const r=V(e.value),o=D1(()=>{r.value=e.value},t,n);return he(e,()=>o()),r}function j1(e,t=!0){Ge()?Ke(e):t?e():Ie(e)}function bu(e,t,n={}){const{immediate:r=!0}=n,o=V(!1);let s=null;function i(){s&&(clearTimeout(s),s=null)}function a(){o.value=!1,i()}function l(...u){i(),o.value=!0,s=setTimeout(()=>{o.value=!1,s=null,e(...u)},Ks(t))}return r&&(o.value=!0,st&&l()),ui(a),{isPending:Mr(o),start:l,stop:a}}function sr(e){var t;const n=Ks(e);return(t=n==null?void 0:n.$el)!=null?t:n}const ci=st?window:void 0,z1=st?window.document:void 0;function tn(...e){let t,n,r,o;if($1(e[0])||Array.isArray(e[0])?([n,r,o]=e,t=ci):[t,n,r,o]=e,!t)return Sa;Array.isArray(n)||(n=[n]),Array.isArray(r)||(r=[r]);const s=[],i=()=>{s.forEach(c=>c()),s.length=0},a=(c,f,d,v)=>(c.addEventListener(f,d,v),()=>c.removeEventListener(f,d,v)),l=he(()=>[sr(t),Ks(o)],([c,f])=>{i(),c&&s.push(...n.flatMap(d=>r.map(v=>a(c,d,v,f))))},{immediate:!0,flush:"post"}),u=()=>{l(),i()};return ui(u),u}let pd=!1;function H1(e,t,n={}){const{window:r=ci,ignore:o=[],capture:s=!0,detectIframe:i=!1}=n;if(!r)return;gu&&!pd&&(pd=!0,Array.from(r.document.body.children).forEach(d=>d.addEventListener("click",Sa)));let a=!0;const l=d=>o.some(v=>{if(typeof v=="string")return Array.from(r.document.querySelectorAll(v)).some(p=>p===d.target||d.composedPath().includes(p));{const p=sr(v);return p&&(d.target===p||d.composedPath().includes(p))}}),c=[tn(r,"click",d=>{const v=sr(e);if(!(!v||v===d.target||d.composedPath().includes(v))){if(d.detail===0&&(a=!l(d)),!a){a=!0;return}t(d)}},{passive:!0,capture:s}),tn(r,"pointerdown",d=>{const v=sr(e);v&&(a=!d.composedPath().includes(v)&&!l(d))},{passive:!0}),i&&tn(r,"blur",d=>{var v;const p=sr(e);((v=r.document.activeElement)==null?void 0:v.tagName)==="IFRAME"&&!(p!=null&&p.contains(r.document.activeElement))&&t(d)})].filter(Boolean);return()=>c.forEach(d=>d())}function om(e,t=!1){const n=V(),r=()=>n.value=!!e();return r(),j1(r,t),n}const hd=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{},vd="__vueuse_ssr_handlers__";hd[vd]=hd[vd]||{};function U1({document:e=z1}={}){if(!e)return V("visible");const t=V(e.visibilityState);return tn(e,"visibilitychange",()=>{t.value=e.visibilityState}),t}var md=Object.getOwnPropertySymbols,K1=Object.prototype.hasOwnProperty,q1=Object.prototype.propertyIsEnumerable,W1=(e,t)=>{var n={};for(var r in e)K1.call(e,r)&&t.indexOf(r)<0&&(n[r]=e[r]);if(e!=null&&md)for(var r of md(e))t.indexOf(r)<0&&q1.call(e,r)&&(n[r]=e[r]);return n};function Dt(e,t,n={}){const r=n,{window:o=ci}=r,s=W1(r,["window"]);let i;const a=om(()=>o&&"ResizeObserver"in o),l=()=>{i&&(i.disconnect(),i=void 0)},u=he(()=>sr(e),f=>{l(),a.value&&o&&f&&(i=new ResizeObserver(t),i.observe(f,s))},{immediate:!0,flush:"post"}),c=()=>{l(),u()};return ui(c),{isSupported:a,stop:c}}var gd=Object.getOwnPropertySymbols,G1=Object.prototype.hasOwnProperty,Y1=Object.prototype.propertyIsEnumerable,J1=(e,t)=>{var n={};for(var r in e)G1.call(e,r)&&t.indexOf(r)<0&&(n[r]=e[r]);if(e!=null&&gd)for(var r of gd(e))t.indexOf(r)<0&&Y1.call(e,r)&&(n[r]=e[r]);return n};function X1(e,t,n={}){const r=n,{window:o=ci}=r,s=J1(r,["window"]);let i;const a=om(()=>o&&"MutationObserver"in o),l=()=>{i&&(i.disconnect(),i=void 0)},u=he(()=>sr(e),f=>{l(),a.value&&o&&f&&(i=new MutationObserver(t),i.observe(f,s))},{immediate:!0}),c=()=>{l(),u()};return ui(c),{isSupported:a,stop:c}}var bd;(function(e){e.UP="UP",e.RIGHT="RIGHT",e.DOWN="DOWN",e.LEFT="LEFT",e.NONE="NONE"})(bd||(bd={}));var Z1=Object.defineProperty,yd=Object.getOwnPropertySymbols,Q1=Object.prototype.hasOwnProperty,eS=Object.prototype.propertyIsEnumerable,wd=(e,t,n)=>t in e?Z1(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n,tS=(e,t)=>{for(var n in t||(t={}))Q1.call(t,n)&&wd(e,n,t[n]);if(yd)for(var n of yd(t))eS.call(t,n)&&wd(e,n,t[n]);return e};const nS={easeInSine:[.12,0,.39,0],easeOutSine:[.61,1,.88,1],easeInOutSine:[.37,0,.63,1],easeInQuad:[.11,0,.5,0],easeOutQuad:[.5,1,.89,1],easeInOutQuad:[.45,0,.55,1],easeInCubic:[.32,0,.67,0],easeOutCubic:[.33,1,.68,1],easeInOutCubic:[.65,0,.35,1],easeInQuart:[.5,0,.75,0],easeOutQuart:[.25,1,.5,1],easeInOutQuart:[.76,0,.24,1],easeInQuint:[.64,0,.78,0],easeOutQuint:[.22,1,.36,1],easeInOutQuint:[.83,0,.17,1],easeInExpo:[.7,0,.84,0],easeOutExpo:[.16,1,.3,1],easeInOutExpo:[.87,0,.13,1],easeInCirc:[.55,0,1,.45],easeOutCirc:[0,.55,.45,1],easeInOutCirc:[.85,0,.15,1],easeInBack:[.36,0,.66,-.56],easeOutBack:[.34,1.56,.64,1],easeInOutBack:[.68,-.6,.32,1.6]};tS({linear:B1},nS);function rS({window:e=ci}={}){if(!e)return V(!1);const t=V(e.document.hasFocus());return tn(e,"blur",()=>{t.value=!1}),tn(e,"focus",()=>{t.value=!0}),t}const oS=()=>st&&/firefox/i.test(window.navigator.userAgent);var sm=typeof global=="object"&&global&&global.Object===Object&&global,sS=typeof self=="object"&&self&&self.Object===Object&&self,Pn=sm||sS||Function("return this")(),fn=Pn.Symbol,im=Object.prototype,iS=im.hasOwnProperty,aS=im.toString,ps=fn?fn.toStringTag:void 0;function lS(e){var t=iS.call(e,ps),n=e[ps];try{e[ps]=void 0;var r=!0}catch{}var o=aS.call(e);return r&&(t?e[ps]=n:delete e[ps]),o}var uS=Object.prototype,cS=uS.toString;function fS(e){return cS.call(e)}var dS="[object Null]",pS="[object Undefined]",Sd=fn?fn.toStringTag:void 0;function Qo(e){return e==null?e===void 0?pS:dS:Sd&&Sd in Object(e)?lS(e):fS(e)}function $r(e){return e!=null&&typeof e=="object"}var hS="[object Symbol]";function Xa(e){return typeof e=="symbol"||$r(e)&&Qo(e)==hS}function vS(e,t){for(var n=-1,r=e==null?0:e.length,o=Array(r);++n0){if(++t>=HS)return arguments[0]}else t=0;return e.apply(void 0,arguments)}}function WS(e){return function(){return e}}var Ea=function(){try{var e=yo(Object,"defineProperty");return e({},"",{}),e}catch{}}(),GS=Ea?function(e,t){return Ea(e,"toString",{configurable:!0,enumerable:!1,value:WS(t),writable:!0})}:lm,YS=qS(GS);function JS(e,t){for(var n=-1,r=e==null?0:e.length;++n-1&&e%1==0&&e-1&&e%1==0&&e<=rE}function fm(e){return e!=null&&mc(e.length)&&!um(e)}var oE=Object.prototype;function gc(e){var t=e&&e.constructor,n=typeof t=="function"&&t.prototype||oE;return e===n}function sE(e,t){for(var n=-1,r=Array(e);++n-1}function h2(e,t){var n=this.__data__,r=Qa(n,e);return r<0?(++this.size,n.push([e,t])):n[r][1]=t,this}function gr(e){var t=-1,n=e==null?0:e.length;for(this.clear();++ta))return!1;var u=s.get(e),c=s.get(t);if(u&&c)return u==t&&c==e;var f=-1,d=!0,v=n&vC?new Ta:void 0;for(s.set(e,t),s.set(t,e);++f=t||R<0||f&&N>=s}function m(){var A=kl();if(b(A))return S(A);a=setTimeout(m,h(A))}function S(A){return a=void 0,d&&r?v(A):(r=o=void 0,i)}function _(){a!==void 0&&clearTimeout(a),u=0,r=l=o=a=void 0}function w(){return a===void 0?i:S(kl())}function y(){var A=kl(),R=b(A);if(r=arguments,o=this,l=A,R){if(a===void 0)return p(l);if(f)return clearTimeout(a),a=setTimeout(m,t),v(l)}return a===void 0&&(a=setTimeout(m,t)),i}return y.cancel=_,y.flush=w,y}function rT(e,t,n){var r=e==null?0:e.length;if(!r)return-1;var o=r-1;return XS(e,ZC(t),o)}function Oa(e){for(var t=-1,n=e==null?0:e.length,r={};++te===void 0,Bt=e=>typeof e=="boolean",je=e=>typeof e=="number",ar=e=>typeof Element>"u"?!1:e instanceof Element,Cu=e=>qn(e),aT=e=>Ae(e)?!Number.isNaN(Number(e)):!1,lT=(e="")=>e.replace(/[|\\{}()[\]^$+*?.]/g,"\\$&").replace(/-/g,"\\x2d"),Pr=e=>si(e),Zd=e=>Object.keys(e),Fl=(e,t,n)=>({get value(){return zn(e,t,n)},set value(r){iT(e,t,r)}});class uT extends Error{constructor(t){super(t),this.name="ElementPlusError"}}function Br(e,t){throw new uT(`[${e}] ${t}`)}const Pm=(e="")=>e.split(" ").filter(t=>!!t.trim()),Qd=(e,t)=>{if(!e||!t)return!1;if(t.includes(" "))throw new Error("className should not contain space.");return e.classList.contains(t)},Tu=(e,t)=>{!e||!t.trim()||e.classList.add(...Pm(t))},Gs=(e,t)=>{!e||!t.trim()||e.classList.remove(...Pm(t))},Po=(e,t)=>{var n;if(!st||!e||!t)return"";let r=Mt(t);r==="float"&&(r="cssFloat");try{const o=e.style[r];if(o)return o;const s=(n=document.defaultView)==null?void 0:n.getComputedStyle(e,"");return s?s[r]:""}catch{return e.style[r]}};function pn(e,t="px"){if(!e)return"";if(je(e)||aT(e))return`${e}${t}`;if(Ae(e))return e}let xi;const cT=e=>{var t;if(!st)return 0;if(xi!==void 0)return xi;const n=document.createElement("div");n.className=`${e}-scrollbar__wrap`,n.style.visibility="hidden",n.style.width="100px",n.style.position="absolute",n.style.top="-9999px",document.body.appendChild(n);const r=n.offsetWidth;n.style.overflow="scroll";const o=document.createElement("div");o.style.width="100%",n.appendChild(o);const s=o.offsetWidth;return(t=n.parentNode)==null||t.removeChild(n),xi=r-s,xi};function fT(e,t){if(!st)return;if(!t){e.scrollTop=0;return}const n=[];let r=t.offsetParent;for(;r!==null&&e!==r&&e.contains(r);)n.push(r),r=r.offsetParent;const o=t.offsetTop+n.reduce((l,u)=>l+u.offsetTop,0),s=o+t.offsetHeight,i=e.scrollTop,a=i+e.clientHeight;oa&&(e.scrollTop=s-e.clientHeight)}/*! Element Plus Icons Vue v2.3.2 */var dT=Z({name:"ArrowDown",__name:"arrow-down",setup(e){return(t,n)=>(L(),ee("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 1024 1024"},[le("path",{fill:"currentColor",d:"M831.872 340.864 512 652.672 192.128 340.864a30.59 30.59 0 0 0-42.752 0 29.12 29.12 0 0 0 0 41.6L489.664 714.24a32 32 0 0 0 44.672 0l340.288-331.712a29.12 29.12 0 0 0 0-41.728 30.59 30.59 0 0 0-42.752 0z"})]))}}),Nm=dT,pT=Z({name:"ArrowLeft",__name:"arrow-left",setup(e){return(t,n)=>(L(),ee("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 1024 1024"},[le("path",{fill:"currentColor",d:"M609.408 149.376 277.76 489.6a32 32 0 0 0 0 44.672l331.648 340.352a29.12 29.12 0 0 0 41.728 0 30.59 30.59 0 0 0 0-42.752L339.264 511.936l311.872-319.872a30.59 30.59 0 0 0 0-42.688 29.12 29.12 0 0 0-41.728 0"})]))}}),hT=pT,vT=Z({name:"ArrowRight",__name:"arrow-right",setup(e){return(t,n)=>(L(),ee("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 1024 1024"},[le("path",{fill:"currentColor",d:"M340.864 149.312a30.59 30.59 0 0 0 0 42.752L652.736 512 340.864 831.872a30.59 30.59 0 0 0 0 42.752 29.12 29.12 0 0 0 41.728 0L714.24 534.336a32 32 0 0 0 0-44.672L382.592 149.376a29.12 29.12 0 0 0-41.728 0z"})]))}}),mT=vT,gT=Z({name:"ArrowUp",__name:"arrow-up",setup(e){return(t,n)=>(L(),ee("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 1024 1024"},[le("path",{fill:"currentColor",d:"m488.832 344.32-339.84 356.672a32 32 0 0 0 0 44.16l.384.384a29.44 29.44 0 0 0 42.688 0l320-335.872 319.872 335.872a29.44 29.44 0 0 0 42.688 0l.384-.384a32 32 0 0 0 0-44.16L535.168 344.32a32 32 0 0 0-46.336 0"})]))}}),bT=gT,yT=Z({name:"CircleCheckFilled",__name:"circle-check-filled",setup(e){return(t,n)=>(L(),ee("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 1024 1024"},[le("path",{fill:"currentColor",d:"M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896m-55.808 536.384-99.52-99.584a38.4 38.4 0 1 0-54.336 54.336l126.72 126.72a38.27 38.27 0 0 0 54.336 0l262.4-262.464a38.4 38.4 0 1 0-54.272-54.336z"})]))}}),_N=yT,wT=Z({name:"CircleCheck",__name:"circle-check",setup(e){return(t,n)=>(L(),ee("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 1024 1024"},[le("path",{fill:"currentColor",d:"M512 896a384 384 0 1 0 0-768 384 384 0 0 0 0 768m0 64a448 448 0 1 1 0-896 448 448 0 0 1 0 896"}),le("path",{fill:"currentColor",d:"M745.344 361.344a32 32 0 0 1 45.312 45.312l-288 288a32 32 0 0 1-45.312 0l-160-160a32 32 0 1 1 45.312-45.312L480 626.752z"})]))}}),ST=wT,ET=Z({name:"CircleCloseFilled",__name:"circle-close-filled",setup(e){return(t,n)=>(L(),ee("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 1024 1024"},[le("path",{fill:"currentColor",d:"M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896m0 393.664L407.936 353.6a38.4 38.4 0 1 0-54.336 54.336L457.664 512 353.6 616.064a38.4 38.4 0 1 0 54.336 54.336L512 566.336 616.064 670.4a38.4 38.4 0 1 0 54.336-54.336L566.336 512 670.4 407.936a38.4 38.4 0 1 0-54.336-54.336z"})]))}}),Lm=ET,_T=Z({name:"CircleClose",__name:"circle-close",setup(e){return(t,n)=>(L(),ee("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 1024 1024"},[le("path",{fill:"currentColor",d:"m466.752 512-90.496-90.496a32 32 0 0 1 45.248-45.248L512 466.752l90.496-90.496a32 32 0 1 1 45.248 45.248L557.248 512l90.496 90.496a32 32 0 1 1-45.248 45.248L512 557.248l-90.496 90.496a32 32 0 0 1-45.248-45.248z"}),le("path",{fill:"currentColor",d:"M512 896a384 384 0 1 0 0-768 384 384 0 0 0 0 768m0 64a448 448 0 1 1 0-896 448 448 0 0 1 0 896"})]))}}),Oc=_T,CT=Z({name:"Close",__name:"close",setup(e){return(t,n)=>(L(),ee("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 1024 1024"},[le("path",{fill:"currentColor",d:"M764.288 214.592 512 466.88 259.712 214.592a31.936 31.936 0 0 0-45.12 45.12L466.752 512 214.528 764.224a31.936 31.936 0 1 0 45.12 45.184L512 557.184l252.288 252.288a31.936 31.936 0 0 0 45.12-45.12L557.12 512.064l252.288-252.352a31.936 31.936 0 1 0-45.12-45.184z"})]))}}),Ys=CT,TT=Z({name:"Delete",__name:"delete",setup(e){return(t,n)=>(L(),ee("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 1024 1024"},[le("path",{fill:"currentColor",d:"M160 256H96a32 32 0 0 1 0-64h256V95.936a32 32 0 0 1 32-32h256a32 32 0 0 1 32 32V192h256a32 32 0 1 1 0 64h-64v672a32 32 0 0 1-32 32H192a32 32 0 0 1-32-32zm448-64v-64H416v64zM224 896h576V256H224zm192-128a32 32 0 0 1-32-32V416a32 32 0 0 1 64 0v320a32 32 0 0 1-32 32m192 0a32 32 0 0 1-32-32V416a32 32 0 0 1 64 0v320a32 32 0 0 1-32 32"})]))}}),CN=TT,OT=Z({name:"Download",__name:"download",setup(e){return(t,n)=>(L(),ee("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 1024 1024"},[le("path",{fill:"currentColor",d:"M160 832h704a32 32 0 1 1 0 64H160a32 32 0 1 1 0-64m384-253.696 236.288-236.352 45.248 45.248L508.8 704 192 387.2l45.248-45.248L480 584.704V128h64z"})]))}}),TN=OT,AT=Z({name:"Edit",__name:"edit",setup(e){return(t,n)=>(L(),ee("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 1024 1024"},[le("path",{fill:"currentColor",d:"M832 512a32 32 0 1 1 64 0v352a32 32 0 0 1-32 32H160a32 32 0 0 1-32-32V160a32 32 0 0 1 32-32h352a32 32 0 0 1 0 64H192v640h640z"}),le("path",{fill:"currentColor",d:"m469.952 554.24 52.8-7.552L847.104 222.4a32 32 0 1 0-45.248-45.248L477.44 501.44l-7.552 52.8zm422.4-422.4a96 96 0 0 1 0 135.808l-331.84 331.84a32 32 0 0 1-18.112 9.088L436.8 623.68a32 32 0 0 1-36.224-36.224l15.104-105.6a32 32 0 0 1 9.024-18.112l331.904-331.84a96 96 0 0 1 135.744 0z"})]))}}),ON=AT,RT=Z({name:"Folder",__name:"folder",setup(e){return(t,n)=>(L(),ee("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 1024 1024"},[le("path",{fill:"currentColor",d:"M128 192v640h768V320H485.76L357.504 192zm-32-64h287.872l128.384 128H928a32 32 0 0 1 32 32v576a32 32 0 0 1-32 32H96a32 32 0 0 1-32-32V160a32 32 0 0 1 32-32"})]))}}),AN=RT,xT=Z({name:"Hide",__name:"hide",setup(e){return(t,n)=>(L(),ee("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 1024 1024"},[le("path",{fill:"currentColor",d:"M876.8 156.8c0-9.6-3.2-16-9.6-22.4s-12.8-9.6-22.4-9.6-16 3.2-22.4 9.6L736 220.8c-64-32-137.6-51.2-224-60.8-160 16-288 73.6-377.6 176S0 496 0 512s48 73.6 134.4 176c22.4 25.6 44.8 48 73.6 67.2l-86.4 89.6c-6.4 6.4-9.6 12.8-9.6 22.4s3.2 16 9.6 22.4 12.8 9.6 22.4 9.6 16-3.2 22.4-9.6l704-710.4c3.2-6.4 6.4-12.8 6.4-22.4m-646.4 528Q115.2 579.2 76.8 512q43.2-72 153.6-172.8C304 272 400 230.4 512 224c64 3.2 124.8 19.2 176 44.8l-54.4 54.4C598.4 300.8 560 288 512 288c-64 0-115.2 22.4-160 64s-64 96-64 160c0 48 12.8 89.6 35.2 124.8L256 707.2c-9.6-6.4-19.2-16-25.6-22.4m140.8-96Q352 555.2 352 512c0-44.8 16-83.2 48-112s67.2-48 112-48c28.8 0 54.4 6.4 73.6 19.2zM889.599 336c-12.8-16-28.8-28.8-41.6-41.6l-48 48c73.6 67.2 124.8 124.8 150.4 169.6q-43.2 72-153.6 172.8c-73.6 67.2-172.8 108.8-284.8 115.2-51.2-3.2-99.2-12.8-140.8-28.8l-48 48c57.6 22.4 118.4 38.4 188.8 44.8 160-16 288-73.6 377.6-176S1024 528 1024 512s-48.001-73.6-134.401-176"}),le("path",{fill:"currentColor",d:"M511.998 672c-12.8 0-25.6-3.2-38.4-6.4l-51.2 51.2c28.8 12.8 57.6 19.2 89.6 19.2 64 0 115.2-22.4 160-64 41.6-41.6 64-96 64-160 0-32-6.4-64-19.2-89.6l-51.2 51.2c3.2 12.8 6.4 25.6 6.4 38.4 0 44.8-16 83.2-48 112s-67.2 48-112 48"})]))}}),IT=xT,PT=Z({name:"InfoFilled",__name:"info-filled",setup(e){return(t,n)=>(L(),ee("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 1024 1024"},[le("path",{fill:"currentColor",d:"M512 64a448 448 0 1 1 0 896.064A448 448 0 0 1 512 64m67.2 275.072c33.28 0 60.288-23.104 60.288-57.344s-27.072-57.344-60.288-57.344c-33.28 0-60.16 23.104-60.16 57.344s26.88 57.344 60.16 57.344M590.912 699.2c0-6.848 2.368-24.64 1.024-34.752l-52.608 60.544c-10.88 11.456-24.512 19.392-30.912 17.28a12.99 12.99 0 0 1-8.256-14.72l87.68-276.992c7.168-35.136-12.544-67.2-54.336-71.296-44.096 0-108.992 44.736-148.48 101.504 0 6.784-1.28 23.68.064 33.792l52.544-60.608c10.88-11.328 23.552-19.328 29.952-17.152a12.8 12.8 0 0 1 7.808 16.128L388.48 728.576c-10.048 32.256 8.96 63.872 55.04 71.04 67.84 0 107.904-43.648 147.456-100.416z"})]))}}),Mm=PT,NT=Z({name:"Link",__name:"link",setup(e){return(t,n)=>(L(),ee("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 1024 1024"},[le("path",{fill:"currentColor",d:"M715.648 625.152 670.4 579.904l90.496-90.56c75.008-74.944 85.12-186.368 22.656-248.896-62.528-62.464-173.952-52.352-248.96 22.656L444.16 353.6l-45.248-45.248 90.496-90.496c100.032-99.968 251.968-110.08 339.456-22.656 87.488 87.488 77.312 239.424-22.656 339.456l-90.496 90.496zm-90.496 90.496-90.496 90.496C434.624 906.112 282.688 916.224 195.2 828.8c-87.488-87.488-77.312-239.424 22.656-339.456l90.496-90.496 45.248 45.248-90.496 90.56c-75.008 74.944-85.12 186.368-22.656 248.896 62.528 62.464 173.952 52.352 248.96-22.656l90.496-90.496zm0-362.048 45.248 45.248L398.848 670.4 353.6 625.152z"})]))}}),RN=NT,LT=Z({name:"Loading",__name:"loading",setup(e){return(t,n)=>(L(),ee("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 1024 1024"},[le("path",{fill:"currentColor",d:"M512 64a32 32 0 0 1 32 32v192a32 32 0 0 1-64 0V96a32 32 0 0 1 32-32m0 640a32 32 0 0 1 32 32v192a32 32 0 1 1-64 0V736a32 32 0 0 1 32-32m448-192a32 32 0 0 1-32 32H736a32 32 0 1 1 0-64h192a32 32 0 0 1 32 32m-640 0a32 32 0 0 1-32 32H96a32 32 0 0 1 0-64h192a32 32 0 0 1 32 32M195.2 195.2a32 32 0 0 1 45.248 0L376.32 331.008a32 32 0 0 1-45.248 45.248L195.2 240.448a32 32 0 0 1 0-45.248m452.544 452.544a32 32 0 0 1 45.248 0L828.8 783.552a32 32 0 0 1-45.248 45.248L647.744 692.992a32 32 0 0 1 0-45.248M828.8 195.264a32 32 0 0 1 0 45.184L692.992 376.32a32 32 0 0 1-45.248-45.248l135.808-135.808a32 32 0 0 1 45.248 0m-452.544 452.48a32 32 0 0 1 0 45.248L240.448 828.8a32 32 0 0 1-45.248-45.248l135.808-135.808a32 32 0 0 1 45.248 0"})]))}}),Js=LT,MT=Z({name:"Minus",__name:"minus",setup(e){return(t,n)=>(L(),ee("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 1024 1024"},[le("path",{fill:"currentColor",d:"M128 544h768a32 32 0 1 0 0-64H128a32 32 0 0 0 0 64"})]))}}),$T=MT,kT=Z({name:"Plus",__name:"plus",setup(e){return(t,n)=>(L(),ee("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 1024 1024"},[le("path",{fill:"currentColor",d:"M480 480V128a32 32 0 0 1 64 0v352h352a32 32 0 1 1 0 64H544v352a32 32 0 1 1-64 0V544H128a32 32 0 0 1 0-64z"})]))}}),$m=kT,FT=Z({name:"Search",__name:"search",setup(e){return(t,n)=>(L(),ee("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 1024 1024"},[le("path",{fill:"currentColor",d:"m795.904 750.72 124.992 124.928a32 32 0 0 1-45.248 45.248L750.656 795.904a416 416 0 1 1 45.248-45.248zM480 832a352 352 0 1 0 0-704 352 352 0 0 0 0 704"})]))}}),xN=FT,BT=Z({name:"SuccessFilled",__name:"success-filled",setup(e){return(t,n)=>(L(),ee("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 1024 1024"},[le("path",{fill:"currentColor",d:"M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896m-55.808 536.384-99.52-99.584a38.4 38.4 0 1 0-54.336 54.336l126.72 126.72a38.27 38.27 0 0 0 54.336 0l262.4-262.464a38.4 38.4 0 1 0-54.272-54.336z"})]))}}),km=BT,DT=Z({name:"View",__name:"view",setup(e){return(t,n)=>(L(),ee("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 1024 1024"},[le("path",{fill:"currentColor",d:"M512 160c320 0 512 352 512 352S832 864 512 864 0 512 0 512s192-352 512-352m0 64c-225.28 0-384.128 208.064-436.8 288 52.608 79.872 211.456 288 436.8 288 225.28 0 384.128-208.064 436.8-288-52.608-79.872-211.456-288-436.8-288m0 64a224 224 0 1 1 0 448 224 224 0 0 1 0-448m0 64a160.19 160.19 0 0 0-160 160c0 88.192 71.744 160 160 160s160-71.808 160-160-71.744-160-160-160"})]))}}),VT=DT,jT=Z({name:"WarningFilled",__name:"warning-filled",setup(e){return(t,n)=>(L(),ee("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 1024 1024"},[le("path",{fill:"currentColor",d:"M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896m0 192a58.43 58.43 0 0 0-58.24 63.744l23.36 256.384a35.072 35.072 0 0 0 69.76 0l23.296-256.384A58.43 58.43 0 0 0 512 256m0 512a51.2 51.2 0 1 0 0-102.4 51.2 51.2 0 0 0 0 102.4"})]))}}),Fm=jT;const Bm="__epPropKey",we=e=>e,zT=e=>Oe(e)&&!!e[Bm],rl=(e,t)=>{if(!Oe(e)||zT(e))return e;const{values:n,required:r,default:o,type:s,validator:i}=e,l={type:s,required:!!r,validator:n||i?u=>{let c=!1,f=[];if(n&&(f=Array.from(n),Ve(e,"default")&&f.push(o),c||(c=f.includes(u))),i&&(c||(c=i(u))),!c&&f.length>0){const d=[...new Set(f)].map(v=>JSON.stringify(v)).join(", ");k0(`Invalid prop: validation failed${t?` for prop "${t}"`:""}. Expected one of [${d}], got value ${JSON.stringify(u)}.`)}return c}:void 0,[Bm]:!0};return Ve(e,"default")&&(l.default=o),l},xe=e=>Oa(Object.entries(e).map(([t,n])=>[t,rl(n,t)])),jt=we([String,Object,Function]),HT={Close:Ys},Dm={Close:Ys,SuccessFilled:km,InfoFilled:Mm,WarningFilled:Fm,CircleCloseFilled:Lm},Ra={success:km,warning:Fm,error:Lm,info:Mm},Vm={validating:Js,success:ST,error:Oc},yt=(e,t)=>{if(e.install=n=>{for(const r of[e,...Object.values(t??{})])n.component(r.name,r)},t)for(const[n,r]of Object.entries(t))e[n]=r;return e},UT=(e,t)=>(e.install=n=>{e._context=n._context,n.config.globalProperties[t]=e},e),KT=(e,t)=>(e.install=n=>{n.directive(t,e)},e),wo=e=>(e.install=ht,e),qT=(...e)=>t=>{e.forEach(n=>{ve(n)?n(t):n.value=t})},_n={tab:"Tab",enter:"Enter",space:"Space",left:"ArrowLeft",up:"ArrowUp",right:"ArrowRight",down:"ArrowDown",esc:"Escape",delete:"Delete",backspace:"Backspace"},rt="update:modelValue",fo="change",io="input",es=["","default","small","large"],jm=e=>["",...es].includes(e);var Zi=(e=>(e[e.TEXT=1]="TEXT",e[e.CLASS=2]="CLASS",e[e.STYLE=4]="STYLE",e[e.PROPS=8]="PROPS",e[e.FULL_PROPS=16]="FULL_PROPS",e[e.HYDRATE_EVENTS=32]="HYDRATE_EVENTS",e[e.STABLE_FRAGMENT=64]="STABLE_FRAGMENT",e[e.KEYED_FRAGMENT=128]="KEYED_FRAGMENT",e[e.UNKEYED_FRAGMENT=256]="UNKEYED_FRAGMENT",e[e.NEED_PATCH=512]="NEED_PATCH",e[e.DYNAMIC_SLOTS=1024]="DYNAMIC_SLOTS",e[e.HOISTED=-1]="HOISTED",e[e.BAIL=-2]="BAIL",e))(Zi||{});const Qi=e=>{const t=pe(e)?e:[e],n=[];return t.forEach(r=>{var o;pe(r)?n.push(...Qi(r)):cn(r)&&pe(r.children)?n.push(...Qi(r.children)):(n.push(r),cn(r)&&((o=r.component)!=null&&o.subTree)&&n.push(...Qi(r.component.subTree)))}),n},WT=e=>/([\uAC00-\uD7AF\u3130-\u318F])+/gi.test(e),ol=e=>e,GT=["class","style"],YT=/^on[A-Z]/,JT=(e={})=>{const{excludeListeners:t=!1,excludeKeys:n}=e,r=C(()=>((n==null?void 0:n.value)||[]).concat(GT)),o=Ge();return C(o?()=>{var s;return Oa(Object.entries((s=o.proxy)==null?void 0:s.$attrs).filter(([i])=>!r.value.includes(i)&&!(t&&YT.test(i))))}:()=>({}))},xs=({from:e,replacement:t,scope:n,version:r,ref:o,type:s="API"},i)=>{he(()=>g(i),a=>{},{immediate:!0})},zm=(e,t,n,r)=>{let o={offsetX:0,offsetY:0};const s=u=>{const c=u.clientX,f=u.clientY,{offsetX:d,offsetY:v}=o,p=e.value.getBoundingClientRect(),h=p.left,b=p.top,m=p.width,S=p.height,_=document.documentElement.clientWidth,w=document.documentElement.clientHeight,y=-h+d,A=-b+v,R=_-h-m+d,N=w-b-S+v,I=k=>{let $=d+k.clientX-c,F=v+k.clientY-f;r!=null&&r.value||($=Math.min(Math.max($,y),R),F=Math.min(Math.max(F,A),N)),o={offsetX:$,offsetY:F},e.value&&(e.value.style.transform=`translate(${pn($)}, ${pn(F)})`)},x=()=>{document.removeEventListener("mousemove",I),document.removeEventListener("mouseup",x)};document.addEventListener("mousemove",I),document.addEventListener("mouseup",x)},i=()=>{t.value&&e.value&&t.value.addEventListener("mousedown",s)},a=()=>{t.value&&e.value&&t.value.removeEventListener("mousedown",s)},l=()=>{o={offsetX:0,offsetY:0},e.value&&(e.value.style.transform="none")};return Ke(()=>{Ha(()=>{n.value?i():a()})}),St(()=>{a()}),{resetPosition:l}};var XT={name:"en",el:{breadcrumb:{label:"Breadcrumb"},colorpicker:{confirm:"OK",clear:"Clear",defaultLabel:"color picker",description:"current color is {color}. press enter to select a new color.",alphaLabel:"pick alpha value"},datepicker:{now:"Now",today:"Today",cancel:"Cancel",clear:"Clear",confirm:"OK",dateTablePrompt:"Use the arrow keys and enter to select the day of the month",monthTablePrompt:"Use the arrow keys and enter to select the month",yearTablePrompt:"Use the arrow keys and enter to select the year",selectedDate:"Selected date",selectDate:"Select date",selectTime:"Select time",startDate:"Start Date",startTime:"Start Time",endDate:"End Date",endTime:"End Time",prevYear:"Previous Year",nextYear:"Next Year",prevMonth:"Previous Month",nextMonth:"Next Month",year:"",month1:"January",month2:"February",month3:"March",month4:"April",month5:"May",month6:"June",month7:"July",month8:"August",month9:"September",month10:"October",month11:"November",month12:"December",week:"week",weeks:{sun:"Sun",mon:"Mon",tue:"Tue",wed:"Wed",thu:"Thu",fri:"Fri",sat:"Sat"},weeksFull:{sun:"Sunday",mon:"Monday",tue:"Tuesday",wed:"Wednesday",thu:"Thursday",fri:"Friday",sat:"Saturday"},months:{jan:"Jan",feb:"Feb",mar:"Mar",apr:"Apr",may:"May",jun:"Jun",jul:"Jul",aug:"Aug",sep:"Sep",oct:"Oct",nov:"Nov",dec:"Dec"}},inputNumber:{decrease:"decrease number",increase:"increase number"},select:{loading:"Loading",noMatch:"No matching data",noData:"No data",placeholder:"Select"},mention:{loading:"Loading"},dropdown:{toggleDropdown:"Toggle Dropdown"},cascader:{noMatch:"No matching data",loading:"Loading",placeholder:"Select",noData:"No data"},pagination:{goto:"Go to",pagesize:"/page",total:"Total {total}",pageClassifier:"",page:"Page",prev:"Go to previous page",next:"Go to next page",currentPage:"page {pager}",prevPages:"Previous {pager} pages",nextPages:"Next {pager} pages",deprecationWarning:"Deprecated usages detected, please refer to the el-pagination documentation for more details"},dialog:{close:"Close this dialog"},drawer:{close:"Close this dialog"},messagebox:{title:"Message",confirm:"OK",cancel:"Cancel",error:"Illegal input",close:"Close this dialog"},upload:{deleteTip:"press delete to remove",delete:"Delete",preview:"Preview",continue:"Continue"},slider:{defaultLabel:"slider between {min} and {max}",defaultRangeStartLabel:"pick start value",defaultRangeEndLabel:"pick end value"},table:{emptyText:"No Data",confirmFilter:"Confirm",resetFilter:"Reset",clearFilter:"All",sumText:"Sum"},tour:{next:"Next",previous:"Previous",finish:"Finish"},tree:{emptyText:"No Data"},transfer:{noMatch:"No matching data",noData:"No data",titles:["List 1","List 2"],filterPlaceholder:"Enter keyword",noCheckedFormat:"{total} items",hasCheckedFormat:"{checked}/{total} checked"},image:{error:"FAILED"},pageHeader:{title:"Back"},popconfirm:{confirmButtonText:"Yes",cancelButtonText:"No"},carousel:{leftArrow:"Carousel arrow left",rightArrow:"Carousel arrow right",indicator:"Carousel switch to index {index}"}}};const ZT=e=>(t,n)=>QT(t,n,g(e)),QT=(e,t,n)=>zn(n,e,e).replace(/\{(\w+)\}/g,(r,o)=>{var s;return`${(s=t==null?void 0:t[o])!=null?s:`{${o}}`}`}),eO=e=>({lang:C(()=>g(e).name),locale:Ue(e)?e:V(e),t:ZT(e)}),Hm=Symbol("localeContextKey"),sl=e=>{const t=e||Ee(Hm,V());return eO(C(()=>t.value||XT))},Is="el",tO="is-",Wr=(e,t,n,r,o)=>{let s=`${e}-${t}`;return n&&(s+=`-${n}`),r&&(s+=`__${r}`),o&&(s+=`--${o}`),s},Um=Symbol("namespaceContextKey"),Ac=e=>{const t=e||(Ge()?Ee(Um,V(Is)):V(Is));return C(()=>g(t)||Is)},$e=(e,t)=>{const n=Ac(t);return{namespace:n,b:(h="")=>Wr(n.value,e,h,"",""),e:h=>h?Wr(n.value,e,"",h,""):"",m:h=>h?Wr(n.value,e,"","",h):"",be:(h,b)=>h&&b?Wr(n.value,e,h,b,""):"",em:(h,b)=>h&&b?Wr(n.value,e,"",h,b):"",bm:(h,b)=>h&&b?Wr(n.value,e,h,"",b):"",bem:(h,b,m)=>h&&b&&m?Wr(n.value,e,h,b,m):"",is:(h,...b)=>{const m=b.length>=1?b[0]:!0;return h&&m?`${tO}${h}`:""},cssVar:h=>{const b={};for(const m in h)h[m]&&(b[`--${n.value}-${m}`]=h[m]);return b},cssVarName:h=>`--${n.value}-${h}`,cssVarBlock:h=>{const b={};for(const m in h)h[m]&&(b[`--${n.value}-${e}-${m}`]=h[m]);return b},cssVarBlockName:h=>`--${n.value}-${e}-${h}`}},Km=(e,t={})=>{Ue(e)||Br("[useLockscreen]","You need to pass a ref param to this function");const n=t.ns||$e("popup"),r=C(()=>n.bm("parent","hidden"));if(!st||Qd(document.body,r.value))return;let o=0,s=!1,i="0";const a=()=>{setTimeout(()=>{typeof document>"u"||(Gs(document==null?void 0:document.body,r.value),s&&document&&(document.body.style.width=i))},200)};he(e,l=>{if(!l){a();return}s=!Qd(document.body,r.value),s&&(i=document.body.style.width),o=cT(n.namespace.value);const u=document.documentElement.clientHeight0&&(u||c==="scroll")&&s&&(document.body.style.width=`calc(100% - ${o}px)`),Tu(document.body,r.value)}),Ba(()=>a())},nO=rl({type:we(Boolean),default:null}),rO=rl({type:we(Function)}),oO=e=>{const t=`update:${e}`,n=`onUpdate:${e}`,r=[t],o={[e]:nO,[n]:rO};return{useModelToggle:({indicator:i,toggleReason:a,shouldHideWhenRouteChanges:l,shouldProceed:u,onShow:c,onHide:f})=>{const d=Ge(),{emit:v}=d,p=d.props,h=C(()=>ve(p[n])),b=C(()=>p[e]===null),m=R=>{i.value!==!0&&(i.value=!0,a&&(a.value=R),ve(c)&&c(R))},S=R=>{i.value!==!1&&(i.value=!1,a&&(a.value=R),ve(f)&&f(R))},_=R=>{if(p.disabled===!0||ve(u)&&!u())return;const N=h.value&&st;N&&v(t,!0),(b.value||!N)&&m(R)},w=R=>{if(p.disabled===!0||!st)return;const N=h.value&&st;N&&v(t,!1),(b.value||!N)&&S(R)},y=R=>{Bt(R)&&(p.disabled&&R?h.value&&v(t,!1):i.value!==R&&(R?m():S()))},A=()=>{i.value?w():_()};return he(()=>p[e],y),l&&d.appContext.config.globalProperties.$route!==void 0&&he(()=>({...d.proxy.$route}),()=>{l.value&&i.value&&w()}),Ke(()=>{y(p[e])}),{hide:w,show:_,toggle:A,hasUpdateHandler:h}},useModelToggleProps:o,useModelToggleEmits:r}},qm=e=>{const t=Ge();return C(()=>{var n,r;return(r=(n=t==null?void 0:t.proxy)==null?void 0:n.$props)==null?void 0:r[e]})};var zt="top",hn="bottom",vn="right",Ht="left",Rc="auto",di=[zt,hn,vn,Ht],Uo="start",Xs="end",sO="clippingParents",Wm="viewport",hs="popper",iO="reference",ep=di.reduce(function(e,t){return e.concat([t+"-"+Uo,t+"-"+Xs])},[]),il=[].concat(di,[Rc]).reduce(function(e,t){return e.concat([t,t+"-"+Uo,t+"-"+Xs])},[]),aO="beforeRead",lO="read",uO="afterRead",cO="beforeMain",fO="main",dO="afterMain",pO="beforeWrite",hO="write",vO="afterWrite",mO=[aO,lO,uO,cO,fO,dO,pO,hO,vO];function Gn(e){return e?(e.nodeName||"").toLowerCase():null}function nn(e){if(e==null)return window;if(e.toString()!=="[object Window]"){var t=e.ownerDocument;return t&&t.defaultView||window}return e}function po(e){var t=nn(e).Element;return e instanceof t||e instanceof Element}function un(e){var t=nn(e).HTMLElement;return e instanceof t||e instanceof HTMLElement}function xc(e){if(typeof ShadowRoot>"u")return!1;var t=nn(e).ShadowRoot;return e instanceof t||e instanceof ShadowRoot}function gO(e){var t=e.state;Object.keys(t.elements).forEach(function(n){var r=t.styles[n]||{},o=t.attributes[n]||{},s=t.elements[n];!un(s)||!Gn(s)||(Object.assign(s.style,r),Object.keys(o).forEach(function(i){var a=o[i];a===!1?s.removeAttribute(i):s.setAttribute(i,a===!0?"":a)}))})}function bO(e){var t=e.state,n={popper:{position:t.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(t.elements.popper.style,n.popper),t.styles=n,t.elements.arrow&&Object.assign(t.elements.arrow.style,n.arrow),function(){Object.keys(t.elements).forEach(function(r){var o=t.elements[r],s=t.attributes[r]||{},i=Object.keys(t.styles.hasOwnProperty(r)?t.styles[r]:n[r]),a=i.reduce(function(l,u){return l[u]="",l},{});!un(o)||!Gn(o)||(Object.assign(o.style,a),Object.keys(s).forEach(function(l){o.removeAttribute(l)}))})}}var Gm={name:"applyStyles",enabled:!0,phase:"write",fn:gO,effect:bO,requires:["computeStyles"]};function Wn(e){return e.split("-")[0]}var ao=Math.max,xa=Math.min,Ko=Math.round;function Ou(){var e=navigator.userAgentData;return e!=null&&e.brands&&Array.isArray(e.brands)?e.brands.map(function(t){return t.brand+"/"+t.version}).join(" "):navigator.userAgent}function Ym(){return!/^((?!chrome|android).)*safari/i.test(Ou())}function qo(e,t,n){t===void 0&&(t=!1),n===void 0&&(n=!1);var r=e.getBoundingClientRect(),o=1,s=1;t&&un(e)&&(o=e.offsetWidth>0&&Ko(r.width)/e.offsetWidth||1,s=e.offsetHeight>0&&Ko(r.height)/e.offsetHeight||1);var i=po(e)?nn(e):window,a=i.visualViewport,l=!Ym()&&n,u=(r.left+(l&&a?a.offsetLeft:0))/o,c=(r.top+(l&&a?a.offsetTop:0))/s,f=r.width/o,d=r.height/s;return{width:f,height:d,top:c,right:u+f,bottom:c+d,left:u,x:u,y:c}}function Ic(e){var t=qo(e),n=e.offsetWidth,r=e.offsetHeight;return Math.abs(t.width-n)<=1&&(n=t.width),Math.abs(t.height-r)<=1&&(r=t.height),{x:e.offsetLeft,y:e.offsetTop,width:n,height:r}}function Jm(e,t){var n=t.getRootNode&&t.getRootNode();if(e.contains(t))return!0;if(n&&xc(n)){var r=t;do{if(r&&e.isSameNode(r))return!0;r=r.parentNode||r.host}while(r)}return!1}function dr(e){return nn(e).getComputedStyle(e)}function yO(e){return["table","td","th"].indexOf(Gn(e))>=0}function Dr(e){return((po(e)?e.ownerDocument:e.document)||window.document).documentElement}function al(e){return Gn(e)==="html"?e:e.assignedSlot||e.parentNode||(xc(e)?e.host:null)||Dr(e)}function tp(e){return!un(e)||dr(e).position==="fixed"?null:e.offsetParent}function wO(e){var t=/firefox/i.test(Ou()),n=/Trident/i.test(Ou());if(n&&un(e)){var r=dr(e);if(r.position==="fixed")return null}var o=al(e);for(xc(o)&&(o=o.host);un(o)&&["html","body"].indexOf(Gn(o))<0;){var s=dr(o);if(s.transform!=="none"||s.perspective!=="none"||s.contain==="paint"||["transform","perspective"].indexOf(s.willChange)!==-1||t&&s.willChange==="filter"||t&&s.filter&&s.filter!=="none")return o;o=o.parentNode}return null}function pi(e){for(var t=nn(e),n=tp(e);n&&yO(n)&&dr(n).position==="static";)n=tp(n);return n&&(Gn(n)==="html"||Gn(n)==="body"&&dr(n).position==="static")?t:n||wO(e)||t}function Pc(e){return["top","bottom"].indexOf(e)>=0?"x":"y"}function Ps(e,t,n){return ao(e,xa(t,n))}function SO(e,t,n){var r=Ps(e,t,n);return r>n?n:r}function Xm(){return{top:0,right:0,bottom:0,left:0}}function Zm(e){return Object.assign({},Xm(),e)}function Qm(e,t){return t.reduce(function(n,r){return n[r]=e,n},{})}var EO=function(e,t){return e=typeof e=="function"?e(Object.assign({},t.rects,{placement:t.placement})):e,Zm(typeof e!="number"?e:Qm(e,di))};function _O(e){var t,n=e.state,r=e.name,o=e.options,s=n.elements.arrow,i=n.modifiersData.popperOffsets,a=Wn(n.placement),l=Pc(a),u=[Ht,vn].indexOf(a)>=0,c=u?"height":"width";if(!(!s||!i)){var f=EO(o.padding,n),d=Ic(s),v=l==="y"?zt:Ht,p=l==="y"?hn:vn,h=n.rects.reference[c]+n.rects.reference[l]-i[l]-n.rects.popper[c],b=i[l]-n.rects.reference[l],m=pi(s),S=m?l==="y"?m.clientHeight||0:m.clientWidth||0:0,_=h/2-b/2,w=f[v],y=S-d[c]-f[p],A=S/2-d[c]/2+_,R=Ps(w,A,y),N=l;n.modifiersData[r]=(t={},t[N]=R,t.centerOffset=R-A,t)}}function CO(e){var t=e.state,n=e.options,r=n.element,o=r===void 0?"[data-popper-arrow]":r;o!=null&&(typeof o=="string"&&(o=t.elements.popper.querySelector(o),!o)||Jm(t.elements.popper,o)&&(t.elements.arrow=o))}var TO={name:"arrow",enabled:!0,phase:"main",fn:_O,effect:CO,requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function Wo(e){return e.split("-")[1]}var OO={top:"auto",right:"auto",bottom:"auto",left:"auto"};function AO(e,t){var n=e.x,r=e.y,o=t.devicePixelRatio||1;return{x:Ko(n*o)/o||0,y:Ko(r*o)/o||0}}function np(e){var t,n=e.popper,r=e.popperRect,o=e.placement,s=e.variation,i=e.offsets,a=e.position,l=e.gpuAcceleration,u=e.adaptive,c=e.roundOffsets,f=e.isFixed,d=i.x,v=d===void 0?0:d,p=i.y,h=p===void 0?0:p,b=typeof c=="function"?c({x:v,y:h}):{x:v,y:h};v=b.x,h=b.y;var m=i.hasOwnProperty("x"),S=i.hasOwnProperty("y"),_=Ht,w=zt,y=window;if(u){var A=pi(n),R="clientHeight",N="clientWidth";if(A===nn(n)&&(A=Dr(n),dr(A).position!=="static"&&a==="absolute"&&(R="scrollHeight",N="scrollWidth")),A=A,o===zt||(o===Ht||o===vn)&&s===Xs){w=hn;var I=f&&A===y&&y.visualViewport?y.visualViewport.height:A[R];h-=I-r.height,h*=l?1:-1}if(o===Ht||(o===zt||o===hn)&&s===Xs){_=vn;var x=f&&A===y&&y.visualViewport?y.visualViewport.width:A[N];v-=x-r.width,v*=l?1:-1}}var k=Object.assign({position:a},u&&OO),$=c===!0?AO({x:v,y:h},nn(n)):{x:v,y:h};if(v=$.x,h=$.y,l){var F;return Object.assign({},k,(F={},F[w]=S?"0":"",F[_]=m?"0":"",F.transform=(y.devicePixelRatio||1)<=1?"translate("+v+"px, "+h+"px)":"translate3d("+v+"px, "+h+"px, 0)",F))}return Object.assign({},k,(t={},t[w]=S?h+"px":"",t[_]=m?v+"px":"",t.transform="",t))}function RO(e){var t=e.state,n=e.options,r=n.gpuAcceleration,o=r===void 0?!0:r,s=n.adaptive,i=s===void 0?!0:s,a=n.roundOffsets,l=a===void 0?!0:a,u={placement:Wn(t.placement),variation:Wo(t.placement),popper:t.elements.popper,popperRect:t.rects.popper,gpuAcceleration:o,isFixed:t.options.strategy==="fixed"};t.modifiersData.popperOffsets!=null&&(t.styles.popper=Object.assign({},t.styles.popper,np(Object.assign({},u,{offsets:t.modifiersData.popperOffsets,position:t.options.strategy,adaptive:i,roundOffsets:l})))),t.modifiersData.arrow!=null&&(t.styles.arrow=Object.assign({},t.styles.arrow,np(Object.assign({},u,{offsets:t.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:l})))),t.attributes.popper=Object.assign({},t.attributes.popper,{"data-popper-placement":t.placement})}var eg={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:RO,data:{}},Ii={passive:!0};function xO(e){var t=e.state,n=e.instance,r=e.options,o=r.scroll,s=o===void 0?!0:o,i=r.resize,a=i===void 0?!0:i,l=nn(t.elements.popper),u=[].concat(t.scrollParents.reference,t.scrollParents.popper);return s&&u.forEach(function(c){c.addEventListener("scroll",n.update,Ii)}),a&&l.addEventListener("resize",n.update,Ii),function(){s&&u.forEach(function(c){c.removeEventListener("scroll",n.update,Ii)}),a&&l.removeEventListener("resize",n.update,Ii)}}var tg={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:xO,data:{}},IO={left:"right",right:"left",bottom:"top",top:"bottom"};function ea(e){return e.replace(/left|right|bottom|top/g,function(t){return IO[t]})}var PO={start:"end",end:"start"};function rp(e){return e.replace(/start|end/g,function(t){return PO[t]})}function Nc(e){var t=nn(e),n=t.pageXOffset,r=t.pageYOffset;return{scrollLeft:n,scrollTop:r}}function Lc(e){return qo(Dr(e)).left+Nc(e).scrollLeft}function NO(e,t){var n=nn(e),r=Dr(e),o=n.visualViewport,s=r.clientWidth,i=r.clientHeight,a=0,l=0;if(o){s=o.width,i=o.height;var u=Ym();(u||!u&&t==="fixed")&&(a=o.offsetLeft,l=o.offsetTop)}return{width:s,height:i,x:a+Lc(e),y:l}}function LO(e){var t,n=Dr(e),r=Nc(e),o=(t=e.ownerDocument)==null?void 0:t.body,s=ao(n.scrollWidth,n.clientWidth,o?o.scrollWidth:0,o?o.clientWidth:0),i=ao(n.scrollHeight,n.clientHeight,o?o.scrollHeight:0,o?o.clientHeight:0),a=-r.scrollLeft+Lc(e),l=-r.scrollTop;return dr(o||n).direction==="rtl"&&(a+=ao(n.clientWidth,o?o.clientWidth:0)-s),{width:s,height:i,x:a,y:l}}function Mc(e){var t=dr(e),n=t.overflow,r=t.overflowX,o=t.overflowY;return/auto|scroll|overlay|hidden/.test(n+o+r)}function ng(e){return["html","body","#document"].indexOf(Gn(e))>=0?e.ownerDocument.body:un(e)&&Mc(e)?e:ng(al(e))}function Ns(e,t){var n;t===void 0&&(t=[]);var r=ng(e),o=r===((n=e.ownerDocument)==null?void 0:n.body),s=nn(r),i=o?[s].concat(s.visualViewport||[],Mc(r)?r:[]):r,a=t.concat(i);return o?a:a.concat(Ns(al(i)))}function Au(e){return Object.assign({},e,{left:e.x,top:e.y,right:e.x+e.width,bottom:e.y+e.height})}function MO(e,t){var n=qo(e,!1,t==="fixed");return n.top=n.top+e.clientTop,n.left=n.left+e.clientLeft,n.bottom=n.top+e.clientHeight,n.right=n.left+e.clientWidth,n.width=e.clientWidth,n.height=e.clientHeight,n.x=n.left,n.y=n.top,n}function op(e,t,n){return t===Wm?Au(NO(e,n)):po(t)?MO(t,n):Au(LO(Dr(e)))}function $O(e){var t=Ns(al(e)),n=["absolute","fixed"].indexOf(dr(e).position)>=0,r=n&&un(e)?pi(e):e;return po(r)?t.filter(function(o){return po(o)&&Jm(o,r)&&Gn(o)!=="body"}):[]}function kO(e,t,n,r){var o=t==="clippingParents"?$O(e):[].concat(t),s=[].concat(o,[n]),i=s[0],a=s.reduce(function(l,u){var c=op(e,u,r);return l.top=ao(c.top,l.top),l.right=xa(c.right,l.right),l.bottom=xa(c.bottom,l.bottom),l.left=ao(c.left,l.left),l},op(e,i,r));return a.width=a.right-a.left,a.height=a.bottom-a.top,a.x=a.left,a.y=a.top,a}function rg(e){var t=e.reference,n=e.element,r=e.placement,o=r?Wn(r):null,s=r?Wo(r):null,i=t.x+t.width/2-n.width/2,a=t.y+t.height/2-n.height/2,l;switch(o){case zt:l={x:i,y:t.y-n.height};break;case hn:l={x:i,y:t.y+t.height};break;case vn:l={x:t.x+t.width,y:a};break;case Ht:l={x:t.x-n.width,y:a};break;default:l={x:t.x,y:t.y}}var u=o?Pc(o):null;if(u!=null){var c=u==="y"?"height":"width";switch(s){case Uo:l[u]=l[u]-(t[c]/2-n[c]/2);break;case Xs:l[u]=l[u]+(t[c]/2-n[c]/2);break}}return l}function Zs(e,t){t===void 0&&(t={});var n=t,r=n.placement,o=r===void 0?e.placement:r,s=n.strategy,i=s===void 0?e.strategy:s,a=n.boundary,l=a===void 0?sO:a,u=n.rootBoundary,c=u===void 0?Wm:u,f=n.elementContext,d=f===void 0?hs:f,v=n.altBoundary,p=v===void 0?!1:v,h=n.padding,b=h===void 0?0:h,m=Zm(typeof b!="number"?b:Qm(b,di)),S=d===hs?iO:hs,_=e.rects.popper,w=e.elements[p?S:d],y=kO(po(w)?w:w.contextElement||Dr(e.elements.popper),l,c,i),A=qo(e.elements.reference),R=rg({reference:A,element:_,placement:o}),N=Au(Object.assign({},_,R)),I=d===hs?N:A,x={top:y.top-I.top+m.top,bottom:I.bottom-y.bottom+m.bottom,left:y.left-I.left+m.left,right:I.right-y.right+m.right},k=e.modifiersData.offset;if(d===hs&&k){var $=k[o];Object.keys(x).forEach(function(F){var Y=[vn,hn].indexOf(F)>=0?1:-1,P=[zt,hn].indexOf(F)>=0?"y":"x";x[F]+=$[P]*Y})}return x}function FO(e,t){t===void 0&&(t={});var n=t,r=n.placement,o=n.boundary,s=n.rootBoundary,i=n.padding,a=n.flipVariations,l=n.allowedAutoPlacements,u=l===void 0?il:l,c=Wo(r),f=c?a?ep:ep.filter(function(p){return Wo(p)===c}):di,d=f.filter(function(p){return u.indexOf(p)>=0});d.length===0&&(d=f);var v=d.reduce(function(p,h){return p[h]=Zs(e,{placement:h,boundary:o,rootBoundary:s,padding:i})[Wn(h)],p},{});return Object.keys(v).sort(function(p,h){return v[p]-v[h]})}function BO(e){if(Wn(e)===Rc)return[];var t=ea(e);return[rp(e),t,rp(t)]}function DO(e){var t=e.state,n=e.options,r=e.name;if(!t.modifiersData[r]._skip){for(var o=n.mainAxis,s=o===void 0?!0:o,i=n.altAxis,a=i===void 0?!0:i,l=n.fallbackPlacements,u=n.padding,c=n.boundary,f=n.rootBoundary,d=n.altBoundary,v=n.flipVariations,p=v===void 0?!0:v,h=n.allowedAutoPlacements,b=t.options.placement,m=Wn(b),S=m===b,_=l||(S||!p?[ea(b)]:BO(b)),w=[b].concat(_).reduce(function(qe,ze){return qe.concat(Wn(ze)===Rc?FO(t,{placement:ze,boundary:c,rootBoundary:f,padding:u,flipVariations:p,allowedAutoPlacements:h}):ze)},[]),y=t.rects.reference,A=t.rects.popper,R=new Map,N=!0,I=w[0],x=0;x=0,P=Y?"width":"height",O=Zs(t,{placement:k,boundary:c,rootBoundary:f,altBoundary:d,padding:u}),j=Y?F?vn:Ht:F?hn:zt;y[P]>A[P]&&(j=ea(j));var Q=ea(j),me=[];if(s&&me.push(O[$]<=0),a&&me.push(O[j]<=0,O[Q]<=0),me.every(function(qe){return qe})){I=k,N=!1;break}R.set(k,me)}if(N)for(var Pe=p?3:1,Re=function(qe){var ze=w.find(function(De){var B=R.get(De);if(B)return B.slice(0,qe).every(function(K){return K})});if(ze)return I=ze,"break"},Ce=Pe;Ce>0;Ce--){var _e=Re(Ce);if(_e==="break")break}t.placement!==I&&(t.modifiersData[r]._skip=!0,t.placement=I,t.reset=!0)}}var VO={name:"flip",enabled:!0,phase:"main",fn:DO,requiresIfExists:["offset"],data:{_skip:!1}};function sp(e,t,n){return n===void 0&&(n={x:0,y:0}),{top:e.top-t.height-n.y,right:e.right-t.width+n.x,bottom:e.bottom-t.height+n.y,left:e.left-t.width-n.x}}function ip(e){return[zt,vn,hn,Ht].some(function(t){return e[t]>=0})}function jO(e){var t=e.state,n=e.name,r=t.rects.reference,o=t.rects.popper,s=t.modifiersData.preventOverflow,i=Zs(t,{elementContext:"reference"}),a=Zs(t,{altBoundary:!0}),l=sp(i,r),u=sp(a,o,s),c=ip(l),f=ip(u);t.modifiersData[n]={referenceClippingOffsets:l,popperEscapeOffsets:u,isReferenceHidden:c,hasPopperEscaped:f},t.attributes.popper=Object.assign({},t.attributes.popper,{"data-popper-reference-hidden":c,"data-popper-escaped":f})}var zO={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:jO};function HO(e,t,n){var r=Wn(e),o=[Ht,zt].indexOf(r)>=0?-1:1,s=typeof n=="function"?n(Object.assign({},t,{placement:e})):n,i=s[0],a=s[1];return i=i||0,a=(a||0)*o,[Ht,vn].indexOf(r)>=0?{x:a,y:i}:{x:i,y:a}}function UO(e){var t=e.state,n=e.options,r=e.name,o=n.offset,s=o===void 0?[0,0]:o,i=il.reduce(function(c,f){return c[f]=HO(f,t.rects,s),c},{}),a=i[t.placement],l=a.x,u=a.y;t.modifiersData.popperOffsets!=null&&(t.modifiersData.popperOffsets.x+=l,t.modifiersData.popperOffsets.y+=u),t.modifiersData[r]=i}var KO={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:UO};function qO(e){var t=e.state,n=e.name;t.modifiersData[n]=rg({reference:t.rects.reference,element:t.rects.popper,placement:t.placement})}var og={name:"popperOffsets",enabled:!0,phase:"read",fn:qO,data:{}};function WO(e){return e==="x"?"y":"x"}function GO(e){var t=e.state,n=e.options,r=e.name,o=n.mainAxis,s=o===void 0?!0:o,i=n.altAxis,a=i===void 0?!1:i,l=n.boundary,u=n.rootBoundary,c=n.altBoundary,f=n.padding,d=n.tether,v=d===void 0?!0:d,p=n.tetherOffset,h=p===void 0?0:p,b=Zs(t,{boundary:l,rootBoundary:u,padding:f,altBoundary:c}),m=Wn(t.placement),S=Wo(t.placement),_=!S,w=Pc(m),y=WO(w),A=t.modifiersData.popperOffsets,R=t.rects.reference,N=t.rects.popper,I=typeof h=="function"?h(Object.assign({},t.rects,{placement:t.placement})):h,x=typeof I=="number"?{mainAxis:I,altAxis:I}:Object.assign({mainAxis:0,altAxis:0},I),k=t.modifiersData.offset?t.modifiersData.offset[t.placement]:null,$={x:0,y:0};if(A){if(s){var F,Y=w==="y"?zt:Ht,P=w==="y"?hn:vn,O=w==="y"?"height":"width",j=A[w],Q=j+b[Y],me=j-b[P],Pe=v?-N[O]/2:0,Re=S===Uo?R[O]:N[O],Ce=S===Uo?-N[O]:-R[O],_e=t.elements.arrow,qe=v&&_e?Ic(_e):{width:0,height:0},ze=t.modifiersData["arrow#persistent"]?t.modifiersData["arrow#persistent"].padding:Xm(),De=ze[Y],B=ze[P],K=Ps(0,R[O],qe[O]),J=_?R[O]/2-Pe-K-De-x.mainAxis:Re-K-De-x.mainAxis,oe=_?-R[O]/2+Pe+K+B+x.mainAxis:Ce+K+B+x.mainAxis,ge=t.elements.arrow&&pi(t.elements.arrow),E=ge?w==="y"?ge.clientTop||0:ge.clientLeft||0:0,T=(F=k==null?void 0:k[w])!=null?F:0,M=j+J-T-E,W=j+oe-T,G=Ps(v?xa(Q,M):Q,j,v?ao(me,W):me);A[w]=G,$[w]=G-j}if(a){var q,se=w==="x"?zt:Ht,ne=w==="x"?hn:vn,te=A[y],X=y==="y"?"height":"width",Se=te+b[se],ue=te-b[ne],ye=[zt,Ht].indexOf(m)!==-1,z=(q=k==null?void 0:k[y])!=null?q:0,be=ye?Se:te-R[X]-N[X]-z+x.altAxis,Le=ye?te+R[X]+N[X]-z-x.altAxis:ue,ke=v&&ye?SO(be,te,Le):Ps(v?be:Se,te,v?Le:ue);A[y]=ke,$[y]=ke-te}t.modifiersData[r]=$}}var YO={name:"preventOverflow",enabled:!0,phase:"main",fn:GO,requiresIfExists:["offset"]};function JO(e){return{scrollLeft:e.scrollLeft,scrollTop:e.scrollTop}}function XO(e){return e===nn(e)||!un(e)?Nc(e):JO(e)}function ZO(e){var t=e.getBoundingClientRect(),n=Ko(t.width)/e.offsetWidth||1,r=Ko(t.height)/e.offsetHeight||1;return n!==1||r!==1}function QO(e,t,n){n===void 0&&(n=!1);var r=un(t),o=un(t)&&ZO(t),s=Dr(t),i=qo(e,o,n),a={scrollLeft:0,scrollTop:0},l={x:0,y:0};return(r||!r&&!n)&&((Gn(t)!=="body"||Mc(s))&&(a=XO(t)),un(t)?(l=qo(t,!0),l.x+=t.clientLeft,l.y+=t.clientTop):s&&(l.x=Lc(s))),{x:i.left+a.scrollLeft-l.x,y:i.top+a.scrollTop-l.y,width:i.width,height:i.height}}function eA(e){var t=new Map,n=new Set,r=[];e.forEach(function(s){t.set(s.name,s)});function o(s){n.add(s.name);var i=[].concat(s.requires||[],s.requiresIfExists||[]);i.forEach(function(a){if(!n.has(a)){var l=t.get(a);l&&o(l)}}),r.push(s)}return e.forEach(function(s){n.has(s.name)||o(s)}),r}function tA(e){var t=eA(e);return mO.reduce(function(n,r){return n.concat(t.filter(function(o){return o.phase===r}))},[])}function nA(e){var t;return function(){return t||(t=new Promise(function(n){Promise.resolve().then(function(){t=void 0,n(e())})})),t}}function rA(e){var t=e.reduce(function(n,r){var o=n[r.name];return n[r.name]=o?Object.assign({},o,r,{options:Object.assign({},o.options,r.options),data:Object.assign({},o.data,r.data)}):r,n},{});return Object.keys(t).map(function(n){return t[n]})}var ap={placement:"bottom",modifiers:[],strategy:"absolute"};function lp(){for(var e=arguments.length,t=new Array(e),n=0;n{const r={name:"updateState",enabled:!0,phase:"write",fn:({state:l})=>{const u=lA(l);Object.assign(i.value,u)},requires:["computeStyles"]},o=C(()=>{const{onFirstUpdate:l,placement:u,strategy:c,modifiers:f}=g(n);return{onFirstUpdate:l,placement:u||"bottom",strategy:c||"absolute",modifiers:[...f||[],r,{name:"applyStyles",enabled:!1}]}}),s=ir(),i=V({styles:{popper:{position:g(o).strategy,left:"0",top:"0"},arrow:{position:"absolute"}},attributes:{}}),a=()=>{s.value&&(s.value.destroy(),s.value=void 0)};return he(o,l=>{const u=g(s);u&&u.setOptions(l)},{deep:!0}),he([e,t],([l,u])=>{a(),!(!l||!u)&&(s.value=iA(l,u,g(o)))}),St(()=>{a()}),{state:C(()=>{var l;return{...((l=g(s))==null?void 0:l.state)||{}}}),styles:C(()=>g(i).styles),attributes:C(()=>g(i).attributes),update:()=>{var l;return(l=g(s))==null?void 0:l.update()},forceUpdate:()=>{var l;return(l=g(s))==null?void 0:l.forceUpdate()},instanceRef:C(()=>g(s))}};function lA(e){const t=Object.keys(e.elements),n=Oa(t.map(o=>[o,e.styles[o]||{}])),r=Oa(t.map(o=>[o,e.attributes[o]]));return{styles:n,attributes:r}}const kc=e=>{if(!e)return{onClick:ht,onMousedown:ht,onMouseup:ht};let t=!1,n=!1;return{onClick:i=>{t&&n&&e(i),t=n=!1},onMousedown:i=>{t=i.target===i.currentTarget},onMouseup:i=>{n=i.target===i.currentTarget}}};function up(){let e;const t=(r,o)=>{n(),e=window.setTimeout(r,o)},n=()=>window.clearTimeout(e);return ui(()=>n()),{registerTimeout:t,cancelTimeout:n}}const cp={prefix:Math.floor(Math.random()*1e4),current:0},uA=Symbol("elIdInjection"),sg=()=>Ge()?Ee(uA,cp):cp,pr=e=>{const t=sg(),n=Ac();return C(()=>g(e)||`${n.value}-id-${t.prefix}-${t.current++}`)};let No=[];const fp=e=>{const t=e;t.key===_n.esc&&No.forEach(n=>n(t))},cA=e=>{Ke(()=>{No.length===0&&document.addEventListener("keydown",fp),st&&No.push(e)}),St(()=>{No=No.filter(t=>t!==e),No.length===0&&st&&document.removeEventListener("keydown",fp)})},ig=()=>{const e=Ac(),t=sg(),n=C(()=>`${e.value}-popper-container-${t.prefix}`),r=C(()=>`#${n.value}`);return{id:n,selector:r}},fA=e=>{const t=document.createElement("div");return t.id=e,document.body.appendChild(t),t},dA=()=>{const{id:e,selector:t}=ig();return sc(()=>{st&&(document.body.querySelector(t.value)||fA(e.value))}),{id:e,selector:t}},pA=xe({showAfter:{type:Number,default:0},hideAfter:{type:Number,default:200},autoClose:{type:Number,default:0}}),hA=({showAfter:e,hideAfter:t,autoClose:n,open:r,close:o})=>{const{registerTimeout:s}=up(),{registerTimeout:i,cancelTimeout:a}=up();return{onOpen:c=>{s(()=>{r(c);const f=g(n);je(f)&&f>0&&i(()=>{o(c)},f)},g(e))},onClose:c=>{a(),s(()=>{o(c)},g(t))}}},ag=Symbol("elForwardRef"),vA=e=>{ft(ag,{setForwardRef:n=>{e.value=n}})},mA=e=>({mounted(t){e(t)},updated(t){e(t)},unmounted(){e(null)}}),dp={current:0},pp=V(0),lg=2e3,hp=Symbol("elZIndexContextKey"),ug=Symbol("zIndexContextKey"),Fc=e=>{const t=Ge()?Ee(hp,dp):dp,n=e||(Ge()?Ee(ug,void 0):void 0),r=C(()=>{const i=g(n);return je(i)?i:lg}),o=C(()=>r.value+pp.value),s=()=>(t.current++,pp.value=t.current,o.value);return!st&&Ee(hp),{initialZIndex:r,currentZIndex:o,nextZIndex:s}};function gA(e){let t;function n(){if(e.value==null)return;const{selectionStart:o,selectionEnd:s,value:i}=e.value;if(o==null||s==null)return;const a=i.slice(0,Math.max(0,o)),l=i.slice(Math.max(0,s));t={selectionStart:o,selectionEnd:s,value:i,beforeTxt:a,afterTxt:l}}function r(){if(e.value==null||t==null)return;const{value:o}=e.value,{beforeTxt:s,afterTxt:i,selectionStart:a}=t;if(s==null||i==null||a==null)return;let l=o.length;if(o.endsWith(i))l=o.length-i.length;else if(o.startsWith(s))l=s.length;else{const u=s[a-1],c=o.indexOf(u,a-1);c!==-1&&(l=c+1)}e.value.setSelectionRange(l,l)}return[n,r]}const bA=(e,t,n)=>Qi(e.subTree).filter(s=>{var i;return cn(s)&&((i=s.type)==null?void 0:i.name)===t&&!!s.component}).map(s=>s.component.uid).map(s=>n[s]).filter(s=>!!s),yA=(e,t)=>{const n={},r=ir([]);return{children:r,addChild:i=>{n[i.uid]=i,r.value=bA(e,t,n)},removeChild:i=>{delete n[i],r.value=r.value.filter(a=>a.uid!==i)}}},So=rl({type:String,values:es,required:!1}),cg=Symbol("size"),wA=()=>{const e=Ee(cg,{});return C(()=>g(e.size)||"")};function fg(e,{beforeFocus:t,afterFocus:n,beforeBlur:r,afterBlur:o}={}){const s=Ge(),{emit:i}=s,a=ir(),l=V(!1),u=d=>{ve(t)&&t(d)||l.value||(l.value=!0,i("focus",d),n==null||n())},c=d=>{var v;ve(r)&&r(d)||d.relatedTarget&&((v=a.value)!=null&&v.contains(d.relatedTarget))||(l.value=!1,i("blur",d),o==null||o())},f=()=>{var d,v;(d=a.value)!=null&&d.contains(document.activeElement)&&a.value!==document.activeElement||(v=e.value)==null||v.focus()};return he(a,d=>{d&&d.setAttribute("tabindex","-1")}),tn(a,"focus",u,!0),tn(a,"blur",c,!0),tn(a,"click",f,!0),{isFocused:l,wrapperRef:a,handleFocus:u,handleBlur:c}}function dg({afterComposition:e,emit:t}){const n=V(!1),r=a=>{t==null||t("compositionstart",a),n.value=!0},o=a=>{var l;t==null||t("compositionupdate",a);const u=(l=a.target)==null?void 0:l.value,c=u[u.length-1]||"";n.value=!WT(c)},s=a=>{t==null||t("compositionend",a),n.value&&(n.value=!1,Ie(()=>e(a)))};return{isComposing:n,handleComposition:a=>{a.type==="compositionend"?s(a):o(a)},handleCompositionStart:r,handleCompositionUpdate:o,handleCompositionEnd:s}}const pg=Symbol("emptyValuesContextKey"),SA=["",void 0,null],EA=void 0,hg=xe({emptyValues:Array,valueOnClear:{type:[String,Number,Boolean,Function],default:void 0,validator:e=>ve(e)?!e():!e}}),_A=(e,t)=>{const n=Ge()?Ee(pg,V({})):V({}),r=C(()=>e.emptyValues||n.value.emptyValues||SA),o=C(()=>ve(e.valueOnClear)?e.valueOnClear():e.valueOnClear!==void 0?e.valueOnClear:ve(n.value.valueOnClear)?n.value.valueOnClear():n.value.valueOnClear!==void 0?n.value.valueOnClear:EA),s=i=>r.value.includes(i);return r.value.includes(o.value),{emptyValues:r,valueOnClear:o,isEmptyValue:s}},CA=xe({ariaLabel:String,ariaOrientation:{type:String,values:["horizontal","vertical","undefined"]},ariaControls:String}),yr=e=>Im(CA,e),vg=Symbol(),Ia=V();function ll(e,t=void 0){const n=Ge()?Ee(vg,Ia):Ia;return e?C(()=>{var r,o;return(o=(r=n.value)==null?void 0:r[e])!=null?o:t}):n}function Bc(e,t){const n=ll(),r=$e(e,C(()=>{var a;return((a=n.value)==null?void 0:a.namespace)||Is})),o=sl(C(()=>{var a;return(a=n.value)==null?void 0:a.locale})),s=Fc(C(()=>{var a;return((a=n.value)==null?void 0:a.zIndex)||lg})),i=C(()=>{var a;return g(t)||((a=n.value)==null?void 0:a.size)||""});return TA(C(()=>g(n)||{})),{ns:r,locale:o,zIndex:s,size:i}}const TA=(e,t,n=!1)=>{var r;const o=!!Ge(),s=o?ll():void 0,i=(r=void 0)!=null?r:o?ft:void 0;if(!i)return;const a=C(()=>{const l=g(e);return s!=null&&s.value?OA(s.value,l):l});return i(vg,a),i(Hm,C(()=>a.value.locale)),i(Um,C(()=>a.value.namespace)),i(ug,C(()=>a.value.zIndex)),i(cg,{size:C(()=>a.value.size||"")}),i(pg,C(()=>({emptyValues:a.value.emptyValues,valueOnClear:a.value.valueOnClear}))),(n||!Ia.value)&&(Ia.value=a.value),a},OA=(e,t)=>{const n=[...new Set([...Zd(e),...Zd(t)])],r={};for(const o of n)r[o]=t[o]!==void 0?t[o]:e[o];return r};xe({a11y:{type:Boolean,default:!0},locale:{type:we(Object)},size:So,button:{type:we(Object)},experimentalFeatures:{type:we(Object)},keyboardNavigation:{type:Boolean,default:!0},message:{type:we(Object)},zIndex:Number,namespace:{type:String,default:"el"},...hg});const Fn={};var Ne=(e,t)=>{const n=e.__vccOpts||e;for(const[r,o]of t)n[r]=o;return n};const AA=xe({size:{type:we([Number,String])},color:{type:String}}),RA=Z({name:"ElIcon",inheritAttrs:!1}),xA=Z({...RA,props:AA,setup(e){const t=e,n=$e("icon"),r=C(()=>{const{size:o,color:s}=t;return!o&&!s?{}:{fontSize:Lt(o)?void 0:pn(o),"--color":s}});return(o,s)=>(L(),ee("i",En({class:g(n).b(),style:g(r)},o.$attrs),[de(o.$slots,"default")],16))}});var IA=Ne(xA,[["__file","icon.vue"]]);const Je=yt(IA),ts=Symbol("formContextKey"),ho=Symbol("formItemContextKey"),In=(e,t={})=>{const n=V(void 0),r=t.prop?n:qm("size"),o=t.global?n:wA(),s=t.form?{size:void 0}:Ee(ts,void 0),i=t.formItem?{size:void 0}:Ee(ho,void 0);return C(()=>r.value||g(e)||(i==null?void 0:i.size)||(s==null?void 0:s.size)||o.value||"")},ns=e=>{const t=qm("disabled"),n=Ee(ts,void 0);return C(()=>t.value||g(e)||(n==null?void 0:n.disabled)||!1)},Vr=()=>{const e=Ee(ts,void 0),t=Ee(ho,void 0);return{form:e,formItem:t}},hi=(e,{formItemContext:t,disableIdGeneration:n,disableIdManagement:r})=>{n||(n=V(!1)),r||(r=V(!1));const o=V();let s;const i=C(()=>{var a;return!!(!(e.label||e.ariaLabel)&&t&&t.inputIds&&((a=t.inputIds)==null?void 0:a.length)<=1)});return Ke(()=>{s=he([Jt(e,"id"),n],([a,l])=>{const u=a??(l?void 0:pr().value);u!==o.value&&(t!=null&&t.removeInputId&&(o.value&&t.removeInputId(o.value),!(r!=null&&r.value)&&!l&&u&&t.addInputId(u)),o.value=u)},{immediate:!0})}),kr(()=>{s&&s(),t!=null&&t.removeInputId&&o.value&&t.removeInputId(o.value)}),{isLabeledByFormItem:i,inputId:o}},PA=xe({size:{type:String,values:es},disabled:Boolean}),NA=xe({...PA,model:Object,rules:{type:we(Object)},labelPosition:{type:String,values:["left","right","top"],default:"right"},requireAsteriskPosition:{type:String,values:["left","right"],default:"left"},labelWidth:{type:[String,Number],default:""},labelSuffix:{type:String,default:""},inline:Boolean,inlineMessage:Boolean,statusIcon:Boolean,showMessage:{type:Boolean,default:!0},validateOnRuleChange:{type:Boolean,default:!0},hideRequiredAsterisk:Boolean,scrollToError:Boolean,scrollIntoViewOptions:{type:[Object,Boolean]}}),LA={validate:(e,t,n)=>(pe(e)||Ae(e))&&Bt(t)&&Ae(n)};function MA(){const e=V([]),t=C(()=>{if(!e.value.length)return"0";const s=Math.max(...e.value);return s?`${s}px`:""});function n(s){const i=e.value.indexOf(s);return i===-1&&t.value,i}function r(s,i){if(s&&i){const a=n(i);e.value.splice(a,1,s)}else s&&e.value.push(s)}function o(s){const i=n(s);i>-1&&e.value.splice(i,1)}return{autoLabelWidth:t,registerLabelWidth:r,deregisterLabelWidth:o}}const Pi=(e,t)=>{const n=yn(t);return n.length>0?e.filter(r=>r.prop&&n.includes(r.prop)):e},$A="ElForm",kA=Z({name:$A}),FA=Z({...kA,props:NA,emits:LA,setup(e,{expose:t,emit:n}){const r=e,o=[],s=In(),i=$e("form"),a=C(()=>{const{labelPosition:_,inline:w}=r;return[i.b(),i.m(s.value||"default"),{[i.m(`label-${_}`)]:_,[i.m("inline")]:w}]}),l=_=>o.find(w=>w.prop===_),u=_=>{o.push(_)},c=_=>{_.prop&&o.splice(o.indexOf(_),1)},f=(_=[])=>{r.model&&Pi(o,_).forEach(w=>w.resetField())},d=(_=[])=>{Pi(o,_).forEach(w=>w.clearValidate())},v=C(()=>!!r.model),p=_=>{if(o.length===0)return[];const w=Pi(o,_);return w.length?w:[]},h=async _=>m(void 0,_),b=async(_=[])=>{if(!v.value)return!1;const w=p(_);if(w.length===0)return!0;let y={};for(const A of w)try{await A.validate("")}catch(R){y={...y,...R}}return Object.keys(y).length===0?!0:Promise.reject(y)},m=async(_=[],w)=>{const y=!ve(w);try{const A=await b(_);return A===!0&&await(w==null?void 0:w(A)),A}catch(A){if(A instanceof Error)throw A;const R=A;return r.scrollToError&&S(Object.keys(R)[0]),await(w==null?void 0:w(!1,R)),y&&Promise.reject(R)}},S=_=>{var w;const y=Pi(o,_)[0];y&&((w=y.$el)==null||w.scrollIntoView(r.scrollIntoViewOptions))};return he(()=>r.rules,()=>{r.validateOnRuleChange&&h().catch(_=>void 0)},{deep:!0}),ft(ts,wt({...vr(r),emit:n,resetFields:f,clearValidate:d,validateField:m,getField:l,addField:u,removeField:c,...MA()})),t({validate:h,validateField:m,resetFields:f,clearValidate:d,scrollToField:S,fields:o}),(_,w)=>(L(),ee("form",{class:U(g(a))},[de(_.$slots,"default")],2))}});var BA=Ne(FA,[["__file","form.vue"]]);function eo(){return eo=Object.assign?Object.assign.bind():function(e){for(var t=1;t"u"||!Reflect.construct||Reflect.construct.sham)return!1;if(typeof Proxy=="function")return!0;try{return Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],function(){})),!0}catch{return!1}}function ta(e,t,n){return VA()?ta=Reflect.construct.bind():ta=function(o,s,i){var a=[null];a.push.apply(a,s);var l=Function.bind.apply(o,a),u=new l;return i&&Qs(u,i.prototype),u},ta.apply(null,arguments)}function jA(e){return Function.toString.call(e).indexOf("[native code]")!==-1}function xu(e){var t=typeof Map=="function"?new Map:void 0;return xu=function(r){if(r===null||!jA(r))return r;if(typeof r!="function")throw new TypeError("Super expression must either be null or a function");if(typeof t<"u"){if(t.has(r))return t.get(r);t.set(r,o)}function o(){return ta(r,arguments,Ru(this).constructor)}return o.prototype=Object.create(r.prototype,{constructor:{value:o,enumerable:!1,writable:!0,configurable:!0}}),Qs(o,r)},xu(e)}var zA=/%[sdj%]/g,HA=function(){};function Iu(e){if(!e||!e.length)return null;var t={};return e.forEach(function(n){var r=n.field;t[r]=t[r]||[],t[r].push(n)}),t}function Zt(e){for(var t=arguments.length,n=new Array(t>1?t-1:0),r=1;r=s)return a;switch(a){case"%s":return String(n[o++]);case"%d":return Number(n[o++]);case"%j":try{return JSON.stringify(n[o++])}catch{return"[Circular]"}break;default:return a}});return i}return e}function UA(e){return e==="string"||e==="url"||e==="hex"||e==="email"||e==="date"||e==="pattern"}function bt(e,t){return!!(e==null||t==="array"&&Array.isArray(e)&&!e.length||UA(t)&&typeof e=="string"&&!e)}function KA(e,t,n){var r=[],o=0,s=e.length;function i(a){r.push.apply(r,a||[]),o++,o===s&&n(r)}e.forEach(function(a){t(a,i)})}function vp(e,t,n){var r=0,o=e.length;function s(i){if(i&&i.length){n(i);return}var a=r;r=r+1,a()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]+\.)+[a-zA-Z\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]{2,}))$/,hex:/^#?([a-f0-9]{6}|[a-f0-9]{3})$/i},bs={integer:function(t){return bs.number(t)&&parseInt(t,10)===t},float:function(t){return bs.number(t)&&!bs.integer(t)},array:function(t){return Array.isArray(t)},regexp:function(t){if(t instanceof RegExp)return!0;try{return!!new RegExp(t)}catch{return!1}},date:function(t){return typeof t.getTime=="function"&&typeof t.getMonth=="function"&&typeof t.getYear=="function"&&!isNaN(t.getTime())},number:function(t){return isNaN(t)?!1:typeof t=="number"},object:function(t){return typeof t=="object"&&!bs.array(t)},method:function(t){return typeof t=="function"},email:function(t){return typeof t=="string"&&t.length<=320&&!!t.match(yp.email)},url:function(t){return typeof t=="string"&&t.length<=2048&&!!t.match(XA())},hex:function(t){return typeof t=="string"&&!!t.match(yp.hex)}},ZA=function(t,n,r,o,s){if(t.required&&n===void 0){mg(t,n,r,o,s);return}var i=["integer","float","array","regexp","object","method","email","number","date","url","hex"],a=t.type;i.indexOf(a)>-1?bs[a](n)||o.push(Zt(s.messages.types[a],t.fullField,t.type)):a&&typeof n!==t.type&&o.push(Zt(s.messages.types[a],t.fullField,t.type))},QA=function(t,n,r,o,s){var i=typeof t.len=="number",a=typeof t.min=="number",l=typeof t.max=="number",u=/[\uD800-\uDBFF][\uDC00-\uDFFF]/g,c=n,f=null,d=typeof n=="number",v=typeof n=="string",p=Array.isArray(n);if(d?f="number":v?f="string":p&&(f="array"),!f)return!1;p&&(c=n.length),v&&(c=n.replace(u,"_").length),i?c!==t.len&&o.push(Zt(s.messages[f].len,t.fullField,t.len)):a&&!l&&ct.max?o.push(Zt(s.messages[f].max,t.fullField,t.max)):a&&l&&(ct.max)&&o.push(Zt(s.messages[f].range,t.fullField,t.min,t.max))},Co="enum",e4=function(t,n,r,o,s){t[Co]=Array.isArray(t[Co])?t[Co]:[],t[Co].indexOf(n)===-1&&o.push(Zt(s.messages[Co],t.fullField,t[Co].join(", ")))},t4=function(t,n,r,o,s){if(t.pattern){if(t.pattern instanceof RegExp)t.pattern.lastIndex=0,t.pattern.test(n)||o.push(Zt(s.messages.pattern.mismatch,t.fullField,n,t.pattern));else if(typeof t.pattern=="string"){var i=new RegExp(t.pattern);i.test(n)||o.push(Zt(s.messages.pattern.mismatch,t.fullField,n,t.pattern))}}},Be={required:mg,whitespace:JA,type:ZA,range:QA,enum:e4,pattern:t4},n4=function(t,n,r,o,s){var i=[],a=t.required||!t.required&&o.hasOwnProperty(t.field);if(a){if(bt(n,"string")&&!t.required)return r();Be.required(t,n,o,i,s,"string"),bt(n,"string")||(Be.type(t,n,o,i,s),Be.range(t,n,o,i,s),Be.pattern(t,n,o,i,s),t.whitespace===!0&&Be.whitespace(t,n,o,i,s))}r(i)},r4=function(t,n,r,o,s){var i=[],a=t.required||!t.required&&o.hasOwnProperty(t.field);if(a){if(bt(n)&&!t.required)return r();Be.required(t,n,o,i,s),n!==void 0&&Be.type(t,n,o,i,s)}r(i)},o4=function(t,n,r,o,s){var i=[],a=t.required||!t.required&&o.hasOwnProperty(t.field);if(a){if(n===""&&(n=void 0),bt(n)&&!t.required)return r();Be.required(t,n,o,i,s),n!==void 0&&(Be.type(t,n,o,i,s),Be.range(t,n,o,i,s))}r(i)},s4=function(t,n,r,o,s){var i=[],a=t.required||!t.required&&o.hasOwnProperty(t.field);if(a){if(bt(n)&&!t.required)return r();Be.required(t,n,o,i,s),n!==void 0&&Be.type(t,n,o,i,s)}r(i)},i4=function(t,n,r,o,s){var i=[],a=t.required||!t.required&&o.hasOwnProperty(t.field);if(a){if(bt(n)&&!t.required)return r();Be.required(t,n,o,i,s),bt(n)||Be.type(t,n,o,i,s)}r(i)},a4=function(t,n,r,o,s){var i=[],a=t.required||!t.required&&o.hasOwnProperty(t.field);if(a){if(bt(n)&&!t.required)return r();Be.required(t,n,o,i,s),n!==void 0&&(Be.type(t,n,o,i,s),Be.range(t,n,o,i,s))}r(i)},l4=function(t,n,r,o,s){var i=[],a=t.required||!t.required&&o.hasOwnProperty(t.field);if(a){if(bt(n)&&!t.required)return r();Be.required(t,n,o,i,s),n!==void 0&&(Be.type(t,n,o,i,s),Be.range(t,n,o,i,s))}r(i)},u4=function(t,n,r,o,s){var i=[],a=t.required||!t.required&&o.hasOwnProperty(t.field);if(a){if(n==null&&!t.required)return r();Be.required(t,n,o,i,s,"array"),n!=null&&(Be.type(t,n,o,i,s),Be.range(t,n,o,i,s))}r(i)},c4=function(t,n,r,o,s){var i=[],a=t.required||!t.required&&o.hasOwnProperty(t.field);if(a){if(bt(n)&&!t.required)return r();Be.required(t,n,o,i,s),n!==void 0&&Be.type(t,n,o,i,s)}r(i)},f4="enum",d4=function(t,n,r,o,s){var i=[],a=t.required||!t.required&&o.hasOwnProperty(t.field);if(a){if(bt(n)&&!t.required)return r();Be.required(t,n,o,i,s),n!==void 0&&Be[f4](t,n,o,i,s)}r(i)},p4=function(t,n,r,o,s){var i=[],a=t.required||!t.required&&o.hasOwnProperty(t.field);if(a){if(bt(n,"string")&&!t.required)return r();Be.required(t,n,o,i,s),bt(n,"string")||Be.pattern(t,n,o,i,s)}r(i)},h4=function(t,n,r,o,s){var i=[],a=t.required||!t.required&&o.hasOwnProperty(t.field);if(a){if(bt(n,"date")&&!t.required)return r();if(Be.required(t,n,o,i,s),!bt(n,"date")){var l;n instanceof Date?l=n:l=new Date(n),Be.type(t,l,o,i,s),l&&Be.range(t,l.getTime(),o,i,s)}}r(i)},v4=function(t,n,r,o,s){var i=[],a=Array.isArray(n)?"array":typeof n;Be.required(t,n,o,i,s,a),r(i)},Bl=function(t,n,r,o,s){var i=t.type,a=[],l=t.required||!t.required&&o.hasOwnProperty(t.field);if(l){if(bt(n,i)&&!t.required)return r();Be.required(t,n,o,a,s,i),bt(n,i)||Be.type(t,n,o,a,s)}r(a)},m4=function(t,n,r,o,s){var i=[],a=t.required||!t.required&&o.hasOwnProperty(t.field);if(a){if(bt(n)&&!t.required)return r();Be.required(t,n,o,i,s)}r(i)},Ls={string:n4,method:r4,number:o4,boolean:s4,regexp:i4,integer:a4,float:l4,array:u4,object:c4,enum:d4,pattern:p4,date:h4,url:Bl,hex:Bl,email:Bl,required:v4,any:m4};function Pu(){return{default:"Validation error on field %s",required:"%s is required",enum:"%s must be one of %s",whitespace:"%s cannot be empty",date:{format:"%s date %s is invalid for format %s",parse:"%s date could not be parsed, %s is invalid ",invalid:"%s date %s is invalid"},types:{string:"%s is not a %s",method:"%s is not a %s (function)",array:"%s is not an %s",object:"%s is not an %s",number:"%s is not a %s",date:"%s is not a %s",boolean:"%s is not a %s",integer:"%s is not an %s",float:"%s is not a %s",regexp:"%s is not a valid %s",email:"%s is not a valid %s",url:"%s is not a valid %s",hex:"%s is not a valid %s"},string:{len:"%s must be exactly %s characters",min:"%s must be at least %s characters",max:"%s cannot be longer than %s characters",range:"%s must be between %s and %s characters"},number:{len:"%s must equal %s",min:"%s cannot be less than %s",max:"%s cannot be greater than %s",range:"%s must be between %s and %s"},array:{len:"%s must be exactly %s in length",min:"%s cannot be less than %s in length",max:"%s cannot be greater than %s in length",range:"%s must be between %s and %s in length"},pattern:{mismatch:"%s value %s does not match pattern %s"},clone:function(){var t=JSON.parse(JSON.stringify(this));return t.clone=this.clone,t}}}var Nu=Pu(),vi=function(){function e(n){this.rules=null,this._messages=Nu,this.define(n)}var t=e.prototype;return t.define=function(r){var o=this;if(!r)throw new Error("Cannot configure a schema with no rules");if(typeof r!="object"||Array.isArray(r))throw new Error("Rules must be an object");this.rules={},Object.keys(r).forEach(function(s){var i=r[s];o.rules[s]=Array.isArray(i)?i:[i]})},t.messages=function(r){return r&&(this._messages=bp(Pu(),r)),this._messages},t.validate=function(r,o,s){var i=this;o===void 0&&(o={}),s===void 0&&(s=function(){});var a=r,l=o,u=s;if(typeof l=="function"&&(u=l,l={}),!this.rules||Object.keys(this.rules).length===0)return u&&u(null,a),Promise.resolve(a);function c(h){var b=[],m={};function S(w){if(Array.isArray(w)){var y;b=(y=b).concat.apply(y,w)}else b.push(w)}for(var _=0;_");const o=$e("form"),s=V(),i=V(0),a=()=>{var c;if((c=s.value)!=null&&c.firstElementChild){const f=window.getComputedStyle(s.value.firstElementChild).width;return Math.ceil(Number.parseFloat(f))}else return 0},l=(c="update")=>{Ie(()=>{t.default&&e.isAutoWidth&&(c==="update"?i.value=a():c==="remove"&&(n==null||n.deregisterLabelWidth(i.value)))})},u=()=>l("update");return Ke(()=>{u()}),St(()=>{l("remove")}),mo(()=>u()),he(i,(c,f)=>{e.updateAll&&(n==null||n.registerLabelWidth(c,f))}),Dt(C(()=>{var c,f;return(f=(c=s.value)==null?void 0:c.firstElementChild)!=null?f:null}),u),()=>{var c,f;if(!t)return null;const{isAutoWidth:d}=e;if(d){const v=n==null?void 0:n.autoLabelWidth,p=r==null?void 0:r.hasLabel,h={};if(p&&v&&v!=="auto"){const b=Math.max(0,Number.parseInt(v,10)-i.value),S=(r.labelPosition||n.labelPosition)==="left"?"marginRight":"marginLeft";b&&(h[S]=`${b}px`)}return re("div",{ref:s,class:[o.be("item","label-wrap")],style:h},[(c=t.default)==null?void 0:c.call(t)])}else return re(nt,{ref:s},[(f=t.default)==null?void 0:f.call(t)])}}});const w4=Z({name:"ElFormItem"}),S4=Z({...w4,props:b4,setup(e,{expose:t}){const n=e,r=go(),o=Ee(ts,void 0),s=Ee(ho,void 0),i=In(void 0,{formItem:!1}),a=$e("form-item"),l=pr().value,u=V([]),c=V(""),f=V1(c,100),d=V(""),v=V();let p,h=!1;const b=C(()=>n.labelPosition||(o==null?void 0:o.labelPosition)),m=C(()=>{if(b.value==="top")return{};const K=pn(n.labelWidth||(o==null?void 0:o.labelWidth)||"");return K?{width:K}:{}}),S=C(()=>{if(b.value==="top"||o!=null&&o.inline)return{};if(!n.label&&!n.labelWidth&&x)return{};const K=pn(n.labelWidth||(o==null?void 0:o.labelWidth)||"");return!n.label&&!r.label?{marginLeft:K}:{}}),_=C(()=>[a.b(),a.m(i.value),a.is("error",c.value==="error"),a.is("validating",c.value==="validating"),a.is("success",c.value==="success"),a.is("required",P.value||n.required),a.is("no-asterisk",o==null?void 0:o.hideRequiredAsterisk),(o==null?void 0:o.requireAsteriskPosition)==="right"?"asterisk-right":"asterisk-left",{[a.m("feedback")]:o==null?void 0:o.statusIcon,[a.m(`label-${b.value}`)]:b.value}]),w=C(()=>Bt(n.inlineMessage)?n.inlineMessage:(o==null?void 0:o.inlineMessage)||!1),y=C(()=>[a.e("error"),{[a.em("error","inline")]:w.value}]),A=C(()=>n.prop?Ae(n.prop)?n.prop:n.prop.join("."):""),R=C(()=>!!(n.label||r.label)),N=C(()=>n.for||(u.value.length===1?u.value[0]:void 0)),I=C(()=>!N.value&&R.value),x=!!s,k=C(()=>{const K=o==null?void 0:o.model;if(!(!K||!n.prop))return Fl(K,n.prop).value}),$=C(()=>{const{required:K}=n,J=[];n.rules&&J.push(...yn(n.rules));const oe=o==null?void 0:o.rules;if(oe&&n.prop){const ge=Fl(oe,n.prop).value;ge&&J.push(...yn(ge))}if(K!==void 0){const ge=J.map((E,T)=>[E,T]).filter(([E])=>Object.keys(E).includes("required"));if(ge.length>0)for(const[E,T]of ge)E.required!==K&&(J[T]={...E,required:K});else J.push({required:K})}return J}),F=C(()=>$.value.length>0),Y=K=>$.value.filter(oe=>!oe.trigger||!K?!0:Array.isArray(oe.trigger)?oe.trigger.includes(K):oe.trigger===K).map(({trigger:oe,...ge})=>ge),P=C(()=>$.value.some(K=>K.required)),O=C(()=>{var K;return f.value==="error"&&n.showMessage&&((K=o==null?void 0:o.showMessage)!=null?K:!0)}),j=C(()=>`${n.label||""}${(o==null?void 0:o.labelSuffix)||""}`),Q=K=>{c.value=K},me=K=>{var J,oe;const{errors:ge,fields:E}=K;Q("error"),d.value=ge?(oe=(J=ge==null?void 0:ge[0])==null?void 0:J.message)!=null?oe:`${n.prop} is required`:"",o==null||o.emit("validate",n.prop,!1,d.value)},Pe=()=>{Q("success"),o==null||o.emit("validate",n.prop,!0,"")},Re=async K=>{const J=A.value;return new vi({[J]:K}).validate({[J]:k.value},{firstFields:!0}).then(()=>(Pe(),!0)).catch(ge=>(me(ge),Promise.reject(ge)))},Ce=async(K,J)=>{if(h||!n.prop)return!1;const oe=ve(J);if(!F.value)return J==null||J(!1),!1;const ge=Y(K);return ge.length===0?(J==null||J(!0),!0):(Q("validating"),Re(ge).then(()=>(J==null||J(!0),!0)).catch(E=>{const{fields:T}=E;return J==null||J(!1,T),oe?!1:Promise.reject(T)}))},_e=()=>{Q(""),d.value="",h=!1},qe=async()=>{const K=o==null?void 0:o.model;if(!K||!n.prop)return;const J=Fl(K,n.prop);h=!0,J.value=Wd(p),await Ie(),_e(),h=!1},ze=K=>{u.value.includes(K)||u.value.push(K)},De=K=>{u.value=u.value.filter(J=>J!==K)};he(()=>n.error,K=>{d.value=K||"",Q(K?"error":"")},{immediate:!0}),he(()=>n.validateStatus,K=>Q(K||""));const B=wt({...vr(n),$el:v,size:i,validateState:c,labelId:l,inputIds:u,isGroup:I,hasLabel:R,fieldValue:k,addInputId:ze,removeInputId:De,resetField:qe,clearValidate:_e,validate:Ce});return ft(ho,B),Ke(()=>{n.prop&&(o==null||o.addField(B),p=Wd(k.value))}),St(()=>{o==null||o.removeField(B)}),t({size:i,validateMessage:d,validateState:c,validate:Ce,clearValidate:_e,resetField:qe}),(K,J)=>{var oe;return L(),ee("div",{ref_key:"formItemRef",ref:v,class:U(g(_)),role:g(I)?"group":void 0,"aria-labelledby":g(I)?g(l):void 0},[re(g(y4),{"is-auto-width":g(m).width==="auto","update-all":((oe=g(o))==null?void 0:oe.labelWidth)==="auto"},{default:ce(()=>[g(R)?(L(),fe(Xe(g(N)?"label":"div"),{key:0,id:g(l),for:g(N),class:U(g(a).e("label")),style:ot(g(m))},{default:ce(()=>[de(K.$slots,"label",{label:g(j)},()=>[Un(He(g(j)),1)])]),_:3},8,["id","for","class","style"])):ae("v-if",!0)]),_:3},8,["is-auto-width","update-all"]),le("div",{class:U(g(a).e("content")),style:ot(g(S))},[de(K.$slots,"default"),re(sw,{name:`${g(a).namespace.value}-zoom-in-top`},{default:ce(()=>[g(O)?de(K.$slots,"error",{key:0,error:d.value},()=>[le("div",{class:U(g(y))},He(d.value),3)]):ae("v-if",!0)]),_:3},8,["name"])],6)],10,["role","aria-labelledby"])}}});var gg=Ne(S4,[["__file","form-item.vue"]]);const IN=yt(BA,{FormItem:gg}),PN=wo(gg);let mn;const E4=` height:0 !important; visibility:hidden !important; ${oS()?"":"overflow:hidden !important;"} position:absolute !important; z-index:-1000 !important; top:0 !important; right:0 !important; `,_4=["letter-spacing","line-height","padding-top","padding-bottom","font-family","font-weight","font-size","text-rendering","text-transform","width","text-indent","padding-left","padding-right","border-width","box-sizing"];function C4(e){const t=window.getComputedStyle(e),n=t.getPropertyValue("box-sizing"),r=Number.parseFloat(t.getPropertyValue("padding-bottom"))+Number.parseFloat(t.getPropertyValue("padding-top")),o=Number.parseFloat(t.getPropertyValue("border-bottom-width"))+Number.parseFloat(t.getPropertyValue("border-top-width"));return{contextStyle:_4.map(i=>`${i}:${t.getPropertyValue(i)}`).join(";"),paddingSize:r,borderSize:o,boxSizing:n}}function Sp(e,t=1,n){var r;mn||(mn=document.createElement("textarea"),document.body.appendChild(mn));const{paddingSize:o,borderSize:s,boxSizing:i,contextStyle:a}=C4(e);mn.setAttribute("style",`${a};${E4}`),mn.value=e.value||e.placeholder||"";let l=mn.scrollHeight;const u={};i==="border-box"?l=l+s:i==="content-box"&&(l=l-o),mn.value="";const c=mn.scrollHeight-o;if(je(t)){let f=c*t;i==="border-box"&&(f=f+o+s),l=Math.max(f,l),u.minHeight=`${f}px`}if(je(n)){let f=c*n;i==="border-box"&&(f=f+o+s),l=Math.min(f,l)}return u.height=`${l}px`,(r=mn.parentNode)==null||r.removeChild(mn),mn=void 0,u}const T4=xe({id:{type:String,default:void 0},size:So,disabled:Boolean,modelValue:{type:we([String,Number,Object]),default:""},maxlength:{type:[String,Number]},minlength:{type:[String,Number]},type:{type:String,default:"text"},resize:{type:String,values:["none","both","horizontal","vertical"]},autosize:{type:we([Boolean,Object]),default:!1},autocomplete:{type:String,default:"off"},formatter:{type:Function},parser:{type:Function},placeholder:{type:String},form:{type:String},readonly:Boolean,clearable:Boolean,showPassword:Boolean,showWordLimit:Boolean,suffixIcon:{type:jt},prefixIcon:{type:jt},containerRole:{type:String,default:void 0},tabindex:{type:[String,Number],default:0},validateEvent:{type:Boolean,default:!0},inputStyle:{type:we([Object,Array,String]),default:()=>ol({})},autofocus:Boolean,rows:{type:Number,default:2},...yr(["ariaLabel"])}),O4={[rt]:e=>Ae(e),input:e=>Ae(e),change:e=>Ae(e),focus:e=>e instanceof FocusEvent,blur:e=>e instanceof FocusEvent,clear:()=>!0,mouseleave:e=>e instanceof MouseEvent,mouseenter:e=>e instanceof MouseEvent,keydown:e=>e instanceof Event,compositionstart:e=>e instanceof CompositionEvent,compositionupdate:e=>e instanceof CompositionEvent,compositionend:e=>e instanceof CompositionEvent},A4=Z({name:"ElInput",inheritAttrs:!1}),R4=Z({...A4,props:T4,emits:O4,setup(e,{expose:t,emit:n}){const r=e,o=Zy(),s=go(),i=C(()=>{const z={};return r.containerRole==="combobox"&&(z["aria-haspopup"]=o["aria-haspopup"],z["aria-owns"]=o["aria-owns"],z["aria-expanded"]=o["aria-expanded"]),z}),a=C(()=>[r.type==="textarea"?b.b():h.b(),h.m(v.value),h.is("disabled",p.value),h.is("exceed",_e.value),{[h.b("group")]:s.prepend||s.append,[h.m("prefix")]:s.prefix||r.prefixIcon,[h.m("suffix")]:s.suffix||r.suffixIcon||r.clearable||r.showPassword,[h.bm("suffix","password-clear")]:me.value&&Pe.value,[h.b("hidden")]:r.type==="hidden"},o.class]),l=C(()=>[h.e("wrapper"),h.is("focus",I.value)]),u=JT({excludeKeys:C(()=>Object.keys(i.value))}),{form:c,formItem:f}=Vr(),{inputId:d}=hi(r,{formItemContext:f}),v=In(),p=ns(),h=$e("input"),b=$e("textarea"),m=ir(),S=ir(),_=V(!1),w=V(!1),y=V(),A=ir(r.inputStyle),R=C(()=>m.value||S.value),{wrapperRef:N,isFocused:I,handleFocus:x,handleBlur:k}=fg(R,{beforeFocus(){return p.value},afterBlur(){var z;r.validateEvent&&((z=f==null?void 0:f.validate)==null||z.call(f,"blur").catch(be=>void 0))}}),$=C(()=>{var z;return(z=c==null?void 0:c.statusIcon)!=null?z:!1}),F=C(()=>(f==null?void 0:f.validateState)||""),Y=C(()=>F.value&&Vm[F.value]),P=C(()=>w.value?VT:IT),O=C(()=>[o.style]),j=C(()=>[r.inputStyle,A.value,{resize:r.resize}]),Q=C(()=>qn(r.modelValue)?"":String(r.modelValue)),me=C(()=>r.clearable&&!p.value&&!r.readonly&&!!Q.value&&(I.value||_.value)),Pe=C(()=>r.showPassword&&!p.value&&!r.readonly&&!!Q.value&&(!!Q.value||I.value)),Re=C(()=>r.showWordLimit&&!!r.maxlength&&(r.type==="text"||r.type==="textarea")&&!p.value&&!r.readonly&&!r.showPassword),Ce=C(()=>Q.value.length),_e=C(()=>!!Re.value&&Ce.value>Number(r.maxlength)),qe=C(()=>!!s.suffix||!!r.suffixIcon||me.value||r.showPassword||Re.value||!!F.value&&$.value),[ze,De]=gA(m);Dt(S,z=>{if(J(),!Re.value||r.resize!=="both")return;const be=z[0],{width:Le}=be.contentRect;y.value={right:`calc(100% - ${Le+15+6}px)`}});const B=()=>{const{type:z,autosize:be}=r;if(!(!st||z!=="textarea"||!S.value))if(be){const Le=Oe(be)?be.minRows:void 0,ke=Oe(be)?be.maxRows:void 0,dt=Sp(S.value,Le,ke);A.value={overflowY:"hidden",...dt},Ie(()=>{S.value.offsetHeight,A.value=dt})}else A.value={minHeight:Sp(S.value).minHeight}},J=(z=>{let be=!1;return()=>{var Le;if(be||!r.autosize)return;((Le=S.value)==null?void 0:Le.offsetParent)===null||(z(),be=!0)}})(B),oe=()=>{const z=R.value,be=r.formatter?r.formatter(Q.value):Q.value;!z||z.value===be||(z.value=be)},ge=async z=>{ze();let{value:be}=z.target;if(r.formatter&&(be=r.parser?r.parser(be):be),!T.value){if(be===Q.value){oe();return}n(rt,be),n("input",be),await Ie(),oe(),De()}},E=z=>{n("change",z.target.value)},{isComposing:T,handleCompositionStart:M,handleCompositionUpdate:W,handleCompositionEnd:G}=dg({emit:n,afterComposition:ge}),q=()=>{w.value=!w.value,se()},se=async()=>{var z;await Ie(),(z=R.value)==null||z.focus()},ne=()=>{var z;return(z=R.value)==null?void 0:z.blur()},te=z=>{_.value=!1,n("mouseleave",z)},X=z=>{_.value=!0,n("mouseenter",z)},Se=z=>{n("keydown",z)},ue=()=>{var z;(z=R.value)==null||z.select()},ye=()=>{n(rt,""),n("change",""),n("clear"),n("input","")};return he(()=>r.modelValue,()=>{var z;Ie(()=>B()),r.validateEvent&&((z=f==null?void 0:f.validate)==null||z.call(f,"change").catch(be=>void 0))}),he(Q,()=>oe()),he(()=>r.type,async()=>{await Ie(),oe(),B()}),Ke(()=>{!r.formatter&&r.parser,oe(),Ie(B)}),t({input:m,textarea:S,ref:R,textareaStyle:j,autosize:Jt(r,"autosize"),isComposing:T,focus:se,blur:ne,select:ue,clear:ye,resizeTextarea:B}),(z,be)=>(L(),ee("div",En(g(i),{class:[g(a),{[g(h).bm("group","append")]:z.$slots.append,[g(h).bm("group","prepend")]:z.$slots.prepend}],style:g(O),role:z.containerRole,onMouseenter:X,onMouseleave:te}),[ae(" input "),z.type!=="textarea"?(L(),ee(nt,{key:0},[ae(" prepend slot "),z.$slots.prepend?(L(),ee("div",{key:0,class:U(g(h).be("group","prepend"))},[de(z.$slots,"prepend")],2)):ae("v-if",!0),le("div",{ref_key:"wrapperRef",ref:N,class:U(g(l))},[ae(" prefix slot "),z.$slots.prefix||z.prefixIcon?(L(),ee("span",{key:0,class:U(g(h).e("prefix"))},[le("span",{class:U(g(h).e("prefix-inner"))},[de(z.$slots,"prefix"),z.prefixIcon?(L(),fe(g(Je),{key:0,class:U(g(h).e("icon"))},{default:ce(()=>[(L(),fe(Xe(z.prefixIcon)))]),_:1},8,["class"])):ae("v-if",!0)],2)],2)):ae("v-if",!0),le("input",En({id:g(d),ref_key:"input",ref:m,class:g(h).e("inner")},g(u),{minlength:z.minlength,maxlength:z.maxlength,type:z.showPassword?w.value?"text":"password":z.type,disabled:g(p),readonly:z.readonly,autocomplete:z.autocomplete,tabindex:z.tabindex,"aria-label":z.ariaLabel,placeholder:z.placeholder,style:z.inputStyle,form:z.form,autofocus:z.autofocus,onCompositionstart:g(M),onCompositionupdate:g(W),onCompositionend:g(G),onInput:ge,onChange:E,onKeydown:Se}),null,16,["id","minlength","maxlength","type","disabled","readonly","autocomplete","tabindex","aria-label","placeholder","form","autofocus","onCompositionstart","onCompositionupdate","onCompositionend"]),ae(" suffix slot "),g(qe)?(L(),ee("span",{key:1,class:U(g(h).e("suffix"))},[le("span",{class:U(g(h).e("suffix-inner"))},[!g(me)||!g(Pe)||!g(Re)?(L(),ee(nt,{key:0},[de(z.$slots,"suffix"),z.suffixIcon?(L(),fe(g(Je),{key:0,class:U(g(h).e("icon"))},{default:ce(()=>[(L(),fe(Xe(z.suffixIcon)))]),_:1},8,["class"])):ae("v-if",!0)],64)):ae("v-if",!0),g(me)?(L(),fe(g(Je),{key:1,class:U([g(h).e("icon"),g(h).e("clear")]),onMousedown:tt(g(ht),["prevent"]),onClick:ye},{default:ce(()=>[re(g(Oc))]),_:1},8,["class","onMousedown"])):ae("v-if",!0),g(Pe)?(L(),fe(g(Je),{key:2,class:U([g(h).e("icon"),g(h).e("password")]),onClick:q},{default:ce(()=>[(L(),fe(Xe(g(P))))]),_:1},8,["class"])):ae("v-if",!0),g(Re)?(L(),ee("span",{key:3,class:U(g(h).e("count"))},[le("span",{class:U(g(h).e("count-inner"))},He(g(Ce))+" / "+He(z.maxlength),3)],2)):ae("v-if",!0),g(F)&&g(Y)&&g($)?(L(),fe(g(Je),{key:4,class:U([g(h).e("icon"),g(h).e("validateIcon"),g(h).is("loading",g(F)==="validating")])},{default:ce(()=>[(L(),fe(Xe(g(Y))))]),_:1},8,["class"])):ae("v-if",!0)],2)],2)):ae("v-if",!0)],2),ae(" append slot "),z.$slots.append?(L(),ee("div",{key:1,class:U(g(h).be("group","append"))},[de(z.$slots,"append")],2)):ae("v-if",!0)],64)):(L(),ee(nt,{key:1},[ae(" textarea "),le("textarea",En({id:g(d),ref_key:"textarea",ref:S,class:[g(b).e("inner"),g(h).is("focus",g(I))]},g(u),{minlength:z.minlength,maxlength:z.maxlength,tabindex:z.tabindex,disabled:g(p),readonly:z.readonly,autocomplete:z.autocomplete,style:g(j),"aria-label":z.ariaLabel,placeholder:z.placeholder,form:z.form,autofocus:z.autofocus,rows:z.rows,onCompositionstart:g(M),onCompositionupdate:g(W),onCompositionend:g(G),onInput:ge,onFocus:g(x),onBlur:g(k),onChange:E,onKeydown:Se}),null,16,["id","minlength","maxlength","tabindex","disabled","readonly","autocomplete","aria-label","placeholder","form","autofocus","rows","onCompositionstart","onCompositionupdate","onCompositionend","onFocus","onBlur"]),g(Re)?(L(),ee("span",{key:0,style:ot(y.value),class:U(g(h).e("count"))},He(g(Ce))+" / "+He(z.maxlength),7)):ae("v-if",!0)],64))],16,["role"]))}});var x4=Ne(R4,[["__file","input.vue"]]);const bg=yt(x4),To=4,I4={vertical:{offset:"offsetHeight",scroll:"scrollTop",scrollSize:"scrollHeight",size:"height",key:"vertical",axis:"Y",client:"clientY",direction:"top"},horizontal:{offset:"offsetWidth",scroll:"scrollLeft",scrollSize:"scrollWidth",size:"width",key:"horizontal",axis:"X",client:"clientX",direction:"left"}},P4=({move:e,size:t,bar:n})=>({[n.size]:t,transform:`translate${n.axis}(${e}%)`}),Dc=Symbol("scrollbarContextKey"),N4=xe({vertical:Boolean,size:String,move:Number,ratio:{type:Number,required:!0},always:Boolean}),L4="Thumb",M4=Z({__name:"thumb",props:N4,setup(e){const t=e,n=Ee(Dc),r=$e("scrollbar");n||Br(L4,"can not inject scrollbar context");const o=V(),s=V(),i=V({}),a=V(!1);let l=!1,u=!1,c=st?document.onselectstart:null;const f=C(()=>I4[t.vertical?"vertical":"horizontal"]),d=C(()=>P4({size:t.size,move:t.move,bar:f.value})),v=C(()=>o.value[f.value.offset]**2/n.wrapElement[f.value.scrollSize]/t.ratio/s.value[f.value.offset]),p=A=>{var R;if(A.stopPropagation(),A.ctrlKey||[1,2].includes(A.button))return;(R=window.getSelection())==null||R.removeAllRanges(),b(A);const N=A.currentTarget;N&&(i.value[f.value.axis]=N[f.value.offset]-(A[f.value.client]-N.getBoundingClientRect()[f.value.direction]))},h=A=>{if(!s.value||!o.value||!n.wrapElement)return;const R=Math.abs(A.target.getBoundingClientRect()[f.value.direction]-A[f.value.client]),N=s.value[f.value.offset]/2,I=(R-N)*100*v.value/o.value[f.value.offset];n.wrapElement[f.value.scroll]=I*n.wrapElement[f.value.scrollSize]/100},b=A=>{A.stopImmediatePropagation(),l=!0,document.addEventListener("mousemove",m),document.addEventListener("mouseup",S),c=document.onselectstart,document.onselectstart=()=>!1},m=A=>{if(!o.value||!s.value||l===!1)return;const R=i.value[f.value.axis];if(!R)return;const N=(o.value.getBoundingClientRect()[f.value.direction]-A[f.value.client])*-1,I=s.value[f.value.offset]-R,x=(N-I)*100*v.value/o.value[f.value.offset];n.wrapElement[f.value.scroll]=x*n.wrapElement[f.value.scrollSize]/100},S=()=>{l=!1,i.value[f.value.axis]=0,document.removeEventListener("mousemove",m),document.removeEventListener("mouseup",S),y(),u&&(a.value=!1)},_=()=>{u=!1,a.value=!!t.size},w=()=>{u=!0,a.value=l};St(()=>{y(),document.removeEventListener("mouseup",S)});const y=()=>{document.onselectstart!==c&&(document.onselectstart=c)};return tn(Jt(n,"scrollbarElement"),"mousemove",_),tn(Jt(n,"scrollbarElement"),"mouseleave",w),(A,R)=>(L(),fe(Fr,{name:g(r).b("fade"),persisted:""},{default:ce(()=>[ct(le("div",{ref_key:"instance",ref:o,class:U([g(r).e("bar"),g(r).is(g(f).key)]),onMousedown:h},[le("div",{ref_key:"thumb",ref:s,class:U(g(r).e("thumb")),style:ot(g(d)),onMousedown:p},null,38)],34),[[en,A.always||a.value]])]),_:1},8,["name"]))}});var Ep=Ne(M4,[["__file","thumb.vue"]]);const $4=xe({always:{type:Boolean,default:!0},minSize:{type:Number,required:!0}}),k4=Z({__name:"bar",props:$4,setup(e,{expose:t}){const n=e,r=Ee(Dc),o=V(0),s=V(0),i=V(""),a=V(""),l=V(1),u=V(1);return t({handleScroll:d=>{if(d){const v=d.offsetHeight-To,p=d.offsetWidth-To;s.value=d.scrollTop*100/v*l.value,o.value=d.scrollLeft*100/p*u.value}},update:()=>{const d=r==null?void 0:r.wrapElement;if(!d)return;const v=d.offsetHeight-To,p=d.offsetWidth-To,h=v**2/d.scrollHeight,b=p**2/d.scrollWidth,m=Math.max(h,n.minSize),S=Math.max(b,n.minSize);l.value=h/(v-h)/(m/(v-m)),u.value=b/(p-b)/(S/(p-S)),a.value=m+To(L(),ee(nt,null,[re(Ep,{move:o.value,ratio:u.value,size:i.value,always:d.always},null,8,["move","ratio","size","always"]),re(Ep,{move:s.value,ratio:l.value,size:a.value,vertical:"",always:d.always},null,8,["move","ratio","size","always"])],64))}});var F4=Ne(k4,[["__file","bar.vue"]]);const B4=xe({height:{type:[String,Number],default:""},maxHeight:{type:[String,Number],default:""},native:{type:Boolean,default:!1},wrapStyle:{type:we([String,Object,Array]),default:""},wrapClass:{type:[String,Array],default:""},viewClass:{type:[String,Array],default:""},viewStyle:{type:[String,Array,Object],default:""},noresize:Boolean,tag:{type:String,default:"div"},always:Boolean,minSize:{type:Number,default:20},tabindex:{type:[String,Number],default:void 0},id:String,role:String,...yr(["ariaLabel","ariaOrientation"])}),D4={scroll:({scrollTop:e,scrollLeft:t})=>[e,t].every(je)},V4="ElScrollbar",j4=Z({name:V4}),z4=Z({...j4,props:B4,emits:D4,setup(e,{expose:t,emit:n}){const r=e,o=$e("scrollbar");let s,i,a=0,l=0;const u=V(),c=V(),f=V(),d=V(),v=C(()=>{const y={};return r.height&&(y.height=pn(r.height)),r.maxHeight&&(y.maxHeight=pn(r.maxHeight)),[r.wrapStyle,y]}),p=C(()=>[r.wrapClass,o.e("wrap"),{[o.em("wrap","hidden-default")]:!r.native}]),h=C(()=>[o.e("view"),r.viewClass]),b=()=>{var y;c.value&&((y=d.value)==null||y.handleScroll(c.value),a=c.value.scrollTop,l=c.value.scrollLeft,n("scroll",{scrollTop:c.value.scrollTop,scrollLeft:c.value.scrollLeft}))};function m(y,A){Oe(y)?c.value.scrollTo(y):je(y)&&je(A)&&c.value.scrollTo(y,A)}const S=y=>{je(y)&&(c.value.scrollTop=y)},_=y=>{je(y)&&(c.value.scrollLeft=y)},w=()=>{var y;(y=d.value)==null||y.update()};return he(()=>r.noresize,y=>{y?(s==null||s(),i==null||i()):({stop:s}=Dt(f,w),i=tn("resize",w))},{immediate:!0}),he(()=>[r.maxHeight,r.height],()=>{r.native||Ie(()=>{var y;w(),c.value&&((y=d.value)==null||y.handleScroll(c.value))})}),ft(Dc,wt({scrollbarElement:u,wrapElement:c})),Ka(()=>{c.value&&(c.value.scrollTop=a,c.value.scrollLeft=l)}),Ke(()=>{r.native||Ie(()=>{w()})}),mo(()=>w()),t({wrapRef:c,update:w,scrollTo:m,setScrollTop:S,setScrollLeft:_,handleScroll:b}),(y,A)=>(L(),ee("div",{ref_key:"scrollbarRef",ref:u,class:U(g(o).b())},[le("div",{ref_key:"wrapRef",ref:c,class:U(g(p)),style:ot(g(v)),tabindex:y.tabindex,onScroll:b},[(L(),fe(Xe(y.tag),{id:y.id,ref_key:"resizeRef",ref:f,class:U(g(h)),style:ot(y.viewStyle),role:y.role,"aria-label":y.ariaLabel,"aria-orientation":y.ariaOrientation},{default:ce(()=>[de(y.$slots,"default")]),_:3},8,["id","class","style","role","aria-label","aria-orientation"]))],46,["tabindex"]),y.native?ae("v-if",!0):(L(),fe(F4,{key:0,ref_key:"barRef",ref:d,always:y.always,"min-size":y.minSize},null,8,["always","min-size"]))],2))}});var H4=Ne(z4,[["__file","scrollbar.vue"]]);const U4=yt(H4),Vc=Symbol("popper"),yg=Symbol("popperContent"),K4=["dialog","grid","group","listbox","menu","navigation","tooltip","tree"],wg=xe({role:{type:String,values:K4,default:"tooltip"}}),q4=Z({name:"ElPopper",inheritAttrs:!1}),W4=Z({...q4,props:wg,setup(e,{expose:t}){const n=e,r=V(),o=V(),s=V(),i=V(),a=C(()=>n.role),l={triggerRef:r,popperInstanceRef:o,contentRef:s,referenceRef:i,role:a};return t(l),ft(Vc,l),(u,c)=>de(u.$slots,"default")}});var G4=Ne(W4,[["__file","popper.vue"]]);const Sg=xe({arrowOffset:{type:Number,default:5}}),Y4=Z({name:"ElPopperArrow",inheritAttrs:!1}),J4=Z({...Y4,props:Sg,setup(e,{expose:t}){const n=e,r=$e("popper"),{arrowOffset:o,arrowRef:s,arrowStyle:i}=Ee(yg,void 0);return he(()=>n.arrowOffset,a=>{o.value=a}),St(()=>{s.value=void 0}),t({arrowRef:s}),(a,l)=>(L(),ee("span",{ref_key:"arrowRef",ref:s,class:U(g(r).e("arrow")),style:ot(g(i)),"data-popper-arrow":""},null,6))}});var X4=Ne(J4,[["__file","arrow.vue"]]);const Z4="ElOnlyChild",Q4=Z({name:Z4,setup(e,{slots:t,attrs:n}){var r;const o=Ee(ag),s=mA((r=o==null?void 0:o.setForwardRef)!=null?r:ht);return()=>{var i;const a=(i=t.default)==null?void 0:i.call(t,n);if(!a||a.length>1)return null;const l=Eg(a);return l?ct(fr(l,n),[[s]]):null}}});function Eg(e){if(!e)return null;const t=e;for(const n of t){if(Oe(n))switch(n.type){case _t:continue;case Zo:case"svg":return _p(n);case nt:return Eg(n.children);default:return n}return _p(n)}return null}function _p(e){const t=$e("only-child");return re("span",{class:t.e("content")},[e])}const _g=xe({virtualRef:{type:we(Object)},virtualTriggering:Boolean,onMouseenter:{type:we(Function)},onMouseleave:{type:we(Function)},onClick:{type:we(Function)},onKeydown:{type:we(Function)},onFocus:{type:we(Function)},onBlur:{type:we(Function)},onContextmenu:{type:we(Function)},id:String,open:Boolean}),eR=Z({name:"ElPopperTrigger",inheritAttrs:!1}),tR=Z({...eR,props:_g,setup(e,{expose:t}){const n=e,{role:r,triggerRef:o}=Ee(Vc,void 0);vA(o);const s=C(()=>a.value?n.id:void 0),i=C(()=>{if(r&&r.value==="tooltip")return n.open&&n.id?n.id:void 0}),a=C(()=>{if(r&&r.value!=="tooltip")return r.value}),l=C(()=>a.value?`${n.open}`:void 0);let u;const c=["onMouseenter","onMouseleave","onClick","onKeydown","onFocus","onBlur","onContextmenu"];return Ke(()=>{he(()=>n.virtualRef,f=>{f&&(o.value=sr(f))},{immediate:!0}),he(o,(f,d)=>{u==null||u(),u=void 0,ar(f)&&(c.forEach(v=>{var p;const h=n[v];h&&(f.addEventListener(v.slice(2).toLowerCase(),h),(p=d==null?void 0:d.removeEventListener)==null||p.call(d,v.slice(2).toLowerCase(),h))}),u=he([s,i,a,l],v=>{["aria-controls","aria-describedby","aria-haspopup","aria-expanded"].forEach((p,h)=>{qn(v[h])?f.removeAttribute(p):f.setAttribute(p,v[h])})},{immediate:!0})),ar(d)&&["aria-controls","aria-describedby","aria-haspopup","aria-expanded"].forEach(v=>d.removeAttribute(v))},{immediate:!0})}),St(()=>{if(u==null||u(),u=void 0,o.value&&ar(o.value)){const f=o.value;c.forEach(d=>{const v=n[d];v&&f.removeEventListener(d.slice(2).toLowerCase(),v)}),o.value=void 0}}),t({triggerRef:o}),(f,d)=>f.virtualTriggering?ae("v-if",!0):(L(),fe(g(Q4),En({key:0},f.$attrs,{"aria-controls":g(s),"aria-describedby":g(i),"aria-expanded":g(l),"aria-haspopup":g(a)}),{default:ce(()=>[de(f.$slots,"default")]),_:3},16,["aria-controls","aria-describedby","aria-expanded","aria-haspopup"]))}});var nR=Ne(tR,[["__file","trigger.vue"]]);const Dl="focus-trap.focus-after-trapped",Vl="focus-trap.focus-after-released",rR="focus-trap.focusout-prevented",Cp={cancelable:!0,bubbles:!1},oR={cancelable:!0,bubbles:!1},Tp="focusAfterTrapped",Op="focusAfterReleased",Cg=Symbol("elFocusTrap"),jc=V(),ul=V(0),zc=V(0);let Li=0;const Tg=e=>{const t=[],n=document.createTreeWalker(e,NodeFilter.SHOW_ELEMENT,{acceptNode:r=>{const o=r.tagName==="INPUT"&&r.type==="hidden";return r.disabled||r.hidden||o?NodeFilter.FILTER_SKIP:r.tabIndex>=0||r===document.activeElement?NodeFilter.FILTER_ACCEPT:NodeFilter.FILTER_SKIP}});for(;n.nextNode();)t.push(n.currentNode);return t},Ap=(e,t)=>{for(const n of e)if(!sR(n,t))return n},sR=(e,t)=>{if(getComputedStyle(e).visibility==="hidden")return!0;for(;e;){if(t&&e===t)return!1;if(getComputedStyle(e).display==="none")return!0;e=e.parentElement}return!1},iR=e=>{const t=Tg(e),n=Ap(t,e),r=Ap(t.reverse(),e);return[n,r]},aR=e=>e instanceof HTMLInputElement&&"select"in e,Tr=(e,t)=>{if(e&&e.focus){const n=document.activeElement;e.focus({preventScroll:!0}),zc.value=window.performance.now(),e!==n&&aR(e)&&t&&e.select()}};function Rp(e,t){const n=[...e],r=e.indexOf(t);return r!==-1&&n.splice(r,1),n}const lR=()=>{let e=[];return{push:r=>{const o=e[0];o&&r!==o&&o.pause(),e=Rp(e,r),e.unshift(r)},remove:r=>{var o,s;e=Rp(e,r),(s=(o=e[0])==null?void 0:o.resume)==null||s.call(o)}}},uR=(e,t=!1)=>{const n=document.activeElement;for(const r of e)if(Tr(r,t),document.activeElement!==n)return},xp=lR(),cR=()=>ul.value>zc.value,Mi=()=>{jc.value="pointer",ul.value=window.performance.now()},Ip=()=>{jc.value="keyboard",ul.value=window.performance.now()},fR=()=>(Ke(()=>{Li===0&&(document.addEventListener("mousedown",Mi),document.addEventListener("touchstart",Mi),document.addEventListener("keydown",Ip)),Li++}),St(()=>{Li--,Li<=0&&(document.removeEventListener("mousedown",Mi),document.removeEventListener("touchstart",Mi),document.removeEventListener("keydown",Ip))}),{focusReason:jc,lastUserFocusTimestamp:ul,lastAutomatedFocusTimestamp:zc}),$i=e=>new CustomEvent(rR,{...oR,detail:e}),dR=Z({name:"ElFocusTrap",inheritAttrs:!1,props:{loop:Boolean,trapped:Boolean,focusTrapEl:Object,focusStartEl:{type:[Object,String],default:"first"}},emits:[Tp,Op,"focusin","focusout","focusout-prevented","release-requested"],setup(e,{emit:t}){const n=V();let r,o;const{focusReason:s}=fR();cA(p=>{e.trapped&&!i.paused&&t("release-requested",p)});const i={paused:!1,pause(){this.paused=!0},resume(){this.paused=!1}},a=p=>{if(!e.loop&&!e.trapped||i.paused)return;const{key:h,altKey:b,ctrlKey:m,metaKey:S,currentTarget:_,shiftKey:w}=p,{loop:y}=e,A=h===_n.tab&&!b&&!m&&!S,R=document.activeElement;if(A&&R){const N=_,[I,x]=iR(N);if(I&&x){if(!w&&R===x){const $=$i({focusReason:s.value});t("focusout-prevented",$),$.defaultPrevented||(p.preventDefault(),y&&Tr(I,!0))}else if(w&&[I,N].includes(R)){const $=$i({focusReason:s.value});t("focusout-prevented",$),$.defaultPrevented||(p.preventDefault(),y&&Tr(x,!0))}}else if(R===N){const $=$i({focusReason:s.value});t("focusout-prevented",$),$.defaultPrevented||p.preventDefault()}}};ft(Cg,{focusTrapRef:n,onKeydown:a}),he(()=>e.focusTrapEl,p=>{p&&(n.value=p)},{immediate:!0}),he([n],([p],[h])=>{p&&(p.addEventListener("keydown",a),p.addEventListener("focusin",c),p.addEventListener("focusout",f)),h&&(h.removeEventListener("keydown",a),h.removeEventListener("focusin",c),h.removeEventListener("focusout",f))});const l=p=>{t(Tp,p)},u=p=>t(Op,p),c=p=>{const h=g(n);if(!h)return;const b=p.target,m=p.relatedTarget,S=b&&h.contains(b);e.trapped||m&&h.contains(m)||(r=m),S&&t("focusin",p),!i.paused&&e.trapped&&(S?o=b:Tr(o,!0))},f=p=>{const h=g(n);if(!(i.paused||!h))if(e.trapped){const b=p.relatedTarget;!qn(b)&&!h.contains(b)&&setTimeout(()=>{if(!i.paused&&e.trapped){const m=$i({focusReason:s.value});t("focusout-prevented",m),m.defaultPrevented||Tr(o,!0)}},0)}else{const b=p.target;b&&h.contains(b)||t("focusout",p)}};async function d(){await Ie();const p=g(n);if(p){xp.push(i);const h=p.contains(document.activeElement)?r:document.activeElement;if(r=h,!p.contains(h)){const m=new Event(Dl,Cp);p.addEventListener(Dl,l),p.dispatchEvent(m),m.defaultPrevented||Ie(()=>{let S=e.focusStartEl;Ae(S)||(Tr(S),document.activeElement!==S&&(S="first")),S==="first"&&uR(Tg(p),!0),(document.activeElement===h||S==="container")&&Tr(p)})}}}function v(){const p=g(n);if(p){p.removeEventListener(Dl,l);const h=new CustomEvent(Vl,{...Cp,detail:{focusReason:s.value}});p.addEventListener(Vl,u),p.dispatchEvent(h),!h.defaultPrevented&&(s.value=="keyboard"||!cR()||p.contains(document.activeElement))&&Tr(r??document.body),p.removeEventListener(Vl,u),xp.remove(i)}}return Ke(()=>{e.trapped&&d(),he(()=>e.trapped,p=>{p?d():v()})}),St(()=>{e.trapped&&v(),n.value&&(n.value.removeEventListener("keydown",a),n.value.removeEventListener("focusin",c),n.value.removeEventListener("focusout",f),n.value=void 0)}),{onKeydown:a}}});function pR(e,t,n,r,o,s){return de(e.$slots,"default",{handleKeydown:e.onKeydown})}var Hc=Ne(dR,[["render",pR],["__file","focus-trap.vue"]]);const hR=["fixed","absolute"],vR=xe({boundariesPadding:{type:Number,default:0},fallbackPlacements:{type:we(Array),default:void 0},gpuAcceleration:{type:Boolean,default:!0},offset:{type:Number,default:12},placement:{type:String,values:il,default:"bottom"},popperOptions:{type:we(Object),default:()=>({})},strategy:{type:String,values:hR,default:"absolute"}}),Og=xe({...vR,id:String,style:{type:we([String,Array,Object])},className:{type:we([String,Array,Object])},effect:{type:we(String),default:"dark"},visible:Boolean,enterable:{type:Boolean,default:!0},pure:Boolean,focusOnShow:{type:Boolean,default:!1},trapping:{type:Boolean,default:!1},popperClass:{type:we([String,Array,Object])},popperStyle:{type:we([String,Array,Object])},referenceEl:{type:we(Object)},triggerTargetEl:{type:we(Object)},stopPopperMouseEvent:{type:Boolean,default:!0},virtualTriggering:Boolean,zIndex:Number,...yr(["ariaLabel"])}),mR={mouseenter:e=>e instanceof MouseEvent,mouseleave:e=>e instanceof MouseEvent,focus:()=>!0,blur:()=>!0,close:()=>!0},gR=(e,t=[])=>{const{placement:n,strategy:r,popperOptions:o}=e,s={placement:n,strategy:r,...o,modifiers:[...yR(e),...t]};return wR(s,o==null?void 0:o.modifiers),s},bR=e=>{if(st)return sr(e)};function yR(e){const{offset:t,gpuAcceleration:n,fallbackPlacements:r}=e;return[{name:"offset",options:{offset:[0,t??12]}},{name:"preventOverflow",options:{padding:{top:2,bottom:2,left:5,right:5}}},{name:"flip",options:{padding:5,fallbackPlacements:r}},{name:"computeStyles",options:{gpuAcceleration:n}}]}function wR(e,t){t&&(e.modifiers=[...e.modifiers,...t??[]])}const SR=0,ER=e=>{const{popperInstanceRef:t,contentRef:n,triggerRef:r,role:o}=Ee(Vc,void 0),s=V(),i=V(),a=C(()=>({name:"eventListeners",enabled:!!e.visible})),l=C(()=>{var m;const S=g(s),_=(m=g(i))!=null?m:SR;return{name:"arrow",enabled:!Rm(S),options:{element:S,padding:_}}}),u=C(()=>({onFirstUpdate:()=>{p()},...gR(e,[g(l),g(a)])})),c=C(()=>bR(e.referenceEl)||g(r)),{attributes:f,state:d,styles:v,update:p,forceUpdate:h,instanceRef:b}=aA(c,n,u);return he(b,m=>t.value=m),Ke(()=>{he(()=>{var m;return(m=g(c))==null?void 0:m.getBoundingClientRect()},()=>{p()})}),{attributes:f,arrowRef:s,contentRef:n,instanceRef:b,state:d,styles:v,role:o,forceUpdate:h,update:p}},_R=(e,{attributes:t,styles:n,role:r})=>{const{nextZIndex:o}=Fc(),s=$e("popper"),i=C(()=>g(t).popper),a=V(je(e.zIndex)?e.zIndex:o()),l=C(()=>[s.b(),s.is("pure",e.pure),s.is(e.effect),e.popperClass]),u=C(()=>[{zIndex:g(a)},g(n).popper,e.popperStyle||{}]),c=C(()=>r.value==="dialog"?"false":void 0),f=C(()=>g(n).arrow||{});return{ariaModal:c,arrowStyle:f,contentAttrs:i,contentClass:l,contentStyle:u,contentZIndex:a,updateZIndex:()=>{a.value=je(e.zIndex)?e.zIndex:o()}}},CR=(e,t)=>{const n=V(!1),r=V();return{focusStartRef:r,trapped:n,onFocusAfterReleased:u=>{var c;((c=u.detail)==null?void 0:c.focusReason)!=="pointer"&&(r.value="first",t("blur"))},onFocusAfterTrapped:()=>{t("focus")},onFocusInTrap:u=>{e.visible&&!n.value&&(u.target&&(r.value=u.target),n.value=!0)},onFocusoutPrevented:u=>{e.trapping||(u.detail.focusReason==="pointer"&&u.preventDefault(),n.value=!1)},onReleaseRequested:()=>{n.value=!1,t("close")}}},TR=Z({name:"ElPopperContent"}),OR=Z({...TR,props:Og,emits:mR,setup(e,{expose:t,emit:n}){const r=e,{focusStartRef:o,trapped:s,onFocusAfterReleased:i,onFocusAfterTrapped:a,onFocusInTrap:l,onFocusoutPrevented:u,onReleaseRequested:c}=CR(r,n),{attributes:f,arrowRef:d,contentRef:v,styles:p,instanceRef:h,role:b,update:m}=ER(r),{ariaModal:S,arrowStyle:_,contentAttrs:w,contentClass:y,contentStyle:A,updateZIndex:R}=_R(r,{styles:p,attributes:f,role:b}),N=Ee(ho,void 0);ft(yg,{arrowStyle:_,arrowRef:d,arrowOffset:V()}),N&&ft(ho,{...N,addInputId:ht,removeInputId:ht});let x;const k=(F=!0)=>{m(),F&&R()},$=()=>{k(!1),r.visible&&r.focusOnShow?s.value=!0:r.visible===!1&&(s.value=!1)};return Ke(()=>{he(()=>r.triggerTargetEl,(F,Y)=>{x==null||x(),x=void 0;const P=g(F||v.value),O=g(Y||v.value);ar(P)&&(x=he([b,()=>r.ariaLabel,S,()=>r.id],j=>{["role","aria-label","aria-modal","id"].forEach((Q,me)=>{qn(j[me])?P.removeAttribute(Q):P.setAttribute(Q,j[me])})},{immediate:!0})),O!==P&&ar(O)&&["role","aria-label","aria-modal","id"].forEach(j=>{O.removeAttribute(j)})},{immediate:!0}),he(()=>r.visible,$,{immediate:!0})}),St(()=>{x==null||x(),x=void 0}),t({popperContentRef:v,popperInstanceRef:h,updatePopper:k,contentStyle:A}),(F,Y)=>(L(),ee("div",En({ref_key:"contentRef",ref:v},g(w),{style:g(A),class:g(y),tabindex:"-1",onMouseenter:P=>F.$emit("mouseenter",P),onMouseleave:P=>F.$emit("mouseleave",P)}),[re(g(Hc),{trapped:g(s),"trap-on-focus-in":!0,"focus-trap-el":g(v),"focus-start-el":g(o),onFocusAfterTrapped:g(a),onFocusAfterReleased:g(i),onFocusin:g(l),onFocusoutPrevented:g(u),onReleaseRequested:g(c)},{default:ce(()=>[de(F.$slots,"default")]),_:3},8,["trapped","focus-trap-el","focus-start-el","onFocusAfterTrapped","onFocusAfterReleased","onFocusin","onFocusoutPrevented","onReleaseRequested"])],16,["onMouseenter","onMouseleave"]))}});var AR=Ne(OR,[["__file","content.vue"]]);const RR=yt(G4),Uc=Symbol("elTooltip"),Gt=xe({...pA,...Og,appendTo:{type:we([String,Object])},content:{type:String,default:""},rawContent:Boolean,persistent:Boolean,visible:{type:we(Boolean),default:null},transition:String,teleported:{type:Boolean,default:!0},disabled:Boolean,...yr(["ariaLabel"])}),ei=xe({..._g,disabled:Boolean,trigger:{type:we([String,Array]),default:"hover"},triggerKeys:{type:we(Array),default:()=>[_n.enter,_n.space]}}),{useModelToggleProps:xR,useModelToggleEmits:IR,useModelToggle:PR}=oO("visible"),NR=xe({...wg,...xR,...Gt,...ei,...Sg,showArrow:{type:Boolean,default:!0}}),LR=[...IR,"before-show","before-hide","show","hide","open","close"],MR=(e,t)=>pe(e)?e.includes(t):e===t,Oo=(e,t,n)=>r=>{MR(g(e),t)&&n(r)},$R=Z({name:"ElTooltipTrigger"}),kR=Z({...$R,props:ei,setup(e,{expose:t}){const n=e,r=$e("tooltip"),{controlled:o,id:s,open:i,onOpen:a,onClose:l,onToggle:u}=Ee(Uc,void 0),c=V(null),f=()=>{if(g(o)||n.disabled)return!0},d=Jt(n,"trigger"),v=Qn(f,Oo(d,"hover",a)),p=Qn(f,Oo(d,"hover",l)),h=Qn(f,Oo(d,"click",w=>{w.button===0&&u(w)})),b=Qn(f,Oo(d,"focus",a)),m=Qn(f,Oo(d,"focus",l)),S=Qn(f,Oo(d,"contextmenu",w=>{w.preventDefault(),u(w)})),_=Qn(f,w=>{const{code:y}=w;n.triggerKeys.includes(y)&&(w.preventDefault(),u(w))});return t({triggerRef:c}),(w,y)=>(L(),fe(g(nR),{id:g(s),"virtual-ref":w.virtualRef,open:g(i),"virtual-triggering":w.virtualTriggering,class:U(g(r).e("trigger")),onBlur:g(m),onClick:g(h),onContextmenu:g(S),onFocus:g(b),onMouseenter:g(v),onMouseleave:g(p),onKeydown:g(_)},{default:ce(()=>[de(w.$slots,"default")]),_:3},8,["id","virtual-ref","open","virtual-triggering","class","onBlur","onClick","onContextmenu","onFocus","onMouseenter","onMouseleave","onKeydown"]))}});var FR=Ne(kR,[["__file","trigger.vue"]]);const BR=xe({to:{type:we([String,Object]),required:!0},disabled:Boolean}),DR=Z({__name:"teleport",props:BR,setup(e){return(t,n)=>t.disabled?de(t.$slots,"default",{key:0}):(L(),fe(jy,{key:1,to:t.to},[de(t.$slots,"default")],8,["to"]))}});var VR=Ne(DR,[["__file","teleport.vue"]]);const Ag=yt(VR),jR=Z({name:"ElTooltipContent",inheritAttrs:!1}),zR=Z({...jR,props:Gt,setup(e,{expose:t}){const n=e,{selector:r}=ig(),o=$e("tooltip"),s=V(null);let i;const{controlled:a,id:l,open:u,trigger:c,onClose:f,onOpen:d,onShow:v,onHide:p,onBeforeShow:h,onBeforeHide:b}=Ee(Uc,void 0),m=C(()=>n.transition||`${o.namespace.value}-fade-in-linear`),S=C(()=>n.persistent);St(()=>{i==null||i()});const _=C(()=>g(S)?!0:g(u)),w=C(()=>n.disabled?!1:g(u)),y=C(()=>n.appendTo||r.value),A=C(()=>{var O;return(O=n.style)!=null?O:{}}),R=V(!0),N=()=>{p(),R.value=!0},I=()=>{if(g(a))return!0},x=Qn(I,()=>{n.enterable&&g(c)==="hover"&&d()}),k=Qn(I,()=>{g(c)==="hover"&&f()}),$=()=>{var O,j;(j=(O=s.value)==null?void 0:O.updatePopper)==null||j.call(O),h==null||h()},F=()=>{b==null||b()},Y=()=>{v(),i=H1(C(()=>{var O;return(O=s.value)==null?void 0:O.popperContentRef}),()=>{if(g(a))return;g(c)!=="hover"&&f()})},P=()=>{n.virtualTriggering||f()};return he(()=>g(u),O=>{O?R.value=!1:i==null||i()},{flush:"post"}),he(()=>n.content,()=>{var O,j;(j=(O=s.value)==null?void 0:O.updatePopper)==null||j.call(O)}),t({contentRef:s}),(O,j)=>(L(),fe(g(Ag),{disabled:!O.teleported,to:g(y)},{default:ce(()=>[re(Fr,{name:g(m),onAfterLeave:N,onBeforeEnter:$,onAfterEnter:Y,onBeforeLeave:F},{default:ce(()=>[g(_)?ct((L(),fe(g(AR),En({key:0,id:g(l),ref_key:"contentRef",ref:s},O.$attrs,{"aria-label":O.ariaLabel,"aria-hidden":R.value,"boundaries-padding":O.boundariesPadding,"fallback-placements":O.fallbackPlacements,"gpu-acceleration":O.gpuAcceleration,offset:O.offset,placement:O.placement,"popper-options":O.popperOptions,strategy:O.strategy,effect:O.effect,enterable:O.enterable,pure:O.pure,"popper-class":O.popperClass,"popper-style":[O.popperStyle,g(A)],"reference-el":O.referenceEl,"trigger-target-el":O.triggerTargetEl,visible:g(w),"z-index":O.zIndex,onMouseenter:g(x),onMouseleave:g(k),onBlur:P,onClose:g(f)}),{default:ce(()=>[de(O.$slots,"default")]),_:3},16,["id","aria-label","aria-hidden","boundaries-padding","fallback-placements","gpu-acceleration","offset","placement","popper-options","strategy","effect","enterable","pure","popper-class","popper-style","reference-el","trigger-target-el","visible","z-index","onMouseenter","onMouseleave","onClose"])),[[en,g(w)]]):ae("v-if",!0)]),_:3},8,["name"])]),_:3},8,["disabled","to"]))}});var HR=Ne(zR,[["__file","content.vue"]]);const UR=Z({name:"ElTooltip"}),KR=Z({...UR,props:NR,emits:LR,setup(e,{expose:t,emit:n}){const r=e;dA();const o=pr(),s=V(),i=V(),a=()=>{var m;const S=g(s);S&&((m=S.popperInstanceRef)==null||m.update())},l=V(!1),u=V(),{show:c,hide:f,hasUpdateHandler:d}=PR({indicator:l,toggleReason:u}),{onOpen:v,onClose:p}=hA({showAfter:Jt(r,"showAfter"),hideAfter:Jt(r,"hideAfter"),autoClose:Jt(r,"autoClose"),open:c,close:f}),h=C(()=>Bt(r.visible)&&!d.value);ft(Uc,{controlled:h,id:o,open:Mr(l),trigger:Jt(r,"trigger"),onOpen:m=>{v(m)},onClose:m=>{p(m)},onToggle:m=>{g(l)?p(m):v(m)},onShow:()=>{n("show",u.value)},onHide:()=>{n("hide",u.value)},onBeforeShow:()=>{n("before-show",u.value)},onBeforeHide:()=>{n("before-hide",u.value)},updatePopper:a}),he(()=>r.disabled,m=>{m&&l.value&&(l.value=!1)});const b=m=>{var S,_;const w=(_=(S=i.value)==null?void 0:S.contentRef)==null?void 0:_.popperContentRef,y=(m==null?void 0:m.relatedTarget)||document.activeElement;return w&&w.contains(y)};return oc(()=>l.value&&f()),t({popperRef:s,contentRef:i,isFocusInsideContent:b,updatePopper:a,onOpen:v,onClose:p,hide:f}),(m,S)=>(L(),fe(g(RR),{ref_key:"popperRef",ref:s,role:m.role},{default:ce(()=>[re(FR,{disabled:m.disabled,trigger:m.trigger,"trigger-keys":m.triggerKeys,"virtual-ref":m.virtualRef,"virtual-triggering":m.virtualTriggering},{default:ce(()=>[m.$slots.default?de(m.$slots,"default",{key:0}):ae("v-if",!0)]),_:3},8,["disabled","trigger","trigger-keys","virtual-ref","virtual-triggering"]),re(HR,{ref_key:"contentRef",ref:i,"aria-label":m.ariaLabel,"boundaries-padding":m.boundariesPadding,content:m.content,disabled:m.disabled,effect:m.effect,enterable:m.enterable,"fallback-placements":m.fallbackPlacements,"hide-after":m.hideAfter,"gpu-acceleration":m.gpuAcceleration,offset:m.offset,persistent:m.persistent,"popper-class":m.popperClass,"popper-style":m.popperStyle,placement:m.placement,"popper-options":m.popperOptions,pure:m.pure,"raw-content":m.rawContent,"reference-el":m.referenceEl,"trigger-target-el":m.triggerTargetEl,"show-after":m.showAfter,strategy:m.strategy,teleported:m.teleported,transition:m.transition,"virtual-triggering":m.virtualTriggering,"z-index":m.zIndex,"append-to":m.appendTo},{default:ce(()=>[de(m.$slots,"content",{},()=>[m.rawContent?(L(),ee("span",{key:0,innerHTML:m.content},null,8,["innerHTML"])):(L(),ee("span",{key:1},He(m.content),1))]),m.showArrow?(L(),fe(g(X4),{key:0,"arrow-offset":m.arrowOffset},null,8,["arrow-offset"])):ae("v-if",!0)]),_:3},8,["aria-label","boundaries-padding","content","disabled","effect","enterable","fallback-placements","hide-after","gpu-acceleration","offset","persistent","popper-class","popper-style","placement","popper-options","pure","raw-content","reference-el","trigger-target-el","show-after","strategy","teleported","transition","virtual-triggering","z-index","append-to"])]),_:3},8,["role"]))}});var qR=Ne(KR,[["__file","tooltip.vue"]]);const Rg=yt(qR),WR=xe({value:{type:[String,Number],default:""},max:{type:Number,default:99},isDot:Boolean,hidden:Boolean,type:{type:String,values:["primary","success","warning","info","danger"],default:"danger"},showZero:{type:Boolean,default:!0},color:String,badgeStyle:{type:we([String,Object,Array])},offset:{type:we(Array),default:[0,0]},badgeClass:{type:String}}),GR=Z({name:"ElBadge"}),YR=Z({...GR,props:WR,setup(e,{expose:t}){const n=e,r=$e("badge"),o=C(()=>n.isDot?"":je(n.value)&&je(n.max)?n.max{var i,a,l,u,c;return[{backgroundColor:n.color,marginRight:pn(-((a=(i=n.offset)==null?void 0:i[0])!=null?a:0)),marginTop:pn((u=(l=n.offset)==null?void 0:l[1])!=null?u:0)},(c=n.badgeStyle)!=null?c:{}]});return t({content:o}),(i,a)=>(L(),ee("div",{class:U(g(r).b())},[de(i.$slots,"default"),re(Fr,{name:`${g(r).namespace.value}-zoom-in-center`,persisted:""},{default:ce(()=>[ct(le("sup",{class:U([g(r).e("content"),g(r).em("content",i.type),g(r).is("fixed",!!i.$slots.default),g(r).is("dot",i.isDot),g(r).is("hide-zero",!i.showZero&&n.value===0),i.badgeClass]),style:ot(g(s)),textContent:He(g(o))},null,14,["textContent"]),[[en,!i.hidden&&(g(o)||i.isDot)]])]),_:1},8,["name"])],2))}});var JR=Ne(YR,[["__file","badge.vue"]]);const XR=yt(JR),xg=Symbol("buttonGroupContextKey"),ZR=(e,t)=>{xs({from:"type.text",replacement:"link",version:"3.0.0",scope:"props",ref:"https://element-plus.org/en-US/component/button.html#button-attributes"},C(()=>e.type==="text"));const n=Ee(xg,void 0),r=ll("button"),{form:o}=Vr(),s=In(C(()=>n==null?void 0:n.size)),i=ns(),a=V(),l=go(),u=C(()=>e.type||(n==null?void 0:n.type)||""),c=C(()=>{var p,h,b;return(b=(h=e.autoInsertSpace)!=null?h:(p=r.value)==null?void 0:p.autoInsertSpace)!=null?b:!1}),f=C(()=>e.tag==="button"?{ariaDisabled:i.value||e.loading,disabled:i.value||e.loading,autofocus:e.autofocus,type:e.nativeType}:{}),d=C(()=>{var p;const h=(p=l.default)==null?void 0:p.call(l);if(c.value&&(h==null?void 0:h.length)===1){const b=h[0];if((b==null?void 0:b.type)===Zo){const m=b.children;return new RegExp("^\\p{Unified_Ideograph}{2}$","u").test(m.trim())}}return!1});return{_disabled:i,_size:s,_type:u,_ref:a,_props:f,shouldAddSpace:d,handleClick:p=>{if(i.value||e.loading){p.stopPropagation();return}e.nativeType==="reset"&&(o==null||o.resetFields()),t("click",p)}}},QR=["default","primary","success","warning","info","danger","text",""],ex=["button","submit","reset"],Lu=xe({size:So,disabled:Boolean,type:{type:String,values:QR,default:""},icon:{type:jt},nativeType:{type:String,values:ex,default:"button"},loading:Boolean,loadingIcon:{type:jt,default:()=>Js},plain:Boolean,text:Boolean,link:Boolean,bg:Boolean,autofocus:Boolean,round:Boolean,circle:Boolean,color:String,dark:Boolean,autoInsertSpace:{type:Boolean,default:void 0},tag:{type:we([String,Object]),default:"button"}}),tx={click:e=>e instanceof MouseEvent};function Tt(e,t){nx(e)&&(e="100%");var n=rx(e);return e=t===360?e:Math.min(t,Math.max(0,parseFloat(e))),n&&(e=parseInt(String(e*t),10)/100),Math.abs(e-t)<1e-6?1:(t===360?e=(e<0?e%t+t:e%t)/parseFloat(String(t)):e=e%t/parseFloat(String(t)),e)}function ki(e){return Math.min(1,Math.max(0,e))}function nx(e){return typeof e=="string"&&e.indexOf(".")!==-1&&parseFloat(e)===1}function rx(e){return typeof e=="string"&&e.indexOf("%")!==-1}function Ig(e){return e=parseFloat(e),(isNaN(e)||e<0||e>1)&&(e=1),e}function Fi(e){return e<=1?"".concat(Number(e)*100,"%"):e}function to(e){return e.length===1?"0"+e:String(e)}function ox(e,t,n){return{r:Tt(e,255)*255,g:Tt(t,255)*255,b:Tt(n,255)*255}}function Pp(e,t,n){e=Tt(e,255),t=Tt(t,255),n=Tt(n,255);var r=Math.max(e,t,n),o=Math.min(e,t,n),s=0,i=0,a=(r+o)/2;if(r===o)i=0,s=0;else{var l=r-o;switch(i=a>.5?l/(2-r-o):l/(r+o),r){case e:s=(t-n)/l+(t1&&(n-=1),n<1/6?e+(t-e)*(6*n):n<1/2?t:n<2/3?e+(t-e)*(2/3-n)*6:e}function sx(e,t,n){var r,o,s;if(e=Tt(e,360),t=Tt(t,100),n=Tt(n,100),t===0)o=n,s=n,r=n;else{var i=n<.5?n*(1+t):n+t-n*t,a=2*n-i;r=jl(a,i,e+1/3),o=jl(a,i,e),s=jl(a,i,e-1/3)}return{r:r*255,g:o*255,b:s*255}}function Np(e,t,n){e=Tt(e,255),t=Tt(t,255),n=Tt(n,255);var r=Math.max(e,t,n),o=Math.min(e,t,n),s=0,i=r,a=r-o,l=r===0?0:a/r;if(r===o)s=0;else{switch(r){case e:s=(t-n)/a+(t>16,g:(e&65280)>>8,b:e&255}}var Mu={aliceblue:"#f0f8ff",antiquewhite:"#faebd7",aqua:"#00ffff",aquamarine:"#7fffd4",azure:"#f0ffff",beige:"#f5f5dc",bisque:"#ffe4c4",black:"#000000",blanchedalmond:"#ffebcd",blue:"#0000ff",blueviolet:"#8a2be2",brown:"#a52a2a",burlywood:"#deb887",cadetblue:"#5f9ea0",chartreuse:"#7fff00",chocolate:"#d2691e",coral:"#ff7f50",cornflowerblue:"#6495ed",cornsilk:"#fff8dc",crimson:"#dc143c",cyan:"#00ffff",darkblue:"#00008b",darkcyan:"#008b8b",darkgoldenrod:"#b8860b",darkgray:"#a9a9a9",darkgreen:"#006400",darkgrey:"#a9a9a9",darkkhaki:"#bdb76b",darkmagenta:"#8b008b",darkolivegreen:"#556b2f",darkorange:"#ff8c00",darkorchid:"#9932cc",darkred:"#8b0000",darksalmon:"#e9967a",darkseagreen:"#8fbc8f",darkslateblue:"#483d8b",darkslategray:"#2f4f4f",darkslategrey:"#2f4f4f",darkturquoise:"#00ced1",darkviolet:"#9400d3",deeppink:"#ff1493",deepskyblue:"#00bfff",dimgray:"#696969",dimgrey:"#696969",dodgerblue:"#1e90ff",firebrick:"#b22222",floralwhite:"#fffaf0",forestgreen:"#228b22",fuchsia:"#ff00ff",gainsboro:"#dcdcdc",ghostwhite:"#f8f8ff",goldenrod:"#daa520",gold:"#ffd700",gray:"#808080",green:"#008000",greenyellow:"#adff2f",grey:"#808080",honeydew:"#f0fff0",hotpink:"#ff69b4",indianred:"#cd5c5c",indigo:"#4b0082",ivory:"#fffff0",khaki:"#f0e68c",lavenderblush:"#fff0f5",lavender:"#e6e6fa",lawngreen:"#7cfc00",lemonchiffon:"#fffacd",lightblue:"#add8e6",lightcoral:"#f08080",lightcyan:"#e0ffff",lightgoldenrodyellow:"#fafad2",lightgray:"#d3d3d3",lightgreen:"#90ee90",lightgrey:"#d3d3d3",lightpink:"#ffb6c1",lightsalmon:"#ffa07a",lightseagreen:"#20b2aa",lightskyblue:"#87cefa",lightslategray:"#778899",lightslategrey:"#778899",lightsteelblue:"#b0c4de",lightyellow:"#ffffe0",lime:"#00ff00",limegreen:"#32cd32",linen:"#faf0e6",magenta:"#ff00ff",maroon:"#800000",mediumaquamarine:"#66cdaa",mediumblue:"#0000cd",mediumorchid:"#ba55d3",mediumpurple:"#9370db",mediumseagreen:"#3cb371",mediumslateblue:"#7b68ee",mediumspringgreen:"#00fa9a",mediumturquoise:"#48d1cc",mediumvioletred:"#c71585",midnightblue:"#191970",mintcream:"#f5fffa",mistyrose:"#ffe4e1",moccasin:"#ffe4b5",navajowhite:"#ffdead",navy:"#000080",oldlace:"#fdf5e6",olive:"#808000",olivedrab:"#6b8e23",orange:"#ffa500",orangered:"#ff4500",orchid:"#da70d6",palegoldenrod:"#eee8aa",palegreen:"#98fb98",paleturquoise:"#afeeee",palevioletred:"#db7093",papayawhip:"#ffefd5",peachpuff:"#ffdab9",peru:"#cd853f",pink:"#ffc0cb",plum:"#dda0dd",powderblue:"#b0e0e6",purple:"#800080",rebeccapurple:"#663399",red:"#ff0000",rosybrown:"#bc8f8f",royalblue:"#4169e1",saddlebrown:"#8b4513",salmon:"#fa8072",sandybrown:"#f4a460",seagreen:"#2e8b57",seashell:"#fff5ee",sienna:"#a0522d",silver:"#c0c0c0",skyblue:"#87ceeb",slateblue:"#6a5acd",slategray:"#708090",slategrey:"#708090",snow:"#fffafa",springgreen:"#00ff7f",steelblue:"#4682b4",tan:"#d2b48c",teal:"#008080",thistle:"#d8bfd8",tomato:"#ff6347",turquoise:"#40e0d0",violet:"#ee82ee",wheat:"#f5deb3",white:"#ffffff",whitesmoke:"#f5f5f5",yellow:"#ffff00",yellowgreen:"#9acd32"};function cx(e){var t={r:0,g:0,b:0},n=1,r=null,o=null,s=null,i=!1,a=!1;return typeof e=="string"&&(e=px(e)),typeof e=="object"&&(Xn(e.r)&&Xn(e.g)&&Xn(e.b)?(t=ox(e.r,e.g,e.b),i=!0,a=String(e.r).substr(-1)==="%"?"prgb":"rgb"):Xn(e.h)&&Xn(e.s)&&Xn(e.v)?(r=Fi(e.s),o=Fi(e.v),t=ix(e.h,r,o),i=!0,a="hsv"):Xn(e.h)&&Xn(e.s)&&Xn(e.l)&&(r=Fi(e.s),s=Fi(e.l),t=sx(e.h,r,s),i=!0,a="hsl"),Object.prototype.hasOwnProperty.call(e,"a")&&(n=e.a)),n=Ig(n),{ok:i,format:e.format||a,r:Math.min(255,Math.max(t.r,0)),g:Math.min(255,Math.max(t.g,0)),b:Math.min(255,Math.max(t.b,0)),a:n}}var fx="[-\\+]?\\d+%?",dx="[-\\+]?\\d*\\.\\d+%?",Nr="(?:".concat(dx,")|(?:").concat(fx,")"),zl="[\\s|\\(]+(".concat(Nr,")[,|\\s]+(").concat(Nr,")[,|\\s]+(").concat(Nr,")\\s*\\)?"),Hl="[\\s|\\(]+(".concat(Nr,")[,|\\s]+(").concat(Nr,")[,|\\s]+(").concat(Nr,")[,|\\s]+(").concat(Nr,")\\s*\\)?"),gn={CSS_UNIT:new RegExp(Nr),rgb:new RegExp("rgb"+zl),rgba:new RegExp("rgba"+Hl),hsl:new RegExp("hsl"+zl),hsla:new RegExp("hsla"+Hl),hsv:new RegExp("hsv"+zl),hsva:new RegExp("hsva"+Hl),hex3:/^#?([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$/,hex6:/^#?([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/,hex4:/^#?([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$/,hex8:/^#?([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/};function px(e){if(e=e.trim().toLowerCase(),e.length===0)return!1;var t=!1;if(Mu[e])e=Mu[e],t=!0;else if(e==="transparent")return{r:0,g:0,b:0,a:0,format:"name"};var n=gn.rgb.exec(e);return n?{r:n[1],g:n[2],b:n[3]}:(n=gn.rgba.exec(e),n?{r:n[1],g:n[2],b:n[3],a:n[4]}:(n=gn.hsl.exec(e),n?{h:n[1],s:n[2],l:n[3]}:(n=gn.hsla.exec(e),n?{h:n[1],s:n[2],l:n[3],a:n[4]}:(n=gn.hsv.exec(e),n?{h:n[1],s:n[2],v:n[3]}:(n=gn.hsva.exec(e),n?{h:n[1],s:n[2],v:n[3],a:n[4]}:(n=gn.hex8.exec(e),n?{r:Wt(n[1]),g:Wt(n[2]),b:Wt(n[3]),a:Mp(n[4]),format:t?"name":"hex8"}:(n=gn.hex6.exec(e),n?{r:Wt(n[1]),g:Wt(n[2]),b:Wt(n[3]),format:t?"name":"hex"}:(n=gn.hex4.exec(e),n?{r:Wt(n[1]+n[1]),g:Wt(n[2]+n[2]),b:Wt(n[3]+n[3]),a:Mp(n[4]+n[4]),format:t?"name":"hex8"}:(n=gn.hex3.exec(e),n?{r:Wt(n[1]+n[1]),g:Wt(n[2]+n[2]),b:Wt(n[3]+n[3]),format:t?"name":"hex"}:!1)))))))))}function Xn(e){return!!gn.CSS_UNIT.exec(String(e))}var hx=function(){function e(t,n){t===void 0&&(t=""),n===void 0&&(n={});var r;if(t instanceof e)return t;typeof t=="number"&&(t=ux(t)),this.originalInput=t;var o=cx(t);this.originalInput=t,this.r=o.r,this.g=o.g,this.b=o.b,this.a=o.a,this.roundA=Math.round(100*this.a)/100,this.format=(r=n.format)!==null&&r!==void 0?r:o.format,this.gradientType=n.gradientType,this.r<1&&(this.r=Math.round(this.r)),this.g<1&&(this.g=Math.round(this.g)),this.b<1&&(this.b=Math.round(this.b)),this.isValid=o.ok}return e.prototype.isDark=function(){return this.getBrightness()<128},e.prototype.isLight=function(){return!this.isDark()},e.prototype.getBrightness=function(){var t=this.toRgb();return(t.r*299+t.g*587+t.b*114)/1e3},e.prototype.getLuminance=function(){var t=this.toRgb(),n,r,o,s=t.r/255,i=t.g/255,a=t.b/255;return s<=.03928?n=s/12.92:n=Math.pow((s+.055)/1.055,2.4),i<=.03928?r=i/12.92:r=Math.pow((i+.055)/1.055,2.4),a<=.03928?o=a/12.92:o=Math.pow((a+.055)/1.055,2.4),.2126*n+.7152*r+.0722*o},e.prototype.getAlpha=function(){return this.a},e.prototype.setAlpha=function(t){return this.a=Ig(t),this.roundA=Math.round(100*this.a)/100,this},e.prototype.isMonochrome=function(){var t=this.toHsl().s;return t===0},e.prototype.toHsv=function(){var t=Np(this.r,this.g,this.b);return{h:t.h*360,s:t.s,v:t.v,a:this.a}},e.prototype.toHsvString=function(){var t=Np(this.r,this.g,this.b),n=Math.round(t.h*360),r=Math.round(t.s*100),o=Math.round(t.v*100);return this.a===1?"hsv(".concat(n,", ").concat(r,"%, ").concat(o,"%)"):"hsva(".concat(n,", ").concat(r,"%, ").concat(o,"%, ").concat(this.roundA,")")},e.prototype.toHsl=function(){var t=Pp(this.r,this.g,this.b);return{h:t.h*360,s:t.s,l:t.l,a:this.a}},e.prototype.toHslString=function(){var t=Pp(this.r,this.g,this.b),n=Math.round(t.h*360),r=Math.round(t.s*100),o=Math.round(t.l*100);return this.a===1?"hsl(".concat(n,", ").concat(r,"%, ").concat(o,"%)"):"hsla(".concat(n,", ").concat(r,"%, ").concat(o,"%, ").concat(this.roundA,")")},e.prototype.toHex=function(t){return t===void 0&&(t=!1),Lp(this.r,this.g,this.b,t)},e.prototype.toHexString=function(t){return t===void 0&&(t=!1),"#"+this.toHex(t)},e.prototype.toHex8=function(t){return t===void 0&&(t=!1),ax(this.r,this.g,this.b,this.a,t)},e.prototype.toHex8String=function(t){return t===void 0&&(t=!1),"#"+this.toHex8(t)},e.prototype.toHexShortString=function(t){return t===void 0&&(t=!1),this.a===1?this.toHexString(t):this.toHex8String(t)},e.prototype.toRgb=function(){return{r:Math.round(this.r),g:Math.round(this.g),b:Math.round(this.b),a:this.a}},e.prototype.toRgbString=function(){var t=Math.round(this.r),n=Math.round(this.g),r=Math.round(this.b);return this.a===1?"rgb(".concat(t,", ").concat(n,", ").concat(r,")"):"rgba(".concat(t,", ").concat(n,", ").concat(r,", ").concat(this.roundA,")")},e.prototype.toPercentageRgb=function(){var t=function(n){return"".concat(Math.round(Tt(n,255)*100),"%")};return{r:t(this.r),g:t(this.g),b:t(this.b),a:this.a}},e.prototype.toPercentageRgbString=function(){var t=function(n){return Math.round(Tt(n,255)*100)};return this.a===1?"rgb(".concat(t(this.r),"%, ").concat(t(this.g),"%, ").concat(t(this.b),"%)"):"rgba(".concat(t(this.r),"%, ").concat(t(this.g),"%, ").concat(t(this.b),"%, ").concat(this.roundA,")")},e.prototype.toName=function(){if(this.a===0)return"transparent";if(this.a<1)return!1;for(var t="#"+Lp(this.r,this.g,this.b,!1),n=0,r=Object.entries(Mu);n=0,s=!n&&o&&(t.startsWith("hex")||t==="name");return s?t==="name"&&this.a===0?this.toName():this.toRgbString():(t==="rgb"&&(r=this.toRgbString()),t==="prgb"&&(r=this.toPercentageRgbString()),(t==="hex"||t==="hex6")&&(r=this.toHexString()),t==="hex3"&&(r=this.toHexString(!0)),t==="hex4"&&(r=this.toHex8String(!0)),t==="hex8"&&(r=this.toHex8String()),t==="name"&&(r=this.toName()),t==="hsl"&&(r=this.toHslString()),t==="hsv"&&(r=this.toHsvString()),r||this.toHexString())},e.prototype.toNumber=function(){return(Math.round(this.r)<<16)+(Math.round(this.g)<<8)+Math.round(this.b)},e.prototype.clone=function(){return new e(this.toString())},e.prototype.lighten=function(t){t===void 0&&(t=10);var n=this.toHsl();return n.l+=t/100,n.l=ki(n.l),new e(n)},e.prototype.brighten=function(t){t===void 0&&(t=10);var n=this.toRgb();return n.r=Math.max(0,Math.min(255,n.r-Math.round(255*-(t/100)))),n.g=Math.max(0,Math.min(255,n.g-Math.round(255*-(t/100)))),n.b=Math.max(0,Math.min(255,n.b-Math.round(255*-(t/100)))),new e(n)},e.prototype.darken=function(t){t===void 0&&(t=10);var n=this.toHsl();return n.l-=t/100,n.l=ki(n.l),new e(n)},e.prototype.tint=function(t){return t===void 0&&(t=10),this.mix("white",t)},e.prototype.shade=function(t){return t===void 0&&(t=10),this.mix("black",t)},e.prototype.desaturate=function(t){t===void 0&&(t=10);var n=this.toHsl();return n.s-=t/100,n.s=ki(n.s),new e(n)},e.prototype.saturate=function(t){t===void 0&&(t=10);var n=this.toHsl();return n.s+=t/100,n.s=ki(n.s),new e(n)},e.prototype.greyscale=function(){return this.desaturate(100)},e.prototype.spin=function(t){var n=this.toHsl(),r=(n.h+t)%360;return n.h=r<0?360+r:r,new e(n)},e.prototype.mix=function(t,n){n===void 0&&(n=50);var r=this.toRgb(),o=new e(t).toRgb(),s=n/100,i={r:(o.r-r.r)*s+r.r,g:(o.g-r.g)*s+r.g,b:(o.b-r.b)*s+r.b,a:(o.a-r.a)*s+r.a};return new e(i)},e.prototype.analogous=function(t,n){t===void 0&&(t=6),n===void 0&&(n=30);var r=this.toHsl(),o=360/n,s=[this];for(r.h=(r.h-(o*t>>1)+720)%360;--t;)r.h=(r.h+o)%360,s.push(new e(r));return s},e.prototype.complement=function(){var t=this.toHsl();return t.h=(t.h+180)%360,new e(t)},e.prototype.monochromatic=function(t){t===void 0&&(t=6);for(var n=this.toHsv(),r=n.h,o=n.s,s=n.v,i=[],a=1/t;t--;)i.push(new e({h:r,s:o,v:s})),s=(s+a)%1;return i},e.prototype.splitcomplement=function(){var t=this.toHsl(),n=t.h;return[this,new e({h:(n+72)%360,s:t.s,l:t.l}),new e({h:(n+216)%360,s:t.s,l:t.l})]},e.prototype.onBackground=function(t){var n=this.toRgb(),r=new e(t).toRgb(),o=n.a+r.a*(1-n.a);return new e({r:(n.r*n.a+r.r*r.a*(1-n.a))/o,g:(n.g*n.a+r.g*r.a*(1-n.a))/o,b:(n.b*n.a+r.b*r.a*(1-n.a))/o,a:o})},e.prototype.triad=function(){return this.polyad(3)},e.prototype.tetrad=function(){return this.polyad(4)},e.prototype.polyad=function(t){for(var n=this.toHsl(),r=n.h,o=[this],s=360/t,i=1;i{let r={},o=e.color;if(o){const s=o.match(/var\((.*?)\)/);s&&(o=window.getComputedStyle(window.document.documentElement).getPropertyValue(s[1]));const i=new hx(o),a=e.dark?i.tint(20).toString():_r(i,20);if(e.plain)r=n.cssVarBlock({"bg-color":e.dark?_r(i,90):i.tint(90).toString(),"text-color":o,"border-color":e.dark?_r(i,50):i.tint(50).toString(),"hover-text-color":`var(${n.cssVarName("color-white")})`,"hover-bg-color":o,"hover-border-color":o,"active-bg-color":a,"active-text-color":`var(${n.cssVarName("color-white")})`,"active-border-color":a}),t.value&&(r[n.cssVarBlockName("disabled-bg-color")]=e.dark?_r(i,90):i.tint(90).toString(),r[n.cssVarBlockName("disabled-text-color")]=e.dark?_r(i,50):i.tint(50).toString(),r[n.cssVarBlockName("disabled-border-color")]=e.dark?_r(i,80):i.tint(80).toString());else{const l=e.dark?_r(i,30):i.tint(30).toString(),u=i.isDark()?`var(${n.cssVarName("color-white")})`:`var(${n.cssVarName("color-black")})`;if(r=n.cssVarBlock({"bg-color":o,"text-color":u,"border-color":o,"hover-bg-color":l,"hover-text-color":u,"hover-border-color":l,"active-bg-color":a,"active-border-color":a}),t.value){const c=e.dark?_r(i,50):i.tint(50).toString();r[n.cssVarBlockName("disabled-bg-color")]=c,r[n.cssVarBlockName("disabled-text-color")]=e.dark?"rgba(255, 255, 255, 0.5)":`var(${n.cssVarName("color-white")})`,r[n.cssVarBlockName("disabled-border-color")]=c}}}return r})}const mx=Z({name:"ElButton"}),gx=Z({...mx,props:Lu,emits:tx,setup(e,{expose:t,emit:n}){const r=e,o=vx(r),s=$e("button"),{_ref:i,_size:a,_type:l,_disabled:u,_props:c,shouldAddSpace:f,handleClick:d}=ZR(r,n),v=C(()=>[s.b(),s.m(l.value),s.m(a.value),s.is("disabled",u.value),s.is("loading",r.loading),s.is("plain",r.plain),s.is("round",r.round),s.is("circle",r.circle),s.is("text",r.text),s.is("link",r.link),s.is("has-bg",r.bg)]);return t({ref:i,size:a,type:l,disabled:u,shouldAddSpace:f}),(p,h)=>(L(),fe(Xe(p.tag),En({ref_key:"_ref",ref:i},g(c),{class:g(v),style:g(o),onClick:g(d)}),{default:ce(()=>[p.loading?(L(),ee(nt,{key:0},[p.$slots.loading?de(p.$slots,"loading",{key:0}):(L(),fe(g(Je),{key:1,class:U(g(s).is("loading"))},{default:ce(()=>[(L(),fe(Xe(p.loadingIcon)))]),_:1},8,["class"]))],64)):p.icon||p.$slots.icon?(L(),fe(g(Je),{key:1},{default:ce(()=>[p.icon?(L(),fe(Xe(p.icon),{key:0})):de(p.$slots,"icon",{key:1})]),_:3})):ae("v-if",!0),p.$slots.default?(L(),ee("span",{key:2,class:U({[g(s).em("text","expand")]:g(f)})},[de(p.$slots,"default")],2)):ae("v-if",!0)]),_:3},16,["class","style","onClick"]))}});var bx=Ne(gx,[["__file","button.vue"]]);const yx={size:Lu.size,type:Lu.type},wx=Z({name:"ElButtonGroup"}),Sx=Z({...wx,props:yx,setup(e){const t=e;ft(xg,wt({size:Jt(t,"size"),type:Jt(t,"type")}));const n=$e("button");return(r,o)=>(L(),ee("div",{class:U(g(n).b("group"))},[de(r.$slots,"default")],2))}});var Pg=Ne(Sx,[["__file","button-group.vue"]]);const Ex=yt(bx,{ButtonGroup:Pg});wo(Pg);const Or=new Map;if(st){let e;document.addEventListener("mousedown",t=>e=t),document.addEventListener("mouseup",t=>{if(e){for(const n of Or.values())for(const{documentHandler:r}of n)r(t,e);e=void 0}})}function $p(e,t){let n=[];return Array.isArray(t.arg)?n=t.arg:ar(t.arg)&&n.push(t.arg),function(r,o){const s=t.instance.popperRef,i=r.target,a=o==null?void 0:o.target,l=!t||!t.instance,u=!i||!a,c=e.contains(i)||e.contains(a),f=e===i,d=n.length&&n.some(p=>p==null?void 0:p.contains(i))||n.length&&n.includes(a),v=s&&(s.contains(i)||s.contains(a));l||u||c||f||d||v||t.value(r,o)}}const _x={beforeMount(e,t){Or.has(e)||Or.set(e,[]),Or.get(e).push({documentHandler:$p(e,t),bindingFn:t.value})},updated(e,t){Or.has(e)||Or.set(e,[]);const n=Or.get(e),r=n.findIndex(s=>s.bindingFn===t.oldValue),o={documentHandler:$p(e,t),bindingFn:t.value};r>=0?n.splice(r,1,o):n.push(o)},unmounted(e){Or.delete(e)}},Cx=100,Tx=600,kp={beforeMount(e,t){const n=t.value,{interval:r=Cx,delay:o=Tx}=ve(n)?{}:n;let s,i;const a=()=>ve(n)?n():n.handler(),l=()=>{i&&(clearTimeout(i),i=void 0),s&&(clearInterval(s),s=void 0)};e.addEventListener("mousedown",u=>{u.button===0&&(l(),a(),document.addEventListener("mouseup",()=>l(),{once:!0}),i=setTimeout(()=>{s=setInterval(()=>{a()},r)},o))})}},$u="_trap-focus-children",no=[],Fp=e=>{if(no.length===0)return;const t=no[no.length-1][$u];if(t.length>0&&e.code===_n.tab){if(t.length===1){e.preventDefault(),document.activeElement!==t[0]&&t[0].focus();return}const n=e.shiftKey,r=e.target===t[0],o=e.target===t[t.length-1];r&&n&&(e.preventDefault(),t[t.length-1].focus()),o&&!n&&(e.preventDefault(),t[0].focus())}},Ox={beforeMount(e){e[$u]=ld(e),no.push(e),no.length<=1&&document.addEventListener("keydown",Fp)},updated(e){Ie(()=>{e[$u]=ld(e)})},unmounted(){no.shift(),no.length===0&&document.removeEventListener("keydown",Fp)}},Ng={modelValue:{type:[Number,String,Boolean],default:void 0},label:{type:[String,Boolean,Number,Object],default:void 0},value:{type:[String,Boolean,Number,Object],default:void 0},indeterminate:Boolean,disabled:Boolean,checked:Boolean,name:{type:String,default:void 0},trueValue:{type:[String,Number],default:void 0},falseValue:{type:[String,Number],default:void 0},trueLabel:{type:[String,Number],default:void 0},falseLabel:{type:[String,Number],default:void 0},id:{type:String,default:void 0},border:Boolean,size:So,tabindex:[String,Number],validateEvent:{type:Boolean,default:!0},...yr(["ariaControls"])},Lg={[rt]:e=>Ae(e)||je(e)||Bt(e),change:e=>Ae(e)||je(e)||Bt(e)},rs=Symbol("checkboxGroupContextKey"),Ax=({model:e,isChecked:t})=>{const n=Ee(rs,void 0),r=C(()=>{var s,i;const a=(s=n==null?void 0:n.max)==null?void 0:s.value,l=(i=n==null?void 0:n.min)==null?void 0:i.value;return!Lt(a)&&e.value.length>=a&&!t.value||!Lt(l)&&e.value.length<=l&&t.value});return{isDisabled:ns(C(()=>(n==null?void 0:n.disabled.value)||r.value)),isLimitDisabled:r}},Rx=(e,{model:t,isLimitExceeded:n,hasOwnLabel:r,isDisabled:o,isLabeledByFormItem:s})=>{const i=Ee(rs,void 0),{formItem:a}=Vr(),{emit:l}=Ge();function u(p){var h,b,m,S;return[!0,e.trueValue,e.trueLabel].includes(p)?(b=(h=e.trueValue)!=null?h:e.trueLabel)!=null?b:!0:(S=(m=e.falseValue)!=null?m:e.falseLabel)!=null?S:!1}function c(p,h){l("change",u(p),h)}function f(p){if(n.value)return;const h=p.target;l("change",u(h.checked),p)}async function d(p){n.value||!r.value&&!o.value&&s.value&&(p.composedPath().some(m=>m.tagName==="LABEL")||(t.value=u([!1,e.falseValue,e.falseLabel].includes(t.value)),await Ie(),c(t.value,p)))}const v=C(()=>(i==null?void 0:i.validateEvent)||e.validateEvent);return he(()=>e.modelValue,()=>{v.value&&(a==null||a.validate("change").catch(p=>void 0))}),{handleChange:f,onClickRoot:d}},xx=e=>{const t=V(!1),{emit:n}=Ge(),r=Ee(rs,void 0),o=C(()=>Lt(r)===!1),s=V(!1),i=C({get(){var a,l;return o.value?(a=r==null?void 0:r.modelValue)==null?void 0:a.value:(l=e.modelValue)!=null?l:t.value},set(a){var l,u;o.value&&pe(a)?(s.value=((l=r==null?void 0:r.max)==null?void 0:l.value)!==void 0&&a.length>(r==null?void 0:r.max.value)&&a.length>i.value.length,s.value===!1&&((u=r==null?void 0:r.changeEvent)==null||u.call(r,a))):(n(rt,a),t.value=a)}});return{model:i,isGroup:o,isLimitExceeded:s}},Ix=(e,t,{model:n})=>{const r=Ee(rs,void 0),o=V(!1),s=C(()=>Cu(e.value)?e.label:e.value),i=C(()=>{const c=n.value;return Bt(c)?c:pe(c)?Oe(s.value)?c.map(Me).some(f=>Aa(f,s.value)):c.map(Me).includes(s.value):c!=null?c===e.trueValue||c===e.trueLabel:!!c}),a=In(C(()=>{var c;return(c=r==null?void 0:r.size)==null?void 0:c.value}),{prop:!0}),l=In(C(()=>{var c;return(c=r==null?void 0:r.size)==null?void 0:c.value})),u=C(()=>!!t.default||!Cu(s.value));return{checkboxButtonSize:a,isChecked:i,isFocused:o,checkboxSize:l,hasOwnLabel:u,actualValue:s}},Mg=(e,t)=>{const{formItem:n}=Vr(),{model:r,isGroup:o,isLimitExceeded:s}=xx(e),{isFocused:i,isChecked:a,checkboxButtonSize:l,checkboxSize:u,hasOwnLabel:c,actualValue:f}=Ix(e,t,{model:r}),{isDisabled:d}=Ax({model:r,isChecked:a}),{inputId:v,isLabeledByFormItem:p}=hi(e,{formItemContext:n,disableIdGeneration:c,disableIdManagement:o}),{handleChange:h,onClickRoot:b}=Rx(e,{model:r,isLimitExceeded:s,hasOwnLabel:c,isDisabled:d,isLabeledByFormItem:p});return(()=>{function S(){var _,w;pe(r.value)&&!r.value.includes(f.value)?r.value.push(f.value):r.value=(w=(_=e.trueValue)!=null?_:e.trueLabel)!=null?w:!0}e.checked&&S()})(),xs({from:"label act as value",replacement:"value",version:"3.0.0",scope:"el-checkbox",ref:"https://element-plus.org/en-US/component/checkbox.html"},C(()=>o.value&&Cu(e.value))),xs({from:"true-label",replacement:"true-value",version:"3.0.0",scope:"el-checkbox",ref:"https://element-plus.org/en-US/component/checkbox.html"},C(()=>!!e.trueLabel)),xs({from:"false-label",replacement:"false-value",version:"3.0.0",scope:"el-checkbox",ref:"https://element-plus.org/en-US/component/checkbox.html"},C(()=>!!e.falseLabel)),{inputId:v,isLabeledByFormItem:p,isChecked:a,isDisabled:d,isFocused:i,checkboxButtonSize:l,checkboxSize:u,hasOwnLabel:c,model:r,actualValue:f,handleChange:h,onClickRoot:b}},Px=Z({name:"ElCheckbox"}),Nx=Z({...Px,props:Ng,emits:Lg,setup(e){const t=e,n=go(),{inputId:r,isLabeledByFormItem:o,isChecked:s,isDisabled:i,isFocused:a,checkboxSize:l,hasOwnLabel:u,model:c,actualValue:f,handleChange:d,onClickRoot:v}=Mg(t,n),p=$e("checkbox"),h=C(()=>[p.b(),p.m(l.value),p.is("disabled",i.value),p.is("bordered",t.border),p.is("checked",s.value)]),b=C(()=>[p.e("input"),p.is("disabled",i.value),p.is("checked",s.value),p.is("indeterminate",t.indeterminate),p.is("focus",a.value)]);return(m,S)=>(L(),fe(Xe(!g(u)&&g(o)?"span":"label"),{class:U(g(h)),"aria-controls":m.indeterminate?m.ariaControls:null,onClick:g(v)},{default:ce(()=>{var _,w,y,A;return[le("span",{class:U(g(b))},[m.trueValue||m.falseValue||m.trueLabel||m.falseLabel?ct((L(),ee("input",{key:0,id:g(r),"onUpdate:modelValue":R=>Ue(c)?c.value=R:null,class:U(g(p).e("original")),type:"checkbox",indeterminate:m.indeterminate,name:m.name,tabindex:m.tabindex,disabled:g(i),"true-value":(w=(_=m.trueValue)!=null?_:m.trueLabel)!=null?w:!0,"false-value":(A=(y=m.falseValue)!=null?y:m.falseLabel)!=null?A:!1,onChange:g(d),onFocus:R=>a.value=!0,onBlur:R=>a.value=!1,onClick:tt(()=>{},["stop"])},null,42,["id","onUpdate:modelValue","indeterminate","name","tabindex","disabled","true-value","false-value","onChange","onFocus","onBlur","onClick"])),[[ya,g(c)]]):ct((L(),ee("input",{key:1,id:g(r),"onUpdate:modelValue":R=>Ue(c)?c.value=R:null,class:U(g(p).e("original")),type:"checkbox",indeterminate:m.indeterminate,disabled:g(i),value:g(f),name:m.name,tabindex:m.tabindex,onChange:g(d),onFocus:R=>a.value=!0,onBlur:R=>a.value=!1,onClick:tt(()=>{},["stop"])},null,42,["id","onUpdate:modelValue","indeterminate","disabled","value","name","tabindex","onChange","onFocus","onBlur","onClick"])),[[ya,g(c)]]),le("span",{class:U(g(p).e("inner"))},null,2)],2),g(u)?(L(),ee("span",{key:0,class:U(g(p).e("label"))},[de(m.$slots,"default"),m.$slots.default?ae("v-if",!0):(L(),ee(nt,{key:0},[Un(He(m.label),1)],64))],2)):ae("v-if",!0)]}),_:3},8,["class","aria-controls","onClick"]))}});var Lx=Ne(Nx,[["__file","checkbox.vue"]]);const Mx=Z({name:"ElCheckboxButton"}),$x=Z({...Mx,props:Ng,emits:Lg,setup(e){const t=e,n=go(),{isFocused:r,isChecked:o,isDisabled:s,checkboxButtonSize:i,model:a,actualValue:l,handleChange:u}=Mg(t,n),c=Ee(rs,void 0),f=$e("checkbox"),d=C(()=>{var p,h,b,m;const S=(h=(p=c==null?void 0:c.fill)==null?void 0:p.value)!=null?h:"";return{backgroundColor:S,borderColor:S,color:(m=(b=c==null?void 0:c.textColor)==null?void 0:b.value)!=null?m:"",boxShadow:S?`-1px 0 0 0 ${S}`:void 0}}),v=C(()=>[f.b("button"),f.bm("button",i.value),f.is("disabled",s.value),f.is("checked",o.value),f.is("focus",r.value)]);return(p,h)=>{var b,m,S,_;return L(),ee("label",{class:U(g(v))},[p.trueValue||p.falseValue||p.trueLabel||p.falseLabel?ct((L(),ee("input",{key:0,"onUpdate:modelValue":w=>Ue(a)?a.value=w:null,class:U(g(f).be("button","original")),type:"checkbox",name:p.name,tabindex:p.tabindex,disabled:g(s),"true-value":(m=(b=p.trueValue)!=null?b:p.trueLabel)!=null?m:!0,"false-value":(_=(S=p.falseValue)!=null?S:p.falseLabel)!=null?_:!1,onChange:g(u),onFocus:w=>r.value=!0,onBlur:w=>r.value=!1,onClick:tt(()=>{},["stop"])},null,42,["onUpdate:modelValue","name","tabindex","disabled","true-value","false-value","onChange","onFocus","onBlur","onClick"])),[[ya,g(a)]]):ct((L(),ee("input",{key:1,"onUpdate:modelValue":w=>Ue(a)?a.value=w:null,class:U(g(f).be("button","original")),type:"checkbox",name:p.name,tabindex:p.tabindex,disabled:g(s),value:g(l),onChange:g(u),onFocus:w=>r.value=!0,onBlur:w=>r.value=!1,onClick:tt(()=>{},["stop"])},null,42,["onUpdate:modelValue","name","tabindex","disabled","value","onChange","onFocus","onBlur","onClick"])),[[ya,g(a)]]),p.$slots.default||p.label?(L(),ee("span",{key:2,class:U(g(f).be("button","inner")),style:ot(g(o)?g(d):void 0)},[de(p.$slots,"default",{},()=>[Un(He(p.label),1)])],6)):ae("v-if",!0)],2)}}});var $g=Ne($x,[["__file","checkbox-button.vue"]]);const kx=xe({modelValue:{type:we(Array),default:()=>[]},disabled:Boolean,min:Number,max:Number,size:So,fill:String,textColor:String,tag:{type:String,default:"div"},validateEvent:{type:Boolean,default:!0},...yr(["ariaLabel"])}),Fx={[rt]:e=>pe(e),change:e=>pe(e)},Bx=Z({name:"ElCheckboxGroup"}),Dx=Z({...Bx,props:kx,emits:Fx,setup(e,{emit:t}){const n=e,r=$e("checkbox"),{formItem:o}=Vr(),{inputId:s,isLabeledByFormItem:i}=hi(n,{formItemContext:o}),a=async u=>{t(rt,u),await Ie(),t("change",u)},l=C({get(){return n.modelValue},set(u){a(u)}});return ft(rs,{...Im(vr(n),["size","min","max","disabled","validateEvent","fill","textColor"]),modelValue:l,changeEvent:a}),he(()=>n.modelValue,()=>{n.validateEvent&&(o==null||o.validate("change").catch(u=>void 0))}),(u,c)=>{var f;return L(),fe(Xe(u.tag),{id:g(s),class:U(g(r).b("group")),role:"group","aria-label":g(i)?void 0:u.ariaLabel||"checkbox-group","aria-labelledby":g(i)?(f=g(o))==null?void 0:f.labelId:void 0},{default:ce(()=>[de(u.$slots,"default")]),_:3},8,["id","class","aria-label","aria-labelledby"])}}});var kg=Ne(Dx,[["__file","checkbox-group.vue"]]);const NN=yt(Lx,{CheckboxButton:$g,CheckboxGroup:kg});wo($g);const LN=wo(kg),ku=xe({type:{type:String,values:["primary","success","info","warning","danger"],default:"primary"},closable:Boolean,disableTransitions:Boolean,hit:Boolean,color:String,size:{type:String,values:es},effect:{type:String,values:["dark","light","plain"],default:"light"},round:Boolean}),Vx={close:e=>e instanceof MouseEvent,click:e=>e instanceof MouseEvent},jx=Z({name:"ElTag"}),zx=Z({...jx,props:ku,emits:Vx,setup(e,{emit:t}){const n=e,r=In(),o=$e("tag"),s=C(()=>{const{type:u,hit:c,effect:f,closable:d,round:v}=n;return[o.b(),o.is("closable",d),o.m(u||"primary"),o.m(r.value),o.m(f),o.is("hit",c),o.is("round",v)]}),i=u=>{t("close",u)},a=u=>{t("click",u)},l=u=>{u.component.subTree.component.bum=null};return(u,c)=>u.disableTransitions?(L(),ee("span",{key:0,class:U(g(s)),style:ot({backgroundColor:u.color}),onClick:a},[le("span",{class:U(g(o).e("content"))},[de(u.$slots,"default")],2),u.closable?(L(),fe(g(Je),{key:0,class:U(g(o).e("close")),onClick:tt(i,["stop"])},{default:ce(()=>[re(g(Ys))]),_:1},8,["class","onClick"])):ae("v-if",!0)],6)):(L(),fe(Fr,{key:1,name:`${g(o).namespace.value}-zoom-in-center`,appear:"",onVnodeMounted:l},{default:ce(()=>[le("span",{class:U(g(s)),style:ot({backgroundColor:u.color}),onClick:a},[le("span",{class:U(g(o).e("content"))},[de(u.$slots,"default")],2),u.closable?(L(),fe(g(Je),{key:0,class:U(g(o).e("close")),onClick:tt(i,["stop"])},{default:ce(()=>[re(g(Ys))]),_:1},8,["class","onClick"])):ae("v-if",!0)],6)]),_:3},8,["name"]))}});var Hx=Ne(zx,[["__file","tag.vue"]]);const Ux=yt(Hx),Kx=xe({mask:{type:Boolean,default:!0},customMaskEvent:Boolean,overlayClass:{type:we([String,Array,Object])},zIndex:{type:we([String,Number])}}),qx={click:e=>e instanceof MouseEvent},Wx="overlay";var Gx=Z({name:"ElOverlay",props:Kx,emits:qx,setup(e,{slots:t,emit:n}){const r=$e(Wx),o=l=>{n("click",l)},{onClick:s,onMousedown:i,onMouseup:a}=kc(e.customMaskEvent?void 0:o);return()=>e.mask?re("div",{class:[r.b(),e.overlayClass],style:{zIndex:e.zIndex},onClick:s,onMousedown:i,onMouseup:a},[de(t,"default")],Zi.STYLE|Zi.CLASS|Zi.PROPS,["onClick","onMouseup","onMousedown"]):or("div",{class:e.overlayClass,style:{zIndex:e.zIndex,position:"fixed",top:"0px",right:"0px",bottom:"0px",left:"0px"}},[de(t,"default")])}});const Fg=Gx,Bg=Symbol("dialogInjectionKey"),Dg=xe({center:Boolean,alignCenter:Boolean,closeIcon:{type:jt},draggable:Boolean,overflow:Boolean,fullscreen:Boolean,showClose:{type:Boolean,default:!0},title:{type:String,default:""},ariaLevel:{type:String,default:"2"}}),Yx={close:()=>!0},Jx=Z({name:"ElDialogContent"}),Xx=Z({...Jx,props:Dg,emits:Yx,setup(e,{expose:t}){const n=e,{t:r}=sl(),{Close:o}=HT,{dialogRef:s,headerRef:i,bodyId:a,ns:l,style:u}=Ee(Bg),{focusTrapRef:c}=Ee(Cg),f=C(()=>[l.b(),l.is("fullscreen",n.fullscreen),l.is("draggable",n.draggable),l.is("align-center",n.alignCenter),{[l.m("center")]:n.center}]),d=qT(c,s),v=C(()=>n.draggable),p=C(()=>n.overflow),{resetPosition:h}=zm(s,i,v,p);return t({resetPosition:h}),(b,m)=>(L(),ee("div",{ref:g(d),class:U(g(f)),style:ot(g(u)),tabindex:"-1"},[le("header",{ref_key:"headerRef",ref:i,class:U([g(l).e("header"),{"show-close":b.showClose}])},[de(b.$slots,"header",{},()=>[le("span",{role:"heading","aria-level":b.ariaLevel,class:U(g(l).e("title"))},He(b.title),11,["aria-level"])]),b.showClose?(L(),ee("button",{key:0,"aria-label":g(r)("el.dialog.close"),class:U(g(l).e("headerbtn")),type:"button",onClick:S=>b.$emit("close")},[re(g(Je),{class:U(g(l).e("close"))},{default:ce(()=>[(L(),fe(Xe(b.closeIcon||g(o))))]),_:1},8,["class"])],10,["aria-label","onClick"])):ae("v-if",!0)],2),le("div",{id:g(a),class:U(g(l).e("body"))},[de(b.$slots,"default")],10,["id"]),b.$slots.footer?(L(),ee("footer",{key:0,class:U(g(l).e("footer"))},[de(b.$slots,"footer")],2)):ae("v-if",!0)],6))}});var Zx=Ne(Xx,[["__file","dialog-content.vue"]]);const Qx=xe({...Dg,appendToBody:Boolean,appendTo:{type:we([String,Object]),default:"body"},beforeClose:{type:we(Function)},destroyOnClose:Boolean,closeOnClickModal:{type:Boolean,default:!0},closeOnPressEscape:{type:Boolean,default:!0},lockScroll:{type:Boolean,default:!0},modal:{type:Boolean,default:!0},openDelay:{type:Number,default:0},closeDelay:{type:Number,default:0},top:{type:String},modelValue:Boolean,modalClass:String,width:{type:[String,Number]},zIndex:{type:Number},trapFocus:Boolean,headerAriaLevel:{type:String,default:"2"}}),e3={open:()=>!0,opened:()=>!0,close:()=>!0,closed:()=>!0,[rt]:e=>Bt(e),openAutoFocus:()=>!0,closeAutoFocus:()=>!0},t3=(e,t)=>{var n;const o=Ge().emit,{nextZIndex:s}=Fc();let i="";const a=pr(),l=pr(),u=V(!1),c=V(!1),f=V(!1),d=V((n=e.zIndex)!=null?n:s());let v,p;const h=ll("namespace",Is),b=C(()=>{const P={},O=`--${h.value}-dialog`;return e.fullscreen||(e.top&&(P[`${O}-margin-top`]=e.top),e.width&&(P[`${O}-width`]=pn(e.width))),P}),m=C(()=>e.alignCenter?{display:"flex"}:{});function S(){o("opened")}function _(){o("closed"),o(rt,!1),e.destroyOnClose&&(f.value=!1)}function w(){o("close")}function y(){p==null||p(),v==null||v(),e.openDelay&&e.openDelay>0?{stop:v}=bu(()=>I(),e.openDelay):I()}function A(){v==null||v(),p==null||p(),e.closeDelay&&e.closeDelay>0?{stop:p}=bu(()=>x(),e.closeDelay):x()}function R(){function P(O){O||(c.value=!0,u.value=!1)}e.beforeClose?e.beforeClose(P):A()}function N(){e.closeOnClickModal&&R()}function I(){st&&(u.value=!0)}function x(){u.value=!1}function k(){o("openAutoFocus")}function $(){o("closeAutoFocus")}function F(P){var O;((O=P.detail)==null?void 0:O.focusReason)==="pointer"&&P.preventDefault()}e.lockScroll&&Km(u);function Y(){e.closeOnPressEscape&&R()}return he(()=>e.modelValue,P=>{P?(c.value=!1,y(),f.value=!0,d.value=Rm(e.zIndex)?s():d.value++,Ie(()=>{o("open"),t.value&&(t.value.scrollTop=0)})):u.value&&A()}),he(()=>e.fullscreen,P=>{t.value&&(P?(i=t.value.style.transform,t.value.style.transform=""):t.value.style.transform=i)}),Ke(()=>{e.modelValue&&(u.value=!0,f.value=!0,y())}),{afterEnter:S,afterLeave:_,beforeLeave:w,handleClose:R,onModalClick:N,close:A,doClose:x,onOpenAutoFocus:k,onCloseAutoFocus:$,onCloseRequested:Y,onFocusoutPrevented:F,titleId:a,bodyId:l,closed:c,style:b,overlayDialogStyle:m,rendered:f,visible:u,zIndex:d}},n3=Z({name:"ElDialog",inheritAttrs:!1}),r3=Z({...n3,props:Qx,emits:e3,setup(e,{expose:t}){const n=e,r=go();xs({scope:"el-dialog",from:"the title slot",replacement:"the header slot",version:"3.0.0",ref:"https://element-plus.org/en-US/component/dialog.html#slots"},C(()=>!!r.title));const o=$e("dialog"),s=V(),i=V(),a=V(),{visible:l,titleId:u,bodyId:c,style:f,overlayDialogStyle:d,rendered:v,zIndex:p,afterEnter:h,afterLeave:b,beforeLeave:m,handleClose:S,onModalClick:_,onOpenAutoFocus:w,onCloseAutoFocus:y,onCloseRequested:A,onFocusoutPrevented:R}=t3(n,s);ft(Bg,{dialogRef:s,headerRef:i,bodyId:c,ns:o,rendered:v,style:f});const N=kc(_),I=C(()=>n.draggable&&!n.fullscreen);return t({visible:l,dialogContentRef:a,resetPosition:()=>{var k;(k=a.value)==null||k.resetPosition()}}),(k,$)=>(L(),fe(g(Ag),{to:k.appendTo,disabled:k.appendTo!=="body"?!1:!k.appendToBody},{default:ce(()=>[re(Fr,{name:"dialog-fade",onAfterEnter:g(h),onAfterLeave:g(b),onBeforeLeave:g(m),persisted:""},{default:ce(()=>[ct(re(g(Fg),{"custom-mask-event":"",mask:k.modal,"overlay-class":k.modalClass,"z-index":g(p)},{default:ce(()=>[le("div",{role:"dialog","aria-modal":"true","aria-label":k.title||void 0,"aria-labelledby":k.title?void 0:g(u),"aria-describedby":g(c),class:U(`${g(o).namespace.value}-overlay-dialog`),style:ot(g(d)),onClick:g(N).onClick,onMousedown:g(N).onMousedown,onMouseup:g(N).onMouseup},[re(g(Hc),{loop:"",trapped:g(l),"focus-start-el":"container",onFocusAfterTrapped:g(w),onFocusAfterReleased:g(y),onFocusoutPrevented:g(R),onReleaseRequested:g(A)},{default:ce(()=>[g(v)?(L(),fe(Zx,En({key:0,ref_key:"dialogContentRef",ref:a},k.$attrs,{center:k.center,"align-center":k.alignCenter,"close-icon":k.closeIcon,draggable:g(I),overflow:k.overflow,fullscreen:k.fullscreen,"show-close":k.showClose,title:k.title,"aria-level":k.headerAriaLevel,onClose:g(S)}),lv({header:ce(()=>[k.$slots.title?de(k.$slots,"title",{key:1}):de(k.$slots,"header",{key:0,close:g(S),titleId:g(u),titleClass:g(o).e("title")})]),default:ce(()=>[de(k.$slots,"default")]),_:2},[k.$slots.footer?{name:"footer",fn:ce(()=>[de(k.$slots,"footer")])}:void 0]),1040,["center","align-center","close-icon","draggable","overflow","fullscreen","show-close","title","aria-level","onClose"])):ae("v-if",!0)]),_:3},8,["trapped","onFocusAfterTrapped","onFocusAfterReleased","onFocusoutPrevented","onReleaseRequested"])],46,["aria-label","aria-labelledby","aria-describedby","onClick","onMousedown","onMouseup"])]),_:3},8,["mask","overlay-class","z-index"]),[[en,g(l)]])]),_:3},8,["onAfterEnter","onAfterLeave","onBeforeLeave"])]),_:3},8,["to","disabled"]))}});var o3=Ne(r3,[["__file","dialog.vue"]]);const MN=yt(o3),s3=Z({inheritAttrs:!1});function i3(e,t,n,r,o,s){return de(e.$slots,"default")}var a3=Ne(s3,[["render",i3],["__file","collection.vue"]]);const l3=Z({name:"ElCollectionItem",inheritAttrs:!1});function u3(e,t,n,r,o,s){return de(e.$slots,"default")}var c3=Ne(l3,[["render",u3],["__file","collection-item.vue"]]);const f3="data-el-collection-item",d3=e=>{const t=`El${e}Collection`,n=`${t}Item`,r=Symbol(t),o=Symbol(n),s={...a3,name:t,setup(){const a=V(null),l=new Map;ft(r,{itemMap:l,getItems:()=>{const c=g(a);if(!c)return[];const f=Array.from(c.querySelectorAll(`[${f3}]`));return[...l.values()].sort((v,p)=>f.indexOf(v.ref)-f.indexOf(p.ref))},collectionRef:a})}},i={...c3,name:n,setup(a,{attrs:l}){const u=V(null),c=Ee(r,void 0);ft(o,{collectionItemRef:u}),Ke(()=>{const f=g(u);f&&c.itemMap.set(f,{ref:f,...l})}),St(()=>{const f=g(u);c.itemMap.delete(f)})}};return{COLLECTION_INJECTION_KEY:r,COLLECTION_ITEM_INJECTION_KEY:o,ElCollection:s,ElCollectionItem:i}},Ul=xe({trigger:ei.trigger,effect:{...Gt.effect,default:"light"},type:{type:we(String)},placement:{type:we(String),default:"bottom"},popperOptions:{type:we(Object),default:()=>({})},id:String,size:{type:String,default:""},splitButton:Boolean,hideOnClick:{type:Boolean,default:!0},loop:{type:Boolean,default:!0},showTimeout:{type:Number,default:150},hideTimeout:{type:Number,default:150},tabindex:{type:we([Number,String]),default:0},maxHeight:{type:we([Number,String]),default:""},popperClass:{type:String,default:""},disabled:Boolean,role:{type:String,default:"menu"},buttonProps:{type:we(Object)},teleported:Gt.teleported});xe({command:{type:[Object,String,Number],default:()=>({})},disabled:Boolean,divided:Boolean,textValue:String,icon:{type:jt}});xe({onKeydown:{type:we(Function)}});d3("Dropdown");const p3=xe({id:{type:String,default:void 0},step:{type:Number,default:1},stepStrictly:Boolean,max:{type:Number,default:Number.POSITIVE_INFINITY},min:{type:Number,default:Number.NEGATIVE_INFINITY},modelValue:Number,readonly:Boolean,disabled:Boolean,size:So,controls:{type:Boolean,default:!0},controlsPosition:{type:String,default:"",values:["","right"]},valueOnClear:{type:[String,Number,null],validator:e=>e===null||je(e)||["min","max"].includes(e),default:null},name:String,placeholder:String,precision:{type:Number,validator:e=>e>=0&&e===Number.parseInt(`${e}`,10)},validateEvent:{type:Boolean,default:!0},...yr(["ariaLabel"])}),h3={[fo]:(e,t)=>t!==e,blur:e=>e instanceof FocusEvent,focus:e=>e instanceof FocusEvent,[io]:e=>je(e)||qn(e),[rt]:e=>je(e)||qn(e)},v3=Z({name:"ElInputNumber"}),m3=Z({...v3,props:p3,emits:h3,setup(e,{expose:t,emit:n}){const r=e,{t:o}=sl(),s=$e("input-number"),i=V(),a=wt({currentValue:r.modelValue,userInput:null}),{formItem:l}=Vr(),u=C(()=>je(r.modelValue)&&r.modelValue<=r.min),c=C(()=>je(r.modelValue)&&r.modelValue>=r.max),f=C(()=>{const P=m(r.step);return Lt(r.precision)?Math.max(m(r.modelValue),P):(P>r.precision,r.precision)}),d=C(()=>r.controls&&r.controlsPosition==="right"),v=In(),p=ns(),h=C(()=>{if(a.userInput!==null)return a.userInput;let P=a.currentValue;if(qn(P))return"";if(je(P)){if(Number.isNaN(P))return"";Lt(r.precision)||(P=P.toFixed(r.precision))}return P}),b=(P,O)=>{if(Lt(O)&&(O=f.value),O===0)return Math.round(P);let j=String(P);const Q=j.indexOf(".");if(Q===-1||!j.replace(".","").split("")[Q+O])return P;const Re=j.length;return j.charAt(Re-1)==="5"&&(j=`${j.slice(0,Math.max(0,Re-1))}6`),Number.parseFloat(Number(j).toFixed(O))},m=P=>{if(qn(P))return 0;const O=P.toString(),j=O.indexOf(".");let Q=0;return j!==-1&&(Q=O.length-j-1),Q},S=(P,O=1)=>je(P)?b(P+r.step*O):a.currentValue,_=()=>{if(r.readonly||p.value||c.value)return;const P=Number(h.value)||0,O=S(P);A(O),n(io,a.currentValue),F()},w=()=>{if(r.readonly||p.value||u.value)return;const P=Number(h.value)||0,O=S(P,-1);A(O),n(io,a.currentValue),F()},y=(P,O)=>{const{max:j,min:Q,step:me,precision:Pe,stepStrictly:Re,valueOnClear:Ce}=r;jj||_ej?j:Q,O&&n(rt,_e)),_e},A=(P,O=!0)=>{var j;const Q=a.currentValue,me=y(P);if(!O){n(rt,me);return}Q===me&&P||(a.userInput=null,n(rt,me),Q!==me&&n(fo,me,Q),r.validateEvent&&((j=l==null?void 0:l.validate)==null||j.call(l,"change").catch(Pe=>void 0)),a.currentValue=me)},R=P=>{a.userInput=P;const O=P===""?null:Number(P);n(io,O),A(O,!1)},N=P=>{const O=P!==""?Number(P):"";(je(O)&&!Number.isNaN(O)||P==="")&&A(O),F(),a.userInput=null},I=()=>{var P,O;(O=(P=i.value)==null?void 0:P.focus)==null||O.call(P)},x=()=>{var P,O;(O=(P=i.value)==null?void 0:P.blur)==null||O.call(P)},k=P=>{n("focus",P)},$=P=>{var O;a.userInput=null,n("blur",P),r.validateEvent&&((O=l==null?void 0:l.validate)==null||O.call(l,"blur").catch(j=>void 0))},F=()=>{a.currentValue!==r.modelValue&&(a.currentValue=r.modelValue)},Y=P=>{document.activeElement===P.target&&P.preventDefault()};return he(()=>r.modelValue,(P,O)=>{const j=y(P,!0);a.userInput===null&&j!==O&&(a.currentValue=j)},{immediate:!0}),Ke(()=>{var P;const{min:O,max:j,modelValue:Q}=r,me=(P=i.value)==null?void 0:P.input;if(me.setAttribute("role","spinbutton"),Number.isFinite(j)?me.setAttribute("aria-valuemax",String(j)):me.removeAttribute("aria-valuemax"),Number.isFinite(O)?me.setAttribute("aria-valuemin",String(O)):me.removeAttribute("aria-valuemin"),me.setAttribute("aria-valuenow",a.currentValue||a.currentValue===0?String(a.currentValue):""),me.setAttribute("aria-disabled",String(p.value)),!je(Q)&&Q!=null){let Pe=Number(Q);Number.isNaN(Pe)&&(Pe=null),n(rt,Pe)}me.addEventListener("wheel",Y,{passive:!1})}),mo(()=>{var P,O;const j=(P=i.value)==null?void 0:P.input;j==null||j.setAttribute("aria-valuenow",`${(O=a.currentValue)!=null?O:""}`)}),t({focus:I,blur:x}),(P,O)=>(L(),ee("div",{class:U([g(s).b(),g(s).m(g(v)),g(s).is("disabled",g(p)),g(s).is("without-controls",!P.controls),g(s).is("controls-right",g(d))]),onDragstart:tt(()=>{},["prevent"])},[P.controls?ct((L(),ee("span",{key:0,role:"button","aria-label":g(o)("el.inputNumber.decrease"),class:U([g(s).e("decrease"),g(s).is("disabled",g(u))]),onKeydown:Vt(w,["enter"])},[de(P.$slots,"decrease-icon",{},()=>[re(g(Je),null,{default:ce(()=>[g(d)?(L(),fe(g(Nm),{key:0})):(L(),fe(g($T),{key:1}))]),_:1})])],42,["aria-label","onKeydown"])),[[g(kp),w]]):ae("v-if",!0),P.controls?ct((L(),ee("span",{key:1,role:"button","aria-label":g(o)("el.inputNumber.increase"),class:U([g(s).e("increase"),g(s).is("disabled",g(c))]),onKeydown:Vt(_,["enter"])},[de(P.$slots,"increase-icon",{},()=>[re(g(Je),null,{default:ce(()=>[g(d)?(L(),fe(g(bT),{key:0})):(L(),fe(g($m),{key:1}))]),_:1})])],42,["aria-label","onKeydown"])),[[g(kp),_]]):ae("v-if",!0),re(g(bg),{id:P.id,ref_key:"input",ref:i,type:"number",step:P.step,"model-value":g(h),placeholder:P.placeholder,readonly:P.readonly,disabled:g(p),size:g(v),max:P.max,min:P.min,name:P.name,"aria-label":P.ariaLabel,"validate-event":!1,onKeydown:[Vt(tt(_,["prevent"]),["up"]),Vt(tt(w,["prevent"]),["down"])],onBlur:$,onFocus:k,onInput:R,onChange:N},lv({_:2},[P.$slots.prefix?{name:"prefix",fn:ce(()=>[de(P.$slots,"prefix")])}:void 0,P.$slots.suffix?{name:"suffix",fn:ce(()=>[de(P.$slots,"suffix")])}:void 0]),1032,["id","step","model-value","placeholder","readonly","disabled","size","max","min","name","aria-label","onKeydown"])],42,["onDragstart"]))}});var g3=Ne(m3,[["__file","input-number.vue"]]);const $N=yt(g3),b3=xe({type:{type:String,values:["primary","success","warning","info","danger","default"],default:"default"},underline:{type:Boolean,default:!0},disabled:Boolean,href:{type:String,default:""},target:{type:String,default:"_self"},icon:{type:jt}}),y3={click:e=>e instanceof MouseEvent},w3=Z({name:"ElLink"}),S3=Z({...w3,props:b3,emits:y3,setup(e,{emit:t}){const n=e,r=$e("link"),o=C(()=>[r.b(),r.m(n.type),r.is("disabled",n.disabled),r.is("underline",n.underline&&!n.disabled)]);function s(i){n.disabled||t("click",i)}return(i,a)=>(L(),ee("a",{class:U(g(o)),href:i.disabled||!i.href?void 0:i.href,target:i.disabled||!i.href?void 0:i.target,onClick:s},[i.icon?(L(),fe(g(Je),{key:0},{default:ce(()=>[(L(),fe(Xe(i.icon)))]),_:1})):ae("v-if",!0),i.$slots.default?(L(),ee("span",{key:1,class:U(g(r).e("inner"))},[de(i.$slots,"default")],2)):ae("v-if",!0),i.$slots.icon?de(i.$slots,"icon",{key:2}):ae("v-if",!0)],10,["href","target"]))}});var E3=Ne(S3,[["__file","link.vue"]]);const kN=yt(E3),Vg=Symbol("ElSelectGroup"),cl=Symbol("ElSelect");function _3(e,t){const n=Ee(cl),r=Ee(Vg,{disabled:!1}),o=C(()=>c(yn(n.props.modelValue),e.value)),s=C(()=>{var v;if(n.props.multiple){const p=yn((v=n.props.modelValue)!=null?v:[]);return!o.value&&p.length>=n.props.multipleLimit&&n.props.multipleLimit>0}else return!1}),i=C(()=>e.label||(Oe(e.value)?"":e.value)),a=C(()=>e.value||e.label||""),l=C(()=>e.disabled||t.groupDisabled||s.value),u=Ge(),c=(v=[],p)=>{if(Oe(e.value)){const h=n.props.valueKey;return v&&v.some(b=>Me(zn(b,h))===zn(p,h))}else return v&&v.includes(p)},f=()=>{!e.disabled&&!r.disabled&&(n.states.hoveringIndex=n.optionsArray.indexOf(u.proxy))},d=v=>{const p=new RegExp(lT(v),"i");t.visible=p.test(i.value)||e.created};return he(()=>i.value,()=>{!e.created&&!n.props.remote&&n.setSelected()}),he(()=>e.value,(v,p)=>{const{remote:h,valueKey:b}=n.props;if(v!==p&&(n.onOptionDestroy(p,u.proxy),n.onOptionCreate(u.proxy)),!e.created&&!h){if(b&&Oe(v)&&Oe(p)&&v[b]===p[b])return;n.setSelected()}}),he(()=>r.disabled,()=>{t.groupDisabled=r.disabled},{immediate:!0}),{select:n,currentLabel:i,currentValue:a,itemSelected:o,isDisabled:l,hoverItem:f,updateOption:d}}const C3=Z({name:"ElOption",componentName:"ElOption",props:{value:{required:!0,type:[String,Number,Boolean,Object]},label:[String,Number],created:Boolean,disabled:Boolean},setup(e){const t=$e("select"),n=pr(),r=C(()=>[t.be("dropdown","item"),t.is("disabled",g(a)),t.is("selected",g(i)),t.is("hovering",g(d))]),o=wt({index:-1,groupDisabled:!1,visible:!0,hover:!1}),{currentLabel:s,itemSelected:i,isDisabled:a,select:l,hoverItem:u,updateOption:c}=_3(e,o),{visible:f,hover:d}=vr(o),v=Ge().proxy;l.onOptionCreate(v),St(()=>{const h=v.value,{selected:b}=l.states,S=(l.props.multiple?b:[b]).some(_=>_.value===v.value);Ie(()=>{l.states.cachedOptions.get(h)===v&&!S&&l.states.cachedOptions.delete(h)}),l.onOptionDestroy(h,v)});function p(){a.value||l.handleOptionSelect(v)}return{ns:t,id:n,containerKls:r,currentLabel:s,itemSelected:i,isDisabled:a,select:l,hoverItem:u,updateOption:c,visible:f,hover:d,selectOptionClick:p,states:o}}});function T3(e,t,n,r,o,s){return ct((L(),ee("li",{id:e.id,class:U(e.containerKls),role:"option","aria-disabled":e.isDisabled||void 0,"aria-selected":e.itemSelected,onMouseenter:e.hoverItem,onClick:tt(e.selectOptionClick,["stop"])},[de(e.$slots,"default",{},()=>[le("span",null,He(e.currentLabel),1)])],42,["id","aria-disabled","aria-selected","onMouseenter","onClick"])),[[en,e.visible]])}var Kc=Ne(C3,[["render",T3],["__file","option.vue"]]);const O3=Z({name:"ElSelectDropdown",componentName:"ElSelectDropdown",setup(){const e=Ee(cl),t=$e("select"),n=C(()=>e.props.popperClass),r=C(()=>e.props.multiple),o=C(()=>e.props.fitInputWidth),s=V("");function i(){var a;s.value=`${(a=e.selectRef)==null?void 0:a.offsetWidth}px`}return Ke(()=>{i(),Dt(e.selectRef,i)}),{ns:t,minWidth:s,popperClass:n,isMultiple:r,isFitInputWidth:o}}});function A3(e,t,n,r,o,s){return L(),ee("div",{class:U([e.ns.b("dropdown"),e.ns.is("multiple",e.isMultiple),e.popperClass]),style:ot({[e.isFitInputWidth?"width":"minWidth"]:e.minWidth})},[e.$slots.header?(L(),ee("div",{key:0,class:U(e.ns.be("dropdown","header"))},[de(e.$slots,"header")],2)):ae("v-if",!0),de(e.$slots,"default"),e.$slots.footer?(L(),ee("div",{key:1,class:U(e.ns.be("dropdown","footer"))},[de(e.$slots,"footer")],2)):ae("v-if",!0)],6)}var R3=Ne(O3,[["render",A3],["__file","select-dropdown.vue"]]);const x3=11,I3=(e,t)=>{const{t:n}=sl(),r=pr(),o=$e("select"),s=$e("input"),i=wt({inputValue:"",options:new Map,cachedOptions:new Map,disabledOptions:new Map,optionValues:[],selected:[],selectionWidth:0,calculatorWidth:0,collapseItemWidth:0,selectedLabel:"",hoveringIndex:-1,previousQuery:null,inputHovering:!1,menuVisibleOnFocus:!1,isBeforeHide:!1}),a=V(null),l=V(null),u=V(null),c=V(null),f=V(null),d=V(null),v=V(null),p=V(null),h=V(null),b=V(null),m=V(null),S=V(null),{isComposing:_,handleCompositionStart:w,handleCompositionUpdate:y,handleCompositionEnd:A}=dg({afterComposition:H=>ke(H)}),{wrapperRef:R,isFocused:N,handleBlur:I}=fg(f,{beforeFocus(){return j.value},afterFocus(){e.automaticDropdown&&!x.value&&(x.value=!0,i.menuVisibleOnFocus=!0)},beforeBlur(H){var ie,Fe;return((ie=u.value)==null?void 0:ie.isFocusInsideContent(H))||((Fe=c.value)==null?void 0:Fe.isFocusInsideContent(H))},afterBlur(){x.value=!1,i.menuVisibleOnFocus=!1}}),x=V(!1),k=V(),{form:$,formItem:F}=Vr(),{inputId:Y}=hi(e,{formItemContext:F}),{valueOnClear:P,isEmptyValue:O}=_A(e),j=C(()=>e.disabled||($==null?void 0:$.disabled)),Q=C(()=>pe(e.modelValue)?e.modelValue.length>0:!O(e.modelValue)),me=C(()=>e.clearable&&!j.value&&i.inputHovering&&Q.value),Pe=C(()=>e.remote&&e.filterable&&!e.remoteShowSuffix?"":e.suffixIcon),Re=C(()=>o.is("reverse",Pe.value&&x.value)),Ce=C(()=>(F==null?void 0:F.validateState)||""),_e=C(()=>Vm[Ce.value]),qe=C(()=>e.remote?300:0),ze=C(()=>e.loading?e.loadingText||n("el.select.loading"):e.remote&&!i.inputValue&&i.options.size===0?!1:e.filterable&&i.inputValue&&i.options.size>0&&De.value===0?e.noMatchText||n("el.select.noMatch"):i.options.size===0?e.noDataText||n("el.select.noData"):null),De=C(()=>B.value.filter(H=>H.visible).length),B=C(()=>{const H=Array.from(i.options.values()),ie=[];return i.optionValues.forEach(Fe=>{const Ze=H.findIndex(wr=>wr.value===Fe);Ze>-1&&ie.push(H[Ze])}),ie.length>=H.length?ie:H}),K=C(()=>Array.from(i.cachedOptions.values())),J=C(()=>{const H=B.value.filter(ie=>!ie.created).some(ie=>ie.currentLabel===i.inputValue);return e.filterable&&e.allowCreate&&i.inputValue!==""&&!H}),oe=()=>{e.filterable&&ve(e.filterMethod)||e.filterable&&e.remote&&ve(e.remoteMethod)||B.value.forEach(H=>{var ie;(ie=H.updateOption)==null||ie.call(H,i.inputValue)})},ge=In(),E=C(()=>["small"].includes(ge.value)?"small":"default"),T=C({get(){return x.value&&ze.value!==!1},set(H){x.value=H}}),M=C(()=>{if(e.multiple&&!Lt(e.modelValue))return yn(e.modelValue).length===0&&!i.inputValue;const H=pe(e.modelValue)?e.modelValue[0]:e.modelValue;return e.filterable||Lt(H)?!i.inputValue:!0}),W=C(()=>{var H;const ie=(H=e.placeholder)!=null?H:n("el.select.placeholder");return e.multiple||!Q.value?ie:i.selectedLabel}),G=C(()=>gu?null:"mouseenter");he(()=>e.modelValue,(H,ie)=>{e.multiple&&e.filterable&&!e.reserveKeyword&&(i.inputValue="",q("")),ne(),!Aa(H,ie)&&e.validateEvent&&(F==null||F.validate("change").catch(Fe=>void 0))},{flush:"post",deep:!0}),he(()=>x.value,H=>{H?q(i.inputValue):(i.inputValue="",i.previousQuery=null,i.isBeforeHide=!0),t("visible-change",H)}),he(()=>i.options.entries(),()=>{var H;if(!st)return;const ie=((H=a.value)==null?void 0:H.querySelectorAll("input"))||[];(!e.filterable&&!e.defaultFirstOption&&!Lt(e.modelValue)||!Array.from(ie).includes(document.activeElement))&&ne(),e.defaultFirstOption&&(e.filterable||e.remote)&&De.value&&se()},{flush:"post"}),he(()=>i.hoveringIndex,H=>{je(H)&&H>-1?k.value=B.value[H]||{}:k.value={},B.value.forEach(ie=>{ie.hover=k.value===ie})}),Ha(()=>{i.isBeforeHide||oe()});const q=H=>{i.previousQuery===H||_.value||(i.previousQuery=H,e.filterable&&ve(e.filterMethod)?e.filterMethod(H):e.filterable&&e.remote&&ve(e.remoteMethod)&&e.remoteMethod(H),e.defaultFirstOption&&(e.filterable||e.remote)&&De.value?Ie(se):Ie(X))},se=()=>{const H=B.value.filter(Ze=>Ze.visible&&!Ze.disabled&&!Ze.states.groupDisabled),ie=H.find(Ze=>Ze.created),Fe=H[0];i.hoveringIndex=qt(B.value,ie||Fe)},ne=()=>{if(e.multiple)i.selectedLabel="";else{const ie=pe(e.modelValue)?e.modelValue[0]:e.modelValue,Fe=te(ie);i.selectedLabel=Fe.currentLabel,i.selected=[Fe];return}const H=[];Lt(e.modelValue)||yn(e.modelValue).forEach(ie=>{H.push(te(ie))}),i.selected=H},te=H=>{let ie;const Fe=Ui(H).toLowerCase()==="object",Ze=Ui(H).toLowerCase()==="null",wr=Ui(H).toLowerCase()==="undefined";for(let Hr=i.cachedOptions.size-1;Hr>=0;Hr--){const Ln=K.value[Hr];if(Fe?zn(Ln.value,e.valueKey)===zn(H,e.valueKey):Ln.value===H){ie={value:H,currentLabel:Ln.currentLabel,get isDisabled(){return Ln.isDisabled}};break}}if(ie)return ie;const Eo=Fe?H.label:!Ze&&!wr?H:"";return{value:H,currentLabel:Eo}},X=()=>{i.hoveringIndex=B.value.findIndex(H=>i.selected.some(ie=>bl(ie)===bl(H)))},Se=()=>{i.selectionWidth=l.value.getBoundingClientRect().width},ue=()=>{i.calculatorWidth=d.value.getBoundingClientRect().width},ye=()=>{i.collapseItemWidth=m.value.getBoundingClientRect().width},z=()=>{var H,ie;(ie=(H=u.value)==null?void 0:H.updatePopper)==null||ie.call(H)},be=()=>{var H,ie;(ie=(H=c.value)==null?void 0:H.updatePopper)==null||ie.call(H)},Le=()=>{i.inputValue.length>0&&!x.value&&(x.value=!0),q(i.inputValue)},ke=H=>{if(i.inputValue=H.target.value,e.remote)dt();else return Le()},dt=nT(()=>{Le()},qe.value),pt=H=>{Aa(e.modelValue,H)||t(fo,H)},rn=H=>rT(H,ie=>!i.disabledOptions.has(ie)),on=H=>{if(e.multiple&&H.code!==_n.delete&&H.target.value.length<=0){const ie=yn(e.modelValue).slice(),Fe=rn(ie);if(Fe<0)return;const Ze=ie[Fe];ie.splice(Fe,1),t(rt,ie),pt(ie),t("remove-tag",Ze)}},jr=(H,ie)=>{const Fe=i.selected.indexOf(ie);if(Fe>-1&&!j.value){const Ze=yn(e.modelValue).slice();Ze.splice(Fe,1),t(rt,Ze),pt(Ze),t("remove-tag",ie.value)}H.stopPropagation(),Ei()},as=H=>{H.stopPropagation();const ie=e.multiple?[]:P.value;if(e.multiple)for(const Fe of i.selected)Fe.isDisabled&&ie.push(Fe.value);t(rt,ie),pt(ie),i.hoveringIndex=-1,x.value=!1,t("clear"),Ei()},Ot=H=>{var ie;if(e.multiple){const Fe=yn((ie=e.modelValue)!=null?ie:[]).slice(),Ze=qt(Fe,H.value);Ze>-1?Fe.splice(Ze,1):(e.multipleLimit<=0||Fe.length{zr(H)})},qt=(H=[],ie)=>{if(!Oe(ie))return H.indexOf(ie);const Fe=e.valueKey;let Ze=-1;return H.some((wr,Eo)=>Me(zn(wr,Fe))===zn(ie,Fe)?(Ze=Eo,!0):!1),Ze},zr=H=>{var ie,Fe,Ze,wr,Eo;const _i=pe(H)?H[0]:H;let Hr=null;if(_i!=null&&_i.value){const Ln=B.value.filter(tf=>tf.value===_i.value);Ln.length>0&&(Hr=Ln[0].$el)}if(u.value&&Hr){const Ln=(wr=(Ze=(Fe=(ie=u.value)==null?void 0:ie.popperRef)==null?void 0:Fe.contentRef)==null?void 0:Ze.querySelector)==null?void 0:wr.call(Ze,`.${o.be("dropdown","wrap")}`);Ln&&fT(Ln,Hr)}(Eo=S.value)==null||Eo.handleScroll()},Si=H=>{i.options.set(H.value,H),i.cachedOptions.set(H.value,H),H.disabled&&i.disabledOptions.set(H.value,H)},Nb=(H,ie)=>{i.options.get(H)===ie&&i.options.delete(H)},Lb=C(()=>{var H,ie;return(ie=(H=u.value)==null?void 0:H.popperRef)==null?void 0:ie.contentRef}),Mb=()=>{i.isBeforeHide=!1,Ie(()=>zr(i.selected))},Ei=()=>{var H;(H=f.value)==null||H.focus()},$b=()=>{var H;if(x.value){x.value=!1,Ie(()=>{var ie;return(ie=f.value)==null?void 0:ie.blur()});return}(H=f.value)==null||H.blur()},kb=H=>{as(H)},Fb=H=>{if(x.value=!1,N.value){const ie=new FocusEvent("focus",H);Ie(()=>I(ie))}},Bb=()=>{i.inputValue.length>0?i.inputValue="":x.value=!1},Qc=()=>{j.value||(gu&&(i.inputHovering=!0),i.menuVisibleOnFocus?i.menuVisibleOnFocus=!1:x.value=!x.value)},Db=()=>{x.value?B.value[i.hoveringIndex]&&Ot(B.value[i.hoveringIndex]):Qc()},bl=H=>Oe(H.value)?zn(H.value,e.valueKey):H.value,Vb=C(()=>B.value.filter(H=>H.visible).every(H=>H.disabled)),jb=C(()=>e.multiple?e.collapseTags?i.selected.slice(0,e.maxCollapseTags):i.selected:[]),zb=C(()=>e.multiple?e.collapseTags?i.selected.slice(e.maxCollapseTags):[]:[]),ef=H=>{if(!x.value){x.value=!0;return}if(!(i.options.size===0||i.filteredOptionsCount===0||_.value)&&!Vb.value){H==="next"?(i.hoveringIndex++,i.hoveringIndex===i.options.size&&(i.hoveringIndex=0)):H==="prev"&&(i.hoveringIndex--,i.hoveringIndex<0&&(i.hoveringIndex=i.options.size-1));const ie=B.value[i.hoveringIndex];(ie.disabled===!0||ie.states.groupDisabled===!0||!ie.visible)&&ef(H),Ie(()=>zr(k.value))}},Hb=()=>{if(!l.value)return 0;const H=window.getComputedStyle(l.value);return Number.parseFloat(H.gap||"6px")},Ub=C(()=>{const H=Hb();return{maxWidth:`${m.value&&e.maxCollapseTags===1?i.selectionWidth-i.collapseItemWidth-H:i.selectionWidth}px`}}),Kb=C(()=>({maxWidth:`${i.selectionWidth}px`})),qb=C(()=>({width:`${Math.max(i.calculatorWidth,x3)}px`}));return Dt(l,Se),Dt(d,ue),Dt(h,z),Dt(R,z),Dt(b,be),Dt(m,ye),Ke(()=>{ne()}),{inputId:Y,contentId:r,nsSelect:o,nsInput:s,states:i,isFocused:N,expanded:x,optionsArray:B,hoverOption:k,selectSize:ge,filteredOptionsCount:De,resetCalculatorWidth:ue,updateTooltip:z,updateTagTooltip:be,debouncedOnInputChange:dt,onInput:ke,deletePrevTag:on,deleteTag:jr,deleteSelected:as,handleOptionSelect:Ot,scrollToOption:zr,hasModelValue:Q,shouldShowPlaceholder:M,currentPlaceholder:W,mouseEnterEventName:G,showClose:me,iconComponent:Pe,iconReverse:Re,validateState:Ce,validateIcon:_e,showNewOption:J,updateOptions:oe,collapseTagSize:E,setSelected:ne,selectDisabled:j,emptyText:ze,handleCompositionStart:w,handleCompositionUpdate:y,handleCompositionEnd:A,onOptionCreate:Si,onOptionDestroy:Nb,handleMenuEnter:Mb,focus:Ei,blur:$b,handleClearClick:kb,handleClickOutside:Fb,handleEsc:Bb,toggleMenu:Qc,selectOption:Db,getValueKey:bl,navigateOptions:ef,dropdownMenuVisible:T,showTagList:jb,collapseTagList:zb,tagStyle:Ub,collapseTagStyle:Kb,inputStyle:qb,popperRef:Lb,inputRef:f,tooltipRef:u,tagTooltipRef:c,calculatorRef:d,prefixRef:v,suffixRef:p,selectRef:a,wrapperRef:R,selectionRef:l,scrollbarRef:S,menuRef:h,tagMenuRef:b,collapseItemRef:m}};var P3=Z({name:"ElOptions",setup(e,{slots:t}){const n=Ee(cl);let r=[];return()=>{var o,s;const i=(o=t.default)==null?void 0:o.call(t),a=[];function l(u){pe(u)&&u.forEach(c=>{var f,d,v,p;const h=(f=(c==null?void 0:c.type)||{})==null?void 0:f.name;h==="ElOptionGroup"?l(!Ae(c.children)&&!pe(c.children)&&ve((d=c.children)==null?void 0:d.default)?(v=c.children)==null?void 0:v.default():c.children):h==="ElOption"?a.push((p=c.props)==null?void 0:p.value):pe(c.children)&&l(c.children)})}return i.length&&l((s=i[0])==null?void 0:s.children),Aa(a,r)||(r=a,n&&(n.states.optionValues=a)),i}}});const N3=xe({name:String,id:String,modelValue:{type:[Array,String,Number,Boolean,Object],default:void 0},autocomplete:{type:String,default:"off"},automaticDropdown:Boolean,size:So,effect:{type:we(String),default:"light"},disabled:Boolean,clearable:Boolean,filterable:Boolean,allowCreate:Boolean,loading:Boolean,popperClass:{type:String,default:""},popperOptions:{type:we(Object),default:()=>({})},remote:Boolean,loadingText:String,noMatchText:String,noDataText:String,remoteMethod:Function,filterMethod:Function,multiple:Boolean,multipleLimit:{type:Number,default:0},placeholder:{type:String},defaultFirstOption:Boolean,reserveKeyword:{type:Boolean,default:!0},valueKey:{type:String,default:"value"},collapseTags:Boolean,collapseTagsTooltip:Boolean,maxCollapseTags:{type:Number,default:1},teleported:Gt.teleported,persistent:{type:Boolean,default:!0},clearIcon:{type:jt,default:Oc},fitInputWidth:Boolean,suffixIcon:{type:jt,default:Nm},tagType:{...ku.type,default:"info"},tagEffect:{...ku.effect,default:"light"},validateEvent:{type:Boolean,default:!0},remoteShowSuffix:Boolean,placement:{type:we(String),values:il,default:"bottom-start"},fallbackPlacements:{type:we(Array),default:["bottom-start","top-start","right","left"]},appendTo:String,...hg,...yr(["ariaLabel"])}),Bp="ElSelect",L3=Z({name:Bp,componentName:Bp,components:{ElSelectMenu:R3,ElOption:Kc,ElOptions:P3,ElTag:Ux,ElScrollbar:U4,ElTooltip:Rg,ElIcon:Je},directives:{ClickOutside:_x},props:N3,emits:[rt,fo,"remove-tag","clear","visible-change","focus","blur"],setup(e,{emit:t}){const n=C(()=>{const{modelValue:i,multiple:a}=e,l=a?[]:void 0;return pe(i)?a?i:l:a?l:i}),r=wt({...vr(e),modelValue:n}),o=I3(r,t);ft(cl,wt({props:r,states:o.states,optionsArray:o.optionsArray,handleOptionSelect:o.handleOptionSelect,onOptionCreate:o.onOptionCreate,onOptionDestroy:o.onOptionDestroy,selectRef:o.selectRef,setSelected:o.setSelected}));const s=C(()=>e.multiple?o.states.selected.map(i=>i.currentLabel):o.states.selectedLabel);return{...o,modelValue:n,selectedLabel:s}}});function M3(e,t,n,r,o,s){const i=Yt("el-tag"),a=Yt("el-tooltip"),l=Yt("el-icon"),u=Yt("el-option"),c=Yt("el-options"),f=Yt("el-scrollbar"),d=Yt("el-select-menu"),v=Jy("click-outside");return ct((L(),ee("div",{ref:"selectRef",class:U([e.nsSelect.b(),e.nsSelect.m(e.selectSize)]),[Ki(e.mouseEnterEventName)]:p=>e.states.inputHovering=!0,onMouseleave:p=>e.states.inputHovering=!1},[re(a,{ref:"tooltipRef",visible:e.dropdownMenuVisible,placement:e.placement,teleported:e.teleported,"popper-class":[e.nsSelect.e("popper"),e.popperClass],"popper-options":e.popperOptions,"fallback-placements":e.fallbackPlacements,effect:e.effect,pure:"",trigger:"click",transition:`${e.nsSelect.namespace.value}-zoom-in-top`,"stop-popper-mouse-event":!1,"gpu-acceleration":!1,persistent:e.persistent,"append-to":e.appendTo,onBeforeShow:e.handleMenuEnter,onHide:p=>e.states.isBeforeHide=!1},{default:ce(()=>{var p;return[le("div",{ref:"wrapperRef",class:U([e.nsSelect.e("wrapper"),e.nsSelect.is("focused",e.isFocused),e.nsSelect.is("hovering",e.states.inputHovering),e.nsSelect.is("filterable",e.filterable),e.nsSelect.is("disabled",e.selectDisabled)]),onClick:tt(e.toggleMenu,["prevent"])},[e.$slots.prefix?(L(),ee("div",{key:0,ref:"prefixRef",class:U(e.nsSelect.e("prefix"))},[de(e.$slots,"prefix")],2)):ae("v-if",!0),le("div",{ref:"selectionRef",class:U([e.nsSelect.e("selection"),e.nsSelect.is("near",e.multiple&&!e.$slots.prefix&&!!e.states.selected.length)])},[e.multiple?de(e.$slots,"tag",{key:0},()=>[(L(!0),ee(nt,null,vf(e.showTagList,h=>(L(),ee("div",{key:e.getValueKey(h),class:U(e.nsSelect.e("selected-item"))},[re(i,{closable:!e.selectDisabled&&!h.isDisabled,size:e.collapseTagSize,type:e.tagType,effect:e.tagEffect,"disable-transitions":"",style:ot(e.tagStyle),onClose:b=>e.deleteTag(b,h)},{default:ce(()=>[le("span",{class:U(e.nsSelect.e("tags-text"))},[de(e.$slots,"label",{label:h.currentLabel,value:h.value},()=>[Un(He(h.currentLabel),1)])],2)]),_:2},1032,["closable","size","type","effect","style","onClose"])],2))),128)),e.collapseTags&&e.states.selected.length>e.maxCollapseTags?(L(),fe(a,{key:0,ref:"tagTooltipRef",disabled:e.dropdownMenuVisible||!e.collapseTagsTooltip,"fallback-placements":["bottom","top","right","left"],effect:e.effect,placement:"bottom",teleported:e.teleported},{default:ce(()=>[le("div",{ref:"collapseItemRef",class:U(e.nsSelect.e("selected-item"))},[re(i,{closable:!1,size:e.collapseTagSize,type:e.tagType,effect:e.tagEffect,"disable-transitions":"",style:ot(e.collapseTagStyle)},{default:ce(()=>[le("span",{class:U(e.nsSelect.e("tags-text"))}," + "+He(e.states.selected.length-e.maxCollapseTags),3)]),_:1},8,["size","type","effect","style"])],2)]),content:ce(()=>[le("div",{ref:"tagMenuRef",class:U(e.nsSelect.e("selection"))},[(L(!0),ee(nt,null,vf(e.collapseTagList,h=>(L(),ee("div",{key:e.getValueKey(h),class:U(e.nsSelect.e("selected-item"))},[re(i,{class:"in-tooltip",closable:!e.selectDisabled&&!h.isDisabled,size:e.collapseTagSize,type:e.tagType,effect:e.tagEffect,"disable-transitions":"",onClose:b=>e.deleteTag(b,h)},{default:ce(()=>[le("span",{class:U(e.nsSelect.e("tags-text"))},[de(e.$slots,"label",{label:h.currentLabel,value:h.value},()=>[Un(He(h.currentLabel),1)])],2)]),_:2},1032,["closable","size","type","effect","onClose"])],2))),128))],2)]),_:3},8,["disabled","effect","teleported"])):ae("v-if",!0)]):ae("v-if",!0),e.selectDisabled?ae("v-if",!0):(L(),ee("div",{key:1,class:U([e.nsSelect.e("selected-item"),e.nsSelect.e("input-wrapper"),e.nsSelect.is("hidden",!e.filterable)])},[ct(le("input",{id:e.inputId,ref:"inputRef","onUpdate:modelValue":h=>e.states.inputValue=h,type:"text",name:e.name,class:U([e.nsSelect.e("input"),e.nsSelect.is(e.selectSize)]),disabled:e.selectDisabled,autocomplete:e.autocomplete,style:ot(e.inputStyle),role:"combobox",readonly:!e.filterable,spellcheck:"false","aria-activedescendant":((p=e.hoverOption)==null?void 0:p.id)||"","aria-controls":e.contentId,"aria-expanded":e.dropdownMenuVisible,"aria-label":e.ariaLabel,"aria-autocomplete":"none","aria-haspopup":"listbox",onKeydown:[Vt(tt(h=>e.navigateOptions("next"),["stop","prevent"]),["down"]),Vt(tt(h=>e.navigateOptions("prev"),["stop","prevent"]),["up"]),Vt(tt(e.handleEsc,["stop","prevent"]),["esc"]),Vt(tt(e.selectOption,["stop","prevent"]),["enter"]),Vt(tt(e.deletePrevTag,["stop"]),["delete"])],onCompositionstart:e.handleCompositionStart,onCompositionupdate:e.handleCompositionUpdate,onCompositionend:e.handleCompositionEnd,onInput:e.onInput,onClick:tt(e.toggleMenu,["stop"])},null,46,["id","onUpdate:modelValue","name","disabled","autocomplete","readonly","aria-activedescendant","aria-controls","aria-expanded","aria-label","onKeydown","onCompositionstart","onCompositionupdate","onCompositionend","onInput","onClick"]),[[fw,e.states.inputValue]]),e.filterable?(L(),ee("span",{key:0,ref:"calculatorRef","aria-hidden":"true",class:U(e.nsSelect.e("input-calculator")),textContent:He(e.states.inputValue)},null,10,["textContent"])):ae("v-if",!0)],2)),e.shouldShowPlaceholder?(L(),ee("div",{key:2,class:U([e.nsSelect.e("selected-item"),e.nsSelect.e("placeholder"),e.nsSelect.is("transparent",!e.hasModelValue||e.expanded&&!e.states.inputValue)])},[e.hasModelValue?de(e.$slots,"label",{key:0,label:e.currentPlaceholder,value:e.modelValue},()=>[le("span",null,He(e.currentPlaceholder),1)]):(L(),ee("span",{key:1},He(e.currentPlaceholder),1))],2)):ae("v-if",!0)],2),le("div",{ref:"suffixRef",class:U(e.nsSelect.e("suffix"))},[e.iconComponent&&!e.showClose?(L(),fe(l,{key:0,class:U([e.nsSelect.e("caret"),e.nsSelect.e("icon"),e.iconReverse])},{default:ce(()=>[(L(),fe(Xe(e.iconComponent)))]),_:1},8,["class"])):ae("v-if",!0),e.showClose&&e.clearIcon?(L(),fe(l,{key:1,class:U([e.nsSelect.e("caret"),e.nsSelect.e("icon"),e.nsSelect.e("clear")]),onClick:e.handleClearClick},{default:ce(()=>[(L(),fe(Xe(e.clearIcon)))]),_:1},8,["class","onClick"])):ae("v-if",!0),e.validateState&&e.validateIcon?(L(),fe(l,{key:2,class:U([e.nsInput.e("icon"),e.nsInput.e("validateIcon")])},{default:ce(()=>[(L(),fe(Xe(e.validateIcon)))]),_:1},8,["class"])):ae("v-if",!0)],2)],10,["onClick"])]}),content:ce(()=>[re(d,{ref:"menuRef"},{default:ce(()=>[e.$slots.header?(L(),ee("div",{key:0,class:U(e.nsSelect.be("dropdown","header")),onClick:tt(()=>{},["stop"])},[de(e.$slots,"header")],10,["onClick"])):ae("v-if",!0),ct(re(f,{id:e.contentId,ref:"scrollbarRef",tag:"ul","wrap-class":e.nsSelect.be("dropdown","wrap"),"view-class":e.nsSelect.be("dropdown","list"),class:U([e.nsSelect.is("empty",e.filteredOptionsCount===0)]),role:"listbox","aria-label":e.ariaLabel,"aria-orientation":"vertical"},{default:ce(()=>[e.showNewOption?(L(),fe(u,{key:0,value:e.states.inputValue,created:!0},null,8,["value"])):ae("v-if",!0),re(c,null,{default:ce(()=>[de(e.$slots,"default")]),_:3})]),_:3},8,["id","wrap-class","view-class","class","aria-label"]),[[en,e.states.options.size>0&&!e.loading]]),e.$slots.loading&&e.loading?(L(),ee("div",{key:1,class:U(e.nsSelect.be("dropdown","loading"))},[de(e.$slots,"loading")],2)):e.loading||e.filteredOptionsCount===0?(L(),ee("div",{key:2,class:U(e.nsSelect.be("dropdown","empty"))},[de(e.$slots,"empty",{},()=>[le("span",null,He(e.emptyText),1)])],2)):ae("v-if",!0),e.$slots.footer?(L(),ee("div",{key:3,class:U(e.nsSelect.be("dropdown","footer")),onClick:tt(()=>{},["stop"])},[de(e.$slots,"footer")],10,["onClick"])):ae("v-if",!0)]),_:3},512)]),_:3},8,["visible","placement","teleported","popper-class","popper-options","fallback-placements","effect","transition","persistent","append-to","onBeforeShow","onHide"])],16,["onMouseleave"])),[[v,e.handleClickOutside,e.popperRef]])}var $3=Ne(L3,[["render",M3],["__file","select.vue"]]);const k3=Z({name:"ElOptionGroup",componentName:"ElOptionGroup",props:{label:String,disabled:Boolean},setup(e){const t=$e("select"),n=V(null),r=Ge(),o=V([]);ft(Vg,wt({...vr(e)}));const s=C(()=>o.value.some(u=>u.visible===!0)),i=u=>{var c,f;return((c=u.type)==null?void 0:c.name)==="ElOption"&&!!((f=u.component)!=null&&f.proxy)},a=u=>{const c=yn(u),f=[];return c.forEach(d=>{var v,p;i(d)?f.push(d.component.proxy):(v=d.children)!=null&&v.length?f.push(...a(d.children)):(p=d.component)!=null&&p.subTree&&f.push(...a(d.component.subTree))}),f},l=()=>{o.value=a(r.subTree)};return Ke(()=>{l()}),X1(n,l,{attributes:!0,subtree:!0,childList:!0}),{groupRef:n,visible:s,ns:t}}});function F3(e,t,n,r,o,s){return ct((L(),ee("ul",{ref:"groupRef",class:U(e.ns.be("group","wrap"))},[le("li",{class:U(e.ns.be("group","title"))},He(e.label),3),le("li",null,[le("ul",{class:U(e.ns.b("group"))},[de(e.$slots,"default")],2)])],2)),[[en,e.visible]])}var jg=Ne(k3,[["render",F3],["__file","option-group.vue"]]);const FN=yt($3,{Option:Kc,OptionGroup:jg}),BN=wo(Kc);wo(jg);const B3=xe({trigger:ei.trigger,placement:Ul.placement,disabled:ei.disabled,visible:Gt.visible,transition:Gt.transition,popperOptions:Ul.popperOptions,tabindex:Ul.tabindex,content:Gt.content,popperStyle:Gt.popperStyle,popperClass:Gt.popperClass,enterable:{...Gt.enterable,default:!0},effect:{...Gt.effect,default:"light"},teleported:Gt.teleported,title:String,width:{type:[String,Number],default:150},offset:{type:Number,default:void 0},showAfter:{type:Number,default:0},hideAfter:{type:Number,default:200},autoClose:{type:Number,default:0},showArrow:{type:Boolean,default:!0},persistent:{type:Boolean,default:!0},"onUpdate:visible":{type:Function}}),D3={"update:visible":e=>Bt(e),"before-enter":()=>!0,"before-leave":()=>!0,"after-enter":()=>!0,"after-leave":()=>!0},V3="onUpdate:visible",j3=Z({name:"ElPopover"}),z3=Z({...j3,props:B3,emits:D3,setup(e,{expose:t,emit:n}){const r=e,o=C(()=>r[V3]),s=$e("popover"),i=V(),a=C(()=>{var b;return(b=g(i))==null?void 0:b.popperRef}),l=C(()=>[{width:pn(r.width)},r.popperStyle]),u=C(()=>[s.b(),r.popperClass,{[s.m("plain")]:!!r.content}]),c=C(()=>r.transition===`${s.namespace.value}-fade-in-linear`),f=()=>{var b;(b=i.value)==null||b.hide()},d=()=>{n("before-enter")},v=()=>{n("before-leave")},p=()=>{n("after-enter")},h=()=>{n("update:visible",!1),n("after-leave")};return t({popperRef:a,hide:f}),(b,m)=>(L(),fe(g(Rg),En({ref_key:"tooltipRef",ref:i},b.$attrs,{trigger:b.trigger,placement:b.placement,disabled:b.disabled,visible:b.visible,transition:b.transition,"popper-options":b.popperOptions,tabindex:b.tabindex,content:b.content,offset:b.offset,"show-after":b.showAfter,"hide-after":b.hideAfter,"auto-close":b.autoClose,"show-arrow":b.showArrow,"aria-label":b.title,effect:b.effect,enterable:b.enterable,"popper-class":g(u),"popper-style":g(l),teleported:b.teleported,persistent:b.persistent,"gpu-acceleration":g(c),"onUpdate:visible":g(o),onBeforeShow:d,onBeforeHide:v,onShow:p,onHide:h}),{content:ce(()=>[b.title?(L(),ee("div",{key:0,class:U(g(s).e("title")),role:"title"},He(b.title),3)):ae("v-if",!0),de(b.$slots,"default",{},()=>[Un(He(b.content),1)])]),default:ce(()=>[b.$slots.reference?de(b.$slots,"reference",{key:0}):ae("v-if",!0)]),_:3},16,["trigger","placement","disabled","visible","transition","popper-options","tabindex","content","offset","show-after","hide-after","auto-close","show-arrow","aria-label","effect","enterable","popper-class","popper-style","teleported","persistent","gpu-acceleration","onUpdate:visible"]))}});var H3=Ne(z3,[["__file","popover.vue"]]);const Dp=(e,t)=>{const n=t.arg||t.value,r=n==null?void 0:n.popperRef;r&&(r.triggerRef=e)};var U3={mounted(e,t){Dp(e,t)},updated(e,t){Dp(e,t)}};const K3="popover",q3=KT(U3,K3),DN=yt(H3,{directive:q3}),W3=xe({modelValue:{type:[Boolean,String,Number],default:!1},disabled:Boolean,loading:Boolean,size:{type:String,validator:jm},width:{type:[String,Number],default:""},inlinePrompt:Boolean,inactiveActionIcon:{type:jt},activeActionIcon:{type:jt},activeIcon:{type:jt},inactiveIcon:{type:jt},activeText:{type:String,default:""},inactiveText:{type:String,default:""},activeValue:{type:[Boolean,String,Number],default:!0},inactiveValue:{type:[Boolean,String,Number],default:!1},name:{type:String,default:""},validateEvent:{type:Boolean,default:!0},beforeChange:{type:we(Function)},id:String,tabindex:{type:[String,Number]},...yr(["ariaLabel"])}),G3={[rt]:e=>Bt(e)||Ae(e)||je(e),[fo]:e=>Bt(e)||Ae(e)||je(e),[io]:e=>Bt(e)||Ae(e)||je(e)},zg="ElSwitch",Y3=Z({name:zg}),J3=Z({...Y3,props:W3,emits:G3,setup(e,{expose:t,emit:n}){const r=e,{formItem:o}=Vr(),s=In(),i=$e("switch"),{inputId:a}=hi(r,{formItemContext:o}),l=ns(C(()=>r.loading)),u=V(r.modelValue!==!1),c=V(),f=V(),d=C(()=>[i.b(),i.m(s.value),i.is("disabled",l.value),i.is("checked",m.value)]),v=C(()=>[i.e("label"),i.em("label","left"),i.is("active",!m.value)]),p=C(()=>[i.e("label"),i.em("label","right"),i.is("active",m.value)]),h=C(()=>({width:pn(r.width)}));he(()=>r.modelValue,()=>{u.value=!0});const b=C(()=>u.value?r.modelValue:!1),m=C(()=>b.value===r.activeValue);[r.activeValue,r.inactiveValue].includes(b.value)||(n(rt,r.inactiveValue),n(fo,r.inactiveValue),n(io,r.inactiveValue)),he(m,y=>{var A;c.value.checked=y,r.validateEvent&&((A=o==null?void 0:o.validate)==null||A.call(o,"change").catch(R=>void 0))});const S=()=>{const y=m.value?r.inactiveValue:r.activeValue;n(rt,y),n(fo,y),n(io,y),Ie(()=>{c.value.checked=m.value})},_=()=>{if(l.value)return;const{beforeChange:y}=r;if(!y){S();return}const A=y();[ia(A),Bt(A)].includes(!0)||Br(zg,"beforeChange must return type `Promise` or `boolean`"),ia(A)?A.then(N=>{N&&S()}).catch(N=>{}):A&&S()},w=()=>{var y,A;(A=(y=c.value)==null?void 0:y.focus)==null||A.call(y)};return Ke(()=>{c.value.checked=m.value}),t({focus:w,checked:m}),(y,A)=>(L(),ee("div",{class:U(g(d)),onClick:tt(_,["prevent"])},[le("input",{id:g(a),ref_key:"input",ref:c,class:U(g(i).e("input")),type:"checkbox",role:"switch","aria-checked":g(m),"aria-disabled":g(l),"aria-label":y.ariaLabel,name:y.name,"true-value":y.activeValue,"false-value":y.inactiveValue,disabled:g(l),tabindex:y.tabindex,onChange:S,onKeydown:Vt(_,["enter"])},null,42,["id","aria-checked","aria-disabled","aria-label","name","true-value","false-value","disabled","tabindex","onKeydown"]),!y.inlinePrompt&&(y.inactiveIcon||y.inactiveText)?(L(),ee("span",{key:0,class:U(g(v))},[y.inactiveIcon?(L(),fe(g(Je),{key:0},{default:ce(()=>[(L(),fe(Xe(y.inactiveIcon)))]),_:1})):ae("v-if",!0),!y.inactiveIcon&&y.inactiveText?(L(),ee("span",{key:1,"aria-hidden":g(m)},He(y.inactiveText),9,["aria-hidden"])):ae("v-if",!0)],2)):ae("v-if",!0),le("span",{ref_key:"core",ref:f,class:U(g(i).e("core")),style:ot(g(h))},[y.inlinePrompt?(L(),ee("div",{key:0,class:U(g(i).e("inner"))},[y.activeIcon||y.inactiveIcon?(L(),fe(g(Je),{key:0,class:U(g(i).is("icon"))},{default:ce(()=>[(L(),fe(Xe(g(m)?y.activeIcon:y.inactiveIcon)))]),_:1},8,["class"])):y.activeText||y.inactiveText?(L(),ee("span",{key:1,class:U(g(i).is("text")),"aria-hidden":!g(m)},He(g(m)?y.activeText:y.inactiveText),11,["aria-hidden"])):ae("v-if",!0)],2)):ae("v-if",!0),le("div",{class:U(g(i).e("action"))},[y.loading?(L(),fe(g(Je),{key:0,class:U(g(i).is("loading"))},{default:ce(()=>[re(g(Js))]),_:1},8,["class"])):g(m)?de(y.$slots,"active-action",{key:1},()=>[y.activeActionIcon?(L(),fe(g(Je),{key:0},{default:ce(()=>[(L(),fe(Xe(y.activeActionIcon)))]),_:1})):ae("v-if",!0)]):g(m)?ae("v-if",!0):de(y.$slots,"inactive-action",{key:2},()=>[y.inactiveActionIcon?(L(),fe(g(Je),{key:0},{default:ce(()=>[(L(),fe(Xe(y.inactiveActionIcon)))]),_:1})):ae("v-if",!0)])],2)],6),!y.inlinePrompt&&(y.activeIcon||y.activeText)?(L(),ee("span",{key:1,class:U(g(p))},[y.activeIcon?(L(),fe(g(Je),{key:0},{default:ce(()=>[(L(),fe(Xe(y.activeIcon)))]),_:1})):ae("v-if",!0),!y.activeIcon&&y.activeText?(L(),ee("span",{key:1,"aria-hidden":!g(m)},He(y.activeText),9,["aria-hidden"])):ae("v-if",!0)],2)):ae("v-if",!0)],10,["onClick"]))}});var X3=Ne(J3,[["__file","switch.vue"]]);const VN=yt(X3),fl=Symbol("tabsRootContextKey"),Z3=xe({tabs:{type:we(Array),default:()=>ol([])}}),Hg="ElTabBar",Q3=Z({name:Hg}),eI=Z({...Q3,props:Z3,setup(e,{expose:t}){const n=e,r=Ge(),o=Ee(fl);o||Br(Hg,"");const s=$e("tabs"),i=V(),a=V(),l=()=>{let v=0,p=0;const h=["top","bottom"].includes(o.props.tabPosition)?"width":"height",b=h==="width"?"x":"y",m=b==="x"?"left":"top";return n.tabs.every(S=>{var _,w;const y=(w=(_=r.parent)==null?void 0:_.refs)==null?void 0:w[`tab-${S.uid}`];if(!y)return!1;if(!S.active)return!0;v=y[`offset${Pr(m)}`],p=y[`client${Pr(h)}`];const A=window.getComputedStyle(y);return h==="width"&&(p-=Number.parseFloat(A.paddingLeft)+Number.parseFloat(A.paddingRight),v+=Number.parseFloat(A.paddingLeft)),!1}),{[h]:`${p}px`,transform:`translate${Pr(b)}(${v}px)`}},u=()=>a.value=l(),c=[],f=()=>{var v;c.forEach(h=>h.stop()),c.length=0;const p=(v=r.parent)==null?void 0:v.refs;if(p){for(const h in p)if(h.startsWith("tab-")){const b=p[h];b&&c.push(Dt(b,u))}}};he(()=>n.tabs,async()=>{await Ie(),u(),f()},{immediate:!0});const d=Dt(i,()=>u());return St(()=>{c.forEach(v=>v.stop()),c.length=0,d.stop()}),t({ref:i,update:u}),(v,p)=>(L(),ee("div",{ref_key:"barRef",ref:i,class:U([g(s).e("active-bar"),g(s).is(g(o).props.tabPosition)]),style:ot(a.value)},null,6))}});var tI=Ne(eI,[["__file","tab-bar.vue"]]);const nI=xe({panes:{type:we(Array),default:()=>ol([])},currentName:{type:[String,Number],default:""},editable:Boolean,type:{type:String,values:["card","border-card",""],default:""},stretch:Boolean}),rI={tabClick:(e,t,n)=>n instanceof Event,tabRemove:(e,t)=>t instanceof Event},Vp="ElTabNav",oI=Z({name:Vp,props:nI,emits:rI,setup(e,{expose:t,emit:n}){const r=Ee(fl);r||Br(Vp,"");const o=$e("tabs"),s=U1(),i=rS(),a=V(),l=V(),u=V(),c=V(),f=V(!1),d=V(0),v=V(!1),p=V(!0),h=C(()=>["top","bottom"].includes(r.props.tabPosition)?"width":"height"),b=C(()=>({transform:`translate${h.value==="width"?"X":"Y"}(-${d.value}px)`})),m=()=>{if(!a.value)return;const N=a.value[`offset${Pr(h.value)}`],I=d.value;if(!I)return;const x=I>N?I-N:0;d.value=x},S=()=>{if(!a.value||!l.value)return;const N=l.value[`offset${Pr(h.value)}`],I=a.value[`offset${Pr(h.value)}`],x=d.value;if(N-x<=I)return;const k=N-x>I*2?x+I:N-I;d.value=k},_=async()=>{const N=l.value;if(!f.value||!u.value||!a.value||!N)return;await Ie();const I=u.value.querySelector(".is-active");if(!I)return;const x=a.value,k=["top","bottom"].includes(r.props.tabPosition),$=I.getBoundingClientRect(),F=x.getBoundingClientRect(),Y=k?N.offsetWidth-F.width:N.offsetHeight-F.height,P=d.value;let O=P;k?($.leftF.right&&(O=P+$.right-F.right)):($.topF.bottom&&(O=P+($.bottom-F.bottom))),O=Math.max(O,0),d.value=Math.min(O,Y)},w=()=>{var N;if(!l.value||!a.value)return;e.stretch&&((N=c.value)==null||N.update());const I=l.value[`offset${Pr(h.value)}`],x=a.value[`offset${Pr(h.value)}`],k=d.value;x0&&(d.value=0))},y=N=>{const I=N.code,{up:x,down:k,left:$,right:F}=_n;if(![x,k,$,F].includes(I))return;const Y=Array.from(N.currentTarget.querySelectorAll("[role=tab]:not(.is-disabled)")),P=Y.indexOf(N.target);let O;I===$||I===x?P===0?O=Y.length-1:O=P-1:P{p.value&&(v.value=!0)},R=()=>v.value=!1;return he(s,N=>{N==="hidden"?p.value=!1:N==="visible"&&setTimeout(()=>p.value=!0,50)}),he(i,N=>{N?setTimeout(()=>p.value=!0,50):p.value=!1}),Dt(u,w),Ke(()=>setTimeout(()=>_(),0)),mo(()=>w()),t({scrollToActiveTab:_,removeFocus:R}),()=>{const N=f.value?[re("span",{class:[o.e("nav-prev"),o.is("disabled",!f.value.prev)],onClick:m},[re(Je,null,{default:()=>[re(hT,null,null)]})]),re("span",{class:[o.e("nav-next"),o.is("disabled",!f.value.next)],onClick:S},[re(Je,null,{default:()=>[re(mT,null,null)]})])]:null,I=e.panes.map((x,k)=>{var $,F,Y,P;const O=x.uid,j=x.props.disabled,Q=(F=($=x.props.name)!=null?$:x.index)!=null?F:`${k}`,me=!j&&(x.isClosable||e.editable);x.index=`${k}`;const Pe=me?re(Je,{class:"is-icon-close",onClick:_e=>n("tabRemove",x,_e)},{default:()=>[re(Ys,null,null)]}):null,Re=((P=(Y=x.slots).label)==null?void 0:P.call(Y))||x.props.label,Ce=!j&&x.active?0:-1;return re("div",{ref:`tab-${O}`,class:[o.e("item"),o.is(r.props.tabPosition),o.is("active",x.active),o.is("disabled",j),o.is("closable",me),o.is("focus",v.value)],id:`tab-${Q}`,key:`tab-${O}`,"aria-controls":`pane-${Q}`,role:"tab","aria-selected":x.active,tabindex:Ce,onFocus:()=>A(),onBlur:()=>R(),onClick:_e=>{R(),n("tabClick",x,Q,_e)},onKeydown:_e=>{me&&(_e.code===_n.delete||_e.code===_n.backspace)&&n("tabRemove",x,_e)}},[Re,Pe])});return re("div",{ref:u,class:[o.e("nav-wrap"),o.is("scrollable",!!f.value),o.is(r.props.tabPosition)]},[N,re("div",{class:o.e("nav-scroll"),ref:a},[re("div",{class:[o.e("nav"),o.is(r.props.tabPosition),o.is("stretch",e.stretch&&["top","bottom"].includes(r.props.tabPosition))],ref:l,style:b.value,role:"tablist",onKeydown:y},[e.type?null:re(tI,{ref:c,tabs:[...e.panes]},null),I])])])}}}),sI=xe({type:{type:String,values:["card","border-card",""],default:""},closable:Boolean,addable:Boolean,modelValue:{type:[String,Number]},editable:Boolean,tabPosition:{type:String,values:["top","right","bottom","left"],default:"top"},beforeLeave:{type:we(Function),default:()=>!0},stretch:Boolean}),Kl=e=>Ae(e)||je(e),iI={[rt]:e=>Kl(e),tabClick:(e,t)=>t instanceof Event,tabChange:e=>Kl(e),edit:(e,t)=>["remove","add"].includes(t),tabRemove:e=>Kl(e),tabAdd:()=>!0},aI=Z({name:"ElTabs",props:sI,emits:iI,setup(e,{emit:t,slots:n,expose:r}){var o;const s=$e("tabs"),i=C(()=>["left","right"].includes(e.tabPosition)),{children:a,addChild:l,removeChild:u}=yA(Ge(),"ElTabPane"),c=V(),f=V((o=e.modelValue)!=null?o:"0"),d=async(m,S=!1)=>{var _,w,y;if(!(f.value===m||Lt(m)))try{await((_=e.beforeLeave)==null?void 0:_.call(e,m,f.value))!==!1&&(f.value=m,S&&(t(rt,m),t("tabChange",m)),(y=(w=c.value)==null?void 0:w.removeFocus)==null||y.call(w))}catch{}},v=(m,S,_)=>{m.props.disabled||(d(S,!0),t("tabClick",m,_))},p=(m,S)=>{m.props.disabled||Lt(m.props.name)||(S.stopPropagation(),t("edit",m.props.name,"remove"),t("tabRemove",m.props.name))},h=()=>{t("edit",void 0,"add"),t("tabAdd")};he(()=>e.modelValue,m=>d(m)),he(f,async()=>{var m;await Ie(),(m=c.value)==null||m.scrollToActiveTab()}),ft(fl,{props:e,currentName:f,registerPane:m=>{a.value.push(m)},sortPane:l,unregisterPane:u}),r({currentName:f});const b=({render:m})=>m();return()=>{const m=n["add-icon"],S=e.editable||e.addable?re("div",{class:[s.e("new-tab"),i.value&&s.e("new-tab-vertical")],tabindex:"0",onClick:h,onKeydown:y=>{y.code===_n.enter&&h()}},[m?de(n,"add-icon"):re(Je,{class:s.is("icon-plus")},{default:()=>[re($m,null,null)]})]):null,_=re("div",{class:[s.e("header"),i.value&&s.e("header-vertical"),s.is(e.tabPosition)]},[re(b,{render:()=>{const y=a.value.some(A=>A.slots.label);return re(oI,{ref:c,currentName:f.value,editable:e.editable,type:e.type,panes:a.value,stretch:e.stretch,onTabClick:v,onTabRemove:p},{$stable:!y})}},null),S]),w=re("div",{class:s.e("content")},[de(n,"default")]);return re("div",{class:[s.b(),s.m(e.tabPosition),{[s.m("card")]:e.type==="card",[s.m("border-card")]:e.type==="border-card"}]},[w,_])}}}),lI=xe({label:{type:String,default:""},name:{type:[String,Number]},closable:Boolean,disabled:Boolean,lazy:Boolean}),Ug="ElTabPane",uI=Z({name:Ug}),cI=Z({...uI,props:lI,setup(e){const t=e,n=Ge(),r=go(),o=Ee(fl);o||Br(Ug,"usage: ");const s=$e("tab-pane"),i=V(),a=C(()=>t.closable||o.props.closable),l=fd(()=>{var v;return o.currentName.value===((v=t.name)!=null?v:i.value)}),u=V(l.value),c=C(()=>{var v;return(v=t.name)!=null?v:i.value}),f=fd(()=>!t.lazy||u.value||l.value);he(l,v=>{v&&(u.value=!0)});const d=wt({uid:n.uid,slots:r,props:t,paneName:c,active:l,index:i,isClosable:a});return o.registerPane(d),Ke(()=>{o.sortPane(d)}),kr(()=>{o.unregisterPane(d.uid)}),(v,p)=>g(f)?ct((L(),ee("div",{key:0,id:`pane-${g(c)}`,class:U(g(s).b()),role:"tabpanel","aria-hidden":!g(l),"aria-labelledby":`tab-${g(c)}`},[de(v.$slots,"default")],10,["id","aria-hidden","aria-labelledby"])),[[en,g(l)]]):ae("v-if",!0)}});var Kg=Ne(cI,[["__file","tab-pane.vue"]]);const jN=yt(aI,{TabPane:Kg}),zN=wo(Kg),fI=xe({type:{type:String,values:["primary","success","info","warning","danger",""],default:""},size:{type:String,values:es,default:""},truncated:Boolean,lineClamp:{type:[String,Number]},tag:{type:String,default:"span"}}),dI=Z({name:"ElText"}),pI=Z({...dI,props:fI,setup(e){const t=e,n=In(),r=$e("text"),o=C(()=>[r.b(),r.m(t.type),r.m(n.value),r.is("truncated",t.truncated),r.is("line-clamp",!Lt(t.lineClamp))]);return(s,i)=>(L(),fe(Xe(s.tag),{class:U(g(o)),style:ot({"-webkit-line-clamp":s.lineClamp})},{default:ce(()=>[de(s.$slots,"default")]),_:3},8,["class","style"]))}});var hI=Ne(pI,[["__file","text.vue"]]);const HN=yt(hI);function vI(e){let t;const n=V(!1),r=wt({...e,originalPosition:"",originalOverflow:"",visible:!1});function o(d){r.text=d}function s(){const d=r.parent,v=f.ns;if(!d.vLoadingAddClassList){let p=d.getAttribute("loading-number");p=Number.parseInt(p)-1,p?d.setAttribute("loading-number",p.toString()):(Gs(d,v.bm("parent","relative")),d.removeAttribute("loading-number")),Gs(d,v.bm("parent","hidden"))}i(),c.unmount()}function i(){var d,v;(v=(d=f.$el)==null?void 0:d.parentNode)==null||v.removeChild(f.$el)}function a(){var d;e.beforeClose&&!e.beforeClose()||(n.value=!0,clearTimeout(t),t=setTimeout(l,400),r.visible=!1,(d=e.closed)==null||d.call(e))}function l(){if(!n.value)return;const d=r.parent;n.value=!1,d.vLoadingAddClassList=void 0,s()}const c=gw(Z({name:"ElLoading",setup(d,{expose:v}){const{ns:p,zIndex:h}=Bc("loading");return v({ns:p,zIndex:h}),()=>{const b=r.spinner||r.svg,m=or("svg",{class:"circular",viewBox:r.svgViewBox?r.svgViewBox:"0 0 50 50",...b?{innerHTML:b}:{}},[or("circle",{class:"path",cx:"25",cy:"25",r:"20",fill:"none"})]),S=r.text?or("p",{class:p.b("text")},[r.text]):void 0;return or(Fr,{name:p.b("fade"),onAfterLeave:l},{default:ce(()=>[ct(re("div",{style:{backgroundColor:r.background||""},class:[p.b("mask"),r.customClass,r.fullscreen?"is-fullscreen":""]},[or("div",{class:p.b("spinner")},[m,S])]),[[en,r.visible]])])})}}})),f=c.mount(document.createElement("div"));return{...vr(r),setText:o,removeElLoadingChild:i,close:a,handleAfterLeave:l,vm:f,get $el(){return f.$el}}}let Bi;const Fu=function(e={}){if(!st)return;const t=mI(e);if(t.fullscreen&&Bi)return Bi;const n=vI({...t,closed:()=>{var o;(o=t.closed)==null||o.call(t),t.fullscreen&&(Bi=void 0)}});gI(t,t.parent,n),jp(t,t.parent,n),t.parent.vLoadingAddClassList=()=>jp(t,t.parent,n);let r=t.parent.getAttribute("loading-number");return r?r=`${Number.parseInt(r)+1}`:r="1",t.parent.setAttribute("loading-number",r),t.parent.appendChild(n.$el),Ie(()=>n.visible.value=t.visible),t.fullscreen&&(Bi=n),n},mI=e=>{var t,n,r,o;let s;return Ae(e.target)?s=(t=document.querySelector(e.target))!=null?t:document.body:s=e.target||document.body,{parent:s===document.body||e.body?document.body:s,background:e.background||"",svg:e.svg||"",svgViewBox:e.svgViewBox||"",spinner:e.spinner||!1,text:e.text||"",fullscreen:s===document.body&&((n=e.fullscreen)!=null?n:!0),lock:(r=e.lock)!=null?r:!1,customClass:e.customClass||"",visible:(o=e.visible)!=null?o:!0,beforeClose:e.beforeClose,closed:e.closed,target:s}},gI=async(e,t,n)=>{const{nextZIndex:r}=n.vm.zIndex||n.vm._.exposed.zIndex,o={};if(e.fullscreen)n.originalPosition.value=Po(document.body,"position"),n.originalOverflow.value=Po(document.body,"overflow"),o.zIndex=r();else if(e.parent===document.body){n.originalPosition.value=Po(document.body,"position"),await Ie();for(const s of["top","left"]){const i=s==="top"?"scrollTop":"scrollLeft";o[s]=`${e.target.getBoundingClientRect()[s]+document.body[i]+document.documentElement[i]-Number.parseInt(Po(document.body,`margin-${s}`),10)}px`}for(const s of["height","width"])o[s]=`${e.target.getBoundingClientRect()[s]}px`}else n.originalPosition.value=Po(t,"position");for(const[s,i]of Object.entries(o))n.$el.style[s]=i},jp=(e,t,n)=>{const r=n.vm.ns||n.vm._.exposed.ns;["absolute","fixed","sticky"].includes(n.originalPosition.value)?Gs(t,r.bm("parent","relative")):Tu(t,r.bm("parent","relative")),e.fullscreen&&e.lock?Tu(t,r.bm("parent","hidden")):Gs(t,r.bm("parent","hidden"))},na=Symbol("ElLoading"),zp=(e,t)=>{var n,r,o,s;const i=t.instance,a=d=>Oe(t.value)?t.value[d]:void 0,l=d=>{const v=Ae(d)&&(i==null?void 0:i[d])||d;return v&&V(v)},u=d=>l(a(d)||e.getAttribute(`element-loading-${hr(d)}`)),c=(n=a("fullscreen"))!=null?n:t.modifiers.fullscreen,f={text:u("text"),svg:u("svg"),svgViewBox:u("svgViewBox"),spinner:u("spinner"),background:u("background"),customClass:u("customClass"),fullscreen:c,target:(r=a("target"))!=null?r:c?void 0:e,body:(o=a("body"))!=null?o:t.modifiers.body,lock:(s=a("lock"))!=null?s:t.modifiers.lock};e[na]={options:f,instance:Fu(f)}},bI=(e,t)=>{for(const n of Object.keys(t))Ue(t[n])&&(t[n].value=e[n])},Hp={mounted(e,t){t.value&&zp(e,t)},updated(e,t){const n=e[na];t.oldValue!==t.value&&(t.value&&!t.oldValue?zp(e,t):t.value&&t.oldValue?Oe(t.value)&&bI(t.value,n.options):n==null||n.instance.close())},unmounted(e){var t;(t=e[na])==null||t.instance.close(),e[na]=null}},UN={install(e){e.directive("loading",Hp),e.config.globalProperties.$loading=Fu},directive:Hp,service:Fu},qg=["success","info","warning","error"],At=ol({customClass:"",center:!1,dangerouslyUseHTMLString:!1,duration:3e3,icon:void 0,id:"",message:"",onClose:void 0,showClose:!1,type:"info",plain:!1,offset:16,zIndex:0,grouping:!1,repeatNum:1,appendTo:st?document.body:void 0}),yI=xe({customClass:{type:String,default:At.customClass},center:{type:Boolean,default:At.center},dangerouslyUseHTMLString:{type:Boolean,default:At.dangerouslyUseHTMLString},duration:{type:Number,default:At.duration},icon:{type:jt,default:At.icon},id:{type:String,default:At.id},message:{type:we([String,Object,Function]),default:At.message},onClose:{type:we(Function),default:At.onClose},showClose:{type:Boolean,default:At.showClose},type:{type:String,values:qg,default:At.type},plain:{type:Boolean,default:At.plain},offset:{type:Number,default:At.offset},zIndex:{type:Number,default:At.zIndex},grouping:{type:Boolean,default:At.grouping},repeatNum:{type:Number,default:At.repeatNum}}),wI={destroy:()=>!0},wn=Qu([]),SI=e=>{const t=wn.findIndex(o=>o.id===e),n=wn[t];let r;return t>0&&(r=wn[t-1]),{current:n,prev:r}},EI=e=>{const{prev:t}=SI(e);return t?t.vm.exposed.bottom.value:0},_I=(e,t)=>wn.findIndex(r=>r.id===e)>0?16:t,CI=Z({name:"ElMessage"}),TI=Z({...CI,props:yI,emits:wI,setup(e,{expose:t}){const n=e,{Close:r}=Dm,{ns:o,zIndex:s}=Bc("message"),{currentZIndex:i,nextZIndex:a}=s,l=V(),u=V(!1),c=V(0);let f;const d=C(()=>n.type?n.type==="error"?"danger":n.type:"info"),v=C(()=>{const R=n.type;return{[o.bm("icon",R)]:R&&Ra[R]}}),p=C(()=>n.icon||Ra[n.type]||""),h=C(()=>EI(n.id)),b=C(()=>_I(n.id,n.offset)+h.value),m=C(()=>c.value+b.value),S=C(()=>({top:`${b.value}px`,zIndex:i.value}));function _(){n.duration!==0&&({stop:f}=bu(()=>{y()},n.duration))}function w(){f==null||f()}function y(){u.value=!1}function A({code:R}){R===_n.esc&&y()}return Ke(()=>{_(),a(),u.value=!0}),he(()=>n.repeatNum,()=>{w(),_()}),tn(document,"keydown",A),Dt(l,()=>{c.value=l.value.getBoundingClientRect().height}),t({visible:u,bottom:m,close:y}),(R,N)=>(L(),fe(Fr,{name:g(o).b("fade"),onBeforeLeave:R.onClose,onAfterLeave:I=>R.$emit("destroy"),persisted:""},{default:ce(()=>[ct(le("div",{id:R.id,ref_key:"messageRef",ref:l,class:U([g(o).b(),{[g(o).m(R.type)]:R.type},g(o).is("center",R.center),g(o).is("closable",R.showClose),g(o).is("plain",R.plain),R.customClass]),style:ot(g(S)),role:"alert",onMouseenter:w,onMouseleave:_},[R.repeatNum>1?(L(),fe(g(XR),{key:0,value:R.repeatNum,type:g(d),class:U(g(o).e("badge"))},null,8,["value","type","class"])):ae("v-if",!0),g(p)?(L(),fe(g(Je),{key:1,class:U([g(o).e("icon"),g(v)])},{default:ce(()=>[(L(),fe(Xe(g(p))))]),_:1},8,["class"])):ae("v-if",!0),de(R.$slots,"default",{},()=>[R.dangerouslyUseHTMLString?(L(),ee(nt,{key:1},[ae(" Caution here, message could've been compromised, never use user's input as message "),le("p",{class:U(g(o).e("content")),innerHTML:R.message},null,10,["innerHTML"])],2112)):(L(),ee("p",{key:0,class:U(g(o).e("content"))},He(R.message),3))]),R.showClose?(L(),fe(g(Je),{key:2,class:U(g(o).e("closeBtn")),onClick:tt(y,["stop"])},{default:ce(()=>[re(g(r))]),_:1},8,["class","onClick"])):ae("v-if",!0)],46,["id"]),[[en,u.value]])]),_:3},8,["name","onBeforeLeave","onAfterLeave"]))}});var OI=Ne(TI,[["__file","message.vue"]]);let AI=1;const Wg=e=>{const t=!e||Ae(e)||cn(e)||ve(e)?{message:e}:e,n={...At,...t};if(!n.appendTo)n.appendTo=document.body;else if(Ae(n.appendTo)){let r=document.querySelector(n.appendTo);ar(r)||(r=document.body),n.appendTo=r}return Bt(Fn.grouping)&&!n.grouping&&(n.grouping=Fn.grouping),je(Fn.duration)&&n.duration===3e3&&(n.duration=Fn.duration),je(Fn.offset)&&n.offset===16&&(n.offset=Fn.offset),Bt(Fn.showClose)&&!n.showClose&&(n.showClose=Fn.showClose),n},RI=e=>{const t=wn.indexOf(e);if(t===-1)return;wn.splice(t,1);const{handler:n}=e;n.close()},xI=({appendTo:e,...t},n)=>{const r=`message_${AI++}`,o=t.onClose,s=document.createElement("div"),i={...t,id:r,onClose:()=>{o==null||o(),RI(c)},onDestroy:()=>{wa(null,s)}},a=re(OI,i,ve(i.message)||cn(i.message)?{default:ve(i.message)?i.message:()=>i.message}:null);a.appContext=n||Go._context,wa(a,s),e.appendChild(s.firstElementChild);const l=a.component,c={id:r,vnode:a,vm:l,handler:{close:()=>{l.exposed.visible.value=!1}},props:a.component.props};return c},Go=(e={},t)=>{if(!st)return{close:()=>{}};const n=Wg(e);if(n.grouping&&wn.length){const o=wn.find(({vnode:s})=>{var i;return((i=s.props)==null?void 0:i.message)===n.message});if(o)return o.props.repeatNum+=1,o.props.type=n.type,o.handler}if(je(Fn.max)&&wn.length>=Fn.max)return{close:()=>{}};const r=xI(n,t);return wn.push(r),r.handler};qg.forEach(e=>{Go[e]=(t={},n)=>{const r=Wg(t);return Go({...r,type:e},n)}});function II(e){for(const t of wn)(!e||e===t.props.type)&&t.handler.close()}Go.closeAll=II;Go._context=null;const KN=UT(Go,"$message"),PI=Z({name:"ElMessageBox",directives:{TrapFocus:Ox},components:{ElButton:Ex,ElFocusTrap:Hc,ElInput:bg,ElOverlay:Fg,ElIcon:Je,...Dm},inheritAttrs:!1,props:{buttonSize:{type:String,validator:jm},modal:{type:Boolean,default:!0},lockScroll:{type:Boolean,default:!0},showClose:{type:Boolean,default:!0},closeOnClickModal:{type:Boolean,default:!0},closeOnPressEscape:{type:Boolean,default:!0},closeOnHashChange:{type:Boolean,default:!0},center:Boolean,draggable:Boolean,overflow:Boolean,roundButton:{default:!1,type:Boolean},container:{type:String,default:"body"},boxType:{type:String,default:""}},emits:["vanish","action"],setup(e,{emit:t}){const{locale:n,zIndex:r,ns:o,size:s}=Bc("message-box",C(()=>e.buttonSize)),{t:i}=n,{nextZIndex:a}=r,l=V(!1),u=wt({autofocus:!0,beforeClose:null,callback:null,cancelButtonText:"",cancelButtonClass:"",confirmButtonText:"",confirmButtonClass:"",customClass:"",customStyle:{},dangerouslyUseHTMLString:!1,distinguishCancelAndClose:!1,icon:"",inputPattern:null,inputPlaceholder:"",inputType:"text",inputValue:null,inputValidator:null,inputErrorMessage:"",message:null,modalFade:!0,modalClass:"",showCancelButton:!1,showConfirmButton:!0,type:"",title:void 0,showInput:!1,action:"",confirmButtonLoading:!1,cancelButtonLoading:!1,confirmButtonLoadingIcon:Ds(Js),cancelButtonLoadingIcon:Ds(Js),confirmButtonDisabled:!1,editorErrorMessage:"",validateError:!1,zIndex:a()}),c=C(()=>{const O=u.type;return{[o.bm("icon",O)]:O&&Ra[O]}}),f=pr(),d=pr(),v=C(()=>u.icon||Ra[u.type]||""),p=C(()=>!!u.message),h=V(),b=V(),m=V(),S=V(),_=V(),w=C(()=>u.confirmButtonClass);he(()=>u.inputValue,async O=>{await Ie(),e.boxType==="prompt"&&O!==null&&$()},{immediate:!0}),he(()=>l.value,O=>{var j,Q;O&&(e.boxType!=="prompt"&&(u.autofocus?m.value=(Q=(j=_.value)==null?void 0:j.$el)!=null?Q:h.value:m.value=h.value),u.zIndex=a()),e.boxType==="prompt"&&(O?Ie().then(()=>{var me;S.value&&S.value.$el&&(u.autofocus?m.value=(me=F())!=null?me:h.value:m.value=h.value)}):(u.editorErrorMessage="",u.validateError=!1))});const y=C(()=>e.draggable),A=C(()=>e.overflow);zm(h,b,y,A),Ke(async()=>{await Ie(),e.closeOnHashChange&&window.addEventListener("hashchange",R)}),St(()=>{e.closeOnHashChange&&window.removeEventListener("hashchange",R)});function R(){l.value&&(l.value=!1,Ie(()=>{u.action&&t("action",u.action)}))}const N=()=>{e.closeOnClickModal&&k(u.distinguishCancelAndClose?"close":"cancel")},I=kc(N),x=O=>{if(u.inputType!=="textarea")return O.preventDefault(),k("confirm")},k=O=>{var j;e.boxType==="prompt"&&O==="confirm"&&!$()||(u.action=O,u.beforeClose?(j=u.beforeClose)==null||j.call(u,O,u,R):R())},$=()=>{if(e.boxType==="prompt"){const O=u.inputPattern;if(O&&!O.test(u.inputValue||""))return u.editorErrorMessage=u.inputErrorMessage||i("el.messagebox.error"),u.validateError=!0,!1;const j=u.inputValidator;if(typeof j=="function"){const Q=j(u.inputValue);if(Q===!1)return u.editorErrorMessage=u.inputErrorMessage||i("el.messagebox.error"),u.validateError=!0,!1;if(typeof Q=="string")return u.editorErrorMessage=Q,u.validateError=!0,!1}}return u.editorErrorMessage="",u.validateError=!1,!0},F=()=>{const O=S.value.$refs;return O.input||O.textarea},Y=()=>{k("close")},P=()=>{e.closeOnPressEscape&&Y()};return e.lockScroll&&Km(l),{...vr(u),ns:o,overlayEvent:I,visible:l,hasMessage:p,typeClass:c,contentId:f,inputId:d,btnSize:s,iconComponent:v,confirmButtonClasses:w,rootRef:h,focusStartRef:m,headerRef:b,inputRef:S,confirmRef:_,doClose:R,handleClose:Y,onCloseRequested:P,handleWrapperClick:N,handleInputEnter:x,handleAction:k,t:i}}});function NI(e,t,n,r,o,s){const i=Yt("el-icon"),a=Yt("close"),l=Yt("el-input"),u=Yt("el-button"),c=Yt("el-focus-trap"),f=Yt("el-overlay");return L(),fe(Fr,{name:"fade-in-linear",onAfterLeave:d=>e.$emit("vanish"),persisted:""},{default:ce(()=>[ct(re(f,{"z-index":e.zIndex,"overlay-class":[e.ns.is("message-box"),e.modalClass],mask:e.modal},{default:ce(()=>[le("div",{role:"dialog","aria-label":e.title,"aria-modal":"true","aria-describedby":e.showInput?void 0:e.contentId,class:U(`${e.ns.namespace.value}-overlay-message-box`),onClick:e.overlayEvent.onClick,onMousedown:e.overlayEvent.onMousedown,onMouseup:e.overlayEvent.onMouseup},[re(c,{loop:"",trapped:e.visible,"focus-trap-el":e.rootRef,"focus-start-el":e.focusStartRef,onReleaseRequested:e.onCloseRequested},{default:ce(()=>[le("div",{ref:"rootRef",class:U([e.ns.b(),e.customClass,e.ns.is("draggable",e.draggable),{[e.ns.m("center")]:e.center}]),style:ot(e.customStyle),tabindex:"-1",onClick:tt(()=>{},["stop"])},[e.title!==null&&e.title!==void 0?(L(),ee("div",{key:0,ref:"headerRef",class:U([e.ns.e("header"),{"show-close":e.showClose}])},[le("div",{class:U(e.ns.e("title"))},[e.iconComponent&&e.center?(L(),fe(i,{key:0,class:U([e.ns.e("status"),e.typeClass])},{default:ce(()=>[(L(),fe(Xe(e.iconComponent)))]),_:1},8,["class"])):ae("v-if",!0),le("span",null,He(e.title),1)],2),e.showClose?(L(),ee("button",{key:0,type:"button",class:U(e.ns.e("headerbtn")),"aria-label":e.t("el.messagebox.close"),onClick:d=>e.handleAction(e.distinguishCancelAndClose?"close":"cancel"),onKeydown:Vt(tt(d=>e.handleAction(e.distinguishCancelAndClose?"close":"cancel"),["prevent"]),["enter"])},[re(i,{class:U(e.ns.e("close"))},{default:ce(()=>[re(a)]),_:1},8,["class"])],42,["aria-label","onClick","onKeydown"])):ae("v-if",!0)],2)):ae("v-if",!0),le("div",{id:e.contentId,class:U(e.ns.e("content"))},[le("div",{class:U(e.ns.e("container"))},[e.iconComponent&&!e.center&&e.hasMessage?(L(),fe(i,{key:0,class:U([e.ns.e("status"),e.typeClass])},{default:ce(()=>[(L(),fe(Xe(e.iconComponent)))]),_:1},8,["class"])):ae("v-if",!0),e.hasMessage?(L(),ee("div",{key:1,class:U(e.ns.e("message"))},[de(e.$slots,"default",{},()=>[e.dangerouslyUseHTMLString?(L(),fe(Xe(e.showInput?"label":"p"),{key:1,for:e.showInput?e.inputId:void 0,innerHTML:e.message},null,8,["for","innerHTML"])):(L(),fe(Xe(e.showInput?"label":"p"),{key:0,for:e.showInput?e.inputId:void 0},{default:ce(()=>[Un(He(e.dangerouslyUseHTMLString?"":e.message),1)]),_:1},8,["for"]))])],2)):ae("v-if",!0)],2),ct(le("div",{class:U(e.ns.e("input"))},[re(l,{id:e.inputId,ref:"inputRef",modelValue:e.inputValue,"onUpdate:modelValue":d=>e.inputValue=d,type:e.inputType,placeholder:e.inputPlaceholder,"aria-invalid":e.validateError,class:U({invalid:e.validateError}),onKeydown:Vt(e.handleInputEnter,["enter"])},null,8,["id","modelValue","onUpdate:modelValue","type","placeholder","aria-invalid","class","onKeydown"]),le("div",{class:U(e.ns.e("errormsg")),style:ot({visibility:e.editorErrorMessage?"visible":"hidden"})},He(e.editorErrorMessage),7)],2),[[en,e.showInput]])],10,["id"]),le("div",{class:U(e.ns.e("btns"))},[e.showCancelButton?(L(),fe(u,{key:0,loading:e.cancelButtonLoading,"loading-icon":e.cancelButtonLoadingIcon,class:U([e.cancelButtonClass]),round:e.roundButton,size:e.btnSize,onClick:d=>e.handleAction("cancel"),onKeydown:Vt(tt(d=>e.handleAction("cancel"),["prevent"]),["enter"])},{default:ce(()=>[Un(He(e.cancelButtonText||e.t("el.messagebox.cancel")),1)]),_:1},8,["loading","loading-icon","class","round","size","onClick","onKeydown"])):ae("v-if",!0),ct(re(u,{ref:"confirmRef",type:"primary",loading:e.confirmButtonLoading,"loading-icon":e.confirmButtonLoadingIcon,class:U([e.confirmButtonClasses]),round:e.roundButton,disabled:e.confirmButtonDisabled,size:e.btnSize,onClick:d=>e.handleAction("confirm"),onKeydown:Vt(tt(d=>e.handleAction("confirm"),["prevent"]),["enter"])},{default:ce(()=>[Un(He(e.confirmButtonText||e.t("el.messagebox.confirm")),1)]),_:1},8,["loading","loading-icon","class","round","disabled","size","onClick","onKeydown"]),[[en,e.showConfirmButton]])],2)],14,["onClick"])]),_:3},8,["trapped","focus-trap-el","focus-start-el","onReleaseRequested"])],42,["aria-label","aria-describedby","onClick","onMousedown","onMouseup"])]),_:3},8,["z-index","overlay-class","mask"]),[[en,e.visible]])]),_:3},8,["onAfterLeave"])}var LI=Ne(PI,[["render",NI],["__file","index.vue"]]);const ti=new Map,MI=e=>{let t=document.body;return e.appendTo&&(Ae(e.appendTo)&&(t=document.querySelector(e.appendTo)),ar(e.appendTo)&&(t=e.appendTo),ar(t)||(t=document.body)),t},$I=(e,t,n=null)=>{const r=re(LI,e,ve(e.message)||cn(e.message)?{default:ve(e.message)?e.message:()=>e.message}:null);return r.appContext=n,wa(r,t),MI(e).appendChild(t.firstElementChild),r.component},kI=()=>document.createElement("div"),FI=(e,t)=>{const n=kI();e.onVanish=()=>{wa(null,n),ti.delete(o)},e.onAction=s=>{const i=ti.get(o);let a;e.showInput?a={value:o.inputValue,action:s}:a=s,e.callback?e.callback(a,r.proxy):s==="cancel"||s==="close"?e.distinguishCancelAndClose&&s!=="cancel"?i.reject("close"):i.reject("cancel"):i.resolve(a)};const r=$I(e,n,t),o=r.proxy;for(const s in e)Ve(e,s)&&!Ve(o.$props,s)&&(o[s]=e[s]);return o.visible=!0,o};function os(e,t=null){if(!st)return Promise.reject();let n;return Ae(e)||cn(e)?e={message:e}:n=e.callback,new Promise((r,o)=>{const s=FI(e,t??os._context);ti.set(s,{options:e,callback:n,resolve:r,reject:o})})}const BI=["alert","confirm","prompt"],DI={alert:{closeOnPressEscape:!1,closeOnClickModal:!1},confirm:{showCancelButton:!0},prompt:{showCancelButton:!0,showInput:!0}};BI.forEach(e=>{os[e]=VI(e)});function VI(e){return(t,n,r,o)=>{let s="";return Oe(n)?(r=n,s=""):Lt(n)?s="":s=n,os(Object.assign({title:s,message:t,type:"",...DI[e]},r,{boxType:e}),o)}}os.close=()=>{ti.forEach((e,t)=>{t.doClose()}),ti.clear()};os._context=null;const Ar=os;Ar.install=e=>{Ar._context=e._context,e.config.globalProperties.$msgbox=Ar,e.config.globalProperties.$messageBox=Ar,e.config.globalProperties.$alert=Ar.alert,e.config.globalProperties.$confirm=Ar.confirm,e.config.globalProperties.$prompt=Ar.prompt};const qN=Ar;function Gg(e,t){return function(){return e.apply(t,arguments)}}const{toString:jI}=Object.prototype,{getPrototypeOf:qc}=Object,{iterator:dl,toStringTag:Yg}=Symbol,pl=(e=>t=>{const n=jI.call(t);return e[n]||(e[n]=n.slice(8,-1).toLowerCase())})(Object.create(null)),Nn=e=>(e=e.toLowerCase(),t=>pl(t)===e),hl=e=>t=>typeof t===e,{isArray:ss}=Array,Yo=hl("undefined");function mi(e){return e!==null&&!Yo(e)&&e.constructor!==null&&!Yo(e.constructor)&&Ut(e.constructor.isBuffer)&&e.constructor.isBuffer(e)}const Jg=Nn("ArrayBuffer");function zI(e){let t;return typeof ArrayBuffer<"u"&&ArrayBuffer.isView?t=ArrayBuffer.isView(e):t=e&&e.buffer&&Jg(e.buffer),t}const HI=hl("string"),Ut=hl("function"),Xg=hl("number"),gi=e=>e!==null&&typeof e=="object",UI=e=>e===!0||e===!1,ra=e=>{if(pl(e)!=="object")return!1;const t=qc(e);return(t===null||t===Object.prototype||Object.getPrototypeOf(t)===null)&&!(Yg in e)&&!(dl in e)},KI=e=>{if(!gi(e)||mi(e))return!1;try{return Object.keys(e).length===0&&Object.getPrototypeOf(e)===Object.prototype}catch{return!1}},qI=Nn("Date"),WI=Nn("File"),GI=e=>!!(e&&typeof e.uri<"u"),YI=e=>e&&typeof e.getParts<"u",JI=Nn("Blob"),XI=Nn("FileList"),ZI=e=>gi(e)&&Ut(e.pipe);function QI(){return typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{}}const Up=QI(),Kp=typeof Up.FormData<"u"?Up.FormData:void 0,eP=e=>{let t;return e&&(Kp&&e instanceof Kp||Ut(e.append)&&((t=pl(e))==="formdata"||t==="object"&&Ut(e.toString)&&e.toString()==="[object FormData]"))},tP=Nn("URLSearchParams"),[nP,rP,oP,sP]=["ReadableStream","Request","Response","Headers"].map(Nn),iP=e=>e.trim?e.trim():e.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"");function bi(e,t,{allOwnKeys:n=!1}={}){if(e===null||typeof e>"u")return;let r,o;if(typeof e!="object"&&(e=[e]),ss(e))for(r=0,o=e.length;r0;)if(o=n[r],t===o.toLowerCase())return o;return null}const ro=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:global,Qg=e=>!Yo(e)&&e!==ro;function Bu(){const{caseless:e,skipUndefined:t}=Qg(this)&&this||{},n={},r=(o,s)=>{if(s==="__proto__"||s==="constructor"||s==="prototype")return;const i=e&&Zg(n,s)||s;ra(n[i])&&ra(o)?n[i]=Bu(n[i],o):ra(o)?n[i]=Bu({},o):ss(o)?n[i]=o.slice():(!t||!Yo(o))&&(n[i]=o)};for(let o=0,s=arguments.length;o(bi(t,(o,s)=>{n&&Ut(o)?Object.defineProperty(e,s,{value:Gg(o,n),writable:!0,enumerable:!0,configurable:!0}):Object.defineProperty(e,s,{value:o,writable:!0,enumerable:!0,configurable:!0})},{allOwnKeys:r}),e),lP=e=>(e.charCodeAt(0)===65279&&(e=e.slice(1)),e),uP=(e,t,n,r)=>{e.prototype=Object.create(t.prototype,r),Object.defineProperty(e.prototype,"constructor",{value:e,writable:!0,enumerable:!1,configurable:!0}),Object.defineProperty(e,"super",{value:t.prototype}),n&&Object.assign(e.prototype,n)},cP=(e,t,n,r)=>{let o,s,i;const a={};if(t=t||{},e==null)return t;do{for(o=Object.getOwnPropertyNames(e),s=o.length;s-- >0;)i=o[s],(!r||r(i,e,t))&&!a[i]&&(t[i]=e[i],a[i]=!0);e=n!==!1&&qc(e)}while(e&&(!n||n(e,t))&&e!==Object.prototype);return t},fP=(e,t,n)=>{e=String(e),(n===void 0||n>e.length)&&(n=e.length),n-=t.length;const r=e.indexOf(t,n);return r!==-1&&r===n},dP=e=>{if(!e)return null;if(ss(e))return e;let t=e.length;if(!Xg(t))return null;const n=new Array(t);for(;t-- >0;)n[t]=e[t];return n},pP=(e=>t=>e&&t instanceof e)(typeof Uint8Array<"u"&&qc(Uint8Array)),hP=(e,t)=>{const r=(e&&e[dl]).call(e);let o;for(;(o=r.next())&&!o.done;){const s=o.value;t.call(e,s[0],s[1])}},vP=(e,t)=>{let n;const r=[];for(;(n=e.exec(t))!==null;)r.push(n);return r},mP=Nn("HTMLFormElement"),gP=e=>e.toLowerCase().replace(/[-_\s]([a-z\d])(\w*)/g,function(n,r,o){return r.toUpperCase()+o}),qp=(({hasOwnProperty:e})=>(t,n)=>e.call(t,n))(Object.prototype),bP=Nn("RegExp"),eb=(e,t)=>{const n=Object.getOwnPropertyDescriptors(e),r={};bi(n,(o,s)=>{let i;(i=t(o,s,e))!==!1&&(r[s]=i||o)}),Object.defineProperties(e,r)},yP=e=>{eb(e,(t,n)=>{if(Ut(e)&&["arguments","caller","callee"].indexOf(n)!==-1)return!1;const r=e[n];if(Ut(r)){if(t.enumerable=!1,"writable"in t){t.writable=!1;return}t.set||(t.set=()=>{throw Error("Can not rewrite read-only method '"+n+"'")})}})},wP=(e,t)=>{const n={},r=o=>{o.forEach(s=>{n[s]=!0})};return ss(e)?r(e):r(String(e).split(t)),n},SP=()=>{},EP=(e,t)=>e!=null&&Number.isFinite(e=+e)?e:t;function _P(e){return!!(e&&Ut(e.append)&&e[Yg]==="FormData"&&e[dl])}const CP=e=>{const t=new Array(10),n=(r,o)=>{if(gi(r)){if(t.indexOf(r)>=0)return;if(mi(r))return r;if(!("toJSON"in r)){t[o]=r;const s=ss(r)?[]:{};return bi(r,(i,a)=>{const l=n(i,o+1);!Yo(l)&&(s[a]=l)}),t[o]=void 0,s}}return r};return n(e,0)},TP=Nn("AsyncFunction"),OP=e=>e&&(gi(e)||Ut(e))&&Ut(e.then)&&Ut(e.catch),tb=((e,t)=>e?setImmediate:t?((n,r)=>(ro.addEventListener("message",({source:o,data:s})=>{o===ro&&s===n&&r.length&&r.shift()()},!1),o=>{r.push(o),ro.postMessage(n,"*")}))(`axios@${Math.random()}`,[]):n=>setTimeout(n))(typeof setImmediate=="function",Ut(ro.postMessage)),AP=typeof queueMicrotask<"u"?queueMicrotask.bind(ro):typeof process<"u"&&process.nextTick||tb,RP=e=>e!=null&&Ut(e[dl]),D={isArray:ss,isArrayBuffer:Jg,isBuffer:mi,isFormData:eP,isArrayBufferView:zI,isString:HI,isNumber:Xg,isBoolean:UI,isObject:gi,isPlainObject:ra,isEmptyObject:KI,isReadableStream:nP,isRequest:rP,isResponse:oP,isHeaders:sP,isUndefined:Yo,isDate:qI,isFile:WI,isReactNativeBlob:GI,isReactNative:YI,isBlob:JI,isRegExp:bP,isFunction:Ut,isStream:ZI,isURLSearchParams:tP,isTypedArray:pP,isFileList:XI,forEach:bi,merge:Bu,extend:aP,trim:iP,stripBOM:lP,inherits:uP,toFlatObject:cP,kindOf:pl,kindOfTest:Nn,endsWith:fP,toArray:dP,forEachEntry:hP,matchAll:vP,isHTMLForm:mP,hasOwnProperty:qp,hasOwnProp:qp,reduceDescriptors:eb,freezeMethods:yP,toObjectSet:wP,toCamelCase:gP,noop:SP,toFiniteNumber:EP,findKey:Zg,global:ro,isContextDefined:Qg,isSpecCompliantForm:_P,toJSONObject:CP,isAsyncFn:TP,isThenable:OP,setImmediate:tb,asap:AP,isIterable:RP};let Te=class nb extends Error{static from(t,n,r,o,s,i){const a=new nb(t.message,n||t.code,r,o,s);return a.cause=t,a.name=t.name,t.status!=null&&a.status==null&&(a.status=t.status),i&&Object.assign(a,i),a}constructor(t,n,r,o,s){super(t),Object.defineProperty(this,"message",{value:t,enumerable:!0,writable:!0,configurable:!0}),this.name="AxiosError",this.isAxiosError=!0,n&&(this.code=n),r&&(this.config=r),o&&(this.request=o),s&&(this.response=s,this.status=s.status)}toJSON(){return{message:this.message,name:this.name,description:this.description,number:this.number,fileName:this.fileName,lineNumber:this.lineNumber,columnNumber:this.columnNumber,stack:this.stack,config:D.toJSONObject(this.config),code:this.code,status:this.status}}};Te.ERR_BAD_OPTION_VALUE="ERR_BAD_OPTION_VALUE";Te.ERR_BAD_OPTION="ERR_BAD_OPTION";Te.ECONNABORTED="ECONNABORTED";Te.ETIMEDOUT="ETIMEDOUT";Te.ERR_NETWORK="ERR_NETWORK";Te.ERR_FR_TOO_MANY_REDIRECTS="ERR_FR_TOO_MANY_REDIRECTS";Te.ERR_DEPRECATED="ERR_DEPRECATED";Te.ERR_BAD_RESPONSE="ERR_BAD_RESPONSE";Te.ERR_BAD_REQUEST="ERR_BAD_REQUEST";Te.ERR_CANCELED="ERR_CANCELED";Te.ERR_NOT_SUPPORT="ERR_NOT_SUPPORT";Te.ERR_INVALID_URL="ERR_INVALID_URL";const xP=null;function Du(e){return D.isPlainObject(e)||D.isArray(e)}function rb(e){return D.endsWith(e,"[]")?e.slice(0,-2):e}function ql(e,t,n){return e?e.concat(t).map(function(o,s){return o=rb(o),!n&&s?"["+o+"]":o}).join(n?".":""):t}function IP(e){return D.isArray(e)&&!e.some(Du)}const PP=D.toFlatObject(D,{},null,function(t){return/^is[A-Z]/.test(t)});function vl(e,t,n){if(!D.isObject(e))throw new TypeError("target must be an object");t=t||new FormData,n=D.toFlatObject(n,{metaTokens:!0,dots:!1,indexes:!1},!1,function(h,b){return!D.isUndefined(b[h])});const r=n.metaTokens,o=n.visitor||c,s=n.dots,i=n.indexes,l=(n.Blob||typeof Blob<"u"&&Blob)&&D.isSpecCompliantForm(t);if(!D.isFunction(o))throw new TypeError("visitor must be a function");function u(p){if(p===null)return"";if(D.isDate(p))return p.toISOString();if(D.isBoolean(p))return p.toString();if(!l&&D.isBlob(p))throw new Te("Blob is not supported. Use a Buffer instead.");return D.isArrayBuffer(p)||D.isTypedArray(p)?l&&typeof Blob=="function"?new Blob([p]):Buffer.from(p):p}function c(p,h,b){let m=p;if(D.isReactNative(t)&&D.isReactNativeBlob(p))return t.append(ql(b,h,s),u(p)),!1;if(p&&!b&&typeof p=="object"){if(D.endsWith(h,"{}"))h=r?h:h.slice(0,-2),p=JSON.stringify(p);else if(D.isArray(p)&&IP(p)||(D.isFileList(p)||D.endsWith(h,"[]"))&&(m=D.toArray(p)))return h=rb(h),m.forEach(function(_,w){!(D.isUndefined(_)||_===null)&&t.append(i===!0?ql([h],w,s):i===null?h:h+"[]",u(_))}),!1}return Du(p)?!0:(t.append(ql(b,h,s),u(p)),!1)}const f=[],d=Object.assign(PP,{defaultVisitor:c,convertValue:u,isVisitable:Du});function v(p,h){if(!D.isUndefined(p)){if(f.indexOf(p)!==-1)throw Error("Circular reference detected in "+h.join("."));f.push(p),D.forEach(p,function(m,S){(!(D.isUndefined(m)||m===null)&&o.call(t,m,D.isString(S)?S.trim():S,h,d))===!0&&v(m,h?h.concat(S):[S])}),f.pop()}}if(!D.isObject(e))throw new TypeError("data must be an object");return v(e),t}function Wp(e){const t={"!":"%21","'":"%27","(":"%28",")":"%29","~":"%7E","%20":"+","%00":"\0"};return encodeURIComponent(e).replace(/[!'()~]|%20|%00/g,function(r){return t[r]})}function Wc(e,t){this._pairs=[],e&&vl(e,this,t)}const ob=Wc.prototype;ob.append=function(t,n){this._pairs.push([t,n])};ob.toString=function(t){const n=t?function(r){return t.call(this,r,Wp)}:Wp;return this._pairs.map(function(o){return n(o[0])+"="+n(o[1])},"").join("&")};function NP(e){return encodeURIComponent(e).replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%20/g,"+")}function sb(e,t,n){if(!t)return e;const r=n&&n.encode||NP,o=D.isFunction(n)?{serialize:n}:n,s=o&&o.serialize;let i;if(s?i=s(t,o):i=D.isURLSearchParams(t)?t.toString():new Wc(t,o).toString(r),i){const a=e.indexOf("#");a!==-1&&(e=e.slice(0,a)),e+=(e.indexOf("?")===-1?"?":"&")+i}return e}class Gp{constructor(){this.handlers=[]}use(t,n,r){return this.handlers.push({fulfilled:t,rejected:n,synchronous:r?r.synchronous:!1,runWhen:r?r.runWhen:null}),this.handlers.length-1}eject(t){this.handlers[t]&&(this.handlers[t]=null)}clear(){this.handlers&&(this.handlers=[])}forEach(t){D.forEach(this.handlers,function(r){r!==null&&t(r)})}}const Gc={silentJSONParsing:!0,forcedJSONParsing:!0,clarifyTimeoutError:!1,legacyInterceptorReqResOrdering:!0},LP=typeof URLSearchParams<"u"?URLSearchParams:Wc,MP=typeof FormData<"u"?FormData:null,$P=typeof Blob<"u"?Blob:null,kP={isBrowser:!0,classes:{URLSearchParams:LP,FormData:MP,Blob:$P},protocols:["http","https","file","blob","url","data"]},Yc=typeof window<"u"&&typeof document<"u",Vu=typeof navigator=="object"&&navigator||void 0,FP=Yc&&(!Vu||["ReactNative","NativeScript","NS"].indexOf(Vu.product)<0),BP=typeof WorkerGlobalScope<"u"&&self instanceof WorkerGlobalScope&&typeof self.importScripts=="function",DP=Yc&&window.location.href||"http://localhost",VP=Object.freeze(Object.defineProperty({__proto__:null,hasBrowserEnv:Yc,hasStandardBrowserEnv:FP,hasStandardBrowserWebWorkerEnv:BP,navigator:Vu,origin:DP},Symbol.toStringTag,{value:"Module"})),Nt={...VP,...kP};function jP(e,t){return vl(e,new Nt.classes.URLSearchParams,{visitor:function(n,r,o,s){return Nt.isNode&&D.isBuffer(n)?(this.append(r,n.toString("base64")),!1):s.defaultVisitor.apply(this,arguments)},...t})}function zP(e){return D.matchAll(/\w+|\[(\w*)]/g,e).map(t=>t[0]==="[]"?"":t[1]||t[0])}function HP(e){const t={},n=Object.keys(e);let r;const o=n.length;let s;for(r=0;r=n.length;return i=!i&&D.isArray(o)?o.length:i,l?(D.hasOwnProp(o,i)?o[i]=[o[i],r]:o[i]=r,!a):((!o[i]||!D.isObject(o[i]))&&(o[i]=[]),t(n,r,o[i],s)&&D.isArray(o[i])&&(o[i]=HP(o[i])),!a)}if(D.isFormData(e)&&D.isFunction(e.entries)){const n={};return D.forEachEntry(e,(r,o)=>{t(zP(r),o,n,0)}),n}return null}function UP(e,t,n){if(D.isString(e))try{return(t||JSON.parse)(e),D.trim(e)}catch(r){if(r.name!=="SyntaxError")throw r}return(n||JSON.stringify)(e)}const yi={transitional:Gc,adapter:["xhr","http","fetch"],transformRequest:[function(t,n){const r=n.getContentType()||"",o=r.indexOf("application/json")>-1,s=D.isObject(t);if(s&&D.isHTMLForm(t)&&(t=new FormData(t)),D.isFormData(t))return o?JSON.stringify(ib(t)):t;if(D.isArrayBuffer(t)||D.isBuffer(t)||D.isStream(t)||D.isFile(t)||D.isBlob(t)||D.isReadableStream(t))return t;if(D.isArrayBufferView(t))return t.buffer;if(D.isURLSearchParams(t))return n.setContentType("application/x-www-form-urlencoded;charset=utf-8",!1),t.toString();let a;if(s){if(r.indexOf("application/x-www-form-urlencoded")>-1)return jP(t,this.formSerializer).toString();if((a=D.isFileList(t))||r.indexOf("multipart/form-data")>-1){const l=this.env&&this.env.FormData;return vl(a?{"files[]":t}:t,l&&new l,this.formSerializer)}}return s||o?(n.setContentType("application/json",!1),UP(t)):t}],transformResponse:[function(t){const n=this.transitional||yi.transitional,r=n&&n.forcedJSONParsing,o=this.responseType==="json";if(D.isResponse(t)||D.isReadableStream(t))return t;if(t&&D.isString(t)&&(r&&!this.responseType||o)){const i=!(n&&n.silentJSONParsing)&&o;try{return JSON.parse(t,this.parseReviver)}catch(a){if(i)throw a.name==="SyntaxError"?Te.from(a,Te.ERR_BAD_RESPONSE,this,null,this.response):a}}return t}],timeout:0,xsrfCookieName:"XSRF-TOKEN",xsrfHeaderName:"X-XSRF-TOKEN",maxContentLength:-1,maxBodyLength:-1,env:{FormData:Nt.classes.FormData,Blob:Nt.classes.Blob},validateStatus:function(t){return t>=200&&t<300},headers:{common:{Accept:"application/json, text/plain, */*","Content-Type":void 0}}};D.forEach(["delete","get","head","post","put","patch"],e=>{yi.headers[e]={}});const KP=D.toObjectSet(["age","authorization","content-length","content-type","etag","expires","from","host","if-modified-since","if-unmodified-since","last-modified","location","max-forwards","proxy-authorization","referer","retry-after","user-agent"]),qP=e=>{const t={};let n,r,o;return e&&e.split(` `).forEach(function(i){o=i.indexOf(":"),n=i.substring(0,o).trim().toLowerCase(),r=i.substring(o+1).trim(),!(!n||t[n]&&KP[n])&&(n==="set-cookie"?t[n]?t[n].push(r):t[n]=[r]:t[n]=t[n]?t[n]+", "+r:r)}),t},Yp=Symbol("internals");function vs(e){return e&&String(e).trim().toLowerCase()}function oa(e){return e===!1||e==null?e:D.isArray(e)?e.map(oa):String(e)}function WP(e){const t=Object.create(null),n=/([^\s,;=]+)\s*(?:=\s*([^,;]+))?/g;let r;for(;r=n.exec(e);)t[r[1]]=r[2];return t}const GP=e=>/^[-_a-zA-Z0-9^`|~,!#$%&'*+.]+$/.test(e.trim());function Wl(e,t,n,r,o){if(D.isFunction(r))return r.call(this,t,n);if(o&&(t=n),!!D.isString(t)){if(D.isString(r))return t.indexOf(r)!==-1;if(D.isRegExp(r))return r.test(t)}}function YP(e){return e.trim().toLowerCase().replace(/([a-z\d])(\w*)/g,(t,n,r)=>n.toUpperCase()+r)}function JP(e,t){const n=D.toCamelCase(" "+t);["get","set","has"].forEach(r=>{Object.defineProperty(e,r+n,{value:function(o,s,i){return this[r].call(this,t,o,s,i)},configurable:!0})})}let Kt=class{constructor(t){t&&this.set(t)}set(t,n,r){const o=this;function s(a,l,u){const c=vs(l);if(!c)throw new Error("header name must be a non-empty string");const f=D.findKey(o,c);(!f||o[f]===void 0||u===!0||u===void 0&&o[f]!==!1)&&(o[f||l]=oa(a))}const i=(a,l)=>D.forEach(a,(u,c)=>s(u,c,l));if(D.isPlainObject(t)||t instanceof this.constructor)i(t,n);else if(D.isString(t)&&(t=t.trim())&&!GP(t))i(qP(t),n);else if(D.isObject(t)&&D.isIterable(t)){let a={},l,u;for(const c of t){if(!D.isArray(c))throw TypeError("Object iterator must return a key-value pair");a[u=c[0]]=(l=a[u])?D.isArray(l)?[...l,c[1]]:[l,c[1]]:c[1]}i(a,n)}else t!=null&&s(n,t,r);return this}get(t,n){if(t=vs(t),t){const r=D.findKey(this,t);if(r){const o=this[r];if(!n)return o;if(n===!0)return WP(o);if(D.isFunction(n))return n.call(this,o,r);if(D.isRegExp(n))return n.exec(o);throw new TypeError("parser must be boolean|regexp|function")}}}has(t,n){if(t=vs(t),t){const r=D.findKey(this,t);return!!(r&&this[r]!==void 0&&(!n||Wl(this,this[r],r,n)))}return!1}delete(t,n){const r=this;let o=!1;function s(i){if(i=vs(i),i){const a=D.findKey(r,i);a&&(!n||Wl(r,r[a],a,n))&&(delete r[a],o=!0)}}return D.isArray(t)?t.forEach(s):s(t),o}clear(t){const n=Object.keys(this);let r=n.length,o=!1;for(;r--;){const s=n[r];(!t||Wl(this,this[s],s,t,!0))&&(delete this[s],o=!0)}return o}normalize(t){const n=this,r={};return D.forEach(this,(o,s)=>{const i=D.findKey(r,s);if(i){n[i]=oa(o),delete n[s];return}const a=t?YP(s):String(s).trim();a!==s&&delete n[s],n[a]=oa(o),r[a]=!0}),this}concat(...t){return this.constructor.concat(this,...t)}toJSON(t){const n=Object.create(null);return D.forEach(this,(r,o)=>{r!=null&&r!==!1&&(n[o]=t&&D.isArray(r)?r.join(", "):r)}),n}[Symbol.iterator](){return Object.entries(this.toJSON())[Symbol.iterator]()}toString(){return Object.entries(this.toJSON()).map(([t,n])=>t+": "+n).join(` `)}getSetCookie(){return this.get("set-cookie")||[]}get[Symbol.toStringTag](){return"AxiosHeaders"}static from(t){return t instanceof this?t:new this(t)}static concat(t,...n){const r=new this(t);return n.forEach(o=>r.set(o)),r}static accessor(t){const r=(this[Yp]=this[Yp]={accessors:{}}).accessors,o=this.prototype;function s(i){const a=vs(i);r[a]||(JP(o,i),r[a]=!0)}return D.isArray(t)?t.forEach(s):s(t),this}};Kt.accessor(["Content-Type","Content-Length","Accept","Accept-Encoding","User-Agent","Authorization"]);D.reduceDescriptors(Kt.prototype,({value:e},t)=>{let n=t[0].toUpperCase()+t.slice(1);return{get:()=>e,set(r){this[n]=r}}});D.freezeMethods(Kt);function Gl(e,t){const n=this||yi,r=t||n,o=Kt.from(r.headers);let s=r.data;return D.forEach(e,function(a){s=a.call(n,s,o.normalize(),t?t.status:void 0)}),o.normalize(),s}function ab(e){return!!(e&&e.__CANCEL__)}let wi=class extends Te{constructor(t,n,r){super(t??"canceled",Te.ERR_CANCELED,n,r),this.name="CanceledError",this.__CANCEL__=!0}};function lb(e,t,n){const r=n.config.validateStatus;!n.status||!r||r(n.status)?e(n):t(new Te("Request failed with status code "+n.status,[Te.ERR_BAD_REQUEST,Te.ERR_BAD_RESPONSE][Math.floor(n.status/100)-4],n.config,n.request,n))}function XP(e){const t=/^([-+\w]{1,25})(:?\/\/|:)/.exec(e);return t&&t[1]||""}function ZP(e,t){e=e||10;const n=new Array(e),r=new Array(e);let o=0,s=0,i;return t=t!==void 0?t:1e3,function(l){const u=Date.now(),c=r[s];i||(i=u),n[o]=l,r[o]=u;let f=s,d=0;for(;f!==o;)d+=n[f++],f=f%e;if(o=(o+1)%e,o===s&&(s=(s+1)%e),u-i{n=c,o=null,s&&(clearTimeout(s),s=null),e(...u)};return[(...u)=>{const c=Date.now(),f=c-n;f>=r?i(u,c):(o=u,s||(s=setTimeout(()=>{s=null,i(o)},r-f)))},()=>o&&i(o)]}const Pa=(e,t,n=3)=>{let r=0;const o=ZP(50,250);return QP(s=>{const i=s.loaded,a=s.lengthComputable?s.total:void 0,l=i-r,u=o(l),c=i<=a;r=i;const f={loaded:i,total:a,progress:a?i/a:void 0,bytes:l,rate:u||void 0,estimated:u&&a&&c?(a-i)/u:void 0,event:s,lengthComputable:a!=null,[t?"download":"upload"]:!0};e(f)},n)},Jp=(e,t)=>{const n=e!=null;return[r=>t[0]({lengthComputable:n,total:e,loaded:r}),t[1]]},Xp=e=>(...t)=>D.asap(()=>e(...t)),e8=Nt.hasStandardBrowserEnv?((e,t)=>n=>(n=new URL(n,Nt.origin),e.protocol===n.protocol&&e.host===n.host&&(t||e.port===n.port)))(new URL(Nt.origin),Nt.navigator&&/(msie|trident)/i.test(Nt.navigator.userAgent)):()=>!0,t8=Nt.hasStandardBrowserEnv?{write(e,t,n,r,o,s,i){if(typeof document>"u")return;const a=[`${e}=${encodeURIComponent(t)}`];D.isNumber(n)&&a.push(`expires=${new Date(n).toUTCString()}`),D.isString(r)&&a.push(`path=${r}`),D.isString(o)&&a.push(`domain=${o}`),s===!0&&a.push("secure"),D.isString(i)&&a.push(`SameSite=${i}`),document.cookie=a.join("; ")},read(e){if(typeof document>"u")return null;const t=document.cookie.match(new RegExp("(?:^|; )"+e+"=([^;]*)"));return t?decodeURIComponent(t[1]):null},remove(e){this.write(e,"",Date.now()-864e5,"/")}}:{write(){},read(){return null},remove(){}};function n8(e){return typeof e!="string"?!1:/^([a-z][a-z\d+\-.]*:)?\/\//i.test(e)}function r8(e,t){return t?e.replace(/\/?\/$/,"")+"/"+t.replace(/^\/+/,""):e}function ub(e,t,n){let r=!n8(t);return e&&(r||n==!1)?r8(e,t):t}const Zp=e=>e instanceof Kt?{...e}:e;function vo(e,t){t=t||{};const n={};function r(u,c,f,d){return D.isPlainObject(u)&&D.isPlainObject(c)?D.merge.call({caseless:d},u,c):D.isPlainObject(c)?D.merge({},c):D.isArray(c)?c.slice():c}function o(u,c,f,d){if(D.isUndefined(c)){if(!D.isUndefined(u))return r(void 0,u,f,d)}else return r(u,c,f,d)}function s(u,c){if(!D.isUndefined(c))return r(void 0,c)}function i(u,c){if(D.isUndefined(c)){if(!D.isUndefined(u))return r(void 0,u)}else return r(void 0,c)}function a(u,c,f){if(f in t)return r(u,c);if(f in e)return r(void 0,u)}const l={url:s,method:s,data:s,baseURL:i,transformRequest:i,transformResponse:i,paramsSerializer:i,timeout:i,timeoutMessage:i,withCredentials:i,withXSRFToken:i,adapter:i,responseType:i,xsrfCookieName:i,xsrfHeaderName:i,onUploadProgress:i,onDownloadProgress:i,decompress:i,maxContentLength:i,maxBodyLength:i,beforeRedirect:i,transport:i,httpAgent:i,httpsAgent:i,cancelToken:i,socketPath:i,responseEncoding:i,validateStatus:a,headers:(u,c,f)=>o(Zp(u),Zp(c),f,!0)};return D.forEach(Object.keys({...e,...t}),function(c){if(c==="__proto__"||c==="constructor"||c==="prototype")return;const f=D.hasOwnProp(l,c)?l[c]:o,d=f(e[c],t[c],c);D.isUndefined(d)&&f!==a||(n[c]=d)}),n}const cb=e=>{const t=vo({},e);let{data:n,withXSRFToken:r,xsrfHeaderName:o,xsrfCookieName:s,headers:i,auth:a}=t;if(t.headers=i=Kt.from(i),t.url=sb(ub(t.baseURL,t.url,t.allowAbsoluteUrls),e.params,e.paramsSerializer),a&&i.set("Authorization","Basic "+btoa((a.username||"")+":"+(a.password?unescape(encodeURIComponent(a.password)):""))),D.isFormData(n)){if(Nt.hasStandardBrowserEnv||Nt.hasStandardBrowserWebWorkerEnv)i.setContentType(void 0);else if(D.isFunction(n.getHeaders)){const l=n.getHeaders(),u=["content-type","content-length"];Object.entries(l).forEach(([c,f])=>{u.includes(c.toLowerCase())&&i.set(c,f)})}}if(Nt.hasStandardBrowserEnv&&(r&&D.isFunction(r)&&(r=r(t)),r||r!==!1&&e8(t.url))){const l=o&&s&&t8.read(s);l&&i.set(o,l)}return t},o8=typeof XMLHttpRequest<"u",s8=o8&&function(e){return new Promise(function(n,r){const o=cb(e);let s=o.data;const i=Kt.from(o.headers).normalize();let{responseType:a,onUploadProgress:l,onDownloadProgress:u}=o,c,f,d,v,p;function h(){v&&v(),p&&p(),o.cancelToken&&o.cancelToken.unsubscribe(c),o.signal&&o.signal.removeEventListener("abort",c)}let b=new XMLHttpRequest;b.open(o.method.toUpperCase(),o.url,!0),b.timeout=o.timeout;function m(){if(!b)return;const _=Kt.from("getAllResponseHeaders"in b&&b.getAllResponseHeaders()),y={data:!a||a==="text"||a==="json"?b.responseText:b.response,status:b.status,statusText:b.statusText,headers:_,config:e,request:b};lb(function(R){n(R),h()},function(R){r(R),h()},y),b=null}"onloadend"in b?b.onloadend=m:b.onreadystatechange=function(){!b||b.readyState!==4||b.status===0&&!(b.responseURL&&b.responseURL.indexOf("file:")===0)||setTimeout(m)},b.onabort=function(){b&&(r(new Te("Request aborted",Te.ECONNABORTED,e,b)),b=null)},b.onerror=function(w){const y=w&&w.message?w.message:"Network Error",A=new Te(y,Te.ERR_NETWORK,e,b);A.event=w||null,r(A),b=null},b.ontimeout=function(){let w=o.timeout?"timeout of "+o.timeout+"ms exceeded":"timeout exceeded";const y=o.transitional||Gc;o.timeoutErrorMessage&&(w=o.timeoutErrorMessage),r(new Te(w,y.clarifyTimeoutError?Te.ETIMEDOUT:Te.ECONNABORTED,e,b)),b=null},s===void 0&&i.setContentType(null),"setRequestHeader"in b&&D.forEach(i.toJSON(),function(w,y){b.setRequestHeader(y,w)}),D.isUndefined(o.withCredentials)||(b.withCredentials=!!o.withCredentials),a&&a!=="json"&&(b.responseType=o.responseType),u&&([d,p]=Pa(u,!0),b.addEventListener("progress",d)),l&&b.upload&&([f,v]=Pa(l),b.upload.addEventListener("progress",f),b.upload.addEventListener("loadend",v)),(o.cancelToken||o.signal)&&(c=_=>{b&&(r(!_||_.type?new wi(null,e,b):_),b.abort(),b=null)},o.cancelToken&&o.cancelToken.subscribe(c),o.signal&&(o.signal.aborted?c():o.signal.addEventListener("abort",c)));const S=XP(o.url);if(S&&Nt.protocols.indexOf(S)===-1){r(new Te("Unsupported protocol "+S+":",Te.ERR_BAD_REQUEST,e));return}b.send(s||null)})},i8=(e,t)=>{const{length:n}=e=e?e.filter(Boolean):[];if(t||n){let r=new AbortController,o;const s=function(u){if(!o){o=!0,a();const c=u instanceof Error?u:this.reason;r.abort(c instanceof Te?c:new wi(c instanceof Error?c.message:c))}};let i=t&&setTimeout(()=>{i=null,s(new Te(`timeout of ${t}ms exceeded`,Te.ETIMEDOUT))},t);const a=()=>{e&&(i&&clearTimeout(i),i=null,e.forEach(u=>{u.unsubscribe?u.unsubscribe(s):u.removeEventListener("abort",s)}),e=null)};e.forEach(u=>u.addEventListener("abort",s));const{signal:l}=r;return l.unsubscribe=()=>D.asap(a),l}},a8=function*(e,t){let n=e.byteLength;if(n{const o=l8(e,t);let s=0,i,a=l=>{i||(i=!0,r&&r(l))};return new ReadableStream({async pull(l){try{const{done:u,value:c}=await o.next();if(u){a(),l.close();return}let f=c.byteLength;if(n){let d=s+=f;n(d)}l.enqueue(new Uint8Array(c))}catch(u){throw a(u),u}},cancel(l){return a(l),o.return()}},{highWaterMark:2})},eh=64*1024,{isFunction:Di}=D,c8=(({Request:e,Response:t})=>({Request:e,Response:t}))(D.global),{ReadableStream:th,TextEncoder:nh}=D.global,rh=(e,...t)=>{try{return!!e(...t)}catch{return!1}},f8=e=>{e=D.merge.call({skipUndefined:!0},c8,e);const{fetch:t,Request:n,Response:r}=e,o=t?Di(t):typeof fetch=="function",s=Di(n),i=Di(r);if(!o)return!1;const a=o&&Di(th),l=o&&(typeof nh=="function"?(p=>h=>p.encode(h))(new nh):async p=>new Uint8Array(await new n(p).arrayBuffer())),u=s&&a&&rh(()=>{let p=!1;const h=new n(Nt.origin,{body:new th,method:"POST",get duplex(){return p=!0,"half"}}).headers.has("Content-Type");return p&&!h}),c=i&&a&&rh(()=>D.isReadableStream(new r("").body)),f={stream:c&&(p=>p.body)};o&&["text","arrayBuffer","blob","formData","stream"].forEach(p=>{!f[p]&&(f[p]=(h,b)=>{let m=h&&h[p];if(m)return m.call(h);throw new Te(`Response type '${p}' is not supported`,Te.ERR_NOT_SUPPORT,b)})});const d=async p=>{if(p==null)return 0;if(D.isBlob(p))return p.size;if(D.isSpecCompliantForm(p))return(await new n(Nt.origin,{method:"POST",body:p}).arrayBuffer()).byteLength;if(D.isArrayBufferView(p)||D.isArrayBuffer(p))return p.byteLength;if(D.isURLSearchParams(p)&&(p=p+""),D.isString(p))return(await l(p)).byteLength},v=async(p,h)=>{const b=D.toFiniteNumber(p.getContentLength());return b??d(h)};return async p=>{let{url:h,method:b,data:m,signal:S,cancelToken:_,timeout:w,onDownloadProgress:y,onUploadProgress:A,responseType:R,headers:N,withCredentials:I="same-origin",fetchOptions:x}=cb(p),k=t||fetch;R=R?(R+"").toLowerCase():"text";let $=i8([S,_&&_.toAbortSignal()],w),F=null;const Y=$&&$.unsubscribe&&(()=>{$.unsubscribe()});let P;try{if(A&&u&&b!=="get"&&b!=="head"&&(P=await v(N,m))!==0){let Re=new n(h,{method:"POST",body:m,duplex:"half"}),Ce;if(D.isFormData(m)&&(Ce=Re.headers.get("content-type"))&&N.setContentType(Ce),Re.body){const[_e,qe]=Jp(P,Pa(Xp(A)));m=Qp(Re.body,eh,_e,qe)}}D.isString(I)||(I=I?"include":"omit");const O=s&&"credentials"in n.prototype,j={...x,signal:$,method:b.toUpperCase(),headers:N.normalize().toJSON(),body:m,duplex:"half",credentials:O?I:void 0};F=s&&new n(h,j);let Q=await(s?k(F,x):k(h,j));const me=c&&(R==="stream"||R==="response");if(c&&(y||me&&Y)){const Re={};["status","statusText","headers"].forEach(ze=>{Re[ze]=Q[ze]});const Ce=D.toFiniteNumber(Q.headers.get("content-length")),[_e,qe]=y&&Jp(Ce,Pa(Xp(y),!0))||[];Q=new r(Qp(Q.body,eh,_e,()=>{qe&&qe(),Y&&Y()}),Re)}R=R||"text";let Pe=await f[D.findKey(f,R)||"text"](Q,p);return!me&&Y&&Y(),await new Promise((Re,Ce)=>{lb(Re,Ce,{data:Pe,headers:Kt.from(Q.headers),status:Q.status,statusText:Q.statusText,config:p,request:F})})}catch(O){throw Y&&Y(),O&&O.name==="TypeError"&&/Load failed|fetch/i.test(O.message)?Object.assign(new Te("Network Error",Te.ERR_NETWORK,p,F,O&&O.response),{cause:O.cause||O}):Te.from(O,O&&O.code,p,F,O&&O.response)}}},d8=new Map,fb=e=>{let t=e&&e.env||{};const{fetch:n,Request:r,Response:o}=t,s=[r,o,n];let i=s.length,a=i,l,u,c=d8;for(;a--;)l=s[a],u=c.get(l),u===void 0&&c.set(l,u=a?new Map:f8(t)),c=u;return u};fb();const Jc={http:xP,xhr:s8,fetch:{get:fb}};D.forEach(Jc,(e,t)=>{if(e){try{Object.defineProperty(e,"name",{value:t})}catch{}Object.defineProperty(e,"adapterName",{value:t})}});const oh=e=>`- ${e}`,p8=e=>D.isFunction(e)||e===null||e===!1;function h8(e,t){e=D.isArray(e)?e:[e];const{length:n}=e;let r,o;const s={};for(let i=0;i`adapter ${l} `+(u===!1?"is not supported by the environment":"is not available in the build"));let a=n?i.length>1?`since : `+i.map(oh).join(` `):" "+oh(i[0]):"as no adapter specified";throw new Te("There is no suitable adapter to dispatch the request "+a,"ERR_NOT_SUPPORT")}return o}const db={getAdapter:h8,adapters:Jc};function Yl(e){if(e.cancelToken&&e.cancelToken.throwIfRequested(),e.signal&&e.signal.aborted)throw new wi(null,e)}function sh(e){return Yl(e),e.headers=Kt.from(e.headers),e.data=Gl.call(e,e.transformRequest),["post","put","patch"].indexOf(e.method)!==-1&&e.headers.setContentType("application/x-www-form-urlencoded",!1),db.getAdapter(e.adapter||yi.adapter,e)(e).then(function(r){return Yl(e),r.data=Gl.call(e,e.transformResponse,r),r.headers=Kt.from(r.headers),r},function(r){return ab(r)||(Yl(e),r&&r.response&&(r.response.data=Gl.call(e,e.transformResponse,r.response),r.response.headers=Kt.from(r.response.headers))),Promise.reject(r)})}const pb="1.13.6",ml={};["object","boolean","number","function","string","symbol"].forEach((e,t)=>{ml[e]=function(r){return typeof r===e||"a"+(t<1?"n ":" ")+e}});const ih={};ml.transitional=function(t,n,r){function o(s,i){return"[Axios v"+pb+"] Transitional option '"+s+"'"+i+(r?". "+r:"")}return(s,i,a)=>{if(t===!1)throw new Te(o(i," has been removed"+(n?" in "+n:"")),Te.ERR_DEPRECATED);return n&&!ih[i]&&(ih[i]=!0),t?t(s,i,a):!0}};ml.spelling=function(t){return(n,r)=>!0};function v8(e,t,n){if(typeof e!="object")throw new Te("options must be an object",Te.ERR_BAD_OPTION_VALUE);const r=Object.keys(e);let o=r.length;for(;o-- >0;){const s=r[o],i=t[s];if(i){const a=e[s],l=a===void 0||i(a,s,e);if(l!==!0)throw new Te("option "+s+" must be "+l,Te.ERR_BAD_OPTION_VALUE);continue}if(n!==!0)throw new Te("Unknown option "+s,Te.ERR_BAD_OPTION)}}const sa={assertOptions:v8,validators:ml},an=sa.validators;let lo=class{constructor(t){this.defaults=t||{},this.interceptors={request:new Gp,response:new Gp}}async request(t,n){try{return await this._request(t,n)}catch(r){if(r instanceof Error){let o={};Error.captureStackTrace?Error.captureStackTrace(o):o=new Error;const s=o.stack?o.stack.replace(/^.+\n/,""):"";try{r.stack?s&&!String(r.stack).endsWith(s.replace(/^.+\n.+\n/,""))&&(r.stack+=` `+s):r.stack=s}catch{}}throw r}}_request(t,n){typeof t=="string"?(n=n||{},n.url=t):n=t||{},n=vo(this.defaults,n);const{transitional:r,paramsSerializer:o,headers:s}=n;r!==void 0&&sa.assertOptions(r,{silentJSONParsing:an.transitional(an.boolean),forcedJSONParsing:an.transitional(an.boolean),clarifyTimeoutError:an.transitional(an.boolean),legacyInterceptorReqResOrdering:an.transitional(an.boolean)},!1),o!=null&&(D.isFunction(o)?n.paramsSerializer={serialize:o}:sa.assertOptions(o,{encode:an.function,serialize:an.function},!0)),n.allowAbsoluteUrls!==void 0||(this.defaults.allowAbsoluteUrls!==void 0?n.allowAbsoluteUrls=this.defaults.allowAbsoluteUrls:n.allowAbsoluteUrls=!0),sa.assertOptions(n,{baseUrl:an.spelling("baseURL"),withXsrfToken:an.spelling("withXSRFToken")},!0),n.method=(n.method||this.defaults.method||"get").toLowerCase();let i=s&&D.merge(s.common,s[n.method]);s&&D.forEach(["delete","get","head","post","put","patch","common"],p=>{delete s[p]}),n.headers=Kt.concat(i,s);const a=[];let l=!0;this.interceptors.request.forEach(function(h){if(typeof h.runWhen=="function"&&h.runWhen(n)===!1)return;l=l&&h.synchronous;const b=n.transitional||Gc;b&&b.legacyInterceptorReqResOrdering?a.unshift(h.fulfilled,h.rejected):a.push(h.fulfilled,h.rejected)});const u=[];this.interceptors.response.forEach(function(h){u.push(h.fulfilled,h.rejected)});let c,f=0,d;if(!l){const p=[sh.bind(this),void 0];for(p.unshift(...a),p.push(...u),d=p.length,c=Promise.resolve(n);f{if(!r._listeners)return;let s=r._listeners.length;for(;s-- >0;)r._listeners[s](o);r._listeners=null}),this.promise.then=o=>{let s;const i=new Promise(a=>{r.subscribe(a),s=a}).then(o);return i.cancel=function(){r.unsubscribe(s)},i},t(function(s,i,a){r.reason||(r.reason=new wi(s,i,a),n(r.reason))})}throwIfRequested(){if(this.reason)throw this.reason}subscribe(t){if(this.reason){t(this.reason);return}this._listeners?this._listeners.push(t):this._listeners=[t]}unsubscribe(t){if(!this._listeners)return;const n=this._listeners.indexOf(t);n!==-1&&this._listeners.splice(n,1)}toAbortSignal(){const t=new AbortController,n=r=>{t.abort(r)};return this.subscribe(n),t.signal.unsubscribe=()=>this.unsubscribe(n),t.signal}static source(){let t;return{token:new hb(function(o){t=o}),cancel:t}}};function g8(e){return function(n){return e.apply(null,n)}}function b8(e){return D.isObject(e)&&e.isAxiosError===!0}const ju={Continue:100,SwitchingProtocols:101,Processing:102,EarlyHints:103,Ok:200,Created:201,Accepted:202,NonAuthoritativeInformation:203,NoContent:204,ResetContent:205,PartialContent:206,MultiStatus:207,AlreadyReported:208,ImUsed:226,MultipleChoices:300,MovedPermanently:301,Found:302,SeeOther:303,NotModified:304,UseProxy:305,Unused:306,TemporaryRedirect:307,PermanentRedirect:308,BadRequest:400,Unauthorized:401,PaymentRequired:402,Forbidden:403,NotFound:404,MethodNotAllowed:405,NotAcceptable:406,ProxyAuthenticationRequired:407,RequestTimeout:408,Conflict:409,Gone:410,LengthRequired:411,PreconditionFailed:412,PayloadTooLarge:413,UriTooLong:414,UnsupportedMediaType:415,RangeNotSatisfiable:416,ExpectationFailed:417,ImATeapot:418,MisdirectedRequest:421,UnprocessableEntity:422,Locked:423,FailedDependency:424,TooEarly:425,UpgradeRequired:426,PreconditionRequired:428,TooManyRequests:429,RequestHeaderFieldsTooLarge:431,UnavailableForLegalReasons:451,InternalServerError:500,NotImplemented:501,BadGateway:502,ServiceUnavailable:503,GatewayTimeout:504,HttpVersionNotSupported:505,VariantAlsoNegotiates:506,InsufficientStorage:507,LoopDetected:508,NotExtended:510,NetworkAuthenticationRequired:511,WebServerIsDown:521,ConnectionTimedOut:522,OriginIsUnreachable:523,TimeoutOccurred:524,SslHandshakeFailed:525,InvalidSslCertificate:526};Object.entries(ju).forEach(([e,t])=>{ju[t]=e});function vb(e){const t=new lo(e),n=Gg(lo.prototype.request,t);return D.extend(n,lo.prototype,t,{allOwnKeys:!0}),D.extend(n,t,null,{allOwnKeys:!0}),n.create=function(o){return vb(vo(e,o))},n}const mt=vb(yi);mt.Axios=lo;mt.CanceledError=wi;mt.CancelToken=m8;mt.isCancel=ab;mt.VERSION=pb;mt.toFormData=vl;mt.AxiosError=Te;mt.Cancel=mt.CanceledError;mt.all=function(t){return Promise.all(t)};mt.spread=g8;mt.isAxiosError=b8;mt.mergeConfig=vo;mt.AxiosHeaders=Kt;mt.formToJSON=e=>ib(D.isHTMLForm(e)?new FormData(e):e);mt.getAdapter=db.getAdapter;mt.HttpStatusCode=ju;mt.default=mt;const{Axios:JN,AxiosError:XN,CanceledError:ZN,isCancel:QN,CancelToken:eL,VERSION:tL,all:nL,Cancel:rL,isAxiosError:oL,spread:sL,toFormData:iL,AxiosHeaders:aL,HttpStatusCode:lL,formToJSON:uL,getAdapter:cL,mergeConfig:fL}=mt;function mb(e){return Fa()?(Ba(e),!0):!1}function ln(e){return typeof e=="function"?e():g(e)}const y8=typeof window<"u"&&typeof document<"u";typeof WorkerGlobalScope<"u"&&globalThis instanceof WorkerGlobalScope;const w8=Object.prototype.toString,S8=e=>w8.call(e)==="[object Object]",Jo=()=>{};function Xc(e,t){function n(...r){return new Promise((o,s)=>{Promise.resolve(e(()=>t.apply(this,r),{fn:t,thisArg:this,args:r})).then(o).catch(s)})}return n}const gb=e=>e();function E8(e,t={}){let n,r,o=Jo;const s=a=>{clearTimeout(a),o(),o=Jo};return a=>{const l=ln(e),u=ln(t.maxWait);return n&&s(n),l<=0||u!==void 0&&u<=0?(r&&(s(r),r=null),Promise.resolve(a())):new Promise((c,f)=>{o=t.rejectOnCancel?f:c,u&&!r&&(r=setTimeout(()=>{n&&s(n),r=null,c(a())},u)),n=setTimeout(()=>{r&&s(r),r=null,c(a())},l)})}}function _8(...e){let t=0,n,r=!0,o=Jo,s,i,a,l,u;!Ue(e[0])&&typeof e[0]=="object"?{delay:i,trailing:a=!0,leading:l=!0,rejectOnCancel:u=!1}=e[0]:[i,a=!0,l=!0,u=!1]=e;const c=()=>{n&&(clearTimeout(n),n=void 0,o(),o=Jo)};return d=>{const v=ln(i),p=Date.now()-t,h=()=>s=d();return c(),v<=0?(t=Date.now(),h()):(p>v&&(l||!r)?(t=Date.now(),h()):a&&(s=new Promise((b,m)=>{o=u?m:b,n=setTimeout(()=>{t=Date.now(),r=!0,b(h()),c()},Math.max(0,v-p))})),!l&&!n&&(n=setTimeout(()=>r=!0,v)),r=!1,s)}}function C8(e=gb){const t=V(!0);function n(){t.value=!1}function r(){t.value=!0}return{isActive:Mr(t),pause:n,resume:r,eventFilter:(...s)=>{t.value&&e(...s)}}}function T8(e){return Ge()}function O8(...e){if(e.length!==1)return Jt(...e);const t=e[0];return typeof t=="function"?Mr(Ay(()=>({get:t,set:Jo}))):V(t)}function dL(e,t=200,n={}){return Xc(E8(t,n),e)}function pL(e,t=200,n=!1,r=!0,o=!1){return Xc(_8(t,n,r,o),e)}function A8(e,t,n={}){const{eventFilter:r=gb,...o}=n;return he(e,Xc(r,t),o)}function R8(e,t,n={}){const{eventFilter:r,...o}=n,{eventFilter:s,pause:i,resume:a,isActive:l}=C8(r);return{stop:A8(e,t,{...o,eventFilter:s}),pause:i,resume:a,isActive:l}}function bb(e,t=!0,n){T8()?Ke(e,n):t?e():Ie(e)}const x8=/[YMDHhms]o|\[([^\]]+)\]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a{1,2}|A{1,2}|m{1,2}|s{1,2}|Z{1,2}|SSS/g;function I8(e,t,n,r){let o=e<12?"AM":"PM";return r&&(o=o.split("").reduce((s,i)=>s+=`${i}.`,"")),n?o.toLowerCase():o}function Gr(e){const t=["th","st","nd","rd"],n=e%100;return e+(t[(n-20)%10]||t[n]||t[0])}function hL(e,t,n={}){var r;const o=e.getFullYear(),s=e.getMonth(),i=e.getDate(),a=e.getHours(),l=e.getMinutes(),u=e.getSeconds(),c=e.getMilliseconds(),f=e.getDay(),d=(r=n.customMeridiem)!=null?r:I8,v={Yo:()=>Gr(o),YY:()=>String(o).slice(-2),YYYY:()=>o,M:()=>s+1,Mo:()=>Gr(s+1),MM:()=>`${s+1}`.padStart(2,"0"),MMM:()=>e.toLocaleDateString(ln(n.locales),{month:"short"}),MMMM:()=>e.toLocaleDateString(ln(n.locales),{month:"long"}),D:()=>String(i),Do:()=>Gr(i),DD:()=>`${i}`.padStart(2,"0"),H:()=>String(a),Ho:()=>Gr(a),HH:()=>`${a}`.padStart(2,"0"),h:()=>`${a%12||12}`.padStart(1,"0"),ho:()=>Gr(a%12||12),hh:()=>`${a%12||12}`.padStart(2,"0"),m:()=>String(l),mo:()=>Gr(l),mm:()=>`${l}`.padStart(2,"0"),s:()=>String(u),so:()=>Gr(u),ss:()=>`${u}`.padStart(2,"0"),SSS:()=>`${c}`.padStart(3,"0"),d:()=>f,dd:()=>e.toLocaleDateString(ln(n.locales),{weekday:"narrow"}),ddd:()=>e.toLocaleDateString(ln(n.locales),{weekday:"short"}),dddd:()=>e.toLocaleDateString(ln(n.locales),{weekday:"long"}),A:()=>d(a,l),AA:()=>d(a,l,!1,!0),a:()=>d(a,l,!0),aa:()=>d(a,l,!0,!0)};return t.replace(x8,(p,h)=>{var b,m;return(m=h??((b=v[p])==null?void 0:b.call(v)))!=null?m:p})}/*! * pinia v2.3.1 * (c) 2025 Eduardo San Martin Morote * @license MIT */let yb;const gl=e=>yb=e,wb=Symbol();function zu(e){return e&&typeof e=="object"&&Object.prototype.toString.call(e)==="[object Object]"&&typeof e.toJSON!="function"}var Ms;(function(e){e.direct="direct",e.patchObject="patch object",e.patchFunction="patch function"})(Ms||(Ms={}));function vL(){const e=Th(!0),t=e.run(()=>V({}));let n=[],r=[];const o=Ds({install(s){gl(o),o._a=s,s.provide(wb,o),s.config.globalProperties.$pinia=o,r.forEach(i=>n.push(i)),r=[]},use(s){return this._a?n.push(s):r.push(s),this},_p:n,_a:null,_e:e,_s:new Map,state:t});return o}const Sb=()=>{};function ah(e,t,n,r=Sb){e.push(t);const o=()=>{const s=e.indexOf(t);s>-1&&(e.splice(s,1),r())};return!n&&Fa()&&Ba(o),o}function Ao(e,...t){e.slice().forEach(n=>{n(...t)})}const P8=e=>e(),lh=Symbol(),Jl=Symbol();function Hu(e,t){e instanceof Map&&t instanceof Map?t.forEach((n,r)=>e.set(r,n)):e instanceof Set&&t instanceof Set&&t.forEach(e.add,e);for(const n in t){if(!t.hasOwnProperty(n))continue;const r=t[n],o=e[n];zu(o)&&zu(r)&&e.hasOwnProperty(n)&&!Ue(r)&&!Hn(r)?e[n]=Hu(o,r):e[n]=r}return e}const N8=Symbol();function L8(e){return!zu(e)||!e.hasOwnProperty(N8)}const{assign:Rr}=Object;function M8(e){return!!(Ue(e)&&e.effect)}function $8(e,t,n,r){const{state:o,actions:s,getters:i}=t,a=n.state.value[e];let l;function u(){a||(n.state.value[e]=o?o():{});const c=vr(n.state.value[e]);return Rr(c,s,Object.keys(i||{}).reduce((f,d)=>(f[d]=Ds(C(()=>{gl(n);const v=n._s.get(e);return i[d].call(v,v)})),f),{}))}return l=Eb(e,u,t,n,r,!0),l}function Eb(e,t,n={},r,o,s){let i;const a=Rr({actions:{}},n),l={deep:!0};let u,c,f=[],d=[],v;const p=r.state.value[e];!s&&!p&&(r.state.value[e]={});let h;function b(N){let I;u=c=!1,typeof N=="function"?(N(r.state.value[e]),I={type:Ms.patchFunction,storeId:e,events:v}):(Hu(r.state.value[e],N),I={type:Ms.patchObject,payload:N,storeId:e,events:v});const x=h=Symbol();Ie().then(()=>{h===x&&(u=!0)}),c=!0,Ao(f,I,r.state.value[e])}const m=s?function(){const{state:I}=n,x=I?I():{};this.$patch(k=>{Rr(k,x)})}:Sb;function S(){i.stop(),f=[],d=[],r._s.delete(e)}const _=(N,I="")=>{if(lh in N)return N[Jl]=I,N;const x=function(){gl(r);const k=Array.from(arguments),$=[],F=[];function Y(j){$.push(j)}function P(j){F.push(j)}Ao(d,{args:k,name:x[Jl],store:y,after:Y,onError:P});let O;try{O=N.apply(this&&this.$id===e?this:y,k)}catch(j){throw Ao(F,j),j}return O instanceof Promise?O.then(j=>(Ao($,j),j)).catch(j=>(Ao(F,j),Promise.reject(j))):(Ao($,O),O)};return x[lh]=!0,x[Jl]=I,x},w={_p:r,$id:e,$onAction:ah.bind(null,d),$patch:b,$reset:m,$subscribe(N,I={}){const x=ah(f,N,I.detached,()=>k()),k=i.run(()=>he(()=>r.state.value[e],$=>{(I.flush==="sync"?c:u)&&N({storeId:e,type:Ms.direct,events:v},$)},Rr({},l,I)));return x},$dispose:S},y=wt(w);r._s.set(e,y);const R=(r._a&&r._a.runWithContext||P8)(()=>r._e.run(()=>(i=Th()).run(()=>t({action:_}))));for(const N in R){const I=R[N];if(Ue(I)&&!M8(I)||Hn(I))s||(p&&L8(I)&&(Ue(I)?I.value=p[N]:Hu(I,p[N])),r.state.value[e][N]=I);else if(typeof I=="function"){const x=_(I,N);R[N]=x,a.actions[N]=I}}return Rr(y,R),Rr(Me(y),R),Object.defineProperty(y,"$state",{get:()=>r.state.value[e],set:N=>{b(I=>{Rr(I,N)})}}),r._p.forEach(N=>{Rr(y,i.run(()=>N({store:y,app:r._a,pinia:r,options:a})))}),p&&s&&n.hydrate&&n.hydrate(y.$state,p),u=!0,c=!0,y}/*! #__NO_SIDE_EFFECTS__ */function mL(e,t,n){let r,o;const s=typeof t=="function";typeof e=="string"?(r=e,o=s?n:t):(o=e,r=e.id);function i(a,l){const u=ky();return a=a||(u?Ee(wb,null):null),a&&gl(a),a=yb,a._s.has(r)||(s?Eb(r,t,o,a):$8(r,o,a)),a._s.get(r)}return i.$id=r,i}function gL(e){{const t=Me(e),n={};for(const r in t){const o=t[r];o.effect?n[r]=C({get:()=>e[r],set(s){e[r]=s}}):(Ue(o)||Hn(o))&&(n[r]=Jt(e,r))}return n}}function uh(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter(function(o){return Object.getOwnPropertyDescriptor(e,o).enumerable})),n.push.apply(n,r)}return n}function Vi(e){for(var t=1;te.length)&&(t=e.length);for(var n=0,r=new Array(t);nthis.range.start)){var r=Math.max(n-this.param.buffer,0);this.checkRange(r,this.getEndByStart(r))}}},{key:"handleBehind",value:function(){var n=this.getScrollOvers();nn&&(i=o-1)}return r>0?--r:0}},{key:"getIndexOffset",value:function(n){if(!n)return 0;for(var r=0,o=0,s=0;s=O&&r("tobottom")},m=function(Y){var P=v(),O=p(),j=h();P<0||P+O>j+1||!j||(f.handleScroll(P),b(P,O,j,Y))},S=function(){var Y=t.dataKey,P=t.dataSources,O=P===void 0?[]:P;return O.map(function(j){return typeof Y=="function"?Y(j):j[Y]})},_=function(Y){l.value=Y},w=function(){f=new K8({slotHeaderSize:0,slotFooterSize:0,keeps:t.keeps,estimateSize:t.estimateSize,buffer:Math.round(t.keeps/3),uniqueIds:S()},_),l.value=f.getRange()},y=function(Y){if(Y>=t.dataSources.length-1)x();else{var P=f.getOffset(Y);A(P)}},A=function(Y){t.pageMode?(document.body[a]=Y,document.documentElement[a]=Y):u.value&&(u.value[a]=Y)},R=function(){for(var Y=[],P=l.value,O=P.start,j=P.end,Q=t.dataSources,me=t.dataKey,Pe=t.itemClass,Re=t.itemTag,Ce=t.itemStyle,_e=t.extraProps,qe=t.dataComponent,ze=t.itemScopedSlots,De=O;De<=j;De++){var B=Q[De];if(B){var K=typeof me=="function"?me(B):B[me];(typeof K=="string"||typeof K=="number")&&Y.push(re(Y8,{index:De,tag:Re,event:$s.ITEM,horizontal:i,uniqueKey:K,source:B,extraProps:_e,component:qe,scopedSlots:ze,style:Ce,class:"".concat(Pe).concat(t.itemClassAdd?" "+t.itemClassAdd(De):""),onItemResize:N},null))}}return Y},N=function(Y,P){f.saveSize(Y,P),r("resized",Y,P)},I=function(Y,P,O){Y===Lo.HEADER?f.updateParam("slotHeaderSize",P):Y===Lo.FOOTER&&f.updateParam("slotFooterSize",P),O&&f.handleSlotSizeChange()},x=function F(){if(c.value){var Y=c.value[i?"offsetLeft":"offsetTop"];A(Y),setTimeout(function(){v()+p()0:!1;function Zl(e,t,n,r){e.addEventListener?e.addEventListener(t,n,r):e.attachEvent&&e.attachEvent("on".concat(t),n)}function ms(e,t,n,r){e.removeEventListener?e.removeEventListener(t,n,r):e.detachEvent&&e.detachEvent("on".concat(t),n)}function Cb(e,t){const n=t.slice(0,t.length-1);for(let r=0;r=0;)t[n-1]+=",",t.splice(n,1),n=t.lastIndexOf("");return t}function J8(e,t){const n=e.length>=t.length?e:t,r=e.length>=t.length?t:e;let o=!0;for(let s=0;sni[e.toLowerCase()]||Cn[e.toLowerCase()]||e.toUpperCase().charCodeAt(0),X8=e=>Object.keys(ni).find(t=>ni[t]===e),Z8=e=>Object.keys(Cn).find(t=>Cn[t]===e);function Ab(e){Ob=e||"all"}function ri(){return Ob||"all"}function Q8(){return at.slice(0)}function eN(){return at.map(e=>X8(e)||Z8(e)||String.fromCharCode(e))}function tN(){const e=[];return Object.keys(lt).forEach(t=>{lt[t].forEach(n=>{let{key:r,scope:o,mods:s,shortcut:i}=n;e.push({scope:o,shortcut:i,mods:s,keys:r.split("+").map(a=>is(a))})})}),e}function nN(e){const t=e.target||e.srcElement,{tagName:n}=t;let r=!0;const o=n==="INPUT"&&!["checkbox","radio","range","button","file","reset","submit","color"].includes(t.type);return(t.isContentEditable||(o||n==="TEXTAREA"||n==="SELECT")&&!t.readOnly)&&(r=!1),r}function rN(e){return typeof e=="string"&&(e=is(e)),at.indexOf(e)!==-1}function oN(e,t){let n,r;e||(e=ri());for(const o in lt)if(Object.prototype.hasOwnProperty.call(lt,o))for(n=lt[o],r=0;r{let{element:a}=i;return Zc(a)}):r++;ri()===e&&Ab(t||"all")}function sN(e){let t=e.keyCode||e.which||e.charCode;e.key&&e.key.toLowerCase()==="capslock"&&(t=is(e.key));const n=at.indexOf(t);if(n>=0&&at.splice(n,1),e.key&&e.key.toLowerCase()==="meta"&&at.splice(0,at.length),(t===93||t===224)&&(t=91),t in Et){Et[t]=!1;for(const r in Cn)Cn[r]===t&&(Lr[r]=!1)}}function Rb(e){if(typeof e>"u")Object.keys(lt).forEach(o=>{Array.isArray(lt[o])&<[o].forEach(s=>ji(s)),delete lt[o]}),Zc(null);else if(Array.isArray(e))e.forEach(o=>{o.key&&ji(o)});else if(typeof e=="object")e.key&&ji(e);else if(typeof e=="string"){for(var t=arguments.length,n=new Array(t>1?t-1:0),r=1;r{let{key:t,scope:n,method:r,splitKey:o="+"}=e;Tb(t).forEach(i=>{const a=i.split(o),l=a.length,u=a[l-1],c=u==="*"?"*":is(u);if(!lt[c])return;n||(n=ri());const f=l>1?Cb(Cn,a):[],d=[];lt[c]=lt[c].filter(v=>{const h=(r?v.method===r:!0)&&v.scope===n&&J8(v.mods,f);return h&&d.push(v.element),!h}),d.forEach(v=>Zc(v))})};function dh(e,t,n,r){if(t.element!==r)return;let o;if(t.scope===n||t.scope==="all"){o=t.mods.length>0;for(const s in Et)Object.prototype.hasOwnProperty.call(Et,s)&&(!Et[s]&&t.mods.indexOf(+s)>-1||Et[s]&&t.mods.indexOf(+s)===-1)&&(o=!1);(t.mods.length===0&&!Et[16]&&!Et[18]&&!Et[17]&&!Et[91]||o||t.shortcut==="*")&&(t.keys=[],t.keys=t.keys.concat(at),t.method(e,t)===!1&&(e.preventDefault?e.preventDefault():e.returnValue=!1,e.stopPropagation&&e.stopPropagation(),e.cancelBubble&&(e.cancelBubble=!0)))}}function ph(e,t){const n=lt["*"];let r=e.keyCode||e.which||e.charCode;if(e.key&&e.key.toLowerCase()==="capslock"||!Lr.filter.call(this,e))return;if((r===93||r===224)&&(r=91),at.indexOf(r)===-1&&r!==229&&at.push(r),["metaKey","ctrlKey","altKey","shiftKey"].forEach(a=>{const l=ys[a];e[a]&&at.indexOf(l)===-1?at.push(l):!e[a]&&at.indexOf(l)>-1?at.splice(at.indexOf(l),1):a==="metaKey"&&e[a]&&(at=at.filter(u=>u in ys||u===r))}),r in Et){Et[r]=!0;for(const a in Cn)if(Object.prototype.hasOwnProperty.call(Cn,a)){const l=ys[Cn[a]];Lr[a]=e[l]}if(!n)return}for(const a in Et)Object.prototype.hasOwnProperty.call(Et,a)&&(Et[a]=e[ys[a]]);e.getModifierState&&!(e.altKey&&!e.ctrlKey)&&e.getModifierState("AltGraph")&&(at.indexOf(17)===-1&&at.push(17),at.indexOf(18)===-1&&at.push(18),Et[17]=!0,Et[18]=!0);const o=ri();if(n)for(let a=0;a1&&(o=Cb(Cn,e)),e=e[e.length-1],e=e==="*"?"*":is(e),e in lt||(lt[e]=[]),lt[e].push({keyup:l,keydown:u,scope:s,mods:o,shortcut:r[a],method:n,key:r[a],splitKey:c,element:i});if(typeof i<"u"&&window){if(!er.has(i)){const v=function(){let h=arguments.length>0&&arguments[0]!==void 0?arguments[0]:window.event;return ph(h,i)},p=function(){let h=arguments.length>0&&arguments[0]!==void 0?arguments[0]:window.event;ph(h,i),sN(h)};er.set(i,{keydownListener:v,keyupListenr:p,capture:f}),Zl(i,"keydown",v,f),Zl(i,"keyup",p,f)}if(!ks){const v=()=>{at=[]};ks={listener:v,capture:f},Zl(window,"focus",v,f)}}}function iN(e){let t=arguments.length>1&&arguments[1]!==void 0?arguments[1]:"all";Object.keys(lt).forEach(n=>{lt[n].filter(o=>o.scope===t&&o.shortcut===e).forEach(o=>{o&&o.method&&o.method()})})}function Zc(e){const t=Object.values(lt).flat();if(t.findIndex(r=>{let{element:o}=r;return o===e})<0){const{keydownListener:r,keyupListenr:o,capture:s}=er.get(e)||{};r&&o&&(ms(e,"keyup",o,s),ms(e,"keydown",r,s),er.delete(e))}if((t.length<=0||er.size<=0)&&(Object.keys(er).forEach(o=>{const{keydownListener:s,keyupListenr:i,capture:a}=er.get(o)||{};s&&i&&(ms(o,"keyup",i,a),ms(o,"keydown",s,a),er.delete(o))}),er.clear(),Object.keys(lt).forEach(o=>delete lt[o]),ks)){const{listener:o,capture:s}=ks;ms(window,"focus",o,s),ks=null}}const Ql={getPressedKeyString:eN,setScope:Ab,getScope:ri,deleteScope:oN,getPressedKeyCodes:Q8,getAllKeyCodes:tN,isPressed:rN,filter:nN,trigger:iN,unbind:Rb,keyMap:ni,modifier:Cn,modifierMap:ys};for(const e in Ql)Object.prototype.hasOwnProperty.call(Ql,e)&&(Lr[e]=Ql[e]);if(typeof window<"u"){const e=window.hotkeys;Lr.noConflict=t=>(t&&window.hotkeys===Lr&&(window.hotkeys=e),Lr),window.hotkeys=Lr}const Xo=y8?window:void 0;function xb(e){var t;const n=ln(e);return(t=n==null?void 0:n.$el)!=null?t:n}function hh(...e){let t,n,r,o;if(typeof e[0]=="string"||Array.isArray(e[0])?([n,r,o]=e,t=Xo):[t,n,r,o]=e,!t)return Jo;Array.isArray(n)||(n=[n]),Array.isArray(r)||(r=[r]);const s=[],i=()=>{s.forEach(c=>c()),s.length=0},a=(c,f,d,v)=>(c.addEventListener(f,d,v),()=>c.removeEventListener(f,d,v)),l=he(()=>[xb(t),ln(o)],([c,f])=>{if(i(),!c)return;const d=S8(f)?{...f}:f;s.push(...n.flatMap(v=>r.map(p=>a(c,v,p,d))))},{immediate:!0,flush:"post"}),u=()=>{l(),i()};return mb(u),u}function aN(){const e=V(!1),t=Ge();return t&&Ke(()=>{e.value=!0},t),e}function lN(e){const t=aN();return C(()=>(t.value,!!e()))}function uN(e,t={}){const{window:n=Xo}=t,r=lN(()=>n&&"matchMedia"in n&&typeof n.matchMedia=="function");let o;const s=V(!1),i=u=>{s.value=u.matches},a=()=>{o&&("removeEventListener"in o?o.removeEventListener("change",i):o.removeListener(i))},l=Ha(()=>{r.value&&(a(),o=n.matchMedia(ln(e)),"addEventListener"in o?o.addEventListener("change",i):o.addListener(i),s.value=o.matches)});return mb(()=>{l(),a(),o=void 0}),s}const zi=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{},Hi="__vueuse_ssr_handlers__",cN=fN();function fN(){return Hi in zi||(zi[Hi]=zi[Hi]||{}),zi[Hi]}function Ib(e,t){return cN[e]||t}function Pb(e){return uN("(prefers-color-scheme: dark)",e)}function dN(e){return e==null?"any":e instanceof Set?"set":e instanceof Map?"map":e instanceof Date?"date":typeof e=="boolean"?"boolean":typeof e=="string"?"string":typeof e=="object"?"object":Number.isNaN(e)?"any":"number"}const pN={boolean:{read:e=>e==="true",write:e=>String(e)},object:{read:e=>JSON.parse(e),write:e=>JSON.stringify(e)},number:{read:e=>Number.parseFloat(e),write:e=>String(e)},any:{read:e=>e,write:e=>String(e)},string:{read:e=>e,write:e=>String(e)},map:{read:e=>new Map(JSON.parse(e)),write:e=>JSON.stringify(Array.from(e.entries()))},set:{read:e=>new Set(JSON.parse(e)),write:e=>JSON.stringify(Array.from(e))},date:{read:e=>new Date(e),write:e=>e.toISOString()}},vh="vueuse-storage";function hN(e,t,n,r={}){var o;const{flush:s="pre",deep:i=!0,listenToStorageChanges:a=!0,writeDefaults:l=!0,mergeDefaults:u=!1,shallow:c,window:f=Xo,eventFilter:d,onError:v=x=>{},initOnMounted:p}=r,h=(c?ir:V)(typeof t=="function"?t():t);if(!n)try{n=Ib("getDefaultStorage",()=>{var x;return(x=Xo)==null?void 0:x.localStorage})()}catch(x){v(x)}if(!n)return h;const b=ln(t),m=dN(b),S=(o=r.serializer)!=null?o:pN[m],{pause:_,resume:w}=R8(h,()=>A(h.value),{flush:s,deep:i,eventFilter:d});f&&a&&bb(()=>{n instanceof Storage?hh(f,"storage",N):hh(f,vh,I),p&&N()}),p||N();function y(x,k){if(f){const $={key:e,oldValue:x,newValue:k,storageArea:n};f.dispatchEvent(n instanceof Storage?new StorageEvent("storage",$):new CustomEvent(vh,{detail:$}))}}function A(x){try{const k=n.getItem(e);if(x==null)y(k,null),n.removeItem(e);else{const $=S.write(x);k!==$&&(n.setItem(e,$),y(k,$))}}catch(k){v(k)}}function R(x){const k=x?x.newValue:n.getItem(e);if(k==null)return l&&b!=null&&n.setItem(e,S.write(b)),b;if(!x&&u){const $=S.read(k);return typeof u=="function"?u($,b):m==="object"&&!Array.isArray($)?{...b,...$}:$}else return typeof k!="string"?k:S.read(k)}function N(x){if(!(x&&x.storageArea!==n)){if(x&&x.key==null){h.value=b;return}if(!(x&&x.key!==e)){_();try{(x==null?void 0:x.newValue)!==S.write(h.value)&&(h.value=R(x))}catch(k){v(k)}finally{x?Ie(w):w()}}}}function I(x){N(x.detail)}return h}const vN="*,*::before,*::after{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}";function mN(e={}){const{selector:t="html",attribute:n="class",initialValue:r="auto",window:o=Xo,storage:s,storageKey:i="vueuse-color-scheme",listenToStorageChanges:a=!0,storageRef:l,emitAuto:u,disableTransition:c=!0}=e,f={auto:"",light:"light",dark:"dark",...e.modes||{}},d=Pb({window:o}),v=C(()=>d.value?"dark":"light"),p=l||(i==null?O8(r):hN(i,r,s,{window:o,listenToStorageChanges:a})),h=C(()=>p.value==="auto"?v.value:p.value),b=Ib("updateHTMLAttrs",(w,y,A)=>{const R=typeof w=="string"?o==null?void 0:o.document.querySelector(w):xb(w);if(!R)return;const N=new Set,I=new Set;let x=null;if(y==="class"){const $=A.split(/\s/g);Object.values(f).flatMap(F=>(F||"").split(/\s/g)).filter(Boolean).forEach(F=>{$.includes(F)?N.add(F):I.add(F)})}else x={key:y,value:A};if(N.size===0&&I.size===0&&x===null)return;let k;c&&(k=o.document.createElement("style"),k.appendChild(document.createTextNode(vN)),o.document.head.appendChild(k));for(const $ of N)R.classList.add($);for(const $ of I)R.classList.remove($);x&&R.setAttribute(x.key,x.value),c&&(o.getComputedStyle(k).opacity,document.head.removeChild(k))});function m(w){var y;b(t,n,(y=f[w])!=null?y:w)}function S(w){e.onChanged?e.onChanged(w,m):m(w)}he(h,S,{flush:"post",immediate:!0}),bb(()=>S(h.value));const _=C({get(){return u?p.value:h.value},set(w){p.value=w}});try{return Object.assign(_,{store:p,system:v,state:h})}catch{return _}}function yL(e={}){const{valueDark:t="dark",valueLight:n="",window:r=Xo}=e,o=mN({...e,onChanged:(a,l)=>{var u;e.onChanged?(u=e.onChanged)==null||u.call(e,a==="dark",l,a):l(a)},modes:{dark:t,light:n}}),s=C(()=>o.system?o.system.value:Pb({window:r}).value?"dark":"light");return C({get(){return o.value==="dark"},set(a){const l=a?"dark":"light";s.value===l?o.value="auto":o.value=l}})}const wL='';export{VN as $,Ex as A,ON as B,C,bg as D,kN as E,nt as F,xN as G,Ue as H,AN as I,TN as J,CN as K,LN as L,V as M,he as N,Vt as O,ae as P,Ha as Q,vf as R,zN as S,jN as T,Ke as U,bL as V,MN as W,_N as X,Lr as Y,IN as Z,PN as _,SN as a,$N as a0,FN as a1,BN as a2,yL as a3,bN as a4,Ux as a5,gL as a6,EN as a7,qN as a8,kr as a9,ot as aa,Ie as ab,Rg as ac,ct as ad,fw as ae,DN as af,dL as ag,mo as ah,pL as ai,yN as aj,wL as ak,UN as al,wN as b,fe as c,Z as d,ee as e,re as f,Un as g,le as h,HN as i,mt as j,hL as k,RN as l,mL as m,KN as n,L as o,Ds as p,vL as q,Yt as r,ir as s,Me as t,g as u,gw as v,ce as w,NN as x,U as y,He as z}; ================================================ FILE: app/src/main/assets/web/vue/index.html ================================================
================================================ FILE: app/src/main/java/io/legado/app/App.kt ================================================ package io.legado.app import android.app.Application import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context import android.content.pm.ActivityInfo import android.content.pm.ApplicationInfo import android.content.res.Configuration import android.os.Build import com.github.liuyueyi.quick.transfer.constants.TransType import com.jeremyliao.liveeventbus.LiveEventBus import com.jeremyliao.liveeventbus.logger.DefaultLogger import com.script.rhino.ReadOnlyJavaObject import com.script.rhino.RhinoScriptEngine import com.script.rhino.RhinoWrapFactory import io.legado.app.base.AppContextWrapper import io.legado.app.constant.AppConst.channelIdDownload import io.legado.app.constant.AppConst.channelIdReadAloud import io.legado.app.constant.AppConst.channelIdWeb import io.legado.app.constant.PreferKey import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.HttpTTS import io.legado.app.data.entities.RssSource import io.legado.app.data.entities.rule.BookInfoRule import io.legado.app.data.entities.rule.ContentRule import io.legado.app.data.entities.rule.ExploreRule import io.legado.app.data.entities.rule.SearchRule import io.legado.app.help.AppFreezeMonitor import io.legado.app.help.AppWebDav import io.legado.app.help.CrashHandler import io.legado.app.help.DefaultData import io.legado.app.help.DispatchersMonitor import io.legado.app.help.LifecycleHelp import io.legado.app.help.RuleBigDataHelp import io.legado.app.help.book.BookHelp import io.legado.app.help.config.AppConfig import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.config.ThemeConfig import io.legado.app.help.config.ThemeConfig.applyDayNight import io.legado.app.help.config.ThemeConfig.applyDayNightInit import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.http.Cronet import io.legado.app.help.http.ObsoleteUrlFactory import io.legado.app.help.http.okHttpClient import io.legado.app.help.rhino.NativeBaseSource import io.legado.app.help.source.SourceHelp import io.legado.app.help.storage.Backup import io.legado.app.model.BookCover import io.legado.app.utils.ChineseUtils import io.legado.app.utils.LogUtils import io.legado.app.utils.defaultSharedPreferences import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.isDebuggable import kotlinx.coroutines.launch import org.chromium.base.ThreadUtils import splitties.init.appCtx import splitties.systemservices.notificationManager import java.net.URL import java.util.concurrent.TimeUnit import java.util.logging.Level class App : Application() { private lateinit var oldConfig: Configuration override fun onCreate() { super.onCreate() CrashHandler(this) if (isDebuggable) { ThreadUtils.setThreadAssertsDisabledForTesting(true) } oldConfig = Configuration(resources.configuration) applyDayNightInit(this) registerActivityLifecycleCallbacks(LifecycleHelp) defaultSharedPreferences.registerOnSharedPreferenceChangeListener(AppConfig) Coroutine.async { LogUtils.init(this@App) LogUtils.d("App", "onCreate") LogUtils.logDeviceInfo() //预下载Cronet so Cronet.preDownload() createNotificationChannels() LiveEventBus.config() .lifecycleObserverAlwaysActive(true) .autoClear(false) .enableLogger(BuildConfig.DEBUG || AppConfig.recordLog) .setLogger(EventLogger()) DefaultData.upVersion() AppFreezeMonitor.init(this@App) DispatchersMonitor.init() URL.setURLStreamHandlerFactory(ObsoleteUrlFactory(okHttpClient)) launch { installGmsTlsProvider(appCtx) } initRhino() //初始化封面 BookCover.toString() //清除过期数据 appDb.cacheDao.clearDeadline(System.currentTimeMillis()) if (getPrefBoolean(PreferKey.autoClearExpired, true)) { val clearTime = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1) appDb.searchBookDao.clearExpired(clearTime) } RuleBigDataHelp.clearInvalid() BookHelp.clearInvalidCache() Backup.clearCache() ReadBookConfig.clearBgAndCache() ThemeConfig.clearBg() //初始化简繁转换引擎 when (AppConfig.chineseConverterType) { 1 -> { ChineseUtils.fixT2sDict() ChineseUtils.preLoad(true, TransType.TRADITIONAL_TO_SIMPLE) } 2 -> ChineseUtils.preLoad(true, TransType.SIMPLE_TO_TRADITIONAL) } //调整排序序号 SourceHelp.adjustSortNumber() //同步阅读记录 if (AppConfig.syncBookProgress) { AppWebDav.downloadAllBookProgress() } } } override fun attachBaseContext(base: Context) { super.attachBaseContext(AppContextWrapper.wrap(base)) } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) val diff = newConfig.diff(oldConfig) if ((diff and ActivityInfo.CONFIG_UI_MODE) != 0) { applyDayNight(this) } oldConfig = Configuration(newConfig) } /** * 尝试在安装了GMS的设备上(GMS或者MicroG)使用GMS内置的Conscrypt * 作为首选JCE提供程序,而使Okhttp在低版本Android上 * 能够启用TLSv1.3 * https://f-droid.org/zh_Hans/2020/05/29/android-updates-and-tls-connections.html * https://developer.android.google.cn/reference/javax/net/ssl/SSLSocket * * @param context * @return */ private fun installGmsTlsProvider(context: Context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { return } try { val gmsPackageName = "com.google.android.gms" val appInfo = packageManager.getApplicationInfo(gmsPackageName, 0) if ((appInfo.flags and ApplicationInfo.FLAG_SYSTEM) == 0) { return } val gms = context.createPackageContext( gmsPackageName, CONTEXT_INCLUDE_CODE or CONTEXT_IGNORE_SECURITY ) gms.classLoader .loadClass("com.google.android.gms.common.security.ProviderInstallerImpl") .getMethod("insertProvider", Context::class.java) .invoke(null, gms) } catch (e: java.lang.Exception) { e.printStackTrace() } } /** * 创建通知ID */ private fun createNotificationChannels() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val downloadChannel = NotificationChannel( channelIdDownload, getString(R.string.action_download), NotificationManager.IMPORTANCE_DEFAULT ).apply { enableLights(false) enableVibration(false) setSound(null, null) lockscreenVisibility = Notification.VISIBILITY_PUBLIC } val readAloudChannel = NotificationChannel( channelIdReadAloud, getString(R.string.read_aloud), NotificationManager.IMPORTANCE_DEFAULT ).apply { enableLights(false) enableVibration(false) setSound(null, null) lockscreenVisibility = Notification.VISIBILITY_PUBLIC } val webChannel = NotificationChannel( channelIdWeb, getString(R.string.web_service), NotificationManager.IMPORTANCE_DEFAULT ).apply { enableLights(false) enableVibration(false) setSound(null, null) lockscreenVisibility = Notification.VISIBILITY_PUBLIC } //向notification manager 提交channel notificationManager.createNotificationChannels( listOf( downloadChannel, readAloudChannel, webChannel ) ) } private fun initRhino() { RhinoScriptEngine RhinoWrapFactory.register(BookSource::class.java, NativeBaseSource.factory) RhinoWrapFactory.register(RssSource::class.java, NativeBaseSource.factory) RhinoWrapFactory.register(HttpTTS::class.java, NativeBaseSource.factory) RhinoWrapFactory.register(ExploreRule::class.java, ReadOnlyJavaObject.factory) RhinoWrapFactory.register(SearchRule::class.java, ReadOnlyJavaObject.factory) RhinoWrapFactory.register(BookInfoRule::class.java, ReadOnlyJavaObject.factory) RhinoWrapFactory.register(ContentRule::class.java, ReadOnlyJavaObject.factory) RhinoWrapFactory.register(BookChapter::class.java, ReadOnlyJavaObject.factory) RhinoWrapFactory.register(Book.ReadConfig::class.java, ReadOnlyJavaObject.factory) } class EventLogger : DefaultLogger() { override fun log(level: Level, msg: String) { super.log(level, msg) LogUtils.d(TAG, msg) } override fun log(level: Level, msg: String, th: Throwable?) { super.log(level, msg, th) LogUtils.d(TAG, "$msg\n${th?.stackTraceToString()}") } companion object { private const val TAG = "[LiveEventBus]" } } companion object { init { if (BuildConfig.DEBUG) { System.setProperty("kotlinx.coroutines.debug", "on") } } } } ================================================ FILE: app/src/main/java/io/legado/app/README.md ================================================ # 文件结构介绍 * api 提供的接口 * base 基类 * constant 常量 * data 数据 * exception 错误类型 * help 帮助 * lib 库 * model 解析 * receiver 广播侦听 * service 服务 * ui 界面 * utils 辅助类 * web web服务 ================================================ FILE: app/src/main/java/io/legado/app/api/ReaderProvider.kt ================================================ /* * Copyright (C) 2020 w568w */ package io.legado.app.api import android.content.ContentProvider import android.content.ContentValues import android.content.UriMatcher import android.database.Cursor import android.database.MatrixCursor import android.net.Uri import com.google.gson.Gson import io.legado.app.api.controller.BookController import io.legado.app.api.controller.BookSourceController import io.legado.app.api.controller.RssSourceController import kotlinx.coroutines.runBlocking /** * Export book data to other app. */ class ReaderProvider : ContentProvider() { private enum class RequestCode { SaveBookSource, SaveBookSources, DeleteBookSources, GetBookSource, GetBookSources, SaveRssSource, SaveRssSources, DeleteRssSources, GetRssSource, GetRssSources, SaveBook, GetBookshelf, RefreshToc, GetChapterList, GetBookContent, GetBookCover, SaveBookProgress } private val postBodyKey = "json" private val sMatcher by lazy { UriMatcher(UriMatcher.NO_MATCH).apply { "${context?.applicationInfo?.packageName}.readerProvider".also { authority -> addURI(authority, "bookSource/insert", RequestCode.SaveBookSource.ordinal) addURI(authority, "bookSources/insert", RequestCode.SaveBookSources.ordinal) addURI(authority, "bookSources/delete", RequestCode.DeleteBookSources.ordinal) addURI(authority, "bookSource/query", RequestCode.GetBookSource.ordinal) addURI(authority, "bookSources/query", RequestCode.GetBookSources.ordinal) addURI(authority, "rssSource/insert", RequestCode.SaveBookSource.ordinal) addURI(authority, "rssSources/insert", RequestCode.SaveBookSources.ordinal) addURI(authority, "rssSources/delete", RequestCode.DeleteBookSources.ordinal) addURI(authority, "rssSource/query", RequestCode.GetBookSource.ordinal) addURI(authority, "rssSources/query", RequestCode.GetBookSources.ordinal) addURI(authority, "book/insert", RequestCode.SaveBook.ordinal) addURI(authority, "books/query", RequestCode.GetBookshelf.ordinal) addURI(authority, "book/refreshToc/query", RequestCode.RefreshToc.ordinal) addURI(authority, "book/chapter/query", RequestCode.GetChapterList.ordinal) addURI(authority, "book/content/query", RequestCode.GetBookContent.ordinal) addURI(authority, "book/cover/query", RequestCode.GetBookCover.ordinal) } } } override fun onCreate(): Boolean { context?.let { context -> ShortCuts.buildShortCuts(context) } return false } override fun delete( uri: Uri, selection: String?, selectionArgs: Array? ): Int { if (sMatcher.match(uri) < 0) return -1 when (RequestCode.entries[sMatcher.match(uri)]) { RequestCode.DeleteBookSources -> BookSourceController.deleteSources(selection) RequestCode.DeleteRssSources -> BookSourceController.deleteSources(selection) else -> throw IllegalStateException( "Unexpected value: " + RequestCode.entries[sMatcher.match(uri)].name ) } return 0 } override fun getType(uri: Uri) = throw UnsupportedOperationException("Not yet implemented") override fun insert(uri: Uri, values: ContentValues?): Uri? { if (sMatcher.match(uri) < 0) return null runBlocking { when (RequestCode.entries[sMatcher.match(uri)]) { RequestCode.SaveBookSource -> values?.let { BookSourceController.saveSource(values.getAsString(postBodyKey)) } RequestCode.SaveBookSources -> values?.let { BookSourceController.saveSources(values.getAsString(postBodyKey)) } RequestCode.SaveRssSource -> values?.let { RssSourceController.saveSource(values.getAsString(postBodyKey)) } RequestCode.SaveRssSources -> values?.let { RssSourceController.saveSources(values.getAsString(postBodyKey)) } RequestCode.SaveBook -> values?.let { BookController.saveBook(values.getAsString(postBodyKey)) } RequestCode.SaveBookProgress -> values?.let { BookController.saveBookProgress(values.getAsString(postBodyKey)) } else -> throw IllegalStateException( "Unexpected value: " + RequestCode.entries[sMatcher.match(uri)].name ) } } return null } override fun query( uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String? ): Cursor? { val map: MutableMap> = HashMap() uri.getQueryParameter("url")?.let { map["url"] = arrayListOf(it) } uri.getQueryParameter("index")?.let { map["index"] = arrayListOf(it) } uri.getQueryParameter("path")?.let { map["path"] = arrayListOf(it) } return if (sMatcher.match(uri) < 0) null else when (RequestCode.entries[sMatcher.match(uri)]) { RequestCode.GetBookSource -> SimpleCursor(BookSourceController.getSource(map)) RequestCode.GetBookSources -> SimpleCursor(BookSourceController.sources) RequestCode.GetRssSource -> SimpleCursor(RssSourceController.getSource(map)) RequestCode.GetRssSources -> SimpleCursor(RssSourceController.sources) RequestCode.GetBookshelf -> SimpleCursor(BookController.bookshelf) RequestCode.GetBookContent -> SimpleCursor(BookController.getBookContent(map)) RequestCode.RefreshToc -> SimpleCursor(BookController.refreshToc(map)) RequestCode.GetChapterList -> SimpleCursor(BookController.getChapterList(map)) RequestCode.GetBookCover -> SimpleCursor(BookController.getCover(map)) else -> throw IllegalStateException( "Unexpected value: " + RequestCode.entries[sMatcher.match(uri)].name ) } } override fun update( uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array? ) = throw UnsupportedOperationException("Not yet implemented") /** * Simple inner class to deliver json callback data. * * Only getString() makes sense. */ private class SimpleCursor(data: ReturnData?) : MatrixCursor(arrayOf("result"), 1) { private val mData: String = Gson().toJson(data) init { addRow(arrayOf(mData)) } } } ================================================ FILE: app/src/main/java/io/legado/app/api/ReturnData.kt ================================================ package io.legado.app.api import androidx.annotation.Keep @Keep class ReturnData { var isSuccess: Boolean = false private set var errorMsg: String = "未知错误,请联系开发者!" private set var data: Any? = null private set fun setErrorMsg(errorMsg: String): ReturnData { this.isSuccess = false this.errorMsg = errorMsg return this } fun setData(data: Any): ReturnData { this.isSuccess = true this.errorMsg = "" this.data = data return this } } ================================================ FILE: app/src/main/java/io/legado/app/api/ShortCuts.kt ================================================ package io.legado.app.api import android.content.Context import android.content.Intent import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat import io.legado.app.R import io.legado.app.receiver.SharedReceiverActivity import io.legado.app.ui.book.read.ReadBookActivity import io.legado.app.ui.main.MainActivity object ShortCuts { private inline fun buildIntent(context: Context): Intent { val intent = Intent(context, T::class.java) intent.action = Intent.ACTION_VIEW return intent } private fun buildBookShelfShortCutInfo(context: Context): ShortcutInfoCompat { val bookShelfIntent = buildIntent(context) return ShortcutInfoCompat.Builder(context, "bookshelf") .setShortLabel(context.getString(R.string.bookshelf)) .setLongLabel(context.getString(R.string.bookshelf)) .setIcon(IconCompat.createWithResource(context, R.drawable.icon_read_book)) .setIntent(bookShelfIntent) .build() } private fun buildReadBookShortCutInfo(context: Context): ShortcutInfoCompat { val bookShelfIntent = buildIntent(context) val readBookIntent = buildIntent(context) return ShortcutInfoCompat.Builder(context, "lastRead") .setShortLabel(context.getString(R.string.last_read)) .setLongLabel(context.getString(R.string.last_read)) .setIcon(IconCompat.createWithResource(context, R.drawable.icon_read_book)) .setIntents(arrayOf(bookShelfIntent, readBookIntent)) .build() } private fun buildReadAloudShortCutInfo(context: Context): ShortcutInfoCompat { val readAloudIntent = buildIntent(context) readAloudIntent.putExtra("action", "readAloud") return ShortcutInfoCompat.Builder(context, "readAloud") .setShortLabel(context.getString(R.string.read_aloud)) .setLongLabel(context.getString(R.string.read_aloud)) .setIcon(IconCompat.createWithResource(context, R.drawable.icon_read_book)) .setIntent(readAloudIntent) .build() } fun buildShortCuts(context: Context) { ShortcutManagerCompat.setDynamicShortcuts( context, listOf( buildReadBookShortCutInfo(context), buildReadAloudShortCutInfo(context), buildBookShelfShortCutInfo(context) ) ) } } ================================================ FILE: app/src/main/java/io/legado/app/api/controller/BookController.kt ================================================ package io.legado.app.api.controller import android.graphics.Bitmap import android.graphics.drawable.Drawable import androidx.core.graphics.drawable.toBitmap import com.bumptech.glide.Glide import io.legado.app.api.ReturnData import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookProgress import io.legado.app.data.entities.BookSource import io.legado.app.help.AppWebDav import io.legado.app.help.CacheManager import io.legado.app.help.book.BookHelp import io.legado.app.help.book.ContentProcessor import io.legado.app.help.book.isLocal import io.legado.app.help.config.AppConfig import io.legado.app.help.glide.ImageLoader import io.legado.app.model.BookCover import io.legado.app.model.ImageProvider import io.legado.app.model.ReadBook import io.legado.app.model.localBook.LocalBook import io.legado.app.model.webBook.WebBook import io.legado.app.utils.GSON import io.legado.app.utils.cnCompare import io.legado.app.utils.fromJsonObject import io.legado.app.utils.printOnDebug import io.legado.app.utils.stackTraceStr import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import splitties.init.appCtx import java.io.File import java.util.WeakHashMap import java.util.concurrent.TimeUnit object BookController { private lateinit var book: Book private var bookSource: BookSource? = null private var bookUrl: String = "" private val defaultCoverCache by lazy { WeakHashMap() } /** * 书架所有书籍 */ val bookshelf: ReturnData get() { val books = appDb.bookDao.all val returnData = ReturnData() return if (books.isEmpty()) { returnData.setErrorMsg("还没有添加小说") } else { val data = when (AppConfig.bookshelfSort) { 1 -> books.sortedByDescending { it.latestChapterTime } 2 -> books.sortedWith { o1, o2 -> o1.name.cnCompare(o2.name) } 3 -> books.sortedBy { it.order } else -> books.sortedByDescending { it.durChapterTime } } returnData.setData(data) } } /** * 获取封面 */ fun getCover(parameters: Map>): ReturnData { val returnData = ReturnData() val coverPath = parameters["path"]?.firstOrNull() val ftBitmap = ImageLoader.loadBitmap(appCtx, coverPath) .override(84, 112) .centerCrop() .submit() return try { returnData.setData(ftBitmap.get(3, TimeUnit.SECONDS)) } catch (e: Exception) { try { val defaultBitmap = defaultCoverCache.getOrPut(BookCover.defaultDrawable) { Glide.with(appCtx) .asBitmap() .load(BookCover.defaultDrawable.toBitmap()) .override(84, 112) .centerCrop() .submit() .get() } returnData.setData(defaultBitmap) } catch (e: Exception) { returnData.setErrorMsg(e.localizedMessage ?: "getCover error") } } } /** * 获取正文图片 */ fun getImg(parameters: Map>): ReturnData { val returnData = ReturnData() val bookUrl = parameters["url"]?.firstOrNull() ?: return returnData.setErrorMsg("bookUrl为空") val src = parameters["path"]?.firstOrNull() ?: return returnData.setErrorMsg("图片链接为空") val width = parameters["width"]?.firstOrNull()?.toInt() ?: 640 if (this.bookUrl != bookUrl) { this.book = appDb.bookDao.getBook(bookUrl) ?: return returnData.setErrorMsg("bookUrl不对") this.bookSource = appDb.bookSourceDao.getBookSource(book.origin) } this.bookUrl = bookUrl val bitmap = runBlocking { ImageProvider.cacheImage(book, src, bookSource) ImageProvider.getImage(book, src, width) } return returnData.setData(bitmap) } /** * 更新目录 */ fun refreshToc(parameters: Map>): ReturnData { val returnData = ReturnData() try { val bookUrl = parameters["url"]?.firstOrNull() if (bookUrl.isNullOrEmpty()) { return returnData.setErrorMsg("参数url不能为空,请指定书籍地址") } val book = appDb.bookDao.getBook(bookUrl) ?: return returnData.setErrorMsg("未在数据库找到对应书籍,请先添加") if (book.isLocal) { val toc = LocalBook.getChapterList(book) appDb.bookChapterDao.delByBook(book.bookUrl) appDb.bookChapterDao.insert(*toc.toTypedArray()) appDb.bookDao.update(book) return returnData.setData(toc) } else { val bookSource = appDb.bookSourceDao.getBookSource(book.origin) ?: return returnData.setErrorMsg("未找到对应书源,请换源") val toc = runBlocking { if (book.tocUrl.isBlank()) { WebBook.getBookInfoAwait(bookSource, book) } WebBook.getChapterListAwait(bookSource, book).getOrThrow() } appDb.bookChapterDao.delByBook(book.bookUrl) appDb.bookChapterDao.insert(*toc.toTypedArray()) appDb.bookDao.update(book) return returnData.setData(toc) } } catch (e: Exception) { return returnData.setErrorMsg(e.localizedMessage ?: "refresh toc error") } } /** * 获取目录 */ fun getChapterList(parameters: Map>): ReturnData { val bookUrl = parameters["url"]?.firstOrNull() val returnData = ReturnData() if (bookUrl.isNullOrEmpty()) { return returnData.setErrorMsg("参数url不能为空,请指定书籍地址") } val chapterList = appDb.bookChapterDao.getChapterList(bookUrl) if (chapterList.isEmpty()) { return refreshToc(parameters) } return returnData.setData(chapterList) } /** * 获取正文 */ fun getBookContent(parameters: Map>): ReturnData { val bookUrl = parameters["url"]?.firstOrNull() val index = parameters["index"]?.firstOrNull()?.toInt() val returnData = ReturnData() if (bookUrl.isNullOrEmpty()) { return returnData.setErrorMsg("参数url不能为空,请指定书籍地址") } if (index == null) { return returnData.setErrorMsg("参数index不能为空, 请指定目录序号") } val book = appDb.bookDao.getBook(bookUrl) val chapter = runBlocking { var chapter = appDb.bookChapterDao.getChapter(bookUrl, index) var wait = 0 while (chapter == null && wait < 30) { delay(1000) chapter = appDb.bookChapterDao.getChapter(bookUrl, index) wait++ } chapter } if (book == null || chapter == null) { return returnData.setErrorMsg("未找到") } var content: String? = BookHelp.getContent(book, chapter) if (content != null) { val contentProcessor = ContentProcessor.get(book.name, book.origin) content = runBlocking { contentProcessor.getContent(book, chapter, content, includeTitle = false) .toString() } return returnData.setData(content) } val bookSource = appDb.bookSourceDao.getBookSource(book.origin) ?: return returnData.setErrorMsg("未找到书源") try { content = runBlocking { WebBook.getContentAwait(bookSource, book, chapter).let { val contentProcessor = ContentProcessor.get(book.name, book.origin) contentProcessor.getContent(book, chapter, it, includeTitle = false) .toString() } } returnData.setData(content) } catch (e: Exception) { returnData.setErrorMsg(e.stackTraceStr) } return returnData } /** * 保存书籍 */ suspend fun saveBook(postData: String?): ReturnData { val returnData = ReturnData() GSON.fromJsonObject(postData).getOrNull()?.let { book -> AppWebDav.uploadBookProgress(book) book.save() return returnData.setData("") } return returnData.setErrorMsg("格式不对") } /** * 删除书籍 */ fun deleteBook(postData: String?): ReturnData { val returnData = ReturnData() GSON.fromJsonObject(postData).getOrNull()?.let { book -> book.delete() return returnData.setData("") } return returnData.setErrorMsg("格式不对") } /** * 保存进度 */ suspend fun saveBookProgress(postData: String?): ReturnData { val returnData = ReturnData() GSON.fromJsonObject(postData) .onFailure { it.printOnDebug() } .getOrNull()?.let { bookProgress -> appDb.bookDao.getBook(bookProgress.name, bookProgress.author)?.let { book -> book.durChapterIndex = bookProgress.durChapterIndex book.durChapterPos = bookProgress.durChapterPos book.durChapterTitle = bookProgress.durChapterTitle book.durChapterTime = bookProgress.durChapterTime AppWebDav.uploadBookProgress(bookProgress) { book.syncTime = System.currentTimeMillis() } appDb.bookDao.update(book) ReadBook.book?.let { if (it.name == bookProgress.name && it.author == bookProgress.author ) { ReadBook.webBookProgress = bookProgress } } return returnData.setData("") } } return returnData.setErrorMsg("格式不对") } /** * 添加本地书籍 */ fun addLocalBook( parameters: Map>, files: Map ): ReturnData { val returnData = ReturnData() val fileName = parameters["fileName"]?.firstOrNull() ?: return returnData.setErrorMsg("fileName 不能为空") val fileData = files["fileData"] ?: return returnData.setErrorMsg("fileData 不能为空") kotlin.runCatching { val uri = LocalBook.saveBookFile(File(fileData).inputStream(), fileName) LocalBook.importFile(uri) }.onFailure { return when (it) { is SecurityException -> returnData.setErrorMsg("需重新设置书籍保存位置!") else -> returnData.setErrorMsg("保存书籍错误\n${it.localizedMessage}") } } return returnData.setData(true) } /** * 保存web阅读界面配置 */ fun saveWebReadConfig(postData: String?): ReturnData { val returnData = ReturnData() postData?.let { CacheManager.put("webReadConfig", postData) } ?: CacheManager.delete("webReadConfig") return returnData.setData("") } /** * 获取web阅读界面配置 */ fun getWebReadConfig(): ReturnData { val returnData = ReturnData() val data = CacheManager.get("webReadConfig") ?: return returnData.setErrorMsg("没有配置") return returnData.setData(data) } } ================================================ FILE: app/src/main/java/io/legado/app/api/controller/BookSourceController.kt ================================================ package io.legado.app.api.controller import android.text.TextUtils import io.legado.app.api.ReturnData import io.legado.app.data.appDb import io.legado.app.data.entities.BookSource import io.legado.app.help.source.SourceHelp import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonArray import io.legado.app.utils.fromJsonObject object BookSourceController { val sources: ReturnData get() { val bookSources = appDb.bookSourceDao.all val returnData = ReturnData() return if (bookSources.isEmpty()) { returnData.setErrorMsg("设备源列表为空") } else returnData.setData(bookSources) } fun saveSource(postData: String?): ReturnData { val returnData = ReturnData() postData ?: return returnData.setErrorMsg("数据不能为空") val bookSource = GSON.fromJsonObject(postData).getOrNull() if (bookSource != null) { if (TextUtils.isEmpty(bookSource.bookSourceName) || TextUtils.isEmpty(bookSource.bookSourceUrl)) { returnData.setErrorMsg("源名称和URL不能为空") } else { appDb.bookSourceDao.insert(bookSource) returnData.setData("") } } else { returnData.setErrorMsg("转换源失败") } return returnData } fun saveSources(postData: String?): ReturnData { postData ?: return ReturnData().setErrorMsg("数据为空") val okSources = arrayListOf() val bookSources = GSON.fromJsonArray(postData).getOrNull() if (bookSources.isNullOrEmpty()) { return ReturnData().setErrorMsg("转换源失败") } bookSources.forEach { bookSource -> if (bookSource.bookSourceName.isNotBlank() && bookSource.bookSourceUrl.isNotBlank() ) { appDb.bookSourceDao.insert(bookSource) okSources.add(bookSource) } } return ReturnData().setData(okSources) } fun getSource(parameters: Map>): ReturnData { val url = parameters["url"]?.firstOrNull() val returnData = ReturnData() if (url.isNullOrEmpty()) { return returnData.setErrorMsg("参数url不能为空,请指定源地址") } val bookSource = appDb.bookSourceDao.getBookSource(url) ?: return returnData.setErrorMsg("未找到源,请检查书源地址") return returnData.setData(bookSource) } fun deleteSources(postData: String?): ReturnData { kotlin.runCatching { GSON.fromJsonArray(postData).getOrThrow().let { SourceHelp.deleteBookSources(it) } }.onFailure { return ReturnData().setErrorMsg(it.localizedMessage ?: "数据格式错误") } return ReturnData().setData("已执行"/*okSources*/) } } ================================================ FILE: app/src/main/java/io/legado/app/api/controller/ReplaceRuleController.kt ================================================ package io.legado.app.api.controller import io.legado.app.api.ReturnData import io.legado.app.data.appDb import io.legado.app.data.entities.ReplaceRule import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonObject import io.legado.app.utils.replace import io.legado.app.utils.stackTraceStr object ReplaceRuleController { val allRules: ReturnData get() { val rules = appDb.replaceRuleDao.all val returnData = ReturnData() returnData.setData(GSON.toJson(rules)) return returnData } fun saveRule(postData: String?): ReturnData { val returnData = ReturnData() postData ?: return returnData.setErrorMsg("数据不能为空") val rule = GSON.fromJsonObject(postData).getOrNull() if (rule == null) { returnData.setErrorMsg("格式不对") } else { if (rule.order == Int.MIN_VALUE) { rule.order = appDb.replaceRuleDao.maxOrder + 1 } appDb.replaceRuleDao.insert(rule) } return returnData } fun delete(postData: String?): ReturnData { val returnData = ReturnData() postData ?: return returnData.setErrorMsg("数据不能为空") val rule = GSON.fromJsonObject(postData).getOrNull() if (rule == null) { returnData.setErrorMsg("格式不对") } else { appDb.replaceRuleDao.delete(rule) } return returnData } /** * 传入测试数据格式 * { * rule: Replace, * text: "xxx" * } */ fun testRule(postData: String?): ReturnData { val returnData = ReturnData() postData ?: return returnData.setErrorMsg("数据不能为空") val map = GSON.fromJsonObject>(postData).getOrNull() if (map == null) { returnData.setErrorMsg("格式不对") } else { val rule = map["rule"]?.let { if (it is String) { GSON.fromJsonObject(it).getOrNull() } else { GSON.fromJsonObject(GSON.toJson(it)).getOrNull() } } if (rule == null) { returnData.setErrorMsg("格式不对") return returnData } if (rule.pattern.isEmpty()) { returnData.setErrorMsg("替换规则不能为空") } val text = map["text"] as String val content = try { if (rule.isRegex) { text.replace( rule.pattern.toRegex(), rule.replacement, rule.getValidTimeoutMillisecond() ) } else { text.replace(rule.pattern, rule.replacement) } } catch (e: Exception) { e.stackTraceStr } returnData.setData(content) } return returnData } } ================================================ FILE: app/src/main/java/io/legado/app/api/controller/RssSourceController.kt ================================================ package io.legado.app.api.controller import android.text.TextUtils import io.legado.app.api.ReturnData import io.legado.app.data.appDb import io.legado.app.data.entities.RssSource import io.legado.app.help.source.SourceHelp import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonArray import io.legado.app.utils.fromJsonObject object RssSourceController { val sources: ReturnData get() { val source = appDb.rssSourceDao.all val returnData = ReturnData() return if (source.isEmpty()) { returnData.setErrorMsg("源列表为空") } else returnData.setData(source) } fun saveSource(postData: String?): ReturnData { val returnData = ReturnData() postData ?: return returnData.setErrorMsg("数据不能为空") GSON.fromJsonObject(postData).onFailure { returnData.setErrorMsg("转换源失败${it.localizedMessage}") }.onSuccess { source -> if (TextUtils.isEmpty(source.sourceName) || TextUtils.isEmpty(source.sourceUrl)) { returnData.setErrorMsg("源名称和URL不能为空") } else { appDb.rssSourceDao.insert(source) returnData.setData("") } } return returnData } fun saveSources(postData: String?): ReturnData { postData ?: return ReturnData().setErrorMsg("数据不能为空") val okSources = arrayListOf() val source = GSON.fromJsonArray(postData).getOrNull() if (source.isNullOrEmpty()) { return ReturnData().setErrorMsg("转换源失败") } for (rssSource in source) { if (rssSource.sourceName.isBlank() || rssSource.sourceUrl.isBlank()) { continue } appDb.rssSourceDao.insert(rssSource) okSources.add(rssSource) } return ReturnData().setData(okSources) } fun getSource(parameters: Map>): ReturnData { val url = parameters["url"]?.firstOrNull() val returnData = ReturnData() if (url.isNullOrEmpty()) { return returnData.setErrorMsg("参数url不能为空,请指定书源地址") } val source = appDb.rssSourceDao.getByKey(url) ?: return returnData.setErrorMsg("未找到源,请检查源地址") return returnData.setData(source) } fun deleteSources(postData: String?): ReturnData { postData ?: return ReturnData().setErrorMsg("没有传递数据") GSON.fromJsonArray(postData).onFailure { return ReturnData().setErrorMsg("格式不对") }.onSuccess { SourceHelp.deleteRssSources(it) } return ReturnData().setData("已执行"/*okSources*/) } } ================================================ FILE: app/src/main/java/io/legado/app/base/AppContextWrapper.kt ================================================ package io.legado.app.base import android.annotation.SuppressLint import android.content.Context import android.content.res.Configuration import android.content.res.Resources import android.os.Build import android.os.LocaleList import io.legado.app.constant.PreferKey import io.legado.app.utils.getPrefInt import io.legado.app.utils.getPrefString import io.legado.app.utils.sysConfiguration import java.util.* @Suppress("unused") object AppContextWrapper { @SuppressLint("ObsoleteSdkInt") fun wrap(context: Context): Context { val resources: Resources = context.resources val configuration: Configuration = resources.configuration val targetLocale = getSetLocale(context) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { configuration.setLocale(targetLocale) configuration.setLocales(LocaleList(targetLocale)) } else { @Suppress("DEPRECATION") configuration.locale = targetLocale } configuration.fontScale = getFontScale(context) return context.createConfigurationContext(configuration) } fun getFontScale(context: Context): Float { var fontScale = context.getPrefInt(PreferKey.fontScale) / 10f if (fontScale !in 0.8f..1.6f) { fontScale = sysConfiguration.fontScale } return fontScale } /** * 当前系统语言 */ @SuppressLint("ObsoleteSdkInt") private fun getSystemLocale(): Locale { val locale: Locale if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { //7.0有多语言设置获取顶部的语言 locale = sysConfiguration.locales.get(0) } else { @Suppress("DEPRECATION") locale = sysConfiguration.locale } return locale } /** * 当前App语言 */ @SuppressLint("ObsoleteSdkInt") private fun getAppLocale(context: Context): Locale { val locale: Locale if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { locale = context.resources.configuration.locales[0] } else { @Suppress("DEPRECATION") locale = context.resources.configuration.locale } return locale } /** * 当前设置语言 */ private fun getSetLocale(context: Context): Locale { return when (context.getPrefString(PreferKey.language)) { "zh" -> Locale.SIMPLIFIED_CHINESE "tw" -> Locale.TRADITIONAL_CHINESE "en" -> Locale.ENGLISH else -> getSystemLocale() } } /** * 判断App语言和设置语言是否相同 */ fun isSameWithSetting(context: Context): Boolean { val locale = getAppLocale(context) val language = locale.language val country = locale.country val pfLocale = getSetLocale(context) val pfLanguage = pfLocale.language val pfCountry = pfLocale.country return language == pfLanguage && country == pfCountry } } ================================================ FILE: app/src/main/java/io/legado/app/base/BaseActivity.kt ================================================ package io.legado.app.base import android.annotation.SuppressLint import android.content.Context import android.content.res.Configuration import android.graphics.drawable.BitmapDrawable import android.os.Build import android.os.Bundle import android.util.AttributeSet import android.view.Menu import android.view.MenuItem import android.view.MotionEvent import android.view.View import android.widget.FrameLayout import androidx.activity.addCallback import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.viewbinding.ViewBinding import io.legado.app.R import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog import io.legado.app.constant.Theme import io.legado.app.help.config.AppConfig import io.legado.app.help.config.ThemeConfig import io.legado.app.lib.theme.ThemeStore import io.legado.app.lib.theme.backgroundColor import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.widget.TitleBar import io.legado.app.utils.ColorUtils import io.legado.app.utils.applyBackgroundTint import io.legado.app.utils.applyOpenTint import io.legado.app.utils.applyTint import io.legado.app.utils.disableAutoFill import io.legado.app.utils.fullScreen import io.legado.app.utils.hideSoftInput import io.legado.app.utils.setLightStatusBar import io.legado.app.utils.setNavigationBarColorAuto import io.legado.app.utils.setStatusBarColorAuto import io.legado.app.utils.toastOnUi import io.legado.app.utils.windowSize abstract class BaseActivity( val fullScreen: Boolean = true, private val theme: Theme = Theme.Auto, private val toolBarTheme: Theme = Theme.Auto, private val transparent: Boolean = false, private val imageBg: Boolean = true ) : AppCompatActivity() { protected abstract val binding: VB val isInMultiWindow: Boolean @SuppressLint("ObsoleteSdkInt") get() { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { isInMultiWindowMode } else { false } } override fun attachBaseContext(newBase: Context) { super.attachBaseContext(AppContextWrapper.wrap(newBase)) } override fun onCreateView( parent: View?, name: String, context: Context, attrs: AttributeSet ): View? { if (AppConst.menuViewNames.contains(name) && parent?.parent is FrameLayout) { (parent.parent as View).setBackgroundColor(backgroundColor) } return super.onCreateView(parent, name, context, attrs) } @SuppressLint("ObsoleteSdkInt") override fun onCreate(savedInstanceState: Bundle?) { window.decorView.disableAutoFill() initTheme() super.onCreate(savedInstanceState) setupSystemBar() setContentView(binding.root) upBackgroundImage() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { findViewById(R.id.title_bar) ?.onMultiWindowModeChanged(isInMultiWindowMode, fullScreen) } onBackPressedDispatcher.addCallback(this) { finish() } observeLiveBus() onActivityCreated(savedInstanceState) } @RequiresApi(Build.VERSION_CODES.O) override fun onMultiWindowModeChanged(isInMultiWindowMode: Boolean, newConfig: Configuration) { super.onMultiWindowModeChanged(isInMultiWindowMode, newConfig) findViewById(R.id.title_bar) ?.onMultiWindowModeChanged(isInMultiWindowMode, fullScreen) setupSystemBar() } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) findViewById(R.id.title_bar) ?.onMultiWindowModeChanged(isInMultiWindow, fullScreen) setupSystemBar() } abstract fun onActivityCreated(savedInstanceState: Bundle?) final override fun onCreateOptionsMenu(menu: Menu): Boolean { val bool = onCompatCreateOptionsMenu(menu) menu.applyTint(this, toolBarTheme) return bool } override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { menu.applyOpenTint(this) return super.onMenuOpened(featureId, menu) } open fun onCompatCreateOptionsMenu(menu: Menu) = super.onCreateOptionsMenu(menu) final override fun onOptionsItemSelected(item: MenuItem): Boolean { if (item.itemId == android.R.id.home) { supportFinishAfterTransition() return true } return onCompatOptionsItemSelected(item) } open fun onCompatOptionsItemSelected(item: MenuItem) = super.onOptionsItemSelected(item) open fun initTheme() { when (theme) { Theme.Transparent -> setTheme(R.style.AppTheme_Transparent) Theme.Dark -> { setTheme(R.style.AppTheme_Dark) window.decorView.applyBackgroundTint(backgroundColor) } Theme.Light -> { setTheme(R.style.AppTheme_Light) window.decorView.applyBackgroundTint(backgroundColor) } else -> { if (ColorUtils.isColorLight(primaryColor)) { setTheme(R.style.AppTheme_Light) } else { setTheme(R.style.AppTheme_Dark) } window.decorView.applyBackgroundTint(backgroundColor) } } } open fun upBackgroundImage() { if (imageBg) { try { ThemeConfig.getBgImage(this, windowManager.windowSize)?.let { window.decorView.background = BitmapDrawable(resources, it) } } catch (e: OutOfMemoryError) { toastOnUi("背景图片太大,内存溢出") } catch (e: Exception) { AppLog.put("加载背景出错\n${e.localizedMessage}", e) } } } open fun setupSystemBar() { if (fullScreen && !isInMultiWindow) { fullScreen() } val isTransparentStatusBar = AppConfig.isTransparentStatusBar val statusBarColor = ThemeStore.statusBarColor(this, isTransparentStatusBar) setStatusBarColorAuto(statusBarColor, isTransparentStatusBar, fullScreen) if (toolBarTheme == Theme.Dark) { setLightStatusBar(false) } else if (toolBarTheme == Theme.Light) { setLightStatusBar(true) } upNavigationBarColor() } open fun upNavigationBarColor() { if (AppConfig.immNavigationBar) { setNavigationBarColorAuto(ThemeStore.navigationBarColor(this)) } else { val nbColor = ColorUtils.darkenColor(ThemeStore.navigationBarColor(this)) setNavigationBarColorAuto(nbColor) } } open fun observeLiveBus() { } override fun dispatchTouchEvent(ev: MotionEvent): Boolean { return try { super.dispatchTouchEvent(ev) } catch (e: IllegalArgumentException) { e.printStackTrace() false } } override fun finish() { currentFocus?.hideSoftInput() super.finish() } } ================================================ FILE: app/src/main/java/io/legado/app/base/BaseDialogFragment.kt ================================================ package io.legado.app.base import android.content.DialogInterface import android.content.DialogInterface.OnDismissListener import android.os.Build import android.os.Bundle import android.view.Gravity import android.view.View import android.view.WindowManager import androidx.annotation.LayoutRes import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.lifecycleScope import io.legado.app.R import io.legado.app.constant.AppLog import io.legado.app.help.config.AppConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.lib.theme.ThemeStore import io.legado.app.utils.dpToPx import io.legado.app.utils.setBackgroundKeepPadding import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlin.coroutines.CoroutineContext abstract class BaseDialogFragment( @LayoutRes layoutID: Int, private val adaptationSoftKeyboard: Boolean = false ) : DialogFragment(layoutID) { private var onDismissListener: OnDismissListener? = null fun setOnDismissListener(onDismissListener: OnDismissListener?) { this.onDismissListener = onDismissListener } override fun onStart() { super.onStart() if (adaptationSoftKeyboard) { dialog?.window?.setBackgroundDrawableResource(R.color.transparent) } else if (AppConfig.isEInkMode) { dialog?.window?.let { it.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) val attr = it.attributes attr.dimAmount = 0.0f attr.windowAnimations = 0 it.attributes = attr it.decorView.setBackgroundKeepPadding(R.color.transparent) } // 修改gravity的时机一般在子类的onStart方法中, 因此需要在onStart之后执行. lifecycle.addObserver(LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_START) { when (dialog?.window?.attributes?.gravity) { Gravity.TOP -> view?.setBackgroundResource(R.drawable.bg_eink_border_bottom) Gravity.BOTTOM -> view?.setBackgroundResource(R.drawable.bg_eink_border_top) else -> { val padding = 2.dpToPx(); view?.setPadding(padding, padding, padding, padding) view?.setBackgroundResource(R.drawable.bg_eink_border_dialog) } } } }) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { //不加这个android 5.0对话框顶部会有空白 setStyle(STYLE_NO_TITLE, 0) } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) if (adaptationSoftKeyboard) { view.findViewById(R.id.vw_bg)?.setOnClickListener(null) view.setOnClickListener { dismiss() } } else if (!AppConfig.isEInkMode) { view.setBackgroundColor(ThemeStore.backgroundColor()) } onFragmentCreated(view, savedInstanceState) observeLiveBus() } abstract fun onFragmentCreated(view: View, savedInstanceState: Bundle?) override fun show(manager: FragmentManager, tag: String?) { kotlin.runCatching { //在每个add事务前增加一个remove事务,防止连续的add manager.beginTransaction().remove(this).commit() super.show(manager, tag) }.onFailure { AppLog.put("显示对话框失败 tag:$tag", it) } } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) onDismissListener?.onDismiss(dialog) } fun execute( scope: CoroutineScope = lifecycleScope, context: CoroutineContext = Dispatchers.IO, block: suspend CoroutineScope.() -> T ) = Coroutine.async(scope, context) { block() } open fun observeLiveBus() { } } ================================================ FILE: app/src/main/java/io/legado/app/base/BaseFragment.kt ================================================ package io.legado.app.base import android.annotation.SuppressLint import android.content.res.Configuration import android.os.Bundle import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.annotation.LayoutRes import androidx.appcompat.view.SupportMenuInflater import androidx.appcompat.widget.Toolbar import androidx.fragment.app.Fragment import io.legado.app.R import io.legado.app.ui.widget.TitleBar import io.legado.app.utils.applyTint @Suppress("MemberVisibilityCanBePrivate") abstract class BaseFragment(@LayoutRes layoutID: Int) : Fragment(layoutID) { var supportToolbar: Toolbar? = null private set val menuInflater: MenuInflater @SuppressLint("RestrictedApi") get() = SupportMenuInflater(requireContext()) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) onMultiWindowModeChanged() observeLiveBus() onFragmentCreated(view, savedInstanceState) } abstract fun onFragmentCreated(view: View, savedInstanceState: Bundle?) override fun onMultiWindowModeChanged(isInMultiWindowMode: Boolean) { super.onMultiWindowModeChanged(isInMultiWindowMode) onMultiWindowModeChanged() } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) onMultiWindowModeChanged() } private fun onMultiWindowModeChanged() { (activity as? BaseActivity<*>)?.let { view?.findViewById(R.id.title_bar) ?.onMultiWindowModeChanged(it.isInMultiWindow, it.fullScreen) } } fun setSupportToolbar(toolbar: Toolbar) { supportToolbar = toolbar supportToolbar?.let { it.menu.apply { onCompatCreateOptionsMenu(this) applyTint(requireContext()) } it.setOnMenuItemClickListener { item -> onCompatOptionsItemSelected(item) true } } } open fun observeLiveBus() { } open fun onCompatCreateOptionsMenu(menu: Menu) { } open fun onCompatOptionsItemSelected(item: MenuItem) { } } ================================================ FILE: app/src/main/java/io/legado/app/base/BasePrefDialogFragment.kt ================================================ package io.legado.app.base import android.view.Gravity import android.view.WindowManager import androidx.fragment.app.DialogFragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import io.legado.app.R import io.legado.app.help.config.AppConfig import io.legado.app.utils.dpToPx abstract class BasePrefDialogFragment( ) : DialogFragment() { override fun onStart() { super.onStart() if (AppConfig.isEInkMode) { dialog?.window?.let { it.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) val attr = it.attributes attr.dimAmount = 0.0f attr.windowAnimations = 0 it.attributes = attr it.setBackgroundDrawableResource(R.color.transparent) } // 修改gravity的时机一般在子类的onStart方法中, 因此需要在onStart之后执行. lifecycle.addObserver(LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_START) { when (dialog?.window?.attributes?.gravity) { Gravity.TOP -> view?.setBackgroundResource(R.drawable.bg_eink_border_bottom) Gravity.BOTTOM -> view?.setBackgroundResource(R.drawable.bg_eink_border_top) else -> { val padding = 2.dpToPx(); view?.setPadding(padding, padding, padding, padding) view?.setBackgroundResource(R.drawable.bg_eink_border_dialog) } } } }) } } } ================================================ FILE: app/src/main/java/io/legado/app/base/BaseService.kt ================================================ package io.legado.app.base import android.content.Intent import android.os.Build import android.os.IBinder import androidx.annotation.CallSuper import androidx.lifecycle.LifecycleService import androidx.lifecycle.lifecycleScope import io.legado.app.R import io.legado.app.help.LifecycleHelp import io.legado.app.help.coroutine.Coroutine import io.legado.app.lib.permission.Permissions import io.legado.app.lib.permission.PermissionsCompat import io.legado.app.utils.LogUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive import kotlinx.coroutines.sync.Semaphore import kotlin.coroutines.CoroutineContext abstract class BaseService : LifecycleService() { private val simpleName = this::class.simpleName.toString() private var isForeground = false fun execute( scope: CoroutineScope = lifecycleScope, context: CoroutineContext = Dispatchers.IO, start: CoroutineStart = CoroutineStart.DEFAULT, executeContext: CoroutineContext = Dispatchers.Main, semaphore: Semaphore? = null, block: suspend CoroutineScope.() -> T ) = Coroutine.async(scope, context, start, executeContext, semaphore, block) @CallSuper override fun onCreate() { super.onCreate() LifecycleHelp.onServiceCreate(this) checkPermission() } @CallSuper override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { LogUtils.d(simpleName) { "onStartCommand $intent ${intent?.toUri(0)}" } if (!isForeground) { startForegroundNotification() isForeground = true } return super.onStartCommand(intent, flags, startId) } @CallSuper override fun onTaskRemoved(rootIntent: Intent?) { LogUtils.d(simpleName, "onTaskRemoved") super.onTaskRemoved(rootIntent) stopSelf() } override fun onBind(intent: Intent): IBinder? { super.onBind(intent) return null } @CallSuper override fun onDestroy() { super.onDestroy() LifecycleHelp.onServiceDestroy(this) } @CallSuper override fun onTimeout(startId: Int, fgsType: Int) { super.onTimeout(startId, fgsType) LogUtils.d(simpleName, "onTimeout startId:$startId fgsType:$fgsType") stopSelf() } /** * 开启前台服务并发送通知 */ open fun startForegroundNotification() { } /** * 检测通知权限和后台权限 */ private fun checkPermission() { PermissionsCompat.Builder() .addPermissions(Permissions.POST_NOTIFICATIONS) .rationale(R.string.notification_permission_rationale) .onGranted { if (lifecycleScope.isActive) { startForegroundNotification() } } .request() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { PermissionsCompat.Builder() .addPermissions(Permissions.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) .rationale(R.string.ignore_battery_permission_rationale) .request() } } } ================================================ FILE: app/src/main/java/io/legado/app/base/BaseViewModel.kt ================================================ package io.legado.app.base import android.app.Application import android.content.Context import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import io.legado.app.App import io.legado.app.help.coroutine.Coroutine import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Semaphore import kotlin.coroutines.CoroutineContext @Suppress("unused") open class BaseViewModel(application: Application) : AndroidViewModel(application) { val context: Context by lazy { this.getApplication() } fun execute( scope: CoroutineScope = viewModelScope, context: CoroutineContext = Dispatchers.IO, start: CoroutineStart = CoroutineStart.DEFAULT, executeContext: CoroutineContext = Dispatchers.Main, semaphore: Semaphore? = null, block: suspend CoroutineScope.() -> T ): Coroutine { return Coroutine.async(scope, context, start, executeContext, semaphore, block) } fun executeLazy( scope: CoroutineScope = viewModelScope, context: CoroutineContext = Dispatchers.IO, executeContext: CoroutineContext = Dispatchers.Main, semaphore: Semaphore? = null, block: suspend CoroutineScope.() -> T ): Coroutine { return Coroutine.async( scope, context, CoroutineStart.LAZY, executeContext, semaphore, block ) } fun submit( scope: CoroutineScope = viewModelScope, context: CoroutineContext = Dispatchers.IO, block: suspend CoroutineScope.() -> Deferred ): Coroutine { return Coroutine.async(scope, context) { block().await() } } } ================================================ FILE: app/src/main/java/io/legado/app/base/README.md ================================================ # 基类 ================================================ FILE: app/src/main/java/io/legado/app/base/VMBaseActivity.kt ================================================ package io.legado.app.base import androidx.lifecycle.ViewModel import androidx.viewbinding.ViewBinding import io.legado.app.constant.Theme abstract class VMBaseActivity( fullScreen: Boolean = true, theme: Theme = Theme.Auto, toolBarTheme: Theme = Theme.Auto, transparent: Boolean = false, imageBg: Boolean = true ) : BaseActivity(fullScreen, theme, toolBarTheme, transparent, imageBg) { protected abstract val viewModel: VM } ================================================ FILE: app/src/main/java/io/legado/app/base/VMBaseFragment.kt ================================================ package io.legado.app.base import androidx.lifecycle.ViewModel abstract class VMBaseFragment(layoutID: Int) : BaseFragment(layoutID) { protected abstract val viewModel: VM } ================================================ FILE: app/src/main/java/io/legado/app/base/adapter/DiffRecyclerAdapter.kt ================================================ package io.legado.app.base.adapter import android.content.Context import android.os.Parcelable import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding import splitties.views.onLongClick /** * Created by Invincible on 2017/12/15. */ @Suppress("unused", "MemberVisibilityCanBePrivate") abstract class DiffRecyclerAdapter(protected val context: Context) : RecyclerView.Adapter() { val inflater: LayoutInflater = LayoutInflater.from(context) private val asyncListDiffer: AsyncListDiffer by lazy { AsyncListDiffer(this, diffItemCallback).apply { addListListener { _, _ -> onCurrentListChanged() if (keepScrollPosition) { layoutManager?.onRestoreInstanceState(layoutState) layoutState = null } } } } private var itemClickListener: ((holder: ItemViewHolder, item: ITEM) -> Unit)? = null private var itemLongClickListener: ((holder: ItemViewHolder, item: ITEM) -> Boolean)? = null private var layoutManager: RecyclerView.LayoutManager? = null private var layoutState: Parcelable? = null var itemAnimation: ItemAnimation? = null abstract val diffItemCallback: DiffUtil.ItemCallback open val keepScrollPosition = false fun setOnItemClickListener(listener: (holder: ItemViewHolder, item: ITEM) -> Unit) { itemClickListener = listener } fun setOnItemLongClickListener(listener: (holder: ItemViewHolder, item: ITEM) -> Boolean) { itemLongClickListener = listener } fun bindToRecyclerView(recyclerView: RecyclerView) { recyclerView.adapter = this } fun setItems(items: List?) { kotlin.runCatching { if (keepScrollPosition) { layoutState = layoutManager?.onSaveInstanceState() } asyncListDiffer.submitList(items?.toMutableList()) } } fun setItem(position: Int, item: ITEM) { kotlin.runCatching { asyncListDiffer.currentList[position] = item notifyItemChanged(position) } } fun updateItem(item: ITEM) { kotlin.runCatching { val index = asyncListDiffer.currentList.indexOf(item) if (index >= 0) { asyncListDiffer.currentList[index] = item notifyItemChanged(index) } } } fun updateItem(position: Int, payload: Any) { kotlin.runCatching { val size = itemCount if (position in 0 until size) { notifyItemChanged(position, payload) } } } fun updateItems(fromPosition: Int, toPosition: Int, payloads: Any) { kotlin.runCatching { val size = itemCount if (fromPosition in 0 until size && toPosition in 0 until size) { notifyItemRangeChanged( fromPosition, toPosition - fromPosition + 1, payloads ) } } } fun isEmpty() = asyncListDiffer.currentList.isEmpty() fun isNotEmpty() = asyncListDiffer.currentList.isNotEmpty() fun getItem(position: Int): ITEM? = asyncListDiffer.currentList.getOrNull(position) fun getItems(): List = asyncListDiffer.currentList /** * grid 模式下使用 */ protected open fun getSpanSize(viewType: Int, position: Int) = 1 final override fun getItemCount() = getItems().size final override fun getItemViewType(position: Int): Int { return 0 } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder { return ItemViewHolder(getViewBinding(parent)) } protected abstract fun getViewBinding(parent: ViewGroup): VB final override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {} open fun onCurrentListChanged() { //可继承 } @Suppress("UNCHECKED_CAST") final override fun onBindViewHolder( holder: ItemViewHolder, position: Int, payloads: MutableList ) { registerListener(holder, (holder.binding as VB)) registerItemListener(holder) getItem(holder.layoutPosition)?.let { convert(holder, holder.binding, it, payloads) } } private fun registerItemListener(holder: ItemViewHolder) { if (itemClickListener != null) { holder.itemView.setOnClickListener { getItem(holder.layoutPosition)?.let { itemClickListener?.invoke(holder, it) } } } if (itemLongClickListener != null) { holder.itemView.onLongClick { getItem(holder.layoutPosition)?.let { itemLongClickListener?.invoke(holder, it) } } } } override fun onViewAttachedToWindow(holder: ItemViewHolder) { super.onViewAttachedToWindow(holder) addAnimation(holder) } override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { super.onAttachedToRecyclerView(recyclerView) val manager = recyclerView.layoutManager layoutManager = manager if (manager is GridLayoutManager) { manager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { override fun getSpanSize(position: Int): Int { return getSpanSize(getItemViewType(position), position) } } } } private fun addAnimation(holder: ItemViewHolder) { itemAnimation?.let { if (it.itemAnimEnabled) { if (!it.itemAnimFirstOnly || holder.layoutPosition > it.itemAnimStartPosition) { startAnimation(holder, it) it.itemAnimStartPosition = holder.layoutPosition } } } } protected open fun startAnimation(holder: ItemViewHolder, item: ItemAnimation) { item.itemAnimation?.let { for (anim in it.getAnimators(holder.itemView)) { anim.setDuration(item.itemAnimDuration).start() anim.interpolator = item.itemAnimInterpolator } } } /** * 如果使用了事件回调,回调里不要直接使用item,会出现不更新的问题, * 使用getItem(holder.layoutPosition)来获取item */ abstract fun convert( holder: ItemViewHolder, binding: VB, item: ITEM, payloads: MutableList ) /** * 注册事件 */ abstract fun registerListener(holder: ItemViewHolder, binding: VB) } ================================================ FILE: app/src/main/java/io/legado/app/base/adapter/ItemAnimation.kt ================================================ package io.legado.app.base.adapter import android.view.animation.Interpolator import android.view.animation.LinearInterpolator import io.legado.app.base.adapter.animations.* /** * Created by Invincible on 2017/12/15. */ @Suppress("unused") class ItemAnimation private constructor() { var itemAnimEnabled = false var itemAnimFirstOnly = true var itemAnimation: BaseAnimation? = null var itemAnimInterpolator: Interpolator = LinearInterpolator() var itemAnimDuration: Long = 300L var itemAnimStartPosition: Int = -1 fun interpolator(interpolator: Interpolator) = apply { itemAnimInterpolator = interpolator } fun duration(duration: Long) = apply { itemAnimDuration = duration } fun startPosition(startPos: Int) = apply { itemAnimStartPosition = startPos } fun animation(animationType: Int = NONE, animation: BaseAnimation? = null) = apply { if (animation != null) { itemAnimation = animation } else { when (animationType) { FADE_IN -> itemAnimation = AlphaInAnimation() SCALE_IN -> itemAnimation = ScaleInAnimation() BOTTOM_SLIDE_IN -> itemAnimation = SlideInBottomAnimation() LEFT_SLIDE_IN -> itemAnimation = SlideInLeftAnimation() RIGHT_SLIDE_IN -> itemAnimation = SlideInRightAnimation() } } } fun enabled(enabled: Boolean) = apply { itemAnimEnabled = enabled } fun firstOnly(firstOnly: Boolean) = apply { itemAnimFirstOnly = firstOnly } companion object { const val NONE: Int = 0x00000000 /** * Use with [.openLoadAnimation] */ const val FADE_IN: Int = 0x00000001 /** * Use with [.openLoadAnimation] */ const val SCALE_IN: Int = 0x00000002 /** * Use with [.openLoadAnimation] */ const val BOTTOM_SLIDE_IN: Int = 0x00000003 /** * Use with [.openLoadAnimation] */ const val LEFT_SLIDE_IN: Int = 0x00000004 /** * Use with [.openLoadAnimation] */ const val RIGHT_SLIDE_IN: Int = 0x00000005 fun create() = ItemAnimation() } } ================================================ FILE: app/src/main/java/io/legado/app/base/adapter/ItemViewHolder.kt ================================================ package io.legado.app.base.adapter import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding /** * Created by Invincible on 2017/11/28. */ @Suppress("MemberVisibilityCanBePrivate") class ItemViewHolder(val binding: ViewBinding) : RecyclerView.ViewHolder(binding.root) ================================================ FILE: app/src/main/java/io/legado/app/base/adapter/RecyclerAdapter.kt ================================================ package io.legado.app.base.adapter import android.annotation.SuppressLint import android.content.Context import android.util.SparseArray import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding import io.legado.app.help.coroutine.Coroutine import io.legado.app.utils.buildMainHandler import io.legado.app.utils.withTimeoutOrNullAsync import kotlinx.coroutines.ensureActive import splitties.views.onLongClick import java.util.Collections /** * Created by Invincible on 2017/11/24. * * 通用的adapter 可添加header,footer,以及不同类型item */ @Suppress("unused", "MemberVisibilityCanBePrivate") abstract class RecyclerAdapter(protected val context: Context) : RecyclerView.Adapter() { val inflater: LayoutInflater = LayoutInflater.from(context) private val headerItems: SparseArray<(parent: ViewGroup) -> ViewBinding> by lazy { SparseArray() } private val footerItems: SparseArray<(parent: ViewGroup) -> ViewBinding> by lazy { SparseArray() } private val items: MutableList = mutableListOf() private var itemClickListener: ((holder: ItemViewHolder, item: ITEM) -> Unit)? = null private var itemLongClickListener: ((holder: ItemViewHolder, item: ITEM) -> Boolean)? = null private var diffJob: Coroutine<*>? = null var itemAnimation: ItemAnimation? = null fun setOnItemClickListener(listener: (holder: ItemViewHolder, item: ITEM) -> Unit) { itemClickListener = listener } fun setOnItemLongClickListener(listener: (holder: ItemViewHolder, item: ITEM) -> Boolean) { itemLongClickListener = listener } fun bindToRecyclerView(recyclerView: RecyclerView) { recyclerView.adapter = this } @Synchronized fun addHeaderView(header: ((parent: ViewGroup) -> ViewBinding)) { kotlin.runCatching { val index = headerItems.size() headerItems.put(TYPE_HEADER_VIEW + headerItems.size(), header) notifyItemInserted(index) } } @Synchronized fun addFooterView(footer: ((parent: ViewGroup) -> ViewBinding)) { kotlin.runCatching { val index = getActualItemCount() + footerItems.size() footerItems.put(TYPE_FOOTER_VIEW + footerItems.size(), footer) notifyItemInserted(index) } } @Synchronized fun removeHeaderView(header: ((parent: ViewGroup) -> ViewBinding)) { kotlin.runCatching { val index = headerItems.indexOfValue(header) if (index >= 0) { headerItems.remove(index) notifyItemRemoved(index) } } } @Synchronized fun removeFooterView(footer: ((parent: ViewGroup) -> ViewBinding)) { kotlin.runCatching { val index = footerItems.indexOfValue(footer) if (index >= 0) { footerItems.remove(index) notifyItemRemoved(getActualItemCount() + index - 2) } } } @SuppressLint("NotifyDataSetChanged") @Synchronized fun setItems(items: List?) { kotlin.runCatching { if (this.items.isNotEmpty()) { this.items.clear() } if (items != null) { this.items.addAll(items) } notifyDataSetChanged() onCurrentListChanged() } } @Synchronized fun setItems( items: List?, itemCallback: DiffUtil.ItemCallback, skipDiff: Boolean = false ) { kotlin.runCatching { val oldItems = this.items.toList() val itemsSize = items?.size ?: 0 val headerCount = getHeaderCount() val footerCount = getFooterCount() val callback = object : DiffUtil.Callback() { override fun getOldListSize(): Int { return itemCount } override fun getNewListSize(): Int { return itemsSize + headerCount + footerCount } override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { val oldItem = oldItems.getOrNull(oldItemPosition - headerCount) ?: return true val newItem = items?.getOrNull(newItemPosition - headerCount) ?: return true return itemCallback.areItemsTheSame(oldItem, newItem) } override fun areContentsTheSame( oldItemPosition: Int, newItemPosition: Int ): Boolean { val oldItem = oldItems.getOrNull(oldItemPosition - headerCount) ?: return true val newItem = items?.getOrNull(newItemPosition - headerCount) ?: return true return itemCallback.areContentsTheSame(oldItem, newItem) } override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? { val oldItem = oldItems.getOrNull(oldItemPosition - headerCount) ?: return null val newItem = items?.getOrNull(newItemPosition - headerCount) ?: return null return itemCallback.getChangePayload(oldItem, newItem) } } diffJob?.cancel() diffJob = Coroutine.async { val diffResult = if (skipDiff) withTimeoutOrNullAsync(500L) { DiffUtil.calculateDiff(callback, itemsSize < 2000) } else { DiffUtil.calculateDiff(callback, itemsSize < 2000) } ensureActive() handler.post { if (diffResult == null) { setItems(items) return@post } if (this@RecyclerAdapter.items.isNotEmpty()) { this@RecyclerAdapter.items.clear() } if (items != null) { this@RecyclerAdapter.items.addAll(items) } diffResult.dispatchUpdatesTo(this@RecyclerAdapter) onCurrentListChanged() } } } } @Synchronized fun setItem(position: Int, item: ITEM) { kotlin.runCatching { val oldSize = getActualItemCount() if (position in 0 until oldSize) { this.items[position] = item notifyItemChanged(position + getHeaderCount()) } onCurrentListChanged() } } @Synchronized fun addItem(item: ITEM) { kotlin.runCatching { val oldSize = getActualItemCount() if (this.items.add(item)) { notifyItemInserted(oldSize + getHeaderCount()) } onCurrentListChanged() } } @Synchronized fun addItems(position: Int, newItems: List) { kotlin.runCatching { if (this.items.addAll(position, newItems)) { notifyItemRangeInserted(position + getHeaderCount(), newItems.size) } onCurrentListChanged() } } @SuppressLint("NotifyDataSetChanged") @Synchronized fun addItems(newItems: List) { kotlin.runCatching { val oldSize = getActualItemCount() if (this.items.addAll(newItems)) { if (oldSize == 0 && getHeaderCount() == 0) { notifyDataSetChanged() } else { notifyItemRangeInserted(oldSize + getHeaderCount(), newItems.size) } } onCurrentListChanged() } } @Synchronized fun removeItem(position: Int) { kotlin.runCatching { if (this.items.removeAt(position) != null) { notifyItemRemoved(position + getHeaderCount()) } onCurrentListChanged() } } @Synchronized fun removeItem(item: ITEM) { kotlin.runCatching { if (this.items.remove(item)) { notifyItemRemoved(this.items.indexOf(item) + getHeaderCount()) } onCurrentListChanged() } } @SuppressLint("NotifyDataSetChanged") @Synchronized fun removeItems(items: List) { kotlin.runCatching { if (this.items.removeAll(items)) { notifyDataSetChanged() } onCurrentListChanged() } } @Synchronized fun swapItem(oldPosition: Int, newPosition: Int) { kotlin.runCatching { val size = getActualItemCount() if (oldPosition in 0 until size && newPosition in 0 until size) { val srcPosition = oldPosition + getHeaderCount() val targetPosition = newPosition + getHeaderCount() Collections.swap(this.items, srcPosition, targetPosition) notifyItemMoved(srcPosition, targetPosition) } onCurrentListChanged() } } @Synchronized fun updateItem(item: ITEM) { kotlin.runCatching { val index = this.items.indexOf(item) if (index >= 0) { this.items[index] = item notifyItemChanged(index) } onCurrentListChanged() } } @Synchronized fun updateItem(position: Int, payload: Any) { kotlin.runCatching { val size = getActualItemCount() if (position in 0 until size) { notifyItemChanged(position + getHeaderCount(), payload) } } } @Synchronized fun updateItems(fromPosition: Int, toPosition: Int, payloads: Any) { kotlin.runCatching { val size = getActualItemCount() if (fromPosition in 0 until size && toPosition in 0 until size) { notifyItemRangeChanged( fromPosition + getHeaderCount(), toPosition - fromPosition + 1, payloads ) } } } @SuppressLint("NotifyDataSetChanged") @Synchronized fun clearItems() { kotlin.runCatching { this.items.clear() notifyDataSetChanged() onCurrentListChanged() } } fun isEmpty() = items.isEmpty() fun isNotEmpty() = items.isNotEmpty() /** * 除去header和footer */ fun getActualItemCount() = items.size fun getHeaderCount() = headerItems.size() fun getFooterCount() = footerItems.size() fun getItem(position: Int): ITEM? = items.getOrNull(position) fun getItemByLayoutPosition(position: Int) = items.getOrNull(getActualPosition(position)) fun getItems(): List = items.toList() protected open fun getItemViewType(item: ITEM, position: Int) = 0 /** * grid 模式下使用 */ protected open fun getSpanSize(viewType: Int, position: Int) = 1 final override fun getItemCount() = getActualItemCount() + getHeaderCount() + getFooterCount() final override fun getItemViewType(position: Int) = when { isHeader(position) -> TYPE_HEADER_VIEW + position isFooter(position) -> TYPE_FOOTER_VIEW + position - getActualItemCount() - getHeaderCount() else -> getItemByLayoutPosition(position)?.let { getItemViewType(it, getActualPosition(position)) } ?: 0 } open fun onCurrentListChanged() { } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when { viewType < TYPE_HEADER_VIEW + getHeaderCount() -> { ItemViewHolder(headerItems.get(viewType).invoke(parent)) } viewType >= TYPE_FOOTER_VIEW -> { ItemViewHolder(footerItems.get(viewType).invoke(parent)) } else -> { ItemViewHolder(getViewBinding(parent)) } } protected abstract fun getViewBinding(parent: ViewGroup): VB final override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {} @Suppress("UNCHECKED_CAST") final override fun onBindViewHolder( holder: ItemViewHolder, position: Int, payloads: MutableList ) { if (!isHeader(holder.layoutPosition) && !isFooter(holder.layoutPosition)) { registerListener(holder, (holder.binding as VB)) registerItemListener(holder) getItemByLayoutPosition(holder.layoutPosition)?.let { item -> convert(holder, holder.binding, item, payloads) } } } private fun registerItemListener(holder: ItemViewHolder) { if (itemClickListener != null) { holder.itemView.setOnClickListener { getItemByLayoutPosition(holder.layoutPosition)?.let { itemClickListener?.invoke(holder, it) } } } if (itemLongClickListener != null) { holder.itemView.onLongClick { getItemByLayoutPosition(holder.layoutPosition)?.let { itemLongClickListener?.invoke(holder, it) } } } } override fun onViewAttachedToWindow(holder: ItemViewHolder) { super.onViewAttachedToWindow(holder) if (!isHeader(holder.layoutPosition) && !isFooter(holder.layoutPosition)) { addAnimation(holder) } } override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { super.onAttachedToRecyclerView(recyclerView) val manager = recyclerView.layoutManager if (manager is GridLayoutManager) { manager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { override fun getSpanSize(position: Int): Int { return getSpanSize(getItemViewType(position), position) } } } } private fun isHeader(position: Int) = position < getHeaderCount() private fun isFooter(position: Int) = position >= getActualItemCount() + getHeaderCount() private fun getActualPosition(position: Int) = position - getHeaderCount() private fun addAnimation(holder: ItemViewHolder) { itemAnimation?.let { if (it.itemAnimEnabled) { if (!it.itemAnimFirstOnly || holder.layoutPosition > it.itemAnimStartPosition) { startAnimation(holder, it) it.itemAnimStartPosition = holder.layoutPosition } } } } protected open fun startAnimation(holder: ItemViewHolder, item: ItemAnimation) { item.itemAnimation?.let { for (anim in it.getAnimators(holder.itemView)) { anim.setDuration(item.itemAnimDuration).start() anim.interpolator = item.itemAnimInterpolator } } } /** * 如果使用了事件回调,回调里不要直接使用item,会出现不更新的问题, * 使用getItem(holder.layoutPosition)来获取item */ abstract fun convert( holder: ItemViewHolder, binding: VB, item: ITEM, payloads: MutableList ) /** * 注册事件 */ abstract fun registerListener(holder: ItemViewHolder, binding: VB) companion object { private const val TYPE_HEADER_VIEW = Int.MIN_VALUE const val TYPE_FOOTER_VIEW = Int.MAX_VALUE - 999 private val handler by lazy { buildMainHandler() } } } ================================================ FILE: app/src/main/java/io/legado/app/base/adapter/animations/AlphaInAnimation.kt ================================================ package io.legado.app.base.adapter.animations import android.animation.Animator import android.animation.ObjectAnimator import android.view.View class AlphaInAnimation @JvmOverloads constructor(private val mFrom: Float = DEFAULT_ALPHA_FROM) : BaseAnimation { override fun getAnimators(view: View): Array = arrayOf(ObjectAnimator.ofFloat(view, "alpha", mFrom, 1f)) companion object { private const val DEFAULT_ALPHA_FROM = 0f } } ================================================ FILE: app/src/main/java/io/legado/app/base/adapter/animations/BaseAnimation.kt ================================================ package io.legado.app.base.adapter.animations import android.animation.Animator import android.view.View /** * adapter item 动画 */ interface BaseAnimation { fun getAnimators(view: View): Array } ================================================ FILE: app/src/main/java/io/legado/app/base/adapter/animations/ScaleInAnimation.kt ================================================ package io.legado.app.base.adapter.animations import android.animation.Animator import android.animation.ObjectAnimator import android.view.View class ScaleInAnimation @JvmOverloads constructor(private val mFrom: Float = DEFAULT_SCALE_FROM) : BaseAnimation { override fun getAnimators(view: View): Array { val scaleX = ObjectAnimator.ofFloat(view, "scaleX", mFrom, 1f) val scaleY = ObjectAnimator.ofFloat(view, "scaleY", mFrom, 1f) return arrayOf(scaleX, scaleY) } companion object { private const val DEFAULT_SCALE_FROM = .5f } } ================================================ FILE: app/src/main/java/io/legado/app/base/adapter/animations/SlideInBottomAnimation.kt ================================================ package io.legado.app.base.adapter.animations import android.animation.Animator import android.animation.ObjectAnimator import android.view.View class SlideInBottomAnimation : BaseAnimation { override fun getAnimators(view: View): Array = arrayOf(ObjectAnimator.ofFloat(view, "translationY", view.measuredHeight.toFloat(), 0f)) } ================================================ FILE: app/src/main/java/io/legado/app/base/adapter/animations/SlideInLeftAnimation.kt ================================================ package io.legado.app.base.adapter.animations import android.animation.Animator import android.animation.ObjectAnimator import android.view.View class SlideInLeftAnimation : BaseAnimation { override fun getAnimators(view: View): Array = arrayOf(ObjectAnimator.ofFloat(view, "translationX", -view.rootView.width.toFloat(), 0f)) } ================================================ FILE: app/src/main/java/io/legado/app/base/adapter/animations/SlideInRightAnimation.kt ================================================ package io.legado.app.base.adapter.animations import android.animation.Animator import android.animation.ObjectAnimator import android.view.View class SlideInRightAnimation : BaseAnimation { override fun getAnimators(view: View): Array = arrayOf(ObjectAnimator.ofFloat(view, "translationX", view.rootView.width.toFloat(), 0f)) } ================================================ FILE: app/src/main/java/io/legado/app/constant/AppConst.kt ================================================ package io.legado.app.constant import android.annotation.SuppressLint import android.content.pm.PackageManager import android.provider.Settings import androidx.annotation.Keep import cn.hutool.crypto.digest.DigestUtil import io.legado.app.BuildConfig import io.legado.app.help.update.AppVariant import org.apache.commons.lang3.time.FastDateFormat import splitties.init.appCtx @Suppress("ConstPropertyName") @SuppressLint("SimpleDateFormat") object AppConst { const val APP_TAG = "Legado" const val channelIdDownload = "channel_download" const val channelIdReadAloud = "channel_read_aloud" const val channelIdWeb = "channel_web" const val UA_NAME = "User-Agent" const val MAX_THREAD = 9 const val DEFAULT_WEBDAV_ID = -1L private const val OFFICIAL_SIGNATURE = "8DACBF25EC667C9B1374DB1450C1A866C2AAA1173016E80BF6AD2F06FABDDC08" private const val BETA_SIGNATURE = "93A28468B0F69E8D14C8A99AB45841CEF902BBBA3761BBFEE02E67CBA801563E" val timeFormat: FastDateFormat by lazy { FastDateFormat.getInstance("HH:mm") } val dateFormat: FastDateFormat by lazy { FastDateFormat.getInstance("yyyy/MM/dd HH:mm") } val fileNameFormat: FastDateFormat by lazy { FastDateFormat.getInstance("yy-MM-dd-HH-mm-ss") } const val imagePathKey = "imagePath" val menuViewNames = arrayOf( "com.android.internal.view.menu.ListMenuItemView", "androidx.appcompat.view.menu.ListMenuItemView" ) @SuppressLint("PrivateResource") val sysElevation = appCtx.resources .getDimension(com.google.android.material.R.dimen.design_appbar_elevation) .toInt() val androidId: String by lazy { Settings.System.getString(appCtx.contentResolver, Settings.Secure.ANDROID_ID) ?: "null" } val appInfo: AppInfo by lazy { val appInfo = AppInfo() @Suppress("DEPRECATION") appCtx.packageManager.getPackageInfo(appCtx.packageName, PackageManager.GET_ACTIVITIES) ?.let { appInfo.versionName = it.versionName!! appInfo.appVariant = when { it.packageName.contains("releaseA") -> AppVariant.BETA_RELEASEA isBeta -> AppVariant.BETA_RELEASE isOfficial -> AppVariant.OFFICIAL else -> AppVariant.UNKNOWN } if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) { appInfo.versionCode = it.longVersionCode } else { @Suppress("DEPRECATION") appInfo.versionCode = it.versionCode.toLong() } } appInfo } @Suppress("DEPRECATION") private val sha256Signature: String by lazy { val packageInfo = appCtx.packageManager.getPackageInfo(appCtx.packageName, PackageManager.GET_SIGNATURES) DigestUtil.sha256Hex(packageInfo.signatures!![0].toByteArray()).uppercase() } private val isOfficial = sha256Signature == OFFICIAL_SIGNATURE private val isBeta = sha256Signature == BETA_SIGNATURE || BuildConfig.DEBUG val charsets = arrayListOf("UTF-8", "GB2312", "GB18030", "GBK", "Unicode", "UTF-16", "UTF-16LE", "ASCII") @Keep data class AppInfo( var versionCode: Long = 0L, var versionName: String = "", var appVariant: AppVariant = AppVariant.UNKNOWN ) /** * The authority of a FileProvider defined in a element in your app's manifest. */ const val authority = BuildConfig.APPLICATION_ID + ".fileProvider" } ================================================ FILE: app/src/main/java/io/legado/app/constant/AppLog.kt ================================================ package io.legado.app.constant import android.util.Log import io.legado.app.BuildConfig import io.legado.app.help.config.AppConfig import io.legado.app.utils.LogUtils import io.legado.app.utils.toastOnUi import splitties.init.appCtx object AppLog { private val mLogs = arrayListOf>() val logs get() = mLogs.toList() @Synchronized fun put(message: String?, throwable: Throwable? = null, toast: Boolean = false) { message ?: return if (toast) { appCtx.toastOnUi(message) } if (mLogs.size > 100) { mLogs.removeLastOrNull() } if (throwable == null) { LogUtils.d("AppLog", message) } else { LogUtils.d("AppLog", "$message\n${throwable.stackTraceToString()}") } mLogs.add(0, Triple(System.currentTimeMillis(), message, throwable)) if (BuildConfig.DEBUG) { val stackTrace = Thread.currentThread().stackTrace Log.e(stackTrace[3].className, message, throwable) } } @Synchronized fun putNotSave(message: String?, throwable: Throwable? = null, toast: Boolean = false) { message ?: return if (toast) { appCtx.toastOnUi(message) } if (mLogs.size > 100) { mLogs.removeLastOrNull() } mLogs.add(0, Triple(System.currentTimeMillis(), message, throwable)) if (BuildConfig.DEBUG) { val stackTrace = Thread.currentThread().stackTrace Log.e(stackTrace[3].className, message, throwable) } } @Synchronized fun clear() { mLogs.clear() } fun putDebug(message: String?, throwable: Throwable? = null) { if (AppConfig.recordLog) { put(message, throwable) } } } ================================================ FILE: app/src/main/java/io/legado/app/constant/AppPattern.kt ================================================ package io.legado.app.constant import java.util.regex.Pattern @Suppress("RegExpRedundantEscape", "unused") object AppPattern { val JS_PATTERN: Pattern = Pattern.compile("([\\w\\W]*?)|@js:([\\w\\W]*)", Pattern.CASE_INSENSITIVE) val EXP_PATTERN: Pattern = Pattern.compile("\\{\\{([\\w\\W]*?)\\}\\}") //匹配格式化后的图片格式 val imgPattern: Pattern = Pattern.compile("]*src=['\"]([^'\"]*(?:['\"][^>]+\\})?)['\"][^>]*>") //dataURL图片类型 val dataUriRegex = Regex("^data:.*?;base64,(.*)") val nameRegex = Regex("\\s+作\\s*者.*|\\s+\\S+\\s+著") val authorRegex = Regex("^\\s*作\\s*者[::\\s]+|\\s+著") val fileNameRegex = Regex("[\\\\/:*?\"<>|.]") val fileNameRegex2 = Regex("[\\\\/:*?\"<>|]") val splitGroupRegex = Regex("[,;,;]") val titleNumPattern: Pattern = Pattern.compile("(第)(.+?)(章)") //书源调试信息中的各种符号 val debugMessageSymbolRegex = Regex("[⇒◇┌└≡]") //本地书籍支持类型 val bookFileRegex = Regex(".*\\.(txt|epub|umd|pdf|mobi|azw3|azw)", RegexOption.IGNORE_CASE) //压缩文件支持类型 val archiveFileRegex = Regex(".*\\.(zip|rar|7z)$", RegexOption.IGNORE_CASE) /** * 所有标点 */ val bdRegex = Regex("(\\p{P})+") /** * 换行 */ val rnRegex = Regex("[\\r\\n]") /** * 不发音段落判断 */ val notReadAloudRegex = Regex("^(\\s|\\p{C}|\\p{P}|\\p{Z}|\\p{S})+$") val xmlContentTypeRegex = "(application|text)/\\w*\\+?xml.*".toRegex() val semicolonRegex = ";".toRegex() val equalsRegex = "=".toRegex() val spaceRegex = "\\s+".toRegex() val regexCharRegex = "[{}()\\[\\].+*?^$\\\\|]".toRegex() val LFRegex = "\n".toRegex() } ================================================ FILE: app/src/main/java/io/legado/app/constant/BookSourceType.kt ================================================ package io.legado.app.constant import androidx.annotation.IntDef @Suppress("ConstPropertyName") object BookSourceType { const val default = 0 // 0 文本 const val audio = 1 // 1 音频 const val image = 2 // 2 图片 const val file = 3 // 3 只提供下载服务的网站 @Target(AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.SOURCE) @IntDef(default, audio, image, file) annotation class Type } ================================================ FILE: app/src/main/java/io/legado/app/constant/BookType.kt ================================================ package io.legado.app.constant import androidx.annotation.IntDef /** * 以二进制位来区分,可能一本书籍包含多个类型,每一位代表一个类型,数值为2的n次方 * 以二进制位来区分,数据库查询更高效, 数值>=8和老版本类型区分开 */ @Suppress("ConstPropertyName") object BookType { /** * 8 文本 */ const val text = 0b1000 /** * 16 更新失败 */ const val updateError = 0b10000 /** * 32 音频 */ const val audio = 0b100000 /** * 64 图片 */ const val image = 0b1000000 /** * 128 只提供下载服务的网站 */ const val webFile = 0b10000000 /** * 256 本地 */ const val local = 0b100000000 /** * 512 压缩包 表明书籍文件是从压缩包内解压来的 */ const val archive = 0b1000000000 /** * 1024 未正式加入到书架的临时阅读书籍 */ const val notShelf = 0b100_0000_0000 @Target(AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.SOURCE) @IntDef(text, updateError, audio, image, webFile, local, archive, notShelf) annotation class Type /** * 所有可以从书源转换的书籍类型 */ const val allBookType = text or image or audio or webFile const val allBookTypeLocal = text or image or audio or webFile or local /** * 本地书籍书源标志 */ const val localTag = "loc_book" /** * 书源已webDav::开头的书籍,可以从webDav更新或重新下载 */ const val webDavTag = "webDav::" } ================================================ FILE: app/src/main/java/io/legado/app/constant/EventBus.kt ================================================ package io.legado.app.constant object EventBus { const val MEDIA_BUTTON = "mediaButton" const val RECREATE = "RECREATE" const val UP_BOOKSHELF = "upBookToc" const val BOOKSHELF_REFRESH = "bookshelfRefresh" const val ALOUD_STATE = "aloud_state" const val TTS_PROGRESS = "ttsStart" const val AUDIO_DS = "audioDs" const val READ_ALOUD_DS = "readAloudDs" const val BATTERY_CHANGED = "batteryChanged" const val TIME_CHANGED = "timeChanged" const val UP_CONFIG = "upConfig" const val AUDIO_SUB_TITLE = "audioSubTitle" const val AUDIO_STATE = "audioState" const val AUDIO_PROGRESS = "audioProgress" const val AUDIO_BUFFER_PROGRESS = "audioBufferProgress" const val AUDIO_SIZE = "audioSize" const val AUDIO_SPEED = "audioSpeed" const val NOTIFY_MAIN = "notifyMain" const val WEB_SERVICE = "webService" const val UP_DOWNLOAD = "upDownload" const val UP_DOWNLOAD_STATE = "upDownloadState" const val SAVE_CONTENT = "saveContent" const val CHECK_SOURCE = "checkSource" const val CHECK_SOURCE_DONE = "checkSourceDone" const val TIP_COLOR = "tipColor" const val SOURCE_CHANGED = "sourceChanged" const val SEARCH_RESULT = "searchResult" const val UPDATE_READ_ACTION_BAR = "updateReadActionBar" const val UP_SEEK_BAR = "upSeekBar" const val READ_ALOUD_PLAY = "readAloudPlay" const val EXPORT_BOOK = "exportBook" const val UP_MANGA_CONFIG = "upMangaConfig" const val PLAY_MODE_CHANGED = "playModeChanged" } ================================================ FILE: app/src/main/java/io/legado/app/constant/IntentAction.kt ================================================ package io.legado.app.constant @Suppress("ConstPropertyName") object IntentAction { const val start = "start" const val play = "play" const val playNew = "playNew" const val stop = "stop" const val resume = "resume" const val pause = "pause" const val addTimer = "addTimer" const val setTimer = "setTimer" const val prevParagraph = "prevParagraph" const val nextParagraph = "nextParagraph" const val upTtsSpeechRate = "upTtsSpeechRate" const val upTtsProgress = "upTtsProgress" const val adjustProgress = "adjustProgress" const val adjustSpeed = "adjustSpeed" const val prev = "prev" const val next = "next" const val moveTo = "moveTo" const val init = "init" const val remove = "remove" const val stopPlay = "stopPlay" } ================================================ FILE: app/src/main/java/io/legado/app/constant/NotificationId.kt ================================================ package io.legado.app.constant /** * 通知ID不能重复,统一规划通知ID */ @Suppress("ConstPropertyName") object NotificationId { const val ReadAloudService = 101 const val AudioPlayService = 102 const val CacheBookService = 103 const val ExportBookService = 104 const val WebService = 105 const val DownloadService = 106 const val CheckSourceService = 107 const val Download = 10000 const val ExportBook = 201 } ================================================ FILE: app/src/main/java/io/legado/app/constant/PageAnim.kt ================================================ package io.legado.app.constant import androidx.annotation.IntDef @Suppress("ConstPropertyName") object PageAnim { const val coverPageAnim = 0 const val slidePageAnim = 1 const val simulationPageAnim = 2 const val scrollPageAnim = 3 const val noAnim = 4 @Target(AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.SOURCE) @IntDef(coverPageAnim, slidePageAnim, simulationPageAnim, scrollPageAnim, noAnim) annotation class Anim } ================================================ FILE: app/src/main/java/io/legado/app/constant/PreferKey.kt ================================================ package io.legado.app.constant @Suppress("ConstPropertyName") object PreferKey { const val language = "language" const val fontScale = "fontScale" const val themeMode = "themeMode" const val userAgent = "userAgent" const val showUnread = "showUnread" const val bookGroupStyle = "bookGroupStyle" const val useDefaultCover = "useDefaultCover" const val loadCoverOnlyWifi = "loadCoverOnlyWifi" const val coverShowName = "coverShowName" const val coverShowAuthor = "coverShowAuthor" const val coverShowNameN = "coverShowNameN" const val coverShowAuthorN = "coverShowAuthorN" const val remoteServerId = "remoteServerId" const val hideStatusBar = "hideStatusBar" const val clickActionTL = "clickActionTopLeft" const val clickActionTC = "clickActionTopCenter" const val clickActionTR = "clickActionTopRight" const val clickActionML = "clickActionMiddleLeft" const val clickActionMC = "clickActionMiddleCenter" const val clickActionMR = "clickActionMiddleRight" const val clickActionBL = "clickActionBottomLeft" const val clickActionBC = "clickActionBottomCenter" const val clickActionBR = "clickActionBottomRight" const val hideNavigationBar = "hideNavigationBar" const val precisionSearch = "precisionSearch" const val readAloudByPage = "readAloudByPage" const val ttsEngine = "appTtsEngine" const val ttsFollowSys = "ttsFollowSys" const val ttsSpeechRate = "ttsSpeechRate" const val prevKeys = "prevKeyCodes" const val nextKeys = "nextKeyCodes" const val showDiscovery = "showDiscovery" const val enableReview = "enableReview" const val showRss = "showRss" const val bookshelfLayout = "bookshelfLayout" const val bookshelfSort = "bookshelfSort" const val bookExportFileName = "bookExportFileName" const val bookImportFileName = "bookImportFileName" const val episodeExportFileName = "episodeExportFileName" const val recordLog = "recordLog" const val processText = "process_text" const val cleanCache = "cleanCache" const val saveTabPosition = "saveTabPosition" const val fontFolder = "fontFolder" const val backupPath = "backupUri" const val restoreIgnore = "restoreIgnore" const val threadCount = "threadCount" const val webPort = "webPort" const val keepLight = "keep_light" const val webService = "webService" const val webDavUrl = "web_dav_url" const val webDavAccount = "web_dav_account" const val webDavPassword = "web_dav_password" const val webDavDir = "webDavDir" const val enableCustomExport = "enableCustomExport" const val exportToWebDav = "webDavCacheBackup" const val exportNoChapterName = "exportNoChapterName" const val exportType = "exportType" const val exportPictureFile = "exportPictureFile" const val changeSourceCheckAuthor = "changeSourceCheckAuthor" const val changeSourceLoadToc = "changeSourceLoadToc" const val changeSourceLoadInfo = "changeSourceLoadInfo" const val changeSourceLoadWordCount = "changeSourceLoadWordCount" const val chineseConverterType = "chineseConverterType" const val launcherIcon = "launcherIcon" const val textSelectAble = "selectText" const val shareLayout = "shareLayout" const val comicStyleSelect = "comicStyleSelect" const val readStyleSelect = "readStyleSelect" const val systemTypefaces = "system_typefaces" const val readBodyToLh = "readBodyToLh" const val textFullJustify = "textFullJustify" const val textBottomJustify = "textBottomJustify" const val autoReadSpeed = "autoReadSpeed" const val barElevation = "barElevation" const val transparentStatusBar = "transparentStatusBar" const val immNavigationBar = "immNavigationBar" const val defaultCover = "defaultCover" const val defaultCoverDark = "defaultCoverDark" const val replaceEnableDefault = "replaceEnableDefault" const val showBrightnessView = "showBrightnessView" const val autoClearExpired = "autoClearExpired" const val autoChangeSource = "autoChangeSource" const val importKeepName = "importKeepName" const val importKeepGroup = "importKeepGroup" const val screenOrientation = "screenOrientation" const val syncBookProgress = "syncBookProgress" const val syncBookProgressPlus = "syncBookProgressPlus" const val cronet = "Cronet" const val antiAlias = "antiAlias" const val bitmapCacheSize = "bitmapCacheSize" const val imageRetainNum = "imageRetainNum" const val preDownloadNum = "preDownloadNum" const val mangaPreDownloadNum = "mangaPreDownloadNum" const val mangaAutoPageSpeed = "mangaAutoPageSpeed" const val mangaFooterConfig = "mangaFooterConfig" const val disableClickScroll = "disableClickScroll" const val enableMangaHorizontalScroll = "enableMangaHorizontalScroll" const val hideMangaTitle = "hideMangaTitle" const val mangaColorFilter = "mangaColorFilter" const val enableMangaEInk = "enableMangaEInk" const val mangaEInkThreshold = "mangaEInkThreshold" const val disableHorizontalPageSnap = "disableHorizontalPageSnap" const val enableMangaGray = "enableMangaGray" const val autoRefresh = "auto_refresh" const val defaultToRead = "defaultToRead" const val exportCharset = "exportCharset" const val exportUseReplace = "exportUseReplace" const val useZhLayout = "useZhLayout" const val brightness = "brightness" const val nightBrightness = "nightBrightness" const val expandTextMenu = "expandTextMenu" const val doublePageHorizontal = "doubleHorizontalPage" const val readUrlOpenInBrowser = "readUrlInBrowser" const val defaultBookTreeUri = "defaultBookTreeUri" const val checkSource = "checkSource" const val uploadRule = "uploadRule" const val tocUiUseReplace = "tocUiUseReplace" const val tocCountWords = "tocCountWords" const val enableReadRecord = "enableReadRecord" const val localBookImportSort = "localBookImportSort" const val customWelcome = "customWelcome" const val welcomeImage = "welcomeImagePath" const val welcomeImageDark = "welcomeImagePathDark" const val welcomeShowText = "welcomeShowText" const val welcomeShowTextDark = "welcomeShowTextDark" const val welcomeShowIcon = "welcomeShowIcon" const val welcomeShowIconDark = "welcomeShowIconDark" const val pageTouchSlop = "pageTouchSlop" const val showAddToShelfAlert = "showAddToShelfAlert" const val ignoreAudioFocus = "ignoreAudioFocus" const val parallelExportBook = "parallelExportBook" const val progressBarBehavior = "progressBarBehavior" const val sourceEditMaxLine = "sourceEditMaxLine" const val ttsTimer = "ttsTimer" const val noAnimScrollPage = "noAnimScrollPage" const val webDavDeviceName = "webDavDeviceName" const val webServiceWakeLock = "webServiceWakeLock" const val audioPlayWakeLock = "audioPlayWakeLock" const val readAloudWakeLock = "readAloudWakeLock" const val showLastUpdateTime = "showLastUpdateTime" const val showWaitUpCount = "showWaitUpCount" const val clearWebViewData = "clearWebViewData" const val onlyLatestBackup = "onlyLatestBackup" const val brightnessVwPos = "brightnessVwPos" const val shrinkDatabase = "shrinkDatabase" const val batchChangeSourceDelay = "batchChangeSourceDelay" const val openBookInfoByClickTitle = "openBookInfoByClickTitle" const val defaultHomePage = "defaultHomePage" const val showBookshelfFastScroller = "showBookshelfFastScroller" const val importKeepEnable = "importKeepEnable" const val previewImageByClick = "previewImageByClick" const val keyPageOnLongPress = "keyPageOnLongPress" const val volumeKeyPage = "volumeKeyPage" const val volumeKeyPageOnPlay = "volumeKeyPageOnPlay" const val mouseWheelPage = "mouseWheelPage" const val recordHeapDump = "recordHeapDump" const val optimizeRender = "optimizeRender" const val updateToVariant = "updateToVariant" const val streamReadAloudAudio = "streamReadAloudAudio" const val pauseReadAloudWhilePhoneCalls = "pauseReadAloudWhilePhoneCalls" const val readAloudByMediaButton = "readAloudByMediaButton" const val showMangaUi = "showMangaUi" const val disableMangaScale = "disableMangaScale" const val disableMangaPageAnim = "disableMangaPageAnim" const val paddingDisplayCutouts = "paddingDisplayCutouts" const val autoCheckNewBackup = "autoCheckNewBackup" const val cPrimary = "colorPrimary" const val cAccent = "colorAccent" const val cBackground = "colorBackground" const val cBBackground = "colorBottomBackground" const val bgImage = "backgroundImage" const val bgImageBlurring = "backgroundImageBlurring" const val cNPrimary = "colorPrimaryNight" const val cNAccent = "colorAccentNight" const val cNBackground = "colorBackgroundNight" const val cNBBackground = "colorBottomBackgroundNight" const val bgImageN = "backgroundImageNight" const val bgImageNBlurring = "backgroundImageNightBlurring" const val showReadTitleAddition = "showReadTitleAddition" const val readBarStyleFollowPage = "readBarStyleFollowPage" const val contentSelectSpeakMod = "contentReadAloudMod" } ================================================ FILE: app/src/main/java/io/legado/app/constant/SourceType.kt ================================================ package io.legado.app.constant import androidx.annotation.IntDef @Suppress("ConstPropertyName") object SourceType { const val book = 0 const val rss = 1 @Target(AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.SOURCE) @IntDef(book, rss) annotation class Type } ================================================ FILE: app/src/main/java/io/legado/app/constant/Status.kt ================================================ package io.legado.app.constant object Status { const val STOP = 0 const val PLAY = 1 const val PAUSE = 3 } ================================================ FILE: app/src/main/java/io/legado/app/constant/Theme.kt ================================================ package io.legado.app.constant enum class Theme { Dark, Light, Auto, Transparent, EInk; } ================================================ FILE: app/src/main/java/io/legado/app/data/AppDatabase.kt ================================================ package io.legado.app.data import android.content.ContentValues import android.database.sqlite.SQLiteDatabase import android.os.Build import android.util.Log import androidx.room.AutoMigration import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.sqlite.db.SupportSQLiteDatabase import io.legado.app.data.dao.BookChapterDao import io.legado.app.data.dao.BookDao import io.legado.app.data.dao.BookGroupDao import io.legado.app.data.dao.BookSourceDao import io.legado.app.data.dao.BookmarkDao import io.legado.app.data.dao.CacheDao import io.legado.app.data.dao.CookieDao import io.legado.app.data.dao.DictRuleDao import io.legado.app.data.dao.HttpTTSDao import io.legado.app.data.dao.KeyboardAssistsDao import io.legado.app.data.dao.ReadRecordDao import io.legado.app.data.dao.ReplaceRuleDao import io.legado.app.data.dao.RssArticleDao import io.legado.app.data.dao.RssReadRecordDao import io.legado.app.data.dao.RssSourceDao import io.legado.app.data.dao.RssStarDao import io.legado.app.data.dao.RuleSubDao import io.legado.app.data.dao.SearchBookDao import io.legado.app.data.dao.SearchKeywordDao import io.legado.app.data.dao.ServerDao import io.legado.app.data.dao.TxtTocRuleDao import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookGroup import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.Bookmark import io.legado.app.data.entities.Cache 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 import io.legado.app.data.entities.ReplaceRule import io.legado.app.data.entities.RssArticle import io.legado.app.data.entities.RssReadRecord import io.legado.app.data.entities.RssSource import io.legado.app.data.entities.RssStar import io.legado.app.data.entities.RuleSub 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.help.DefaultData import org.intellij.lang.annotations.Language import splitties.init.appCtx import java.util.Locale val appDb by lazy { Room.databaseBuilder(appCtx, AppDatabase::class.java, AppDatabase.DATABASE_NAME) .fallbackToDestructiveMigrationFrom(false, 1, 2, 3, 4, 5, 6, 7, 8, 9) .addMigrations(*DatabaseMigrations.migrations) .allowMainThreadQueries() .addCallback(AppDatabase.dbCallback) .build() } @Database( version = 75, exportSchema = true, entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class, ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class, RssSource::class, Bookmark::class, RssArticle::class, RssReadRecord::class, RssStar::class, TxtTocRule::class, ReadRecord::class, HttpTTS::class, Cache::class, RuleSub::class, DictRule::class, KeyboardAssist::class, Server::class], views = [BookSourcePart::class], autoMigrations = [ AutoMigration(from = 43, to = 44), AutoMigration(from = 44, to = 45), AutoMigration(from = 45, to = 46), AutoMigration(from = 46, to = 47), AutoMigration(from = 47, to = 48), AutoMigration(from = 48, to = 49), AutoMigration(from = 49, to = 50), AutoMigration(from = 50, to = 51), AutoMigration(from = 51, to = 52), AutoMigration(from = 52, to = 53), AutoMigration(from = 53, to = 54), AutoMigration(from = 54, to = 55, spec = DatabaseMigrations.Migration_54_55::class), AutoMigration(from = 55, to = 56), AutoMigration(from = 56, to = 57), AutoMigration(from = 57, to = 58), AutoMigration(from = 58, to = 59), AutoMigration(from = 59, to = 60), AutoMigration(from = 60, to = 61), AutoMigration(from = 61, to = 62), AutoMigration(from = 62, to = 63), AutoMigration(from = 63, to = 64), AutoMigration(from = 64, to = 65, spec = DatabaseMigrations.Migration_64_65::class), AutoMigration(from = 65, to = 66), AutoMigration(from = 66, to = 67), AutoMigration(from = 67, to = 68), AutoMigration(from = 68, to = 69), AutoMigration(from = 69, to = 70), AutoMigration(from = 70, to = 71), AutoMigration(from = 71, to = 72), AutoMigration(from = 72, to = 73), AutoMigration(from = 73, to = 74), AutoMigration(from = 74, to = 75), ] ) abstract class AppDatabase : RoomDatabase() { abstract val bookDao: BookDao abstract val bookGroupDao: BookGroupDao abstract val bookSourceDao: BookSourceDao abstract val bookChapterDao: BookChapterDao abstract val replaceRuleDao: ReplaceRuleDao abstract val searchBookDao: SearchBookDao abstract val searchKeywordDao: SearchKeywordDao abstract val rssSourceDao: RssSourceDao abstract val bookmarkDao: BookmarkDao abstract val rssArticleDao: RssArticleDao abstract val rssStarDao: RssStarDao abstract val rssReadRecordDao: RssReadRecordDao abstract val cookieDao: CookieDao abstract val txtTocRuleDao: TxtTocRuleDao abstract val readRecordDao: ReadRecordDao abstract val httpTTSDao: HttpTTSDao abstract val cacheDao: CacheDao abstract val ruleSubDao: RuleSubDao abstract val dictRuleDao: DictRuleDao abstract val keyboardAssistsDao: KeyboardAssistsDao abstract val serverDao: ServerDao companion object { const val DATABASE_NAME = "legado.db" const val BOOK_TABLE_NAME = "books" const val BOOK_SOURCE_TABLE_NAME = "book_sources" const val RSS_SOURCE_TABLE_NAME = "rssSources" val dbCallback = object : Callback() { override fun onCreate(db: SupportSQLiteDatabase) { // 只在 API 级别 23 (Marshmallow) 及以上版本尝试设置区域设置 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { try { Log.d("AppDatabaseCallback", "准备 设置 locale for API ${Build.VERSION.SDK_INT}...") db.setLocale(Locale.CHINESE) // 在 21 上报错,但无法拦截 Log.d("AppDatabaseCallback", "成功 设置 locale for API ${Build.VERSION.SDK_INT}.") } catch (e: Exception) { Log.e("AppDatabaseCallback", "错误 设置 locale in onCreate for API ${Build.VERSION.SDK_INT}", e) } } else { Log.i("AppDatabaseCallback", "跳过 setLocale for API ${Build.VERSION.SDK_INT} (below M).") } } override fun onOpen(db: SupportSQLiteDatabase) { @Language("sql") val insertBookGroupAllSql = """ insert into book_groups(groupId, groupName, 'order', show) select ${BookGroup.IdAll}, '全部', -10, 1 where not exists (select * from book_groups where groupId = ${BookGroup.IdAll}) """.trimIndent() db.execSQL(insertBookGroupAllSql) @Language("sql") val insertBookGroupLocalSql = """ insert into book_groups(groupId, groupName, 'order', enableRefresh, show) select ${BookGroup.IdLocal}, '本地', -9, 0, 1 where not exists (select * from book_groups where groupId = ${BookGroup.IdLocal}) """.trimIndent() db.execSQL(insertBookGroupLocalSql) @Language("sql") val insertBookGroupMusicSql = """ insert into book_groups(groupId, groupName, 'order', show) select ${BookGroup.IdAudio}, '音频', -8, 1 where not exists (select * from book_groups where groupId = ${BookGroup.IdAudio}) """.trimIndent() db.execSQL(insertBookGroupMusicSql) @Language("sql") val insertBookGroupNetNoneGroupSql = """ insert into book_groups(groupId, groupName, 'order', show) select ${BookGroup.IdNetNone}, '网络未分组', -7, 1 where not exists (select * from book_groups where groupId = ${BookGroup.IdNetNone}) """.trimIndent() db.execSQL(insertBookGroupNetNoneGroupSql) @Language("sql") val insertBookGroupLocalNoneGroupSql = """ insert into book_groups(groupId, groupName, 'order', show) select ${BookGroup.IdLocalNone}, '本地未分组', -6, 0 where not exists (select * from book_groups where groupId = ${BookGroup.IdLocalNone}) """.trimIndent() db.execSQL(insertBookGroupLocalNoneGroupSql) @Language("sql") val insertBookGroupErrorSql = """ insert into book_groups(groupId, groupName, 'order', show) select ${BookGroup.IdError}, '更新失败', -1, 1 where not exists (select * from book_groups where groupId = ${BookGroup.IdError}) """.trimIndent() db.execSQL(insertBookGroupErrorSql) @Language("sql") val upBookSourceLoginUiSql = "update book_sources set loginUi = null where loginUi = 'null'" db.execSQL(upBookSourceLoginUiSql) @Language("sql") val upRssSourceLoginUiSql = "update rssSources set loginUi = null where loginUi = 'null'" db.execSQL(upRssSourceLoginUiSql) @Language("sql") val upHttpTtsLoginUiSql = "update httpTTS set loginUi = null where loginUi = 'null'" db.execSQL(upHttpTtsLoginUiSql) @Language("sql") val upHttpTtsConcurrentRateSql = "update httpTTS set concurrentRate = '0' where concurrentRate is null" db.execSQL(upHttpTtsConcurrentRateSql) db.query("select * from keyboardAssists order by serialNo").use { if (it.count == 0) { DefaultData.keyboardAssists.forEach { keyboardAssist -> val contentValues = ContentValues().apply { put("type", keyboardAssist.type) put("key", keyboardAssist.key) put("value", keyboardAssist.value) put("serialNo", keyboardAssist.serialNo) } db.insert( "keyboardAssists", SQLiteDatabase.CONFLICT_REPLACE, contentValues ) } } } } } } } ================================================ FILE: app/src/main/java/io/legado/app/data/DatabaseMigrations.kt ================================================ package io.legado.app.data import androidx.room.DeleteColumn import androidx.room.migration.AutoMigrationSpec import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import io.legado.app.constant.AppConst import io.legado.app.constant.BookSourceType import io.legado.app.constant.BookType object DatabaseMigrations { val migrations: Array by lazy { arrayOf( migration_10_11, migration_11_12, migration_12_13, migration_13_14, migration_14_15, migration_15_17, migration_17_18, migration_18_19, migration_19_20, migration_20_21, migration_21_22, migration_22_23, migration_23_24, migration_24_25, migration_25_26, migration_26_27, migration_27_28, migration_28_29, migration_29_30, migration_30_31, migration_31_32, migration_32_33, migration_33_34, migration_34_35, migration_35_36, migration_36_37, migration_37_38, migration_38_39, migration_39_40, migration_40_41, migration_41_42, migration_42_43, ) } private val migration_10_11 = object : Migration(10, 11) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("DROP TABLE txtTocRules") db.execSQL( """CREATE TABLE txtTocRules(id INTEGER NOT NULL, name TEXT NOT NULL, rule TEXT NOT NULL, serialNumber INTEGER NOT NULL, enable INTEGER NOT NULL, PRIMARY KEY (id))""" ) } } private val migration_11_12 = object : Migration(11, 12) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE rssSources ADD style TEXT ") } } private val migration_12_13 = object : Migration(12, 13) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE rssSources ADD articleStyle INTEGER NOT NULL DEFAULT 0 ") } } private val migration_13_14 = object : Migration(13, 14) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL( """CREATE TABLE IF NOT EXISTS `books_new` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))""" ) db.execSQL("INSERT INTO books_new select * from books ") db.execSQL("DROP TABLE books") db.execSQL("ALTER TABLE books_new RENAME TO books") db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `books` (`name`, `author`) ") } } private val migration_14_15 = object : Migration(14, 15) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE bookmarks ADD bookAuthor TEXT NOT NULL DEFAULT ''") } } private val migration_15_17 = object : Migration(15, 17) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("CREATE TABLE IF NOT EXISTS `readRecord` (`bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`bookName`))") } } private val migration_17_18 = object : Migration(17, 18) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("CREATE TABLE IF NOT EXISTS `httpTTS` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))") } } private val migration_18_19 = object : Migration(18, 19) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL( """CREATE TABLE IF NOT EXISTS `readRecordNew` (`androidId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`androidId`, `bookName`))""" ) db.execSQL("INSERT INTO readRecordNew(androidId, bookName, readTime) select '${AppConst.androidId}' as androidId, bookName, readTime from readRecord") db.execSQL("DROP TABLE readRecord") db.execSQL("ALTER TABLE readRecordNew RENAME TO readRecord") } } private val migration_19_20 = object : Migration(19, 20) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE book_sources ADD bookSourceComment TEXT") } } private val migration_20_21 = object : Migration(20, 21) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE book_groups ADD show INTEGER NOT NULL DEFAULT 1") } } private val migration_21_22 = object : Migration(21, 22) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL( """CREATE TABLE IF NOT EXISTS `books_new` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))""" ) db.execSQL( """INSERT INTO books_new select `bookUrl`, `tocUrl`, `origin`, `originName`, `name`, `author`, `kind`, `customTag`, `coverUrl`, `customCoverUrl`, `intro`, `customIntro`, `charset`, `type`, `group`, `latestChapterTitle`, `latestChapterTime`, `lastCheckTime`, `lastCheckCount`, `totalChapterNum`, `durChapterTitle`, `durChapterIndex`, `durChapterPos`, `durChapterTime`, `wordCount`, `canUpdate`, `order`, `originOrder`, `variable`, null from books""" ) db.execSQL("DROP TABLE books") db.execSQL("ALTER TABLE books_new RENAME TO books") db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `books` (`name`, `author`) ") } } private val migration_22_23 = object : Migration(22, 23) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE chapters ADD baseUrl TEXT NOT NULL DEFAULT ''") } } private val migration_23_24 = object : Migration(23, 24) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("CREATE TABLE IF NOT EXISTS `caches` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))") db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `caches` (`key`)") } } private val migration_24_25 = object : Migration(24, 25) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL( """CREATE TABLE IF NOT EXISTS `sourceSubs` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`id`))""" ) } } private val migration_25_26 = object : Migration(25, 26) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL( """CREATE TABLE IF NOT EXISTS `ruleSubs` (`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`))""" ) db.execSQL(" insert into `ruleSubs` select *, 0, 0 from `sourceSubs` ") db.execSQL("DROP TABLE `sourceSubs`") } } private val migration_26_27 = object : Migration(26, 27) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL(" ALTER TABLE rssSources ADD singleUrl INTEGER NOT NULL DEFAULT 0 ") db.execSQL( """CREATE TABLE IF NOT EXISTS `bookmarks1` (`time` INTEGER NOT NULL, `bookUrl` TEXT 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`))""" ) db.execSQL( """insert into `bookmarks1` select `time`, `bookUrl`, `bookName`, `bookAuthor`, `chapterIndex`, `pageIndex`, `chapterName`, '', `content` from bookmarks""" ) db.execSQL(" DROP TABLE `bookmarks` ") db.execSQL(" ALTER TABLE bookmarks1 RENAME TO bookmarks ") db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `bookmarks` (`time`)") } } private val migration_27_28 = object : Migration(27, 28) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE rssArticles ADD variable TEXT") db.execSQL("ALTER TABLE rssStars ADD variable TEXT") } } private val migration_28_29 = object : Migration(28, 29) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE rssSources ADD sourceComment TEXT") } } private val migration_29_30 = object : Migration(29, 30) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE chapters ADD `startFragmentId` TEXT") db.execSQL("ALTER TABLE chapters ADD `endFragmentId` TEXT") db.execSQL( """ CREATE TABLE IF NOT EXISTS `epubChapters` (`bookUrl` TEXT NOT NULL, `href` TEXT NOT NULL, `parentHref` TEXT, PRIMARY KEY(`bookUrl`, `href`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE ) """ ) db.execSQL("CREATE INDEX IF NOT EXISTS `index_epubChapters_bookUrl` ON `epubChapters` (`bookUrl`)") db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_epubChapters_bookUrl_href` ON `epubChapters` (`bookUrl`, `href`)") } } private val migration_30_31 = object : Migration(30, 31) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE readRecord RENAME TO readRecord1") db.execSQL( """ CREATE TABLE IF NOT EXISTS `readRecord` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `bookName`)) """ ) db.execSQL("insert into readRecord (deviceId, bookName, readTime) select androidId, bookName, readTime from readRecord1") } } private val migration_31_32 = object : Migration(31, 32) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("DROP TABLE `epubChapters`") } } private val migration_32_33 = object : Migration(32, 33) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE bookmarks RENAME TO bookmarks_old") db.execSQL( """ CREATE TABLE IF NOT EXISTS `bookmarks` (`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`)) """ ) db.execSQL( """ CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `bookmarks` (`bookName`, `bookAuthor`) """ ) db.execSQL( """ insert into bookmarks (time, bookName, bookAuthor, chapterIndex, chapterPos, chapterName, bookText, content) select time, ifNull(b.name, bookName) bookName, ifNull(b.author, bookAuthor) bookAuthor, chapterIndex, chapterPos, chapterName, bookText, content from bookmarks_old o left join books b on o.bookUrl = b.bookUrl """ ) } } private val migration_33_34 = object : Migration(33, 34) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE `book_groups` ADD `cover` TEXT") } } private val migration_34_35 = object : Migration(34, 35) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE `book_sources` ADD `concurrentRate` TEXT") } } private val migration_35_36 = object : Migration(35, 36) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE `book_sources` ADD `loginUi` TEXT") db.execSQL("ALTER TABLE `book_sources` ADD`loginCheckJs` TEXT") } } private val migration_36_37 = object : Migration(36, 37) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE `rssSources` ADD `loginUrl` TEXT") db.execSQL("ALTER TABLE `rssSources` ADD `loginUi` TEXT") db.execSQL("ALTER TABLE `rssSources` ADD `loginCheckJs` TEXT") } } private val migration_37_38 = object : Migration(37, 38) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE `book_sources` ADD `respondTime` INTEGER NOT NULL DEFAULT 180000") } } private val migration_38_39 = object : Migration(38, 39) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE `rssSources` ADD `concurrentRate` TEXT") } } private val migration_39_40 = object : Migration(39, 40) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE `chapters` ADD `isVip` INTEGER NOT NULL DEFAULT 0") db.execSQL("ALTER TABLE `chapters` ADD `isPay` INTEGER NOT NULL DEFAULT 0") } } private val migration_40_41 = object : Migration(40, 41) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE `httpTTS` ADD `loginUrl` TEXT") db.execSQL("ALTER TABLE `httpTTS` ADD `loginUi` TEXT") db.execSQL("ALTER TABLE `httpTTS` ADD `loginCheckJs` TEXT") db.execSQL("ALTER TABLE `httpTTS` ADD `header` TEXT") db.execSQL("ALTER TABLE `httpTTS` ADD `concurrentRate` TEXT") } } private val migration_41_42 = object : Migration(41, 42) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE 'httpTTS' ADD `contentType` TEXT") } } private val migration_42_43 = object : Migration(42, 43) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE `chapters` ADD `isVolume` INTEGER NOT NULL DEFAULT 0") } } @Suppress("ClassName") class Migration_54_55 : AutoMigrationSpec { override fun onPostMigrate(db: SupportSQLiteDatabase) { db.execSQL( """ update books set type = ${BookType.audio} where type = ${BookSourceType.audio} """.trimIndent() ) db.execSQL( """ update books set type = ${BookType.image} where type = ${BookSourceType.image} """.trimIndent() ) db.execSQL( """ update books set type = ${BookType.webFile} where type = ${BookSourceType.file} """.trimIndent() ) db.execSQL( """ update books set type = ${BookType.text} where type = ${BookSourceType.default} """.trimIndent() ) db.execSQL( """ update books set type = type | ${BookType.local} where origin like '${BookType.localTag}%' or origin like '${BookType.webDavTag}%' """.trimIndent() ) } } @Suppress("ClassName") @DeleteColumn( tableName = "book_sources", columnName = "enabledReview" ) class Migration_64_65 : AutoMigrationSpec } ================================================ FILE: app/src/main/java/io/legado/app/data/README.md ================================================ # 存储数据用 * dao 数据操作 * entities 数据模型 * \Book 书籍信息 * \BookChapter 目录信息 * \BookGroup 书籍分组 * \Bookmark 书签 * \BookSource 书源 * \Cookie http cookie * \ReplaceRule 替换规则 * \RssArticle rss条目 * \RssReadRecord rss阅读记录 * \RssSource rss源 * \RssStar rss收藏 * \SearchBook 搜索结果 * \SearchKeyword 搜索关键字 * \TxtTocRule txt文件目录规则 ================================================ FILE: app/src/main/java/io/legado/app/data/dao/BookChapterDao.kt ================================================ package io.legado.app.data.dao 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.BookChapter @Dao interface BookChapterDao { @Query("SELECT * FROM chapters where bookUrl = :bookUrl and title like '%'||:key||'%' order by `index`") fun search(bookUrl: String, key: String): List @Query("SELECT * FROM chapters where bookUrl = :bookUrl and `index` >= :start and `index` <= :end and title like '%'||:key||'%' order by `index`") fun search(bookUrl: String, key: String, start: Int, end: Int): List @Query("select * from chapters where bookUrl = :bookUrl order by `index`") fun getChapterList(bookUrl: String): List @Query("select * from chapters where bookUrl = :bookUrl and `index` >= :start and `index` <= :end order by `index`") fun getChapterList(bookUrl: String, start: Int, end: Int): List @Query("select * from chapters where bookUrl = :bookUrl and `index` = :index") fun getChapter(bookUrl: String, index: Int): BookChapter? @Query("select * from chapters where bookUrl = :bookUrl and `title` = :title") fun getChapter(bookUrl: String, title: String): BookChapter? @Query("select count(url) from chapters where bookUrl = :bookUrl") fun getChapterCount(bookUrl: String): Int @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(vararg bookChapter: BookChapter) @Update fun update(vararg bookChapter: BookChapter) @Query("delete from chapters where bookUrl = :bookUrl") fun delByBook(bookUrl: String) @Query("update chapters set wordCount = :wordCount where bookUrl = :bookUrl and url = :url") fun upWordCount(bookUrl: String, url: String, wordCount: String) } ================================================ FILE: app/src/main/java/io/legado/app/data/dao/BookDao.kt ================================================ package io.legado.app.data.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import androidx.room.Update import io.legado.app.constant.BookType import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookGroup import io.legado.app.data.entities.BookSource import io.legado.app.help.book.isNotShelf import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @Dao interface BookDao { fun flowByGroup(groupId: Long): Flow> { return when (groupId) { BookGroup.IdRoot -> flowRoot() BookGroup.IdAll -> flowAll() BookGroup.IdLocal -> flowLocal() BookGroup.IdAudio -> flowAudio() BookGroup.IdNetNone -> flowNetNoGroup() BookGroup.IdLocalNone -> flowLocalNoGroup() BookGroup.IdError -> flowUpdateError() else -> flowByUserGroup(groupId) }.map { list -> list.filterNot { it.isNotShelf } } } @Query( """ select * from books where type & ${BookType.text} > 0 and type & ${BookType.local} = 0 and ((SELECT sum(groupId) FROM book_groups where groupId > 0) & `group`) = 0 and (select show from book_groups where groupId = ${BookGroup.IdNetNone}) != 1 """ ) fun flowRoot(): Flow> @Query("SELECT * FROM books order by durChapterTime desc") fun flowAll(): Flow> @Query("SELECT * FROM books WHERE type & ${BookType.audio} > 0") fun flowAudio(): Flow> @Query("SELECT * FROM books WHERE type & ${BookType.local} > 0") fun flowLocal(): Flow> @Query( """ select * from books where type & ${BookType.audio} = 0 and type & ${BookType.local} = 0 and ((SELECT sum(groupId) FROM book_groups where groupId > 0) & `group`) = 0 """ ) fun flowNetNoGroup(): Flow> @Query( """ select * from books where type & ${BookType.local} > 0 and ((SELECT sum(groupId) FROM book_groups where groupId > 0) & `group`) = 0 """ ) fun flowLocalNoGroup(): Flow> @Query("SELECT * FROM books WHERE (`group` & :group) > 0") fun flowByUserGroup(group: Long): Flow> @Query("SELECT * FROM books WHERE name like '%'||:key||'%' or author like '%'||:key||'%'") fun flowSearch(key: String): Flow> @Query("SELECT * FROM books where type & ${BookType.updateError} > 0 order by durChapterTime desc") fun flowUpdateError(): Flow> @Query("SELECT * FROM books WHERE (`group` & :group) > 0") fun getBooksByGroup(group: Long): List @Query("SELECT * FROM books WHERE `name` in (:names)") fun findByName(vararg names: String): List @Query("select * from books where originName = :fileName") fun getBookByFileName(fileName: String): Book? @Query("SELECT * FROM books WHERE bookUrl = :bookUrl") fun getBook(bookUrl: String): Book? @Query("SELECT * FROM books WHERE name = :name and author = :author") fun getBook(name: String, author: String): Book? @Query("""select distinct bs.* from books, book_sources bs where origin == bookSourceUrl and origin not like '${BookType.localTag}%' and origin not like '${BookType.webDavTag}%'""") fun getAllUseBookSource(): List @Query("SELECT * FROM books WHERE name = :name and origin = :origin") fun getBookByOrigin(name: String, origin: String): Book? @get:Query("select count(bookUrl) from books where (SELECT sum(groupId) FROM book_groups)") val noGroupSize: Int @get:Query("SELECT * FROM books where type & ${BookType.local} = 0") val webBooks: List @get:Query("SELECT * FROM books where type & ${BookType.local} = 0 and canUpdate = 1") val hasUpdateBooks: List @get:Query("SELECT * FROM books") val all: List @Query("SELECT * FROM books where type & :type > 0 and type & ${BookType.local} = 0") fun getByTypeOnLine(type: Int): List @get:Query("SELECT * FROM books where type & ${BookType.text} > 0 ORDER BY durChapterTime DESC limit 1") val lastReadBook: Book? @get:Query("SELECT bookUrl FROM books") val allBookUrls: List @get:Query("SELECT COUNT(*) FROM books") val allBookCount: Int @get:Query("select min(`order`) from books") val minOrder: Int @get:Query("select max(`order`) from books") val maxOrder: Int @Query("select exists(select 1 from books where bookUrl = :bookUrl)") fun has(bookUrl: String): Boolean @Query("select exists(select 1 from books where name = :name and author = :author)") fun has(name: String, author: String): Boolean @Query( """select exists(select 1 from books where type & ${BookType.local} > 0 and (originName = :fileName or (origin != '${BookType.localTag}' and origin like '%' || :fileName)))""" ) fun hasFile(fileName: String): Boolean @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(vararg book: Book) @Update fun update(vararg book: Book) @Delete fun delete(vararg book: Book) @Transaction fun replace(oldBook: Book, newBook: Book) { delete(oldBook) insert(newBook) } @Query("update books set durChapterPos = :pos where bookUrl = :bookUrl") fun upProgress(bookUrl: String, pos: Int) @Query("update books set `group` = :newGroupId where `group` = :oldGroupId") fun upGroup(oldGroupId: Long, newGroupId: Long) @Query("update books set `group` = `group` - :group where `group` & :group > 0") fun removeGroup(group: Long) @Query("delete from books where type & ${BookType.notShelf} > 0") fun deleteNotShelfBook() } ================================================ FILE: app/src/main/java/io/legado/app/data/dao/BookGroupDao.kt ================================================ package io.legado.app.data.dao import androidx.lifecycle.LiveData import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update import io.legado.app.constant.BookType import io.legado.app.data.entities.BookGroup import kotlinx.coroutines.flow.Flow @Dao interface BookGroupDao { @Query("select * from book_groups where groupId = :id") fun getByID(id: Long): BookGroup? @Query("select * from book_groups where groupName = :groupName") fun getByName(groupName: String): BookGroup? @Query("SELECT * FROM book_groups ORDER BY `order`") fun flowAll(): Flow> @get:Query( """ with const as (SELECT sum(groupId) sumGroupId FROM book_groups where groupId > 0) SELECT book_groups.* FROM book_groups join const where show > 0 and ( (groupId >= 0 and exists (select 1 from books where `group` & book_groups.groupId > 0)) or groupId = -1 or (groupId = -2 and exists (select 1 from books where type & ${BookType.local} > 0)) or (groupId = -3 and exists (select 1 from books where type & ${BookType.audio} > 0)) or (groupId = -11 and exists (select 1 from books where type & ${BookType.updateError} > 0)) or (groupId = -4 and exists ( select 1 from books where type & ${BookType.audio} = 0 and type & ${BookType.local} = 0 and const.sumGroupId & `group` = 0 ) ) or (groupId = -5 and exists ( select 1 from books where type & ${BookType.audio} = 0 and type & ${BookType.local} > 0 and const.sumGroupId & `group` = 0 ) ) ) ORDER BY `order`""" ) val show: LiveData> @Query("SELECT * FROM book_groups where groupId >= 0 ORDER BY `order`") fun flowSelect(): Flow> @get:Query("SELECT sum(groupId) FROM book_groups where groupId >= 0") val idsSum: Long @get:Query("SELECT MAX(`order`) FROM book_groups where groupId >= 0") val maxOrder: Int @get:Query("SELECT * FROM book_groups ORDER BY `order`") val all: List @get:Query("select count(*) < 64 from book_groups where groupId >= 0 or groupId == ${Long.MIN_VALUE}") val canAddGroup: Boolean @Query("update book_groups set show = 1 where groupId = :groupId") fun enableGroup(groupId: Long) @Query("select groupName from book_groups where groupId > 0 and (groupId & :id) > 0") fun getGroupNames(id: Long): List @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(vararg bookGroup: BookGroup) @Update fun update(vararg bookGroup: BookGroup) @Delete fun delete(vararg bookGroup: BookGroup) fun isInRules(id: Long): Boolean { if (id < 0) { return true } return id and (id - 1) == 0L } fun getUnusedId(): Long { var id = 1L val idsSum = idsSum while (id and idsSum != 0L) { id = id.shl(1) } return id } } ================================================ FILE: app/src/main/java/io/legado/app/data/dao/BookSourceDao.kt ================================================ package io.legado.app.data.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import androidx.room.Update import io.legado.app.constant.AppPattern import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSourcePart import io.legado.app.utils.cnCompare import io.legado.app.utils.splitNotBlank import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map @Dao interface BookSourceDao { @Query("select * from book_sources_part order by customOrder asc") fun flowAll(): Flow> @Query( """select bp.* from book_sources b join book_sources_part bp on b.bookSourceUrl = bp.bookSourceUrl where b.bookSourceName like '%' || :searchKey || '%' or b.bookSourceGroup like '%' || :searchKey || '%' or b.bookSourceUrl like '%' || :searchKey || '%' or b.bookSourceComment like '%' || :searchKey || '%' order by b.customOrder asc""" ) fun flowSearch(searchKey: String): Flow> @Query( """select * from book_sources where bookSourceName like '%' || :searchKey || '%' or bookSourceGroup like '%' || :searchKey || '%' or bookSourceUrl like '%' || :searchKey || '%' or bookSourceComment like '%' || :searchKey || '%' order by customOrder asc""" ) fun search(searchKey: String): List @Query( """select bp.* from book_sources b join book_sources_part bp on b.bookSourceUrl = bp.bookSourceUrl where b.enabled = 1 and (b.bookSourceName like '%' || :searchKey || '%' or b.bookSourceGroup like '%' || :searchKey || '%' or b.bookSourceUrl like '%' || :searchKey || '%' or b.bookSourceComment like '%' || :searchKey || '%') order by b.customOrder asc""" ) fun flowSearchEnabled(searchKey: String): Flow> @Query( """select * from book_sources_part where bookSourceGroup = :searchKey or bookSourceGroup like :searchKey || ',%' or bookSourceGroup like '%,' || :searchKey or bookSourceGroup like '%,' || :searchKey || ',%' order by customOrder asc""" ) fun flowGroupSearch(searchKey: String): Flow> @Query( """select * from book_sources where bookSourceGroup = :searchKey or bookSourceGroup like :searchKey || ',%' or bookSourceGroup like '%,' || :searchKey or bookSourceGroup like '%,' || :searchKey || ',%' order by customOrder asc""" ) fun groupSearch(searchKey: String): List @Query("select * from book_sources_part where enabled = 1 order by customOrder asc") fun flowEnabled(): Flow> @Query("select * from book_sources_part where enabled = 0 order by customOrder asc") fun flowDisabled(): Flow> @Query( """select * from book_sources_part where enabledExplore = 1 and hasExploreUrl = 1 order by customOrder asc""" ) fun flowExplore(): Flow> @Query("select * from book_sources_part where hasLoginUrl = 1 order by customOrder asc") fun flowLogin(): Flow> @Query( """select * from book_sources_part where bookSourceGroup is null or bookSourceGroup = '' or bookSourceGroup like '%未分组%' order by customOrder asc""" ) fun flowNoGroup(): Flow> @Query("select * from book_sources_part where enabledExplore = 1 order by customOrder asc") fun flowEnabledExplore(): Flow> @Query("select * from book_sources_part where enabledExplore = 0 order by customOrder asc") fun flowDisabledExplore(): Flow> @Query( """select * from book_sources_part where enabledExplore = 1 and hasExploreUrl = 1 and (bookSourceGroup like '%' || :key || '%' or bookSourceName like '%' || :key || '%') order by customOrder asc""" ) fun flowExplore(key: String): Flow> @Query( """select * from book_sources_part where enabledExplore = 1 and hasExploreUrl = 1 and (bookSourceGroup = :key or bookSourceGroup like :key || ',%' or bookSourceGroup like '%,' || :key or bookSourceGroup like '%,' || :key || ',%') order by customOrder asc""" ) fun flowGroupExplore(key: String): Flow> @Query("select distinct bookSourceGroup from book_sources where trim(bookSourceGroup) <> ''") fun flowGroupsUnProcessed(): Flow> @Query( """select distinct bookSourceGroup from book_sources where enabled = 1 and trim(bookSourceGroup) <> ''""" ) fun flowEnabledGroupsUnProcessed(): Flow> @Query( """select distinct bookSourceGroup from book_sources where enabledExplore = 1 and trim(exploreUrl) <> '' and trim(bookSourceGroup) <> '' order by customOrder""" ) fun flowExploreGroupsUnProcessed(): Flow> @Query( """select * from book_sources where bookSourceGroup like '%' || :group || '%' order by customOrder asc""" ) fun getByGroup(group: String): List @Query( """select * from book_sources where enabled = 1 and (bookSourceGroup = :group or bookSourceGroup like :group || ',%' or bookSourceGroup like '%,' || :group or bookSourceGroup like '%,' || :group || ',%') order by customOrder asc""" ) fun getEnabledByGroup(group: String): List @Query( """select * from book_sources_part where enabled = 1 and (bookSourceGroup = :group or bookSourceGroup like :group || ',%' or bookSourceGroup like '%,' || :group or bookSourceGroup like '%,' || :group || ',%') order by customOrder asc""" ) fun getEnabledPartByGroup(group: String): List @Query( """select * from book_sources where bookUrlPattern != 'NONE' and bookSourceType = :type order by customOrder asc""" ) fun getEnabledByType(type: Int): List @Query("select * from book_sources where enabled = 1 and bookSourceUrl = :baseUrl") fun getBookSourceAddBook(baseUrl: String): BookSource? @get:Query( """select bp.* from book_sources b join book_sources_part bp on b.bookSourceUrl = bp.bookSourceUrl where b.enabled = 1 and trim(b.bookUrlPattern) <> '' and trim(b.bookUrlPattern) <> 'NONE' order by b.customOrder""" ) val hasBookUrlPattern: List @get:Query("select * from book_sources where bookSourceGroup is null or bookSourceGroup = ''") val noGroup: List @get:Query("select * from book_sources order by customOrder asc") val all: List @get:Query("select * from book_sources_part order by customOrder asc") val allPart: List @get:Query("select * from book_sources where enabled = 1 order by customOrder") val allEnabled: List @get:Query("select * from book_sources_part where enabled = 1 order by customOrder asc") val allEnabledPart: List @get:Query("select * from book_sources where enabled = 0 order by customOrder") val allDisabled: List @get:Query( """select * from book_sources where bookSourceGroup is null or bookSourceGroup = '' or bookSourceGroup like '%未分组%'""" ) val allNoGroup: List @get:Query("select * from book_sources where enabledExplore = 1 order by customOrder") val allEnabledExplore: List @get:Query("select * from book_sources where enabledExplore = 0 order by customOrder") val allDisabledExplore: List @get:Query("select * from book_sources where loginUrl is not null and loginUrl != ''") val allLogin: List @get:Query( """select bp.* from book_sources b join book_sources_part bp on b.bookSourceUrl = bp.bookSourceUrl where b.enabled = 1 and b.bookSourceType = 0 order by b.customOrder""" ) val allTextEnabledPart: List @get:Query( """select distinct bookSourceGroup from book_sources where trim(bookSourceGroup) <> ''""" ) val allGroupsUnProcessed: List @get:Query( """select distinct bookSourceGroup from book_sources where enabled = 1 and trim(bookSourceGroup) <> ''""" ) val allEnabledGroupsUnProcessed: List @Query("select * from book_sources where bookSourceUrl = :key") fun getBookSource(key: String): BookSource? @Query("select * from book_sources_part where bookSourceUrl = :key") fun getBookSourcePart(key: String): BookSourcePart? @Query("select count(*) from book_sources") fun allCount(): Int @Query("SELECT EXISTS(select 1 from book_sources where bookSourceUrl = :key)") fun has(key: String): Boolean @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(vararg bookSource: BookSource) @Update fun update(vararg bookSource: BookSource) @Delete fun delete(vararg bookSource: BookSource) @Query("delete from book_sources where bookSourceUrl = :key") fun delete(key: String) @Transaction fun delete(bookSources: List) { for (bs in bookSources) { delete(bs.bookSourceUrl) } } @get:Query("select min(customOrder) from book_sources") val minOrder: Int @get:Query("select max(customOrder) from book_sources") val maxOrder: Int @get:Query( """select exists (select 1 from book_sources group by customOrder having count(customOrder) > 1)""" ) val hasDuplicateOrder: Boolean @Query("update book_sources set enabled = :enable where bookSourceUrl = :bookSourceUrl") fun enable(bookSourceUrl: String, enable: Boolean) @Transaction fun enable(enable: Boolean, bookSources: List) { for (bs in bookSources) { enable(bs.bookSourceUrl, enable) } } @Query("update book_sources set enabledExplore = :enable where bookSourceUrl = :bookSourceUrl") fun enableExplore(bookSourceUrl: String, enable: Boolean) @Transaction fun enableExplore(enable: Boolean, bookSources: List) { for (bs in bookSources) { enableExplore(bs.bookSourceUrl, enable) } } @Query( """update book_sources set customOrder = :customOrder where bookSourceUrl = :bookSourceUrl""" ) fun upOrder(bookSourceUrl: String, customOrder: Int) @Transaction fun upOrder(bookSources: List) { for (bs in bookSources) { upOrder(bs.bookSourceUrl, bs.customOrder) } } fun upOrder(bookSource: BookSourcePart) { upOrder(bookSource.bookSourceUrl, bookSource.customOrder) } @Query( """update book_sources set bookSourceGroup = :bookSourceGroup where bookSourceUrl = :bookSourceUrl""" ) fun upGroup(bookSourceUrl: String, bookSourceGroup: String) @Transaction fun upGroup(bookSources: List) { for (bs in bookSources) { bs.bookSourceGroup?.let { upGroup(bs.bookSourceUrl, it) } } } private fun dealGroups(list: List): List { val groups = linkedSetOf() list.forEach { it.splitNotBlank(AppPattern.splitGroupRegex).forEach { group -> groups.add(group) } } return groups.sortedWith { o1, o2 -> o1.cnCompare(o2) } } fun allGroups(): List = dealGroups(allGroupsUnProcessed) fun allEnabledGroups(): List = dealGroups(allEnabledGroupsUnProcessed) fun flowGroups(): Flow> { return flowGroupsUnProcessed().map { list -> dealGroups(list) }.flowOn(IO) } fun flowExploreGroups(): Flow> { return flowExploreGroupsUnProcessed().map { list -> dealGroups(list) }.flowOn(IO) } fun flowEnabledGroups(): Flow> { return flowEnabledGroupsUnProcessed().map { list -> dealGroups(list) }.flowOn(IO) } } ================================================ FILE: app/src/main/java/io/legado/app/data/dao/BookmarkDao.kt ================================================ package io.legado.app.data.dao import androidx.room.* import io.legado.app.data.entities.Bookmark import kotlinx.coroutines.flow.Flow @Dao interface BookmarkDao { @get:Query( """ select * from bookmarks order by bookName collate localized, bookAuthor collate localized, chapterIndex, chapterPos """ ) val all: List @Query("select * from bookmarks order by bookName collate localized, bookAuthor collate localized, chapterIndex, chapterPos") fun flowAll(): Flow> @Query( """select * from bookmarks where bookName = :bookName and bookAuthor = :bookAuthor order by chapterIndex""" ) fun flowByBook(bookName: String, bookAuthor: String): Flow> @Query( """SELECT * FROM bookmarks where bookName = :bookName and bookAuthor = :bookAuthor and chapterName like '%'||:key||'%' or content like '%'||:key||'%' order by chapterIndex""" ) fun flowSearch(bookName: String, bookAuthor: String, key: String): Flow> @Query( """select * from bookmarks where bookName = :bookName and bookAuthor = :bookAuthor order by chapterIndex""" ) fun getByBook(bookName: String, bookAuthor: String): List @Query( """SELECT * FROM bookmarks where bookName = :bookName and bookAuthor = :bookAuthor and chapterName like '%'||:key||'%' or content like '%'||:key||'%' order by chapterIndex""" ) fun search(bookName: String, bookAuthor: String, key: String): List @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(vararg bookmark: Bookmark) @Update fun update(bookmark: Bookmark) @Delete fun delete(vararg bookmark: Bookmark) } ================================================ FILE: app/src/main/java/io/legado/app/data/dao/CacheDao.kt ================================================ package io.legado.app.data.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import io.legado.app.data.entities.Cache @Dao interface CacheDao { @Query("select * from caches where `key` = :key") fun get(key: String): Cache? @Query("select value from caches where `key` = :key and (deadline = 0 or deadline > :now)") fun get(key: String, now: Long): String? @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(vararg cache: Cache) @Query("delete from caches where `key` = :key") fun delete(key: String) @Query( """delete from caches where `key` like 'v_' || :key || '_%' or `key` = 'userInfo_' || :key or `key` = 'loginHeader_' || :key or `key` = 'sourceVariable_' || :key""" ) fun deleteSourceVariables(key: String) @Query("delete from caches where deadline > 0 and deadline < :now") fun clearDeadline(now: Long) } ================================================ FILE: app/src/main/java/io/legado/app/data/dao/CookieDao.kt ================================================ package io.legado.app.data.dao import androidx.room.* import io.legado.app.data.entities.Cookie @Dao interface CookieDao { @Query("SELECT * FROM cookies Where url = :url") fun get(url: String): Cookie? @Query("select * from cookies where url like '%|%'") fun getOkHttpCookies(): List @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(vararg cookie: Cookie) @Update fun update(vararg cookie: Cookie) @Query("delete from cookies where url = :url") fun delete(url: String) @Query("delete from cookies where url like '%|%'") fun deleteOkHttp() } ================================================ FILE: app/src/main/java/io/legado/app/data/dao/DictRuleDao.kt ================================================ package io.legado.app.data.dao import androidx.room.* import io.legado.app.data.entities.DictRule import kotlinx.coroutines.flow.Flow @Dao interface DictRuleDao { @get:Query("select * from dictRules order by sortNumber") val all: List @get:Query("select * from dictRules where enabled = 1 order by sortNumber") val enabled: List @Query("select * from dictRules order by sortNumber") fun flowAll(): Flow> @Query("select * from dictRules where name = :name") fun getByName(name: String): DictRule? @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(vararg dictRule: DictRule) @Update fun update(vararg dictRule: DictRule) @Delete fun delete(vararg dictRule: DictRule) } ================================================ FILE: app/src/main/java/io/legado/app/data/dao/HttpTTSDao.kt ================================================ package io.legado.app.data.dao import androidx.room.* import io.legado.app.data.entities.HttpTTS import kotlinx.coroutines.flow.Flow @Dao interface HttpTTSDao { @get:Query("select * from httpTTS order by name") val all: List @Query("select * from httpTTS order by name") fun flowAll(): Flow> @get:Query("select count(*) from httpTTS") val count: Int @Query("select * from httpTTS where id = :id") fun get(id: Long): HttpTTS? @Query("select name from httpTTS where id = :id") fun getName(id: Long): String? @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(vararg httpTTS: HttpTTS) @Delete fun delete(vararg httpTTS: HttpTTS) @Update fun update(vararg httpTTS: HttpTTS) @Query("delete from httpTTS where id < 0") fun deleteDefault() } ================================================ FILE: app/src/main/java/io/legado/app/data/dao/KeyboardAssistsDao.kt ================================================ package io.legado.app.data.dao import androidx.room.* import io.legado.app.data.entities.KeyboardAssist import kotlinx.coroutines.flow.Flow @Dao interface KeyboardAssistsDao { @get:Query("select * from keyboardAssists order by serialNo") val all: List @Query("select * from keyboardAssists where type = :type order by serialNo") fun getByType(type: Int): List @get:Query("select * from keyboardAssists order by serialNo") val flowAll: Flow> @Query("select * from keyboardAssists where type = :type order by serialNo") fun flowByType(type: Int): Flow> @get:Query("select max(serialNo) from keyboardAssists order by serialNo") val maxSerialNo: Int @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(vararg keyboardAssist: KeyboardAssist) @Update fun update(vararg keyboardAssist: KeyboardAssist) @Delete fun delete(vararg keyboardAssist: KeyboardAssist) } ================================================ FILE: app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt ================================================ package io.legado.app.data.dao import androidx.room.* import io.legado.app.data.entities.ReadRecord import io.legado.app.data.entities.ReadRecordShow @Dao interface ReadRecordDao { @get:Query("select * from readRecord") val all: List @get:Query( """ select bookName, sum(readTime) as readTime, max(lastRead) as lastRead from readRecord group by bookName order by bookName collate localized""" ) val allShow: List @get:Query("select sum(readTime) from readRecord") val allTime: Long @Query( """ select bookName, sum(readTime) as readTime, max(lastRead) as lastRead from readRecord where bookName like '%' || :searchKey || '%' group by bookName order by bookName collate localized""" ) fun search(searchKey: String): List @Query("select sum(readTime) from readRecord where bookName = :bookName") fun getReadTime(bookName: String): Long? @Query("select readTime from readRecord where deviceId = :androidId and bookName = :bookName") fun getReadTime(androidId: String, bookName: String): Long? @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(vararg readRecord: ReadRecord) @Update fun update(vararg record: ReadRecord) @Delete fun delete(vararg record: ReadRecord) @Query("delete from readRecord") fun clear() @Query("delete from readRecord where bookName = :bookName") fun deleteByName(bookName: String) } ================================================ FILE: app/src/main/java/io/legado/app/data/dao/ReplaceRuleDao.kt ================================================ package io.legado.app.data.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update import io.legado.app.constant.AppPattern import io.legado.app.data.entities.ReplaceRule import io.legado.app.utils.cnCompare import io.legado.app.utils.splitNotBlank import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map @Dao interface ReplaceRuleDao { @Query("SELECT * FROM replace_rules ORDER BY sortOrder ASC") fun flowAll(): Flow> @Query("SELECT * FROM replace_rules where `group` like :key or name like :key ORDER BY sortOrder ASC") fun flowSearch(key: String): Flow> @Query("SELECT * FROM replace_rules where `group` like :key ORDER BY sortOrder ASC") fun flowGroupSearch(key: String): Flow> @Query("select `group` from replace_rules where `group` is not null and `group` <> ''") fun flowGroupsUnProcessed(): Flow> @Query("select * from replace_rules where `group` is null or trim(`group`) = '' or trim(`group`) like '%未分组%'") fun flowNoGroup(): Flow> @get:Query("SELECT MIN(sortOrder) FROM replace_rules") val minOrder: Int @get:Query("SELECT MAX(sortOrder) FROM replace_rules") val maxOrder: Int @get:Query("SELECT * FROM replace_rules ORDER BY sortOrder ASC") val all: List @get:Query("select distinct `group` from replace_rules where trim(`group`) <> ''") val allGroupsUnProcessed: List @get:Query("SELECT * FROM replace_rules WHERE isEnabled = 1 ORDER BY sortOrder ASC") val allEnabled: List @Query("SELECT * FROM replace_rules WHERE id = :id") fun findById(id: Long): ReplaceRule? @Query("SELECT * FROM replace_rules WHERE id in (:ids)") fun findByIds(vararg ids: Long): List @Query( """SELECT * FROM replace_rules WHERE isEnabled = 1 and scopeContent = 1 AND (scope LIKE '%' || :name || '%' or scope LIKE '%' || :origin || '%' or scope is null or scope = '') and (excludeScope is null or (excludeScope not LIKE '%' || :name || '%' and excludeScope not LIKE '%' || :origin || '%')) order by sortOrder""" ) fun findEnabledByContentScope(name: String, origin: String): List @Query( """SELECT * FROM replace_rules WHERE isEnabled = 1 and scopeTitle = 1 AND (scope LIKE '%' || :name || '%' or scope LIKE '%' || :origin || '%' or scope is null or scope = '') and (excludeScope is null or (excludeScope not LIKE '%' || :name || '%' and excludeScope not LIKE '%' || :origin || '%')) order by sortOrder""" ) fun findEnabledByTitleScope(name: String, origin: String): List @Query("select * from replace_rules where `group` like '%' || :group || '%'") fun getByGroup(group: String): List @get:Query("select * from replace_rules where `group` is null or `group` = ''") val noGroup: List @get:Query("SELECT COUNT(*) - SUM(isEnabled) FROM replace_rules") val summary: Int @Query("UPDATE replace_rules SET isEnabled = :enable") fun enableAll(enable: Boolean) @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(vararg replaceRule: ReplaceRule): List @Update fun update(vararg replaceRules: ReplaceRule) @Delete fun delete(vararg replaceRules: ReplaceRule) private fun dealGroups(list: List): List { val groups = linkedSetOf() list.forEach { it.splitNotBlank(AppPattern.splitGroupRegex).forEach { group -> groups.add(group) } } return groups.sortedWith { o1, o2 -> o1.cnCompare(o2) } } fun allGroups(): List = dealGroups(allGroupsUnProcessed) fun flowGroups(): Flow> { return flowGroupsUnProcessed().map { list -> dealGroups(list) }.flowOn(IO) } } ================================================ FILE: app/src/main/java/io/legado/app/data/dao/RssArticleDao.kt ================================================ package io.legado.app.data.dao import androidx.room.* 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") fun get(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 from rssArticles as t1 left join rssReadRecords as t2 on t1.link = t2.record where origin = :origin and sort = :sort order by `order` desc""" ) fun flowByOriginSort(origin: String, sort: String): Flow> @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(vararg rssArticle: RssArticle) @Insert(onConflict = OnConflictStrategy.IGNORE) fun append(vararg rssArticle: RssArticle) @Query("delete from rssArticles where origin = :origin and sort = :sort and `order` < :order") fun clearOld(origin: String, sort: String, order: Long) @Update fun update(vararg rssArticle: RssArticle) @Query("update rssArticles set origin = :origin where origin = :oldOrigin") fun updateOrigin(origin: String, oldOrigin: String) @Query("delete from rssArticles where origin = :origin") fun delete(origin: String) } ================================================ FILE: app/src/main/java/io/legado/app/data/dao/RssReadRecordDao.kt ================================================ package io.legado.app.data.dao import androidx.room.* import io.legado.app.data.entities.RssReadRecord @Dao interface RssReadRecordDao { @Insert(onConflict = OnConflictStrategy.IGNORE) fun insertRecord(vararg rssReadRecord: RssReadRecord) @Query("select * from rssReadRecords order by readTime desc") fun getRecords(): List @get:Query("select count(1) from rssReadRecords") val countRecords: Int @Query("delete from rssReadRecords") fun deleteAllRecord() } ================================================ FILE: app/src/main/java/io/legado/app/data/dao/RssSourceDao.kt ================================================ package io.legado.app.data.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update import io.legado.app.constant.AppPattern import io.legado.app.data.entities.RssSource import io.legado.app.utils.cnCompare import io.legado.app.utils.splitNotBlank import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map @Dao interface RssSourceDao { @Query("select * from rssSources where sourceUrl = :key") fun getByKey(key: String): RssSource? @Query("select * from rssSources where sourceUrl in (:sourceUrls)") fun getRssSources(vararg sourceUrls: String): List @get:Query("SELECT * FROM rssSources order by customOrder") val all: List @get:Query("select count(sourceUrl) from rssSources") val size: Int @Query("SELECT * FROM rssSources order by customOrder") fun flowAll(): Flow> @Query( """SELECT * FROM rssSources where sourceName like '%' || :key || '%' or sourceUrl like '%' || :key || '%' or sourceGroup like '%' || :key || '%' or sourceComment like '%' || :key || '%' order by customOrder""" ) fun flowSearch(key: String): Flow> @Query( """SELECT * FROM rssSources where (sourceGroup = :key or sourceGroup like :key || ',%' or sourceGroup like '%,' || :key or sourceGroup like '%,' || :key || ',%') order by customOrder""" ) fun flowGroupSearch(key: String): Flow> @Query("SELECT * FROM rssSources where enabled = 1 order by customOrder") fun flowEnabled(): Flow> @Query("SELECT * FROM rssSources where enabled = 0 order by customOrder") fun flowDisabled(): Flow> @Query("select * from rssSources where loginUrl is not null and loginUrl != ''") fun flowLogin(): Flow> @Query("select * from rssSources where sourceGroup is null or sourceGroup = '' or sourceGroup like '%未分组%'") fun flowNoGroup(): Flow> @Query( """SELECT * FROM rssSources where enabled = 1 and (sourceName like '%' || :searchKey || '%' or sourceGroup like '%' || :searchKey || '%' or sourceUrl like '%' || :searchKey || '%' or sourceComment like '%' || :searchKey || '%') order by customOrder""" ) fun flowEnabled(searchKey: String): Flow> @Query( """SELECT * FROM rssSources where enabled = 1 and (sourceGroup = :searchKey or sourceGroup like :searchKey || ',%' or sourceGroup like '%,' || :searchKey or sourceGroup like '%,' || :searchKey || ',%') order by customOrder""" ) fun flowEnabledByGroup(searchKey: String): Flow> @Query("select distinct sourceGroup from rssSources where trim(sourceGroup) <> ''") fun flowGroupsUnProcessed(): Flow> @Query("select distinct sourceGroup from rssSources where trim(sourceGroup) <> '' and enabled = 1") fun flowEnabledGroupsUnProcessed(): Flow> @get:Query("select distinct sourceGroup from rssSources where trim(sourceGroup) <> ''") val allGroupsUnProcessed: List @get:Query("select min(customOrder) from rssSources") val minOrder: Int @get:Query("select max(customOrder) from rssSources") val maxOrder: Int @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(vararg rssSource: RssSource) @Update fun update(vararg rssSource: RssSource) @Delete fun delete(vararg rssSource: RssSource) @Query("delete from rssSources where sourceUrl = :sourceUrl") fun delete(sourceUrl: String) @Query("delete from rssSources where sourceGroup like 'legado'") fun deleteDefault() @get:Query("select * from rssSources where sourceGroup is null or sourceGroup = ''") val noGroup: List @Query("select * from rssSources where sourceGroup like '%' || :group || '%'") fun getByGroup(group: String): List @Query("select exists(select 1 from rssSources where sourceUrl = :key)") fun has(key: String): Boolean @Query("update rssSources set enabled = :enable where sourceUrl = :sourceUrl") fun enable(sourceUrl: String, enable: Boolean) private fun dealGroups(list: List): List { val groups = linkedSetOf() list.forEach { it.splitNotBlank(AppPattern.splitGroupRegex).forEach { group -> groups.add(group) } } return groups.sortedWith { o1, o2 -> o1.cnCompare(o2) } } fun allGroups(): List = dealGroups(allGroupsUnProcessed) fun flowGroups(): Flow> { return flowGroupsUnProcessed().map { list -> dealGroups(list) }.flowOn(IO) } fun flowEnabledGroups(): Flow> { return flowEnabledGroupsUnProcessed().map { list -> dealGroups(list) }.flowOn(IO) } } ================================================ FILE: app/src/main/java/io/legado/app/data/dao/RssStarDao.kt ================================================ package io.legado.app.data.dao import androidx.room.* import io.legado.app.data.entities.RssStar import kotlinx.coroutines.flow.Flow @Dao interface RssStarDao { @get:Query("select * from rssStars order by starTime desc") val all: List @Query("select `group` from rssStars group by `group` order by `group`") fun flowGroups(): Flow> @Query("select * from rssStars where `group` = :group order by starTime desc") fun flowByGroup(group: String): Flow> @Query("select * from rssStars where origin = :origin and link = :link") fun get(origin: String, link: String): RssStar? @Query("select * from rssStars order by starTime desc") fun liveAll(): Flow> @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(vararg rssStar: RssStar) @Update fun update(vararg rssStar: RssStar) @Query("update rssStars set origin = :origin where origin = :oldOrigin") fun updateOrigin(origin: String, oldOrigin: String) @Query("delete from rssStars where origin = :origin") fun delete(origin: String) @Query("delete from rssStars where origin = :origin and link = :link") fun delete(origin: String, link: String) @Query("delete from rssStars where `group` = :group") fun deleteByGroup(group: String) @Query("delete from rssStars") fun deleteAll() } ================================================ FILE: app/src/main/java/io/legado/app/data/dao/RuleSubDao.kt ================================================ package io.legado.app.data.dao import androidx.room.* import io.legado.app.data.entities.RuleSub import kotlinx.coroutines.flow.Flow @Dao interface RuleSubDao { @get:Query("select * from ruleSubs order by customOrder") val all: List @Query("select * from ruleSubs order by customOrder") fun flowAll(): Flow> @get:Query("select customOrder from ruleSubs order by customOrder limit 0,1") val maxOrder: Int @Query("select * from ruleSubs where url = :url") fun findByUrl(url: String): RuleSub? @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(vararg ruleSub: RuleSub) @Delete fun delete(vararg ruleSub: RuleSub) @Update fun update(vararg ruleSub: RuleSub) } ================================================ FILE: app/src/main/java/io/legado/app/data/dao/SearchBookDao.kt ================================================ package io.legado.app.data.dao import androidx.room.* import io.legado.app.data.entities.SearchBook @Dao interface SearchBookDao { @Query("select * from searchBooks where bookUrl = :bookUrl") fun getSearchBook(bookUrl: String): SearchBook? @Query("select * from searchBooks where name = :name and author = :author and origin in (select bookSourceUrl from book_sources) order by originOrder limit 1") fun getFirstByNameAuthor(name: String, author: String): SearchBook? @Query( """select t1.name, t1.author, t1.origin, t1.originName, t1.coverUrl, t1.bookUrl, t1.type, t1.time, t1.intro, t1.kind, t1.latestChapterTitle, t1.tocUrl, t1.variable, t1.wordCount, t2.customOrder as originOrder, t1.chapterWordCountText, t1.respondTime, t1.chapterWordCount from searchBooks as t1 inner join book_sources as t2 on t1.origin = t2.bookSourceUrl where t1.name = :name and t1.author like '%'||:author||'%' and t2.enabled = 1 and t2.bookSourceGroup like '%'||:sourceGroup||'%' order by t2.customOrder""" ) fun changeSourceByGroup(name: String, author: String, sourceGroup: String): List @Query( """select t1.name, t1.author, t1.origin, t1.originName, t1.coverUrl, t1.bookUrl, t1.type, t1.time, t1.intro, t1.kind, t1.latestChapterTitle, t1.tocUrl, t1.variable, t1.wordCount, t2.customOrder as originOrder, t1.chapterWordCountText, t1.respondTime, t1.chapterWordCount from searchBooks as t1 inner join book_sources as t2 on t1.origin = t2.bookSourceUrl where t1.name = :name and t1.author like '%'||:author||'%' and t2.bookSourceGroup like '%'||:sourceGroup||'%' and (originName like '%'||:key||'%' or t1.latestChapterTitle like '%'||:key||'%') and t2.enabled = 1 order by t2.customOrder""" ) fun changeSourceSearch( name: String, author: String, key: String, sourceGroup: String ): List @Query( """ select t1.name, t1.author, t1.origin, t1.originName, t1.coverUrl, t1.bookUrl, t1.type, t1.time, t1.intro, t1.kind, t1.latestChapterTitle, t1.tocUrl, t1.variable, t1.wordCount, t2.customOrder as originOrder, t1.chapterWordCountText, t1.respondTime, t1.chapterWordCount from searchBooks as t1 inner join book_sources as t2 on t1.origin = t2.bookSourceUrl where t1.name = :name and t1.author = :author and t1.coverUrl is not null and t1.coverUrl <> '' and t2.enabled = 1 order by t2.customOrder """ ) fun getEnableHasCover(name: String, author: String): List @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(vararg searchBook: SearchBook): List @Query("delete from searchBooks where name = :name and author = :author") fun clear(name: String, author: String) @Query("delete from searchBooks where time < :time") fun clearExpired(time: Long) @Update fun update(vararg searchBook: SearchBook) @Delete fun delete(vararg searchBook: SearchBook) } ================================================ FILE: app/src/main/java/io/legado/app/data/dao/SearchKeywordDao.kt ================================================ package io.legado.app.data.dao import androidx.room.* import io.legado.app.data.entities.SearchKeyword import kotlinx.coroutines.flow.Flow @Dao interface SearchKeywordDao { @get:Query("SELECT * FROM search_keywords") val all: List @Query("SELECT * FROM search_keywords ORDER BY usage DESC") fun flowByUsage(): Flow> @Query("SELECT * FROM search_keywords ORDER BY lastUseTime DESC") fun flowByTime(): Flow> @Query("SELECT * FROM search_keywords where word like '%'||:key||'%' ORDER BY usage DESC") fun flowSearch(key: String): Flow> @Query("select * from search_keywords where word = :key") fun get(key: String): SearchKeyword? @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(vararg keywords: SearchKeyword) @Update fun update(vararg keywords: SearchKeyword) @Delete fun delete(vararg keywords: SearchKeyword) @Query("DELETE FROM search_keywords") fun deleteAll() } ================================================ FILE: app/src/main/java/io/legado/app/data/dao/ServerDao.kt ================================================ package io.legado.app.data.dao import androidx.room.* import io.legado.app.data.entities.Server import kotlinx.coroutines.flow.Flow @Dao interface ServerDao { @Query("select * from servers order by sortNumber") fun observeAll(): Flow> @get:Query("select * from servers order by sortNumber") val all: List @Query("select * from servers where id = :id") fun get(id: Long): Server? @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(vararg server: Server) @Update(onConflict = OnConflictStrategy.REPLACE) fun update(vararg server: Server) @Delete fun delete(vararg server: Server) @Query("delete from servers where id = :id") fun delete(id: Long) @Query("delete from servers where id < 0") fun deleteDefault() } ================================================ FILE: app/src/main/java/io/legado/app/data/dao/TxtTocRuleDao.kt ================================================ package io.legado.app.data.dao import androidx.room.* import io.legado.app.data.entities.TxtTocRule import kotlinx.coroutines.flow.Flow @Dao interface TxtTocRuleDao { @Query("select * from txtTocRules order by serialNumber") fun observeAll(): Flow> @get:Query("select * from txtTocRules order by serialNumber") val all: List @get:Query("select * from txtTocRules where enable = 1 order by serialNumber") val enabled: List @get:Query("select * from txtTocRules where enable != 1 order by serialNumber") val disabled: List @get:Query("select count(*) from txtTocRules") val count: Int @Query("select * from txtTocRules where id = :id") fun get(id: Long): TxtTocRule? @get:Query("select ifNull(min(serialNumber), 0) from txtTocRules") val minOrder: Int @get:Query("select ifNull(max(serialNumber), 0) from txtTocRules") val maxOrder: Int @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(vararg rule: TxtTocRule) @Update(onConflict = OnConflictStrategy.REPLACE) fun update(vararg rule: TxtTocRule) @Delete fun delete(vararg rule: TxtTocRule) @Query("delete from txtTocRules where id < 0") fun deleteDefault() } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/BaseBook.kt ================================================ package io.legado.app.data.entities import io.legado.app.help.RuleBigDataHelp import io.legado.app.model.analyzeRule.RuleDataInterface import io.legado.app.utils.GSON import io.legado.app.utils.splitNotBlank interface BaseBook : RuleDataInterface { var name: String var author: String var bookUrl: String var kind: String? var wordCount: String? var variable: String? var infoHtml: String? var tocHtml: String? override fun putVariable(key: String, value: String?): Boolean { if (super.putVariable(key, value)) { variable = GSON.toJson(variableMap) } return true } fun putCustomVariable(value: String?) { putVariable("custom", value) } fun getCustomVariable(): String { return getVariable("custom") } override fun putBigVariable(key: String, value: String?) { RuleBigDataHelp.putBookVariable(bookUrl, key, value) } override fun getBigVariable(key: String): String? { return RuleBigDataHelp.getBookVariable(bookUrl, key) } fun getKindList(): List { val kindList = arrayListOf() wordCount?.let { if (it.isNotBlank()) kindList.add(it) } kind?.let { val kinds = it.splitNotBlank(",", "\n") kindList.addAll(kinds) } return kindList } } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/BaseRssArticle.kt ================================================ package io.legado.app.data.entities import io.legado.app.help.RuleBigDataHelp import io.legado.app.model.analyzeRule.RuleDataInterface import io.legado.app.utils.GSON interface BaseRssArticle : RuleDataInterface { var origin: String var link: String var variable: String? override fun putVariable(key: String, value: String?): Boolean { if (super.putVariable(key, value)) { variable = GSON.toJson(variableMap) } return true } override fun putBigVariable(key: String, value: String?) { RuleBigDataHelp.putRssVariable(origin, link, key, value) } override fun getBigVariable(key: String): String? { return RuleBigDataHelp.getRssVariable(origin, link, key) } } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/BaseSource.kt ================================================ package io.legado.app.data.entities import cn.hutool.crypto.symmetric.AES import com.script.ScriptBindings import com.script.buildScriptBindings import com.script.rhino.RhinoScriptEngine import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog import io.legado.app.data.entities.rule.RowUi import io.legado.app.help.CacheManager import io.legado.app.help.JsExtensions import io.legado.app.help.config.AppConfig import io.legado.app.help.crypto.SymmetricCryptoAndroid import io.legado.app.help.http.CookieStore import io.legado.app.help.source.getShareScope import io.legado.app.utils.GSON import io.legado.app.utils.GSONStrict import io.legado.app.utils.fromJsonArray import io.legado.app.utils.fromJsonObject import io.legado.app.utils.has import io.legado.app.utils.printOnDebug import org.intellij.lang.annotations.Language /** * 可在js里调用,source.xxx() */ @Suppress("unused") interface BaseSource : JsExtensions { /** * 并发率 */ var concurrentRate: String? /** * 登录地址 */ var loginUrl: String? /** * 登录UI */ var loginUi: String? /** * 请求头 */ var header: String? /** * 启用cookieJar */ var enabledCookieJar: Boolean? /** * js库 */ var jsLib: String? fun getTag(): String fun getKey(): String override fun getSource(): BaseSource? { return this } fun loginUi(): List? { return GSON.fromJsonArray(loginUi).onFailure { it.printOnDebug() }.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规则 */ fun getHeaderMap(hasLoginHeader: Boolean = false) = HashMap().apply { header?.let { try { val json = when { it.startsWith("@js:", true) -> evalJS(it.substring(4)).toString() it.startsWith("", true) -> evalJS( it.substring(4, it.lastIndexOf("<")) ).toString() else -> it } GSONStrict.fromJsonObject>(json).getOrNull()?.let { map -> putAll(map) } ?: GSON.fromJsonObject>(json).getOrNull()?.let { map -> log("请求头规则 JSON 格式不规范,请改为规范格式") putAll(map) } } catch (e: Exception) { AppLog.put("执行请求头规则出错\n$e", e) } } if (!has(AppConst.UA_NAME, true)) { put(AppConst.UA_NAME, AppConfig.userAgent) } if (hasLoginHeader) { getLoginHeaderMap()?.let { putAll(it) } } } /** * 获取用于登录的头部信息 */ fun getLoginHeader(): String? { return CacheManager.get("loginHeader_${getKey()}") } fun getLoginHeaderMap(): Map? { val cache = getLoginHeader() ?: return null return GSON.fromJsonObject>(cache).getOrNull() } /** * 保存登录头部信息,map格式,访问时自动添加 */ fun putLoginHeader(header: String) { val headerMap = GSON.fromJsonObject>(header).getOrNull() val cookie = headerMap?.get("Cookie") ?: headerMap?.get("cookie") cookie?.let { CookieStore.replaceCookie(getKey(), it) } CacheManager.put("loginHeader_${getKey()}", header) } fun removeLoginHeader() { CacheManager.delete("loginHeader_${getKey()}") CookieStore.removeCookie(getKey()) } /** * 获取用户信息,可以用来登录 * 用户信息采用aes加密存储 */ fun getLoginInfo(): String? { try { val key = AppConst.androidId.encodeToByteArray(0, 16) val cache = CacheManager.get("userInfo_${getKey()}") ?: return null return AES(key).decryptStr(cache) } catch (e: Exception) { AppLog.put("获取登陆信息出错", e) return null } } fun getLoginInfoMap(): Map? { return GSON.fromJsonObject>(getLoginInfo()).getOrNull() } /** * 保存用户信息,aes加密 */ fun putLoginInfo(info: String): Boolean { return try { val key = (AppConst.androidId).encodeToByteArray(0, 16) val encodeStr = SymmetricCryptoAndroid("AES", key).encryptBase64(info) CacheManager.put("userInfo_${getKey()}", encodeStr) true } catch (e: Exception) { AppLog.put("保存登陆信息出错", e) false } } fun removeLoginInfo() { CacheManager.delete("userInfo_${getKey()}") } /** * 设置自定义变量 * @param variable 变量内容 */ fun setVariable(variable: String?) { if (variable != null) { CacheManager.put("sourceVariable_${getKey()}", variable) } else { CacheManager.delete("sourceVariable_${getKey()}") } } /** * 获取自定义变量 */ fun getVariable(): String { return CacheManager.get("sourceVariable_${getKey()}") ?: "" } /** * 保存数据 */ fun put(key: String, value: String): String { CacheManager.put("v_${getKey()}_${key}", value) return value } /** * 获取保存的数据 */ fun get(key: String): String { return CacheManager.get("v_${getKey()}_${key}") ?: "" } /** * 执行JS */ @Throws(Exception::class) fun evalJS(jsStr: String, bindingsConfig: ScriptBindings.() -> Unit = {}): Any? { val bindings = buildScriptBindings { bindings -> bindings["java"] = this bindings["source"] = this bindings["baseUrl"] = getKey() bindings["cookie"] = CookieStore bindings["cache"] = CacheManager bindings.apply(bindingsConfig) } val sharedScope = getShareScope() val scope = if (sharedScope == null) { RhinoScriptEngine.getRuntimeScope(bindings) } else { bindings.apply { prototype = sharedScope } } return RhinoScriptEngine.eval(jsStr, scope) } } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/Book.kt ================================================ package io.legado.app.data.entities import android.os.Parcelable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Ignore import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.TypeConverter import androidx.room.TypeConverters import io.legado.app.constant.AppPattern import io.legado.app.constant.BookType import io.legado.app.constant.PageAnim import io.legado.app.data.appDb import io.legado.app.help.book.BookHelp import io.legado.app.help.book.ContentProcessor import io.legado.app.help.book.getFolderNameNoCache import io.legado.app.help.book.isEpub import io.legado.app.help.book.isImage import io.legado.app.help.book.simulatedTotalChapterNum import io.legado.app.help.config.AppConfig import io.legado.app.help.config.ReadBookConfig import io.legado.app.model.ReadBook import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonObject import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import java.nio.charset.Charset import java.time.LocalDate import kotlin.math.max @Parcelize @TypeConverters(Book.Converters::class) @Entity( tableName = "books", indices = [Index(value = ["name", "author"], unique = true)] ) data class Book( // 详情页Url(本地书源存储完整文件路径) @PrimaryKey @ColumnInfo(defaultValue = "") override var bookUrl: String = "", // 目录页Url (toc=table of Contents) @ColumnInfo(defaultValue = "") var tocUrl: String = "", // 书源URL(默认BookType.local) @ColumnInfo(defaultValue = BookType.localTag) var origin: String = BookType.localTag, //书源名称 or 本地书籍文件名 @ColumnInfo(defaultValue = "") var originName: String = "", // 书籍名称(书源获取) @ColumnInfo(defaultValue = "") override var name: String = "", // 作者名称(书源获取) @ColumnInfo(defaultValue = "") override var author: String = "", // 分类信息(书源获取) override var kind: String? = null, // 分类信息(用户修改) var customTag: String? = null, // 封面Url(书源获取) var coverUrl: String? = null, // 封面Url(用户修改) var customCoverUrl: String? = null, // 简介内容(书源获取) var intro: String? = null, // 简介内容(用户修改) var customIntro: String? = null, // 自定义字符集名称(仅适用于本地书籍) var charset: String? = null, // 类型,详见BookType @ColumnInfo(defaultValue = "0") var type: Int = BookType.text, // 自定义分组索引号 @ColumnInfo(defaultValue = "0") var group: Long = 0, // 最新章节标题 var latestChapterTitle: String? = null, // 最新章节标题更新时间 @ColumnInfo(defaultValue = "0") var latestChapterTime: Long = System.currentTimeMillis(), // 最近一次更新书籍信息的时间 @ColumnInfo(defaultValue = "0") var lastCheckTime: Long = System.currentTimeMillis(), // 最近一次发现新章节的数量 @ColumnInfo(defaultValue = "0") var lastCheckCount: Int = 0, // 书籍目录总数 @ColumnInfo(defaultValue = "0") var totalChapterNum: Int = 0, // 当前章节名称 var durChapterTitle: String? = null, // 当前章节索引 @ColumnInfo(defaultValue = "0") var durChapterIndex: Int = 0, // 当前阅读的进度(首行字符的索引位置) @ColumnInfo(defaultValue = "0") var durChapterPos: Int = 0, // 最近一次阅读书籍的时间(打开正文的时间) @ColumnInfo(defaultValue = "0") var durChapterTime: Long = System.currentTimeMillis(), //字数 override var wordCount: String? = null, // 刷新书架时更新书籍信息 @ColumnInfo(defaultValue = "1") var canUpdate: Boolean = true, // 手动排序 @ColumnInfo(defaultValue = "0") var order: Int = 0, //书源排序 @ColumnInfo(defaultValue = "0") var originOrder: Int = 0, // 自定义书籍变量信息(用于书源规则检索书籍信息) override var variable: String? = null, //阅读设置 var readConfig: ReadConfig? = null, //同步时间 @ColumnInfo(defaultValue = "0") var syncTime: Long = 0L ) : Parcelable, BaseBook { override fun equals(other: Any?): Boolean { if (other is Book) { return other.bookUrl == bookUrl } return false } override fun hashCode(): Int { return bookUrl.hashCode() } @delegate:Transient @delegate:Ignore @IgnoredOnParcel override val variableMap: HashMap by lazy { GSON.fromJsonObject>(variable).getOrNull() ?: hashMapOf() } @Ignore @IgnoredOnParcel override var infoHtml: String? = null @Ignore @IgnoredOnParcel override var tocHtml: String? = null @Ignore @IgnoredOnParcel var downloadUrls: List? = null @Ignore @IgnoredOnParcel private var folderName: String? = null @get:Ignore @IgnoredOnParcel val lastChapterIndex get() = totalChapterNum - 1 fun getRealAuthor() = author.replace(AppPattern.authorRegex, "") fun getUnreadChapterNum() = max(simulatedTotalChapterNum() - durChapterIndex - 1, 0) fun getDisplayCover() = if (customCoverUrl.isNullOrEmpty()) coverUrl else customCoverUrl fun getDisplayIntro() = if (customIntro.isNullOrEmpty()) intro else customIntro //自定义简介有自动更新的需求时,可通过更新intro再调用upCustomIntro()完成 @Suppress("unused") fun upCustomIntro() { customIntro = intro } fun fileCharset(): Charset { return charset(charset ?: "UTF-8") } @IgnoredOnParcel val config: ReadConfig get() { if (readConfig == null) { readConfig = ReadConfig() } return readConfig!! } fun setReverseToc(reverseToc: Boolean) { config.reverseToc = reverseToc } fun getReverseToc(): Boolean { return config.reverseToc } fun setUseReplaceRule(useReplaceRule: Boolean) { config.useReplaceRule = useReplaceRule } fun getUseReplaceRule(): Boolean { val useReplaceRule = config.useReplaceRule if (useReplaceRule != null) { return useReplaceRule } //图片类书源 epub本地 默认关闭净化 if (isImage || isEpub) { return false } return AppConfig.replaceEnableDefault } fun setReSegment(reSegment: Boolean) { config.reSegment = reSegment } fun getReSegment(): Boolean { return config.reSegment } fun setPageAnim(pageAnim: Int?) { config.pageAnim = pageAnim } fun getPageAnim(): Int { var pageAnim = config.pageAnim ?: if (isImage) PageAnim.scrollPageAnim else ReadBookConfig.pageAnim if (pageAnim < 0) { pageAnim = ReadBookConfig.pageAnim } return pageAnim } fun setImageStyle(imageStyle: String?) { config.imageStyle = imageStyle } fun getImageStyle(): String? { return config.imageStyle } fun setTtsEngine(ttsEngine: String?) { config.ttsEngine = ttsEngine } fun getTtsEngine(): String? { return config.ttsEngine } fun setSplitLongChapter(limitLongContent: Boolean) { config.splitLongChapter = limitLongContent } fun getSplitLongChapter(): Boolean { return config.splitLongChapter } // readSimulating 的 setter 和 getter fun setReadSimulating(readSimulating: Boolean) { config.readSimulating = readSimulating } fun getReadSimulating(): Boolean { return config.readSimulating } // startDate 的 setter 和 getter fun setStartDate(startDate: LocalDate?) { config.startDate = startDate } fun getStartDate(): LocalDate? { if (!config.readSimulating || config.startDate == null) { return LocalDate.now() } return config.startDate } // startChapter 的 setter 和 getter fun setStartChapter(startChapter: Int) { config.startChapter = startChapter } fun getStartChapter(): Int { if (config.readSimulating) return config.startChapter ?: 0 return this.durChapterIndex } // dailyChapters 的 setter 和 getter fun setDailyChapters(dailyChapters: Int) { config.dailyChapters = dailyChapters } fun getDailyChapters(): Int { return config.dailyChapters } fun getDelTag(tag: Long): Boolean { return config.delTag and tag == tag } fun addDelTag(tag: Long) { config.delTag = config.delTag and tag } fun removeDelTag(tag: Long) { config.delTag = config.delTag and tag.inv() } fun getFolderName(): String { folderName?.let { return it } //防止书名过长,只取9位 folderName = getFolderNameNoCache() return folderName!! } fun toSearchBook() = SearchBook( name = name, author = author, kind = kind, bookUrl = bookUrl, origin = origin, originName = originName, type = type, wordCount = wordCount, latestChapterTitle = latestChapterTitle, coverUrl = coverUrl, intro = intro, tocUrl = tocUrl, originOrder = originOrder, variable = variable ).apply { this.infoHtml = this@Book.infoHtml this.tocHtml = this@Book.tocHtml } /** * 迁移旧的书籍的一些信息到新的书籍中 */ fun migrateTo(newBook: Book, toc: List): Book { newBook.durChapterIndex = BookHelp .getDurChapter(durChapterIndex, durChapterTitle, toc, totalChapterNum) newBook.durChapterTitle = toc[newBook.durChapterIndex].getDisplayTitle( ContentProcessor.get(newBook.name, newBook.origin).getTitleReplaceRules(), getUseReplaceRule() ) newBook.durChapterPos = durChapterPos newBook.durChapterTime = durChapterTime newBook.group = group newBook.order = order newBook.customCoverUrl = customCoverUrl newBook.customIntro = customIntro newBook.customTag = customTag newBook.canUpdate = canUpdate newBook.readConfig = readConfig return newBook } fun createBookMark(): Bookmark { return Bookmark( bookName = name, bookAuthor = author, ) } fun save() { if (appDb.bookDao.has(bookUrl)) { appDb.bookDao.update(this) } else { appDb.bookDao.insert(this) } } fun delete() { if (ReadBook.book?.bookUrl == bookUrl) { ReadBook.book = null } appDb.bookDao.delete(this) } @Suppress("ConstPropertyName") companion object { const val hTag = 2L const val rubyTag = 4L const val imgStyleDefault = "DEFAULT" const val imgStyleFull = "FULL" const val imgStyleText = "TEXT" const val imgStyleSingle = "SINGLE" } @Parcelize data class ReadConfig( var reverseToc: Boolean = false, var pageAnim: Int? = null, var reSegment: Boolean = false, var imageStyle: String? = null, var useReplaceRule: Boolean? = null,// 正文使用净化替换规则 var delTag: Long = 0L,//去除标签 var ttsEngine: String? = null, var splitLongChapter: Boolean = true, var readSimulating: Boolean = false, var startDate: LocalDate? = null, var startChapter: Int? = null, // 用户设置的起始章节 var dailyChapters: Int = 3 // 用户设置的每日更新章节数 ) : Parcelable class Converters { @TypeConverter fun readConfigToString(config: ReadConfig?): String = GSON.toJson(config) @TypeConverter fun stringToReadConfig(json: String?) = GSON.fromJsonObject(json).getOrNull() } } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/BookChapter.kt ================================================ package io.legado.app.data.entities import android.annotation.SuppressLint import android.os.Parcelable import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Ignore import androidx.room.Index import io.legado.app.constant.AppLog import io.legado.app.constant.AppPattern import io.legado.app.data.appDb import io.legado.app.exception.RegexTimeoutException import io.legado.app.help.RuleBigDataHelp import io.legado.app.help.config.AppConfig import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.model.analyzeRule.RuleDataInterface import io.legado.app.utils.ChineseUtils import io.legado.app.utils.GSON import io.legado.app.utils.MD5Utils import io.legado.app.utils.NetworkUtils import io.legado.app.utils.fromJsonObject import io.legado.app.utils.replace import io.legado.app.utils.toastOnUi import kotlinx.coroutines.CancellationException import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import splitties.init.appCtx @Parcelize @Entity( tableName = "chapters", primaryKeys = ["url", "bookUrl"], indices = [(Index(value = ["bookUrl"], unique = false)), (Index(value = ["bookUrl", "index"], unique = true))], foreignKeys = [(ForeignKey( entity = Book::class, parentColumns = ["bookUrl"], childColumns = ["bookUrl"], onDelete = ForeignKey.CASCADE ))] ) // 删除书籍时自动删除章节 data class BookChapter( var url: String = "", // 章节地址 var title: String = "", // 章节标题 var isVolume: Boolean = false, // 是否是卷名 var baseUrl: String = "", // 用来拼接相对url var bookUrl: String = "", // 书籍地址 var index: Int = 0, // 章节序号 var isVip: Boolean = false, // 是否VIP var isPay: Boolean = false, // 是否已购买 var resourceUrl: String? = null, // 音频真实URL var tag: String? = null, // 更新时间或其他章节附加信息 var wordCount: String? = null, // 本章节字数 var start: Long? = null, // 章节起始位置 var end: Long? = null, // 章节终止位置 var startFragmentId: String? = null, //EPUB书籍当前章节的fragmentId var endFragmentId: String? = null, //EPUB书籍下一章节的fragmentId var variable: String? = null //变量 ) : Parcelable, RuleDataInterface { @delegate:Transient @delegate:Ignore @IgnoredOnParcel override val variableMap: HashMap by lazy { GSON.fromJsonObject>(variable).getOrNull() ?: hashMapOf() } @Ignore @IgnoredOnParcel var titleMD5: String? = null override fun putVariable(key: String, value: String?): Boolean { if (super.putVariable(key, value)) { variable = GSON.toJson(variableMap) } return true } override fun putBigVariable(key: String, value: String?) { RuleBigDataHelp.putChapterVariable(bookUrl, url, key, value) } override fun getBigVariable(key: String): String? { return RuleBigDataHelp.getChapterVariable(bookUrl, url, key) } override fun hashCode() = url.hashCode() override fun equals(other: Any?): Boolean { if (other is BookChapter) { return other.url == url } return false } fun primaryStr(): String { return bookUrl + url } fun getDisplayTitle( replaceRules: List? = null, useReplace: Boolean = true, chineseConvert: Boolean = true, ): String { var displayTitle = title.replace(AppPattern.rnRegex, "") if (chineseConvert) { when (AppConfig.chineseConverterType) { 1 -> displayTitle = ChineseUtils.t2s(displayTitle) 2 -> displayTitle = ChineseUtils.s2t(displayTitle) } } if (useReplace && replaceRules != null) kotlin.run { replaceRules.forEach { item -> if (item.pattern.isNotEmpty()) { try { val mDisplayTitle = if (item.isRegex) { displayTitle.replace( item.regex, item.replacement, item.getValidTimeoutMillisecond() ) } else { displayTitle.replace(item.pattern, item.replacement) } if (mDisplayTitle.isNotBlank()) { displayTitle = mDisplayTitle } } catch (e: RegexTimeoutException) { item.isEnabled = false appDb.replaceRuleDao.update(item) } catch (e: CancellationException) { return@run } catch (e: Exception) { AppLog.put("${item.name}替换出错\n替换内容\n${displayTitle}", e) appCtx.toastOnUi("${item.name}替换出错") } } } } return displayTitle } fun getAbsoluteURL(): String { //二级目录解析的卷链接为空 返回目录页的链接 if (url.startsWith(title) && isVolume) return baseUrl val urlMatcher = AnalyzeUrl.paramPattern.matcher(url) val urlBefore = if (urlMatcher.find()) url.substring(0, urlMatcher.start()) else url val urlAbsoluteBefore = NetworkUtils.getAbsoluteURL(baseUrl, urlBefore) return if (urlBefore.length == url.length) { urlAbsoluteBefore } else { "$urlAbsoluteBefore," + url.substring(urlMatcher.end()) } } private fun ensureTitleMD5Init() { if (titleMD5 == null) { titleMD5 = MD5Utils.md5Encode16(title) } } @SuppressLint("DefaultLocale") @Suppress("unused") fun getFileName(suffix: String = "nb"): String { ensureTitleMD5Init() return String.format("%05d-%s.%s", index, titleMD5, suffix) } @SuppressLint("DefaultLocale") @Suppress("unused") fun getFontName(): String { ensureTitleMD5Init() return String.format("%05d-%s.ttf", index, titleMD5) } } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/BookChapterReview.kt ================================================ package io.legado.app.data.entities import android.os.Parcelable import androidx.room.ColumnInfo import kotlinx.parcelize.Parcelize @Parcelize class BookChapterReview( @ColumnInfo(defaultValue = "0") var bookId: Long = 0, var chapterId: Long = 0, var summaryUrl: String = "", ): Parcelable { } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/BookGroup.kt ================================================ package io.legado.app.data.entities import android.content.Context import android.os.Parcelable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import io.legado.app.R import io.legado.app.help.config.AppConfig import kotlinx.parcelize.Parcelize @Suppress("ConstPropertyName") @Parcelize @Entity(tableName = "book_groups") data class BookGroup( @PrimaryKey val groupId: Long = 0b1, var groupName: String = "", var cover: String? = null, var order: Int = 0, @ColumnInfo(defaultValue = "1") var enableRefresh: Boolean = true, @ColumnInfo(defaultValue = "1") var show: Boolean = true, @ColumnInfo(defaultValue = "-1") var bookSort: Int = -1 ) : Parcelable { companion object { const val IdRoot = -100L const val IdAll = -1L const val IdLocal = -2L const val IdAudio = -3L const val IdNetNone = -4L const val IdLocalNone = -5L const val IdError = -11L } fun getManageName(context: Context): String { return when (groupId) { IdAll -> "$groupName(${context.getString(R.string.all)})" IdAudio -> "$groupName(${context.getString(R.string.audio)})" IdLocal -> "$groupName(${context.getString(R.string.local)})" IdNetNone -> "$groupName(${context.getString(R.string.net_no_group)})" IdLocalNone -> "$groupName(${context.getString(R.string.local_no_group)})" IdError -> "$groupName(${context.getString(R.string.update_book_fail)})" else -> groupName } } fun getRealBookSort(): Int { if (bookSort < 0) { return AppConfig.bookshelfSort } return bookSort } override fun hashCode(): Int { return groupId.hashCode() } override fun equals(other: Any?): Boolean { if (other is BookGroup) { return other.groupId == groupId && other.groupName == groupName && other.cover == cover && other.bookSort == bookSort && other.enableRefresh == enableRefresh && other.show == show && other.order == order } return false } } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/BookProgress.kt ================================================ package io.legado.app.data.entities data class BookProgress( val name: String, val author: String, val durChapterIndex: Int, val durChapterPos: Int, val durChapterTime: Long, val durChapterTitle: String? ) { constructor(book: Book) : this( name = book.name, author = book.author, durChapterIndex = book.durChapterIndex, durChapterPos = book.durChapterPos, durChapterTime = book.durChapterTime, durChapterTitle = book.durChapterTitle ) } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/BookSource.kt ================================================ package io.legado.app.data.entities import android.os.Parcelable import android.text.TextUtils import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.TypeConverter import androidx.room.TypeConverters import io.legado.app.constant.AppPattern import io.legado.app.constant.BookSourceType import io.legado.app.data.entities.rule.BookInfoRule import io.legado.app.data.entities.rule.ContentRule import io.legado.app.data.entities.rule.ExploreRule import io.legado.app.data.entities.rule.ReviewRule import io.legado.app.data.entities.rule.SearchRule import io.legado.app.data.entities.rule.TocRule import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonObject import io.legado.app.utils.splitNotBlank import kotlinx.parcelize.Parcelize @Suppress("unused") @Parcelize @TypeConverters(BookSource.Converters::class) @Entity( tableName = "book_sources", indices = [(Index(value = ["bookSourceUrl"], unique = false))] ) data class BookSource( // 地址,包括 http/https @PrimaryKey var bookSourceUrl: String = "", // 名称 var bookSourceName: String = "", // 分组 var bookSourceGroup: String? = null, // 类型,0 文本,1 音频, 2 图片, 3 文件(指的是类似知轩藏书只提供下载的网站) @BookSourceType.Type var bookSourceType: Int = 0, // 详情页url正则 var bookUrlPattern: String? = null, // 手动排序编号 @ColumnInfo(defaultValue = "0") var customOrder: Int = 0, // 是否启用 @ColumnInfo(defaultValue = "1") var enabled: Boolean = true, // 启用发现 @ColumnInfo(defaultValue = "1") var enabledExplore: Boolean = true, // js库 override var jsLib: String? = null, // 启用okhttp CookieJAr 自动保存每次请求的cookie @ColumnInfo(defaultValue = "0") override var enabledCookieJar: Boolean? = true, // 并发率 override var concurrentRate: String? = null, // 请求头 override var header: String? = null, // 登录地址 override var loginUrl: String? = null, // 登录UI override var loginUi: String? = null, // 登录检测js var loginCheckJs: String? = null, // 封面解密js var coverDecodeJs: String? = null, // 注释 var bookSourceComment: String? = null, // 自定义变量说明 var variableComment: String? = null, // 最后更新时间,用于排序 var lastUpdateTime: Long = 0, // 响应时间,用于排序 var respondTime: Long = 180000L, // 智能排序的权重 var weight: Int = 0, // 发现url var exploreUrl: String? = null, // 发现筛选规则 var exploreScreen: String? = null, // 发现规则 var ruleExplore: ExploreRule? = null, // 搜索url var searchUrl: String? = null, // 搜索规则 var ruleSearch: SearchRule? = null, // 书籍信息页规则 var ruleBookInfo: BookInfoRule? = null, // 目录页规则 var ruleToc: TocRule? = null, // 正文页规则 var ruleContent: ContentRule? = null, // 段评规则 var ruleReview: ReviewRule? = null ) : Parcelable, BaseSource { override fun getTag(): String { return bookSourceName } override fun getKey(): String { return bookSourceUrl } override fun hashCode(): Int { return bookSourceUrl.hashCode() } override fun equals(other: Any?): Boolean { return if (other is BookSource) other.bookSourceUrl == bookSourceUrl else false } fun getSearchRule(): SearchRule { ruleSearch?.let { return it } val rule = SearchRule() ruleSearch = rule return rule } fun getExploreRule(): ExploreRule { ruleExplore?.let { return it } val rule = ExploreRule() ruleExplore = rule return rule } fun getBookInfoRule(): BookInfoRule { ruleBookInfo?.let { return it } val rule = BookInfoRule() ruleBookInfo = rule return rule } fun getTocRule(): TocRule { ruleToc?.let { return it } val rule = TocRule() ruleToc = rule return rule } fun getContentRule(): ContentRule { ruleContent?.let { return it } val rule = ContentRule() ruleContent = rule return rule } // fun getReviewRule(): ReviewRule { // ruleReview?.let { return it } // val rule = ReviewRule() // ruleReview = rule // return rule // } fun getDisPlayNameGroup(): String { return if (bookSourceGroup.isNullOrBlank()) { bookSourceName } else { String.format("%s (%s)", bookSourceName, bookSourceGroup) } } fun addGroup(groups: String): BookSource { bookSourceGroup?.splitNotBlank(AppPattern.splitGroupRegex)?.toHashSet()?.let { it.addAll(groups.splitNotBlank(AppPattern.splitGroupRegex)) bookSourceGroup = TextUtils.join(",", it) } if (bookSourceGroup.isNullOrBlank()) bookSourceGroup = groups return this } fun removeGroup(groups: String): BookSource { bookSourceGroup?.splitNotBlank(AppPattern.splitGroupRegex)?.toHashSet()?.let { it.removeAll(groups.splitNotBlank(AppPattern.splitGroupRegex).toSet()) bookSourceGroup = TextUtils.join(",", it) } return this } fun hasGroup(group: String): Boolean { bookSourceGroup?.splitNotBlank(AppPattern.splitGroupRegex)?.toHashSet()?.let { return it.indexOf(group) != -1 } return false } fun removeInvalidGroups() { removeGroup(getInvalidGroupNames()) } fun removeErrorComment() { bookSourceComment = bookSourceComment ?.split("\n\n") ?.filterNot { it.startsWith("// Error: ") }?.joinToString("\n") } fun addErrorComment(e: Throwable) { bookSourceComment = "// Error: ${e.localizedMessage}" + if (bookSourceComment.isNullOrBlank()) "" else "\n\n${bookSourceComment}" } fun getCheckKeyword(default: String): String { ruleSearch?.checkKeyWord?.let { if (it.isNotBlank()) { return it } } return default } fun getInvalidGroupNames(): String { return bookSourceGroup?.splitNotBlank(AppPattern.splitGroupRegex)?.toHashSet()?.filter { "失效" in it || it == "校验超时" }?.joinToString() ?: "" } fun getDisplayVariableComment(otherComment: String): String { return if (variableComment.isNullOrBlank()) { otherComment } else { "${variableComment}\n$otherComment" } } fun equal(source: BookSource): Boolean { return equal(bookSourceName, source.bookSourceName) && equal(bookSourceUrl, source.bookSourceUrl) && equal(bookSourceGroup, source.bookSourceGroup) && bookSourceType == source.bookSourceType && equal(bookUrlPattern, source.bookUrlPattern) && equal(bookSourceComment, source.bookSourceComment) && customOrder == source.customOrder && enabled == source.enabled && enabledExplore == source.enabledExplore && enabledCookieJar == source.enabledCookieJar && equal(variableComment, source.variableComment) && equal(concurrentRate, source.concurrentRate) && equal(jsLib, source.jsLib) && equal(header, source.header) && equal(loginUrl, source.loginUrl) && equal(loginUi, source.loginUi) && equal(loginCheckJs, source.loginCheckJs) && equal(coverDecodeJs, source.coverDecodeJs) && equal(exploreUrl, source.exploreUrl) && equal(searchUrl, source.searchUrl) && getSearchRule() == source.getSearchRule() && getExploreRule() == source.getExploreRule() && getBookInfoRule() == source.getBookInfoRule() && getTocRule() == source.getTocRule() && getContentRule() == source.getContentRule() } private fun equal(a: String?, b: String?) = a == b || (a.isNullOrEmpty() && b.isNullOrEmpty()) class Converters { @TypeConverter fun exploreRuleToString(exploreRule: ExploreRule?): String = GSON.toJson(exploreRule) @TypeConverter fun stringToExploreRule(json: String?) = GSON.fromJsonObject(json).getOrNull() @TypeConverter fun searchRuleToString(searchRule: SearchRule?): String = GSON.toJson(searchRule) @TypeConverter fun stringToSearchRule(json: String?) = GSON.fromJsonObject(json).getOrNull() @TypeConverter fun bookInfoRuleToString(bookInfoRule: BookInfoRule?): String = GSON.toJson(bookInfoRule) @TypeConverter fun stringToBookInfoRule(json: String?) = GSON.fromJsonObject(json).getOrNull() @TypeConverter fun tocRuleToString(tocRule: TocRule?): String = GSON.toJson(tocRule) @TypeConverter fun stringToTocRule(json: String?) = GSON.fromJsonObject(json).getOrNull() @TypeConverter fun contentRuleToString(contentRule: ContentRule?): String = GSON.toJson(contentRule) @TypeConverter fun stringToContentRule(json: String?) = GSON.fromJsonObject(json).getOrNull() @TypeConverter fun stringToReviewRule(json: String?): ReviewRule? = null @TypeConverter fun reviewRuleToString(reviewRule: ReviewRule?): String = "null" } } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/BookSourcePart.kt ================================================ package io.legado.app.data.entities import android.text.TextUtils import androidx.room.DatabaseView import io.legado.app.constant.AppPattern import io.legado.app.data.appDb import io.legado.app.utils.splitNotBlank @DatabaseView( """select bookSourceUrl, bookSourceName, bookSourceGroup, customOrder, enabled, enabledExplore, (loginUrl is not null and trim(loginUrl) <> '') hasLoginUrl, lastUpdateTime, respondTime, weight, (exploreUrl is not null and trim(exploreUrl) <> '') hasExploreUrl from book_sources""", viewName = "book_sources_part" ) data class BookSourcePart( // 地址,包括 http/https var bookSourceUrl: String = "", // 名称 var bookSourceName: String = "", // 分组 var bookSourceGroup: String? = null, // 手动排序编号 var customOrder: Int = 0, // 是否启用 var enabled: Boolean = true, // 启用发现 var enabledExplore: Boolean = true, // 是否有登录地址 var hasLoginUrl: Boolean = false, // 最后更新时间,用于排序 var lastUpdateTime: Long = 0, // 响应时间,用于排序 var respondTime: Long = 180000L, // 智能排序的权重 var weight: Int = 0, // 是否有发现url var hasExploreUrl: Boolean = false ) { override fun hashCode(): Int { return bookSourceUrl.hashCode() } override fun equals(other: Any?): Boolean { return if (other is BookSourcePart) other.bookSourceUrl == bookSourceUrl else false } fun getDisPlayNameGroup(): String { return if (bookSourceGroup.isNullOrBlank()) { bookSourceName } else { String.format("%s (%s)", bookSourceName, bookSourceGroup) } } fun getBookSource(): BookSource? { return appDb.bookSourceDao.getBookSource(bookSourceUrl) } fun addGroup(groups: String) { bookSourceGroup?.splitNotBlank(AppPattern.splitGroupRegex)?.toHashSet()?.let { it.addAll(groups.splitNotBlank(AppPattern.splitGroupRegex)) bookSourceGroup = TextUtils.join(",", it) } if (bookSourceGroup.isNullOrBlank()) bookSourceGroup = groups } fun removeGroup(groups: String) { bookSourceGroup?.splitNotBlank(AppPattern.splitGroupRegex)?.toHashSet()?.let { it.removeAll(groups.splitNotBlank(AppPattern.splitGroupRegex).toSet()) bookSourceGroup = TextUtils.join(",", it) } } } fun List.toBookSource(): List { return mapNotNull { it.getBookSource() } } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/Bookmark.kt ================================================ package io.legado.app.data.entities import android.os.Parcelable import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey import kotlinx.parcelize.Parcelize @Parcelize @Entity( tableName = "bookmarks", indices = [(Index(value = ["bookName", "bookAuthor"], unique = false))] ) data class Bookmark( @PrimaryKey val time: Long = System.currentTimeMillis(), val bookName: String = "", val bookAuthor: String = "", var chapterIndex: Int = 0, var chapterPos: Int = 0, var chapterName: String = "", var bookText: String = "", var content: String = "" ) : Parcelable ================================================ FILE: app/src/main/java/io/legado/app/data/entities/Cache.kt ================================================ package io.legado.app.data.entities import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey @Entity(tableName = "caches", indices = [(Index(value = ["key"], unique = true))]) data class Cache( @PrimaryKey val key: String = "", var value: String? = null, var deadline: Long = 0L ) ================================================ FILE: app/src/main/java/io/legado/app/data/entities/Cookie.kt ================================================ package io.legado.app.data.entities import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey @Entity(tableName = "cookies", indices = [(Index(value = ["url"], unique = true))]) data class Cookie( @PrimaryKey var url: String = "", var cookie: String = "" ) ================================================ FILE: app/src/main/java/io/legado/app/data/entities/DictRule.kt ================================================ package io.legado.app.data.entities import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import io.legado.app.model.analyzeRule.AnalyzeRule import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext import io.legado.app.model.analyzeRule.AnalyzeUrl import kotlin.coroutines.coroutineContext /** * 字典规则 */ @Entity(tableName = "dictRules") data class DictRule( @PrimaryKey var name: String = "", var urlRule: String = "", var showRule: String = "", @ColumnInfo(defaultValue = "1") var enabled: Boolean = true, @ColumnInfo(defaultValue = "0") var sortNumber: Int = 0 ) { override fun hashCode(): Int { return name.hashCode() } override fun equals(other: Any?): Boolean { if (other is DictRule) { return name == other.name } return false } /** * 搜索字典 */ suspend fun search(word: String): String { val analyzeUrl = AnalyzeUrl(urlRule, key = word, coroutineContext = coroutineContext) val body = analyzeUrl.getStrResponseAwait().body if (showRule.isBlank()) { return body!! } val analyzeRule = AnalyzeRule().setCoroutineContext(coroutineContext) return analyzeRule.getString(showRule, mContent = body) } } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/HttpTTS.kt ================================================ package io.legado.app.data.entities import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import com.jayway.jsonpath.DocumentContext import io.legado.app.utils.GSON import io.legado.app.utils.jsonPath import io.legado.app.utils.readLong import io.legado.app.utils.readString /** * 在线朗读引擎 */ @Entity(tableName = "httpTTS") data class HttpTTS( @PrimaryKey val id: Long = System.currentTimeMillis(), var name: String = "", var url: String = "", var contentType: String? = null, @ColumnInfo(defaultValue = "0") override var concurrentRate: String? = "0", override var loginUrl: String? = null, override var loginUi: String? = null, override var header: String? = null, override var jsLib: String? = null, @ColumnInfo(defaultValue = "0") override var enabledCookieJar: Boolean? = false, var loginCheckJs: String? = null, @ColumnInfo(defaultValue = "0") var lastUpdateTime: Long = System.currentTimeMillis() ) : BaseSource { override fun getTag(): String { return name } override fun getKey(): String { return "httpTts:$id" } @Suppress("MemberVisibilityCanBePrivate") companion object { fun fromJsonDoc(doc: DocumentContext): Result { return kotlin.runCatching { val loginUi = doc.read("$.loginUi") HttpTTS( id = doc.readLong("$.id") ?: System.currentTimeMillis(), name = doc.readString("$.name")!!, url = doc.readString("$.url")!!, contentType = doc.readString("$.contentType"), concurrentRate = doc.readString("$.concurrentRate"), loginUrl = doc.readString("$.loginUrl"), loginUi = if (loginUi is List<*>) GSON.toJson(loginUi) else loginUi?.toString(), header = doc.readString("$.header"), loginCheckJs = doc.readString("$.loginCheckJs"), lastUpdateTime = doc.readLong("$.lastUpdateTime") ?: System.currentTimeMillis() ) } } fun fromJson(json: String): Result { return fromJsonDoc(jsonPath.parse(json)) } fun fromJsonArray(jsonArray: String): Result> { return kotlin.runCatching { val sources = arrayListOf() val doc = jsonPath.parse(jsonArray).read>("$") doc.forEach { val jsonItem = jsonPath.parse(it) fromJsonDoc(jsonItem).getOrThrow().let { source -> sources.add(source) } } return@runCatching sources } } } } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/KeyboardAssist.kt ================================================ package io.legado.app.data.entities import android.os.Parcelable import androidx.room.ColumnInfo import androidx.room.Entity import kotlinx.parcelize.Parcelize @Parcelize @Entity(tableName = "keyboardAssists", primaryKeys = ["type", "key"]) data class KeyboardAssist( @ColumnInfo(defaultValue = "0") var type: Int = 0, @ColumnInfo(defaultValue = "") var key: String, @ColumnInfo(defaultValue = "") var value: String, @ColumnInfo(defaultValue = "0") var serialNo: Int = 0 ) : Parcelable ================================================ FILE: app/src/main/java/io/legado/app/data/entities/ReadRecord.kt ================================================ package io.legado.app.data.entities import androidx.room.ColumnInfo import androidx.room.Entity @Entity(tableName = "readRecord", primaryKeys = ["deviceId", "bookName"]) data class ReadRecord( var deviceId: String = "", var bookName: String = "", @ColumnInfo(defaultValue = "0") var readTime: Long = 0L, @ColumnInfo(defaultValue = "0") var lastRead: Long = System.currentTimeMillis() ) ================================================ FILE: app/src/main/java/io/legado/app/data/entities/ReadRecordShow.kt ================================================ package io.legado.app.data.entities data class ReadRecordShow( var bookName: String, var readTime: Long, var lastRead: Long ) ================================================ FILE: app/src/main/java/io/legado/app/data/entities/ReplaceRule.kt ================================================ package io.legado.app.data.entities import android.os.Parcelable import android.text.TextUtils import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Ignore import androidx.room.Index import androidx.room.PrimaryKey import io.legado.app.R import io.legado.app.constant.AppLog import io.legado.app.exception.NoStackTraceException import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import splitties.init.appCtx import java.util.regex.Pattern import java.util.regex.PatternSyntaxException @Parcelize @Entity( tableName = "replace_rules", indices = [(Index(value = ["id"]))] ) data class ReplaceRule( @PrimaryKey(autoGenerate = true) var id: Long = System.currentTimeMillis(), //名称 @ColumnInfo(defaultValue = "") var name: String = "", //分组 var group: String? = null, //替换内容 @ColumnInfo(defaultValue = "") var pattern: String = "", //替换为 @ColumnInfo(defaultValue = "") var replacement: String = "", //作用范围 var scope: String? = null, //作用于标题 @ColumnInfo(defaultValue = "0") var scopeTitle: Boolean = false, //作用于正文 @ColumnInfo(defaultValue = "1") var scopeContent: Boolean = true, //排除范围 var excludeScope: String? = null, //是否启用 @ColumnInfo(defaultValue = "1") var isEnabled: Boolean = true, //是否正则 @ColumnInfo(defaultValue = "1") var isRegex: Boolean = true, //超时时间 @ColumnInfo(defaultValue = "3000") var timeoutMillisecond: Long = 3000L, //排序 @ColumnInfo(name = "sortOrder", defaultValue = "0") var order: Int = Int.MIN_VALUE ) : Parcelable { override fun equals(other: Any?): Boolean { if (other is ReplaceRule) { return other.id == id } return super.equals(other) } override fun hashCode(): Int { return id.hashCode() } @delegate:Transient @delegate:Ignore @IgnoredOnParcel val regex: Regex by lazy { pattern.toRegex() } fun getDisplayNameGroup(): String { return if (group.isNullOrBlank()) { name } else { String.format("%s (%s)", name, group) } } fun isValid(): Boolean { if (TextUtils.isEmpty(pattern)) { return false } //判断正则表达式是否正确 if (isRegex) { try { Pattern.compile(pattern) } catch (ex: PatternSyntaxException) { AppLog.put("正则语法错误或不支持:${ex.localizedMessage}", ex) return false } // Pattern.compile测试通过,但是部分情况下会替换超时,报错,一般发生在修改表达式时漏删了 if (pattern.endsWith('|') && !pattern.endsWith("\\|")) { return false } } return true } @Throws(NoStackTraceException::class) fun checkValid() { if (!isValid()) { throw NoStackTraceException(appCtx.getString(R.string.replace_rule_invalid)) } } fun getValidTimeoutMillisecond(): Long { if (timeoutMillisecond <= 0) { return 3000L } return timeoutMillisecond } } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/RssArticle.kt ================================================ package io.legado.app.data.entities import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Ignore import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonObject import kotlinx.parcelize.IgnoredOnParcel @Entity( tableName = "rssArticles", primaryKeys = ["origin", "link"] ) data class RssArticle( override var origin: String = "", var sort: String = "", var title: String = "", var order: Long = 0, override var link: String = "", var pubDate: String? = null, var description: String? = null, var content: String? = null, var image: String? = null, @ColumnInfo(defaultValue = "默认分组") var group: String = "默认分组", var read: Boolean = false, override var variable: String? = null ) : 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 } @delegate:Transient @delegate:Ignore @IgnoredOnParcel override val variableMap: HashMap by lazy { GSON.fromJsonObject>(variable).getOrNull() ?: hashMapOf() } fun toStar() = RssStar( origin = origin, sort = sort, title = title, starTime = System.currentTimeMillis(), link = link, pubDate = pubDate, description = description, content = content, image = image, group = group, variable = variable ) } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/RssReadRecord.kt ================================================ package io.legado.app.data.entities import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "rssReadRecords") data class RssReadRecord( @PrimaryKey val record: String, val title: String? = null, val readTime: Long? = null, val read: Boolean = true ) ================================================ FILE: app/src/main/java/io/legado/app/data/entities/RssSource.kt ================================================ package io.legado.app.data.entities import android.os.Parcelable import android.text.TextUtils import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey import io.legado.app.constant.AppPattern import io.legado.app.utils.splitNotBlank import kotlinx.parcelize.Parcelize @Parcelize @Entity(tableName = "rssSources", indices = [(Index(value = ["sourceUrl"], unique = false))]) data class RssSource( @PrimaryKey var sourceUrl: String = "", // 名称 var sourceName: String = "", // 图标 var sourceIcon: String = "", // 分组 var sourceGroup: String? = null, // 注释 var sourceComment: String? = null, // 是否启用 var enabled: Boolean = true, // 自定义变量说明 var variableComment: String? = null, // js库 override var jsLib: String? = null, // 启用okhttp CookieJAr 自动保存每次请求的cookie @ColumnInfo(defaultValue = "0") override var enabledCookieJar: Boolean? = true, /**并发率**/ override var concurrentRate: String? = null, /**请求头**/ override var header: String? = null, /**登录地址**/ override var loginUrl: String? = null, /**登录Ui**/ override var loginUi: String? = null, /**登录检测js**/ var loginCheckJs: String? = null, /**封面解密js**/ var coverDecodeJs: String? = null, /**分类Url**/ var sortUrl: String? = null, /**是否单url源**/ var singleUrl: Boolean = false, /*列表规则*/ /**列表样式,0,1,2**/ @ColumnInfo(defaultValue = "0") var articleStyle: Int = 0, /**列表规则**/ var ruleArticles: String? = null, /**下一页规则**/ var ruleNextPage: String? = null, /**标题规则**/ var ruleTitle: String? = null, /**发布日期规则**/ var rulePubDate: String? = null, /*webView规则*/ /**描述规则**/ var ruleDescription: String? = null, /**图片规则**/ var ruleImage: String? = null, /**链接规则**/ var ruleLink: String? = null, /**正文规则**/ var ruleContent: String? = null, /**正文url白名单**/ var contentWhitelist: String? = null, /**正文url黑名单**/ var contentBlacklist: String? = null, /** * 跳转url拦截, * js, 返回true拦截,js变量url,可以通过js打开url,比如调用阅读搜索,添加书架等,简化规则写法,不用webView js注入 * **/ var shouldOverrideUrlLoading: String? = null, /**webView样式**/ var style: String? = null, @ColumnInfo(defaultValue = "1") var enableJs: Boolean = true, @ColumnInfo(defaultValue = "1") var loadWithBaseUrl: Boolean = true, /**注入js**/ var injectJs: String? = null, /*其它规则*/ /**最后更新时间,用于排序**/ @ColumnInfo(defaultValue = "0") var lastUpdateTime: Long = 0, @ColumnInfo(defaultValue = "0") var customOrder: Int = 0 ) : Parcelable, BaseSource { override fun getTag(): String { return sourceName } override fun getKey(): String { return sourceUrl } override fun equals(other: Any?): Boolean { if (other is RssSource) { return other.sourceUrl == sourceUrl } return false } override fun hashCode() = sourceUrl.hashCode() fun equal(source: RssSource): Boolean { return equal(sourceUrl, source.sourceUrl) && equal(sourceName, source.sourceName) && equal(sourceIcon, source.sourceIcon) && enabled == source.enabled && equal(sourceGroup, source.sourceGroup) && enabledCookieJar == source.enabledCookieJar && equal(sourceComment, source.sourceComment) && equal(concurrentRate, source.concurrentRate) && equal(header, source.header) && equal(loginUrl, source.loginUrl) && equal(loginUi, source.loginUi) && equal(loginCheckJs, source.loginCheckJs) && equal(coverDecodeJs, source.coverDecodeJs) && equal(sortUrl, source.sortUrl) && singleUrl == source.singleUrl && articleStyle == source.articleStyle && equal(ruleArticles, source.ruleArticles) && equal(ruleNextPage, source.ruleNextPage) && equal(ruleTitle, source.ruleTitle) && equal(rulePubDate, source.rulePubDate) && equal(ruleDescription, source.ruleDescription) && equal(ruleLink, source.ruleLink) && equal(ruleContent, source.ruleContent) && enableJs == source.enableJs && loadWithBaseUrl == source.loadWithBaseUrl && equal(variableComment, source.variableComment) && equal(style, source.style) && equal(injectJs, source.injectJs) } private fun equal(a: String?, b: String?): Boolean { return a == b || (a.isNullOrEmpty() && b.isNullOrEmpty()) } fun getDisplayNameGroup(): String { return if (sourceGroup.isNullOrBlank()) { sourceName } else { String.format("%s (%s)", sourceName, sourceGroup) } } fun addGroup(groups: String): RssSource { sourceGroup?.splitNotBlank(AppPattern.splitGroupRegex)?.toHashSet()?.let { it.addAll(groups.splitNotBlank(AppPattern.splitGroupRegex)) sourceGroup = TextUtils.join(",", it) } if (sourceGroup.isNullOrBlank()) sourceGroup = groups return this } fun removeGroup(groups: String): RssSource { sourceGroup?.splitNotBlank(AppPattern.splitGroupRegex)?.toHashSet()?.let { it.removeAll(groups.splitNotBlank(AppPattern.splitGroupRegex).toSet()) sourceGroup = TextUtils.join(",", it) } return this } fun getDisplayVariableComment(otherComment: String): String { return if (variableComment.isNullOrBlank()) { otherComment } else { "${variableComment}\n$otherComment" } } } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/RssStar.kt ================================================ package io.legado.app.data.entities import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Ignore import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonObject import kotlinx.parcelize.IgnoredOnParcel @Entity( tableName = "rssStars", primaryKeys = ["origin", "link"] ) data class RssStar( override var origin: String = "", var sort: String = "", var title: String = "", var starTime: Long = 0, override var link: String = "", var pubDate: String? = null, var description: String? = null, var content: String? = null, var image: String? = null, @ColumnInfo(defaultValue = "默认分组") var group: String = "默认分组", override var variable: String? = null ) : BaseRssArticle { @delegate:Transient @delegate:Ignore @IgnoredOnParcel override val variableMap by lazy { GSON.fromJsonObject>(variable).getOrNull() ?: hashMapOf() } fun toRssArticle() = RssArticle( origin = origin, sort = sort, title = title, link = link, pubDate = pubDate, description = description, content = content, image = image, group = group, variable = variable ) } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/RuleSub.kt ================================================ package io.legado.app.data.entities import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "ruleSubs") data class RuleSub( @PrimaryKey val id: Long = System.currentTimeMillis(), var name: String = "", var url: String = "", var type: Int = 0, var customOrder: Int = 0, var autoUpdate: Boolean = false, var update: Long = System.currentTimeMillis() ) ================================================ FILE: app/src/main/java/io/legado/app/data/entities/SearchBook.kt ================================================ package io.legado.app.data.entities import android.content.Context import android.os.Parcelable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Ignore import androidx.room.Index import androidx.room.PrimaryKey import io.legado.app.R import io.legado.app.constant.BookType import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonObject import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Parcelize @Entity( tableName = "searchBooks", indices = [(Index(value = ["bookUrl"], unique = true)), (Index(value = ["origin"], unique = false))], foreignKeys = [(ForeignKey( entity = BookSource::class, parentColumns = ["bookSourceUrl"], childColumns = ["origin"], onDelete = ForeignKey.CASCADE ))] ) data class SearchBook( @PrimaryKey override var bookUrl: String = "", /** 书源 */ var origin: String = "", var originName: String = "", /** BookType */ var type: Int = BookType.text, override var name: String = "", override var author: String = "", override var kind: String? = null, var coverUrl: String? = null, var intro: String? = null, override var wordCount: String? = null, var latestChapterTitle: String? = null, /** 目录页Url (toc=table of Contents) */ var tocUrl: String = "", var time: Long = System.currentTimeMillis(), override var variable: String? = null, var originOrder: Int = 0, var chapterWordCountText: String? = null, @ColumnInfo(defaultValue = "-1") var chapterWordCount: Int = -1, @ColumnInfo(defaultValue = "-1") var respondTime: Int = -1 ) : Parcelable, BaseBook, Comparable { @Ignore @IgnoredOnParcel override var infoHtml: String? = null @Ignore @IgnoredOnParcel override var tocHtml: String? = null override fun equals(other: Any?) = other is SearchBook && other.bookUrl == bookUrl override fun hashCode() = bookUrl.hashCode() override fun compareTo(other: SearchBook): Int { return other.originOrder - this.originOrder } @delegate:Transient @delegate:Ignore @IgnoredOnParcel override val variableMap: HashMap by lazy { GSON.fromJsonObject>(variable).getOrNull() ?: HashMap() } @delegate:Transient @delegate:Ignore @IgnoredOnParcel val origins: LinkedHashSet by lazy { linkedSetOf(origin) } fun addOrigin(origin: String) { origins.add(origin) } fun getDisplayLastChapterTitle(): String { latestChapterTitle?.let { if (it.isNotEmpty()) { return it } } return "无最新章节" } fun trimIntro(context: Context): String { val trimIntro = intro?.trim() return if (trimIntro.isNullOrEmpty()) { context.getString(R.string.intro_show_null) } else { context.getString(R.string.intro_show, trimIntro) } } fun releaseHtmlData() { infoHtml = null tocHtml = null } fun primaryStr(): String { return origin + bookUrl } fun sameBookTypeLocal(bookType: Int): Boolean { return type and BookType.allBookTypeLocal == bookType and BookType.allBookTypeLocal } fun toBook() = Book( name = name, author = author, kind = kind, bookUrl = bookUrl, origin = origin, originName = originName, type = type, wordCount = wordCount, latestChapterTitle = latestChapterTitle, coverUrl = coverUrl, intro = intro, tocUrl = tocUrl, originOrder = originOrder, variable = variable ).apply { this.infoHtml = this@SearchBook.infoHtml this.tocHtml = this@SearchBook.tocHtml } } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/SearchKeyword.kt ================================================ package io.legado.app.data.entities import android.os.Parcelable import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey import kotlinx.parcelize.Parcelize @Parcelize @Entity(tableName = "search_keywords", indices = [(Index(value = ["word"], unique = true))]) data class SearchKeyword( /** 搜索关键词 */ @PrimaryKey var word: String = "", /** 使用次数 */ var usage: Int = 1, /** 最后一次使用时间 */ var lastUseTime: Long = System.currentTimeMillis() ) : Parcelable ================================================ FILE: app/src/main/java/io/legado/app/data/entities/Server.kt ================================================ package io.legado.app.data.entities import android.os.Parcelable import androidx.room.Entity import androidx.room.PrimaryKey import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonObject import kotlinx.parcelize.Parcelize import org.json.JSONObject /** * 服务器 */ @Parcelize @Entity(tableName = "servers") data class Server( @PrimaryKey var id: Long = System.currentTimeMillis(), var name: String = "", var type: TYPE = TYPE.WEBDAV, var config: String? = null, var sortNumber: Int = 0 ) : Parcelable { enum class TYPE { WEBDAV } override fun hashCode(): Int { return id.hashCode() } override fun equals(other: Any?): Boolean { if (other is Server) { return id == other.id } return false } fun getConfigJsonObject(): JSONObject? { val json = config json ?: return null return JSONObject(json) } fun getWebDavConfig(): WebDavConfig? { return if (type == TYPE.WEBDAV) GSON.fromJsonObject(config).getOrNull() else null } @Parcelize data class WebDavConfig( var url: String, var username: String, var password: String ) : Parcelable } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/TxtTocRule.kt ================================================ package io.legado.app.data.entities import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "txtTocRules") data class TxtTocRule( @PrimaryKey var id: Long = System.currentTimeMillis(), var name: String = "", var rule: String = "", var example: String? = null, var serialNumber: Int = -1, var enable: Boolean = true ) { override fun hashCode(): Int { return id.hashCode() } override fun equals(other: Any?): Boolean { if (other is TxtTocRule) { return id == other.id } return false } } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/rule/BookInfoRule.kt ================================================ package io.legado.app.data.entities.rule import android.os.Parcelable import com.google.gson.JsonDeserializer import io.legado.app.utils.INITIAL_GSON import kotlinx.parcelize.Parcelize /** * 书籍详情页规则 */ @Parcelize data class BookInfoRule( var init: String? = null, var name: String? = null, var author: String? = null, var intro: String? = null, var kind: String? = null, var lastChapter: String? = null, var updateTime: String? = null, var coverUrl: String? = null, var tocUrl: String? = null, var wordCount: String? = null, var canReName: String? = null, var downloadUrls: String? = null ) : Parcelable { companion object { val jsonDeserializer = JsonDeserializer { json, _, _ -> when { json.isJsonObject -> INITIAL_GSON.fromJson(json, BookInfoRule::class.java) json.isJsonPrimitive -> INITIAL_GSON.fromJson( json.asString, BookInfoRule::class.java ) else -> null } } } } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/rule/BookListRule.kt ================================================ package io.legado.app.data.entities.rule /** * 书籍列表规则 */ interface BookListRule { var bookList: String? var name: String? var author: String? var intro: String? var kind: String? var lastChapter: String? var updateTime: String? var bookUrl: String? var coverUrl: String? var wordCount: String? } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/rule/ContentRule.kt ================================================ package io.legado.app.data.entities.rule import android.os.Parcelable import com.google.gson.JsonDeserializer import io.legado.app.utils.INITIAL_GSON import kotlinx.parcelize.Parcelize /** * 正文处理规则 */ @Parcelize data class ContentRule( var content: String? = null, var title: String? = null, //有些网站只能在正文中获取标题 var nextContentUrl: String? = null, var webJs: String? = null, var sourceRegex: String? = null, var replaceRegex: String? = null, //替换规则 var imageStyle: String? = null, //默认大小居中,FULL最大宽度 var imageDecode: String? = null, //图片bytes二次解密js, 返回解密后的bytes var payAction: String? = null, //购买操作,js或者包含{{js}}的url ) : Parcelable { companion object { val jsonDeserializer = JsonDeserializer { json, _, _ -> when { json.isJsonObject -> INITIAL_GSON.fromJson(json, ContentRule::class.java) json.isJsonPrimitive -> INITIAL_GSON.fromJson( json.asString, ContentRule::class.java ) else -> null } } } } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/rule/ExploreKind.kt ================================================ package io.legado.app.data.entities.rule /** * 发现分类 */ data class ExploreKind( val title: String = "", val url: String? = null, val style: FlexChildStyle? = null ) { fun style(): FlexChildStyle { return style ?: FlexChildStyle.defaultStyle } } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/rule/ExploreRule.kt ================================================ package io.legado.app.data.entities.rule import android.os.Parcelable import com.google.gson.JsonDeserializer import io.legado.app.utils.INITIAL_GSON import kotlinx.parcelize.Parcelize /** * 发现结果规则 */ @Parcelize data class ExploreRule( override var bookList: String? = null, override var name: String? = null, override var author: String? = null, override var intro: String? = null, override var kind: String? = null, override var lastChapter: String? = null, override var updateTime: String? = null, override var bookUrl: String? = null, override var coverUrl: String? = null, override var wordCount: String? = null ) : BookListRule, Parcelable { companion object { val jsonDeserializer = JsonDeserializer { json, _, _ -> when { json.isJsonObject -> INITIAL_GSON.fromJson(json, ExploreRule::class.java) json.isJsonPrimitive -> INITIAL_GSON.fromJson( json.asString, ExploreRule::class.java ) else -> null } } } } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/rule/FlexChildStyle.kt ================================================ package io.legado.app.data.entities.rule import android.view.View import com.google.android.flexbox.FlexboxLayout data class FlexChildStyle( val layout_flexGrow: Float = 0F, val layout_flexShrink: Float = 1F, val layout_alignSelf: String = "auto", val layout_flexBasisPercent: Float = -1F, val layout_wrapBefore: Boolean = false, ) { fun alignSelf(): Int { return when (layout_alignSelf) { "auto" -> -1 "flex_start" -> 0 "flex_end" -> 1 "center" -> 2 "baseline" -> 3 "stretch" -> 4 else -> -1 } } fun apply(view: View) { val lp = view.layoutParams as FlexboxLayout.LayoutParams lp.flexGrow = layout_flexGrow lp.flexShrink = layout_flexShrink lp.alignSelf = alignSelf() lp.flexBasisPercent = layout_flexBasisPercent lp.isWrapBefore = layout_wrapBefore } companion object { val defaultStyle = FlexChildStyle() } } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/rule/ReviewRule.kt ================================================ package io.legado.app.data.entities.rule import android.os.Parcelable import com.google.gson.JsonDeserializer import io.legado.app.utils.INITIAL_GSON import kotlinx.parcelize.Parcelize @Parcelize data class ReviewRule( var reviewUrl: String? = null, // 段评URL var avatarRule: String? = null, // 段评发布者头像 var contentRule: String? = null, // 段评内容 var postTimeRule: String? = null, // 段评发布时间 var reviewQuoteUrl: String? = null, // 获取段评回复URL // 这些功能将在以上功能完成以后实现 var voteUpUrl: String? = null, // 点赞URL var voteDownUrl: String? = null, // 点踩URL var postReviewUrl: String? = null, // 发送回复URL var postQuoteUrl: String? = null, // 发送回复段评URL var deleteUrl: String? = null, // 删除段评URL ) : Parcelable { companion object { val jsonDeserializer = JsonDeserializer { json, _, _ -> when { json.isJsonObject -> INITIAL_GSON.fromJson(json, ReviewRule::class.java) json.isJsonPrimitive -> INITIAL_GSON.fromJson(json.asString, ReviewRule::class.java) else -> null } } } } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/rule/RowUi.kt ================================================ 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 ) { @Suppress("ConstPropertyName") object Type { const val text = "text" const val password = "password" const val button = "button" } fun style(): FlexChildStyle { return style ?: FlexChildStyle.defaultStyle } } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/rule/SearchRule.kt ================================================ package io.legado.app.data.entities.rule import android.os.Parcelable import com.google.gson.JsonDeserializer import io.legado.app.utils.INITIAL_GSON import kotlinx.parcelize.Parcelize /** * 搜索结果处理规则 */ @Parcelize data class SearchRule( /**校验关键字**/ var checkKeyWord: String? = null, override var bookList: String? = null, override var name: String? = null, override var author: String? = null, override var intro: String? = null, override var kind: String? = null, override var lastChapter: String? = null, override var updateTime: String? = null, override var bookUrl: String? = null, override var coverUrl: String? = null, override var wordCount: String? = null ) : BookListRule, Parcelable { companion object { val jsonDeserializer = JsonDeserializer { json, _, _ -> when { json.isJsonObject -> INITIAL_GSON.fromJson(json, SearchRule::class.java) json.isJsonPrimitive -> INITIAL_GSON.fromJson(json.asString, SearchRule::class.java) else -> null } } } } ================================================ FILE: app/src/main/java/io/legado/app/data/entities/rule/TocRule.kt ================================================ package io.legado.app.data.entities.rule import android.os.Parcelable import com.google.gson.JsonDeserializer import io.legado.app.utils.INITIAL_GSON import kotlinx.parcelize.Parcelize @Parcelize data class TocRule( var preUpdateJs: String? = null, var chapterList: String? = null, var chapterName: String? = null, var chapterUrl: String? = null, var formatJs: String? = null, var isVolume: String? = null, var isVip: String? = null, var isPay: String? = null, var updateTime: String? = null, var nextTocUrl: String? = null ) : Parcelable { companion object { val jsonDeserializer = JsonDeserializer { json, _, _ -> when { json.isJsonObject -> INITIAL_GSON.fromJson(json, TocRule::class.java) json.isJsonPrimitive -> INITIAL_GSON.fromJson(json.asString, TocRule::class.java) else -> null } } } } ================================================ FILE: app/src/main/java/io/legado/app/exception/ConcurrentException.kt ================================================ @file:Suppress("unused") package io.legado.app.exception /** * 并发限制 */ class ConcurrentException(msg: String, val waitTime: Int) : NoStackTraceException(msg) ================================================ FILE: app/src/main/java/io/legado/app/exception/ContentEmptyException.kt ================================================ package io.legado.app.exception /** * 内容为空 */ class ContentEmptyException(msg: String) : NoStackTraceException(msg) ================================================ FILE: app/src/main/java/io/legado/app/exception/EmptyFileException.kt ================================================ package io.legado.app.exception /** * 文件为空 */ class EmptyFileException(msg: String) : NoStackTraceException(msg) ================================================ FILE: app/src/main/java/io/legado/app/exception/InvalidBooksDirException.kt ================================================ package io.legado.app.exception class InvalidBooksDirException(msg: String) : NoStackTraceException(msg) ================================================ FILE: app/src/main/java/io/legado/app/exception/NoBooksDirException.kt ================================================ package io.legado.app.exception import io.legado.app.R import splitties.init.appCtx class NoBooksDirException: NoStackTraceException(appCtx.getString(R.string.no_books_dir)) ================================================ FILE: app/src/main/java/io/legado/app/exception/NoStackTraceException.kt ================================================ package io.legado.app.exception /** * 不记录错误堆栈的报错 */ open class NoStackTraceException(msg: String) : Exception(msg) { override fun fillInStackTrace(): Throwable { stackTrace = emptyStackTrace return this } companion object { private val emptyStackTrace = emptyArray() } } ================================================ FILE: app/src/main/java/io/legado/app/exception/RegexTimeoutException.kt ================================================ package io.legado.app.exception class RegexTimeoutException(msg: String) : NoStackTraceException(msg) ================================================ FILE: app/src/main/java/io/legado/app/exception/TocEmptyException.kt ================================================ package io.legado.app.exception /** * 目录为空 */ class TocEmptyException(msg: String) : NoStackTraceException(msg) ================================================ FILE: app/src/main/java/io/legado/app/help/AppFreezeMonitor.kt ================================================ package io.legado.app.help import android.annotation.SuppressLint import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.Handler import android.os.HandlerThread import android.os.SystemClock import io.legado.app.help.config.AppConfig import io.legado.app.utils.LogUtils object AppFreezeMonitor { private const val TAG = "AppFreezeMonitor" val handler by lazy { Handler(HandlerThread("AppFreezeMonitor").apply { start() }.looper) } val screenStatusReceiver by lazy { ScreenStatusReceiver() } private var registeredReceiver = false @SuppressLint("UnspecifiedRegisterReceiverFlag") fun init(context: Context) { if (!AppConfig.recordLog) { if (registeredReceiver) { registeredReceiver = false context.unregisterReceiver(screenStatusReceiver) } return } if (!registeredReceiver) { registeredReceiver = true context.registerReceiver(screenStatusReceiver, screenStatusReceiver.filter) } var previous = SystemClock.uptimeMillis() val runnable = object : Runnable { override fun run() { val current = SystemClock.uptimeMillis() val elapsed = current - previous val extra = elapsed - 3000 if (extra > 300) { LogUtils.d(TAG, "检测到应用被系统冻结,时长:$extra 毫秒") } previous = current if (AppConfig.recordLog) { handler.postDelayed(this, 3000) } } } handler.postDelayed(runnable, 3000) } class ScreenStatusReceiver : BroadcastReceiver() { val filter = IntentFilter().apply { addAction(Intent.ACTION_SCREEN_ON) addAction(Intent.ACTION_SCREEN_OFF) } override fun onReceive(context: Context?, intent: Intent?) { when (intent?.action) { Intent.ACTION_SCREEN_ON -> LogUtils.d(TAG, "SCREEN_ON") Intent.ACTION_SCREEN_OFF -> LogUtils.d(TAG, "SCREEN_OFF") } } } } ================================================ FILE: app/src/main/java/io/legado/app/help/AppWebDav.kt ================================================ package io.legado.app.help import android.net.Uri import io.legado.app.R import io.legado.app.constant.AppLog import io.legado.app.constant.PreferKey import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookProgress import io.legado.app.exception.NoStackTraceException import io.legado.app.help.config.AppConfig import io.legado.app.help.storage.Backup import io.legado.app.help.storage.Restore import io.legado.app.lib.webdav.Authorization import io.legado.app.lib.webdav.WebDav import io.legado.app.lib.webdav.WebDavException import io.legado.app.lib.webdav.WebDavFile import io.legado.app.model.remote.RemoteBookWebDav import io.legado.app.utils.AlphanumComparator import io.legado.app.utils.FileUtils import io.legado.app.utils.GSON import io.legado.app.utils.NetworkUtils import io.legado.app.utils.UrlUtil import io.legado.app.utils.compress.ZipUtils import io.legado.app.utils.fromJsonObject import io.legado.app.utils.getPrefString import io.legado.app.utils.isJson import io.legado.app.utils.normalizeFileName import io.legado.app.utils.removePref import io.legado.app.utils.toastOnUi import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.runBlocking import splitties.init.appCtx import java.io.File /** * webDav初始化会访问网络,不要放到主线程 */ object AppWebDav { private const val defaultWebDavUrl = "https://dav.jianguoyun.com/dav/" private val bookProgressUrl get() = "${rootWebDavUrl}bookProgress/" private val exportsWebDavUrl get() = "${rootWebDavUrl}books/" private val bgWebDavUrl get() = "${rootWebDavUrl}background/" var authorization: Authorization? = null private set var defaultBookWebDav: RemoteBookWebDav? = null val isOk get() = authorization != null val isJianGuoYun get() = rootWebDavUrl.startsWith(defaultWebDavUrl, true) init { runBlocking { upConfig() } } private val rootWebDavUrl: String get() { val configUrl = appCtx.getPrefString(PreferKey.webDavUrl)?.trim() var url = if (configUrl.isNullOrEmpty()) defaultWebDavUrl else configUrl if (!url.endsWith("/")) url = "${url}/" AppConfig.webDavDir?.trim()?.let { if (it.isNotEmpty()) { url = "${url}${it}/" } } return url } suspend fun upConfig() { kotlin.runCatching { authorization = null defaultBookWebDav = null val account = appCtx.getPrefString(PreferKey.webDavAccount) val password = appCtx.getPrefString(PreferKey.webDavPassword) if (!account.isNullOrEmpty() && !password.isNullOrEmpty()) { val mAuthorization = Authorization(account, password) checkAuthorization(mAuthorization) WebDav(rootWebDavUrl, mAuthorization).makeAsDir() WebDav(bookProgressUrl, mAuthorization).makeAsDir() WebDav(exportsWebDavUrl, mAuthorization).makeAsDir() WebDav(bgWebDavUrl, mAuthorization).makeAsDir() val rootBooksUrl = "${rootWebDavUrl}books/" defaultBookWebDav = RemoteBookWebDav(rootBooksUrl, mAuthorization) authorization = mAuthorization } } } @Throws(WebDavException::class) private suspend fun checkAuthorization(authorization: Authorization) { if (!WebDav(rootWebDavUrl, authorization).check()) { appCtx.removePref(PreferKey.webDavPassword) appCtx.toastOnUi(R.string.webdav_application_authorization_error) throw WebDavException(appCtx.getString(R.string.webdav_application_authorization_error)) } } @Throws(Exception::class) suspend fun getBackupNames(): ArrayList { val names = arrayListOf() authorization?.let { var files = WebDav(rootWebDavUrl, it).listFiles() files = files.sortedWith { o1, o2 -> AlphanumComparator.compare(o1.displayName, o2.displayName) }.reversed() files.forEach { webDav -> val name = webDav.displayName if (name.startsWith("backup")) { names.add(name) } } } ?: throw NoStackTraceException("webDav没有配置") return names } @Throws(WebDavException::class) suspend fun restoreWebDav(name: String) { authorization?.let { val webDav = WebDav(rootWebDavUrl + name, it) webDav.downloadTo(Backup.zipFilePath, true) FileUtils.delete(Backup.backupPath) ZipUtils.unZipToPath(File(Backup.zipFilePath), Backup.backupPath) Restore.restoreLocked(Backup.backupPath) } } suspend fun hasBackUp(backUpName: String): Boolean { authorization?.let { val url = "$rootWebDavUrl${backUpName}" return WebDav(url, it).exists() } return false } suspend fun lastBackUp(): Result { return kotlin.runCatching { authorization?.let { var lastBackupFile: WebDavFile? = null WebDav(rootWebDavUrl, it).listFiles().reversed().forEach { webDavFile -> if (webDavFile.displayName.startsWith("backup")) { if (lastBackupFile == null || webDavFile.lastModify > lastBackupFile.lastModify ) { lastBackupFile = webDavFile } } } lastBackupFile } } } /** * webDav备份 * @param fileName 备份文件名 */ @Throws(Exception::class) suspend fun backUpWebDav(fileName: String) { if (!NetworkUtils.isAvailable()) return authorization?.let { val putUrl = "$rootWebDavUrl$fileName" WebDav(putUrl, it).upload(Backup.zipFilePath) } } /** * 获取云端所有背景名称 */ private suspend fun getAllBgWebDavFiles(): Result> { return kotlin.runCatching { if (!NetworkUtils.isAvailable()) throw NoStackTraceException("网络未连接") authorization.let { it ?: throw NoStackTraceException("webDav未配置") WebDav(bgWebDavUrl, it).listFiles() } } } /** * 上传背景图片 */ suspend fun upBgs(files: Array) { val authorization = authorization ?: return if (!NetworkUtils.isAvailable()) return val bgWebDavFiles = getAllBgWebDavFiles().getOrThrow() .map { it.displayName } .toSet() files.forEach { if (!bgWebDavFiles.contains(it.name) && it.exists()) { WebDav("$bgWebDavUrl${it.name}", authorization) .upload(it) } } } /** * 下载背景图片 */ suspend fun downBgs() { val authorization = authorization ?: return if (!NetworkUtils.isAvailable()) return val bgWebDavFiles = getAllBgWebDavFiles().getOrThrow() .map { it.displayName } .toSet() } @Suppress("unused") suspend fun exportWebDav(byteArray: ByteArray, fileName: String) { if (!NetworkUtils.isAvailable()) return try { authorization?.let { // 如果导出的本地文件存在,开始上传 val putUrl = exportsWebDavUrl + fileName WebDav(putUrl, it).upload(byteArray, "text/plain") } } catch (e: Exception) { currentCoroutineContext().ensureActive() AppLog.put("WebDav导出失败\n${e.localizedMessage}", e, true) } } suspend fun exportWebDav(uri: Uri, fileName: String) { if (!NetworkUtils.isAvailable()) return try { authorization?.let { // 如果导出的本地文件存在,开始上传 val putUrl = exportsWebDavUrl + fileName WebDav(putUrl, it).upload(uri, "text/plain") } } catch (e: Exception) { currentCoroutineContext().ensureActive() AppLog.put("WebDav导出失败\n${e.localizedMessage}", e, true) } } suspend fun uploadBookProgress( book: Book, toast: Boolean = false, onSuccess: (() -> Unit)? = null ) { val authorization = authorization ?: return if (!AppConfig.syncBookProgress) return if (!NetworkUtils.isAvailable()) return try { val bookProgress = BookProgress(book) val json = GSON.toJson(bookProgress) val url = getProgressUrl(book.name, book.author) WebDav(url, authorization).upload(json.toByteArray(), "application/json") book.syncTime = System.currentTimeMillis() onSuccess?.invoke() } catch (e: Exception) { currentCoroutineContext().ensureActive() AppLog.put("上传进度失败\n${e.localizedMessage}", e, toast) } } suspend fun uploadBookProgress(bookProgress: BookProgress, onSuccess: (() -> Unit)? = null) { try { val authorization = authorization ?: return if (!AppConfig.syncBookProgress) return if (!NetworkUtils.isAvailable()) return val json = GSON.toJson(bookProgress) val url = getProgressUrl(bookProgress.name, bookProgress.author) WebDav(url, authorization).upload(json.toByteArray(), "application/json") onSuccess?.invoke() } catch (e: Exception) { currentCoroutineContext().ensureActive() AppLog.put("上传进度失败\n${e.localizedMessage}", e) } } private fun getProgressUrl(name: String, author: String): String { return bookProgressUrl + getProgressFileName(name, author) } private fun getProgressFileName(name: String, author: String): String { return UrlUtil.replaceReservedChar("${name}_${author}".normalizeFileName()) + ".json" } /** * 获取书籍进度 */ suspend fun getBookProgress(book: Book): BookProgress? { val url = getProgressUrl(book.name, book.author) kotlin.runCatching { val authorization = authorization ?: return null WebDav(url, authorization).download().let { byteArray -> val json = String(byteArray) if (json.isJson()) { return GSON.fromJsonObject(json).getOrNull() } } }.onFailure { currentCoroutineContext().ensureActive() AppLog.put("获取书籍进度失败\n${it.localizedMessage}", it) } return null } suspend fun downloadAllBookProgress() { val authorization = authorization ?: return if (!NetworkUtils.isAvailable()) return val bookProgressFiles = WebDav(bookProgressUrl, authorization).listFiles() val map = hashMapOf() bookProgressFiles.forEach { map[it.displayName] = it } appDb.bookDao.all.forEach { book -> val progressFileName = getProgressFileName(book.name, book.author) val webDavFile = map[progressFileName] webDavFile ?: return if (webDavFile.lastModify <= book.syncTime) { //本地同步时间大于上传时间不用同步 return } getBookProgress(book)?.let { bookProgress -> if (bookProgress.durChapterIndex > book.durChapterIndex || (bookProgress.durChapterIndex == book.durChapterIndex && bookProgress.durChapterPos > book.durChapterPos) ) { book.durChapterIndex = bookProgress.durChapterIndex book.durChapterPos = bookProgress.durChapterPos book.durChapterTitle = bookProgress.durChapterTitle book.durChapterTime = bookProgress.durChapterTime book.syncTime = System.currentTimeMillis() appDb.bookDao.update(book) } } } } } ================================================ FILE: app/src/main/java/io/legado/app/help/CacheManager.kt ================================================ package io.legado.app.help import androidx.annotation.Keep import androidx.collection.LruCache import io.legado.app.data.appDb import io.legado.app.data.entities.Cache import io.legado.app.model.analyzeRule.QueryTTF import io.legado.app.utils.ACache import io.legado.app.utils.memorySize private val queryTTFMap = LruCache(4) /** * 最多只缓存50M的数据,防止OOM */ private val memoryLruCache = object : LruCache(1024 * 1024 * 50) { override fun sizeOf(key: String, value: Any): Int { return value.toString().memorySize() } } object AppCacheManager { fun put(key: String, queryTTF: QueryTTF) { queryTTFMap.put(key, queryTTF) } fun getQueryTTF(key: String): QueryTTF? { return queryTTFMap[key] } fun clearSourceVariables() { memoryLruCache.snapshot().keys.forEach { if (it.startsWith("v_") || it.startsWith("userInfo_") || it.startsWith("loginHeader_") || it.startsWith("sourceVariable_") ) { memoryLruCache.remove(it) } } } } @Keep @Suppress("unused") object CacheManager { /** * saveTime 单位为秒 */ @JvmOverloads fun put(key: String, value: Any, saveTime: Int = 0) { val deadline = if (saveTime == 0) 0 else System.currentTimeMillis() + saveTime * 1000 when (value) { is ByteArray -> ACache.get().put(key, value, saveTime) else -> { val cache = Cache(key, value.toString(), deadline) appDb.cacheDao.insert(cache) } } } fun putMemory(key: String, value: Any) { memoryLruCache.put(key, value) } //从内存中获取数据 使用lruCache fun getFromMemory(key: String): Any? { return memoryLruCache[key] } fun deleteMemory(key: String) { memoryLruCache.remove(key) } fun get(key: String): String? { 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() } fun getLong(key: String): Long? { return get(key)?.toLongOrNull() } fun getDouble(key: String): Double? { return get(key)?.toDoubleOrNull() } fun getFloat(key: String): Float? { return get(key)?.toFloatOrNull() } fun getByteArray(key: String): ByteArray? { return ACache.get().getAsBinary(key) } fun putFile(key: String, value: String, saveTime: Int = 0) { ACache.get().put(key, value, saveTime) } fun getFile(key: String): String? { return ACache.get().getAsString(key) } fun delete(key: String) { appDb.cacheDao.delete(key) deleteMemory(key) ACache.get().remove(key) } } ================================================ FILE: app/src/main/java/io/legado/app/help/ConcurrentRateLimiter.kt ================================================ package io.legado.app.help 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 class ConcurrentRateLimiter(val source: BaseSource?) { companion object { private val concurrentRecordMap = hashMapOf() } /** * 开始访问,并发判断 */ @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 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) { return@synchronized 0 } } if (waitTime > 0) { throw ConcurrentException( "根据并发率还需等待${waitTime}毫秒才可以访问", waitTime = waitTime ) } return fetchRecord } /** * 访问结束 */ fun fetchEnd(concurrentRecord: ConcurrentRecord?) { if (concurrentRecord != null && !concurrentRecord.isConcurrent) { synchronized(concurrentRecord) { concurrentRecord.frequency -= 1 } } } /** * 获取并发记录,若处于并发限制状态下则会等待 */ suspend fun getConcurrentRecord(): ConcurrentRecord? { while (true) { try { return fetchStart() } catch (e: ConcurrentException) { delay(e.waitTime.toLong()) } } } fun getConcurrentRecordBlocking(): ConcurrentRecord? { while (true) { try { return fetchStart() } catch (e: ConcurrentException) { Thread.sleep(e.waitTime.toLong()) } } } suspend inline fun withLimit(block: () -> T): T { val concurrentRecord = getConcurrentRecord() try { return block() } finally { fetchEnd(concurrentRecord) } } inline fun withLimitBlocking(block: () -> T): T { val concurrentRecord = getConcurrentRecordBlocking() try { return block() } finally { fetchEnd(concurrentRecord) } } } ================================================ FILE: app/src/main/java/io/legado/app/help/CrashHandler.kt ================================================ package io.legado.app.help import android.annotation.SuppressLint import android.content.Context import android.net.Uri import android.os.Build import android.os.Debug import android.os.Looper import android.webkit.WebSettings import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog import io.legado.app.exception.NoStackTraceException import io.legado.app.help.config.AppConfig import io.legado.app.help.config.LocalConfig import io.legado.app.model.ReadAloud import io.legado.app.utils.FileDoc import io.legado.app.utils.FileUtils import io.legado.app.utils.createFileIfNotExist import io.legado.app.utils.createFolderReplace import io.legado.app.utils.externalCache import io.legado.app.utils.getFile import io.legado.app.utils.longToastOnUiLegacy import io.legado.app.utils.stackTraceStr import io.legado.app.utils.writeText import splitties.init.appCtx import java.io.PrintWriter import java.io.StringWriter import java.text.SimpleDateFormat import java.util.Date import java.util.concurrent.TimeUnit /** * 异常管理类 */ class CrashHandler(val context: Context) : Thread.UncaughtExceptionHandler { /** * 系统默认UncaughtExceptionHandler */ private var mDefaultHandler = Thread.getDefaultUncaughtExceptionHandler() init { //设置该CrashHandler为系统默认的 Thread.setDefaultUncaughtExceptionHandler(this) } /** * uncaughtException 回调函数 */ override fun uncaughtException(thread: Thread, ex: Throwable) { if (shouldAbsorb(ex)) { AppLog.put("发生未捕获的异常\n${ex.localizedMessage}", ex) Looper.loop() } else { ReadAloud.stop(context) handleException(ex) mDefaultHandler?.uncaughtException(thread, ex) } } private fun shouldAbsorb(e: Throwable): Boolean { return when { e::class.simpleName == "CannotDeliverBroadcastException" -> true e is SecurityException && e.message?.contains( "nor current process has android.permission.OBSERVE_GRANT_REVOKE_PERMISSIONS", true ) == true -> true else -> false } } /** * 处理该异常 */ private fun handleException(ex: Throwable?) { if (ex == null) return LocalConfig.appCrash = true //保存日志文件 saveCrashInfo2File(ex) if ((ex is OutOfMemoryError || ex.cause is OutOfMemoryError) && AppConfig.recordHeapDump) { doHeapDump() } context.longToastOnUiLegacy(ex.stackTraceStr) Thread.sleep(3000) } companion object { /** * 存储异常和参数信息 */ private val paramsMap by lazy { val map = LinkedHashMap() kotlin.runCatching { //获取系统信息 map["MANUFACTURER"] = Build.MANUFACTURER map["BRAND"] = Build.BRAND map["MODEL"] = Build.MODEL map["SDK_INT"] = Build.VERSION.SDK_INT.toString() map["RELEASE"] = Build.VERSION.RELEASE map["WebViewUserAgent"] = try { WebSettings.getDefaultUserAgent(appCtx) } catch (e: Throwable) { e.toString() } map["packageName"] = appCtx.packageName map["heapSize"] = Runtime.getRuntime().maxMemory().toString() //获取app版本信息 AppConst.appInfo.let { map["versionName"] = it.versionName map["versionCode"] = it.versionCode.toString() } } map } /** * 格式化时间 */ @SuppressLint("SimpleDateFormat") private val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss") /** * 保存错误信息到文件中 */ fun saveCrashInfo2File(ex: Throwable) { val sb = StringBuilder() for ((key, value) in paramsMap) { sb.append(key).append("=").append(value).append("\n") } val writer = StringWriter() val printWriter = PrintWriter(writer) ex.printStackTrace(printWriter) var cause: Throwable? = ex.cause while (cause != null) { cause.printStackTrace(printWriter) cause = cause.cause } printWriter.close() val result = writer.toString() sb.append(result) val crashLog = sb.toString() val timestamp = System.currentTimeMillis() val time = format.format(Date()) val fileName = "crash-$time-$timestamp.log" try { val backupPath = AppConfig.backupPath ?: throw NoStackTraceException("备份路径未配置") val uri = Uri.parse(backupPath) val fileDoc = FileDoc.fromUri(uri, true) fileDoc.createFileIfNotExist(fileName, "crash") .writeText(crashLog) } catch (_: Exception) { } kotlin.runCatching { appCtx.externalCacheDir?.let { rootFile -> val exceedTimeMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7) rootFile.getFile("crash").listFiles()?.forEach { if (it.lastModified() < exceedTimeMillis) { it.delete() } } FileUtils.createFileIfNotExist(rootFile, "crash", fileName) .writeText(crashLog) } } } /** * 进行堆转储 */ fun doHeapDump(manually: Boolean = false) { val heapDir = appCtx .externalCache .getFile("heapDump") heapDir.createFolderReplace() val fileName = if (manually) { "heap-dump-manually-${System.currentTimeMillis()}.hprof" } else { "heap-dump-${System.currentTimeMillis()}.hprof" } val heapFile = heapDir.getFile(fileName) val heapDumpName = heapFile.absolutePath Debug.dumpHprofData(heapDumpName) } } } ================================================ FILE: app/src/main/java/io/legado/app/help/DefaultData.kt ================================================ package io.legado.app.help import io.legado.app.constant.AppConst import io.legado.app.data.appDb 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.RssSource import io.legado.app.data.entities.TxtTocRule import io.legado.app.help.config.LocalConfig import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.config.ThemeConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.model.BookCover import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonArray import io.legado.app.utils.fromJsonObject import io.legado.app.utils.printOnDebug import splitties.init.appCtx import java.io.File object DefaultData { fun upVersion() { if (LocalConfig.versionCode < AppConst.appInfo.versionCode) { Coroutine.async { if (LocalConfig.needUpHttpTTS) { importDefaultHttpTTS() } if (LocalConfig.needUpTxtTocRule) { importDefaultTocRules() } if (LocalConfig.needUpRssSources) { importDefaultRssSources() } if (LocalConfig.needUpDictRule) { importDefaultDictRules() } }.onError { it.printOnDebug() } } } val httpTTS: List by lazy { val json = String( appCtx.assets.open("defaultData${File.separator}httpTTS.json") .readBytes() ) HttpTTS.fromJsonArray(json).getOrElse { emptyList() } } val readConfigs: List by lazy { val json = String( appCtx.assets.open("defaultData${File.separator}${ReadBookConfig.configFileName}") .readBytes() ) GSON.fromJsonArray(json).getOrNull() ?: emptyList() } val txtTocRules: List by lazy { val json = String( appCtx.assets.open("defaultData${File.separator}txtTocRule.json") .readBytes() ) GSON.fromJsonArray(json).getOrNull() ?: emptyList() } val themeConfigs: List by lazy { val json = String( appCtx.assets.open("defaultData${File.separator}${ThemeConfig.configFileName}") .readBytes() ) GSON.fromJsonArray(json).getOrNull() ?: emptyList() } val rssSources: List by lazy { val json = String( appCtx.assets.open("defaultData${File.separator}rssSources.json") .readBytes() ) GSON.fromJsonArray(json).getOrDefault(emptyList()) } val coverRule: BookCover.CoverRule by lazy { val json = String( appCtx.assets.open("defaultData${File.separator}coverRule.json") .readBytes() ) GSON.fromJsonObject(json).getOrThrow() } val dictRules: List by lazy { val json = String( appCtx.assets.open("defaultData${File.separator}dictRules.json") .readBytes() ) GSON.fromJsonArray(json).getOrThrow() } val keyboardAssists: List by lazy { val json = String( appCtx.assets.open("defaultData${File.separator}keyboardAssists.json") .readBytes() ) GSON.fromJsonArray(json).getOrThrow() } fun importDefaultHttpTTS() { appDb.httpTTSDao.deleteDefault() appDb.httpTTSDao.insert(*httpTTS.toTypedArray()) } fun importDefaultTocRules() { appDb.txtTocRuleDao.deleteDefault() appDb.txtTocRuleDao.insert(*txtTocRules.toTypedArray()) } fun importDefaultRssSources() { appDb.rssSourceDao.deleteDefault() appDb.rssSourceDao.insert(*rssSources.toTypedArray()) } fun importDefaultDictRules() { appDb.dictRuleDao.insert(*dictRules.toTypedArray()) } } ================================================ FILE: app/src/main/java/io/legado/app/help/DirectLinkUpload.kt ================================================ package io.legado.app.help import androidx.annotation.Keep import io.legado.app.exception.NoStackTraceException import io.legado.app.model.analyzeRule.AnalyzeRule import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.utils.ACache import io.legado.app.utils.FileUtils import io.legado.app.utils.GSON import io.legado.app.utils.compress.ZipUtils import io.legado.app.utils.createFileReplace import io.legado.app.utils.externalCache import io.legado.app.utils.fromJsonArray import io.legado.app.utils.fromJsonObject import splitties.init.appCtx import java.io.File import kotlin.coroutines.coroutineContext @Suppress("MemberVisibilityCanBePrivate") object DirectLinkUpload { const val ruleFileName = "directLinkUploadRule.json" @Throws(NoStackTraceException::class) suspend fun upLoad( fileName: String, file: Any, contentType: String, rule: Rule = getRule() ): String { val url = rule.uploadUrl if (url.isBlank()) { throw NoStackTraceException("上传url未配置") } val downloadUrlRule = rule.downloadUrlRule if (downloadUrlRule.isBlank()) { throw NoStackTraceException("下载地址规则未配置") } var mFileName = fileName var mFile = file var mContentType = contentType if (rule.compress && contentType != "application/zip") { mFileName = "$fileName.zip" mContentType = "application/zip" mFile = when (file) { is File -> { val zipFile = File(FileUtils.getPath(appCtx.externalCache, "upload", mFileName)) zipFile.createFileReplace() ZipUtils.zipFile(file, zipFile) zipFile } is ByteArray -> ZipUtils.zipByteArray(file, fileName) is String -> ZipUtils.zipByteArray(file.toByteArray(), fileName) else -> ZipUtils.zipByteArray(GSON.toJson(file).toByteArray(), fileName) } } val analyzeUrl = AnalyzeUrl(url) val res = analyzeUrl.upload(mFileName, mFile, mContentType) if (mFile is File) { mFile.delete() } val analyzeRule = AnalyzeRule().setContent(res.body, res.url) .setCoroutineContext(coroutineContext) val downloadUrl = analyzeRule.getString(downloadUrlRule) if (downloadUrl.isBlank()) { throw NoStackTraceException("上传失败,${res.body}") } return downloadUrl } val defaultRules: List by lazy { val json = String( appCtx.assets.open("defaultData${File.separator}directLinkUpload.json") .readBytes() ) GSON.fromJsonArray(json).getOrThrow() } fun getRule(): Rule { return getConfig() ?: defaultRules[0] } fun getConfig(): Rule? { val json = ACache.get(cacheDir = false).getAsString(ruleFileName) return GSON.fromJsonObject(json).getOrNull() } fun putConfig(rule: Rule) { ACache.get(cacheDir = false).put(ruleFileName, GSON.toJson(rule)) } fun delConfig() { ACache.get(cacheDir = false).remove(ruleFileName) } fun getSummary(): String { return getRule().summary } @Keep data class Rule( var uploadUrl: String, //创建分享链接 var downloadUrlRule: String, //下载链接规则 var summary: String, //注释 var compress: Boolean = false, //是否压缩 ) { override fun toString(): String { return summary } } } ================================================ FILE: app/src/main/java/io/legado/app/help/DispatchersMonitor.kt ================================================ package io.legado.app.help import io.legado.app.help.config.AppConfig import io.legado.app.utils.LogUtils import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.Default import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select import kotlinx.coroutines.withContext import java.util.concurrent.Executors object DispatchersMonitor { private const val TAG = "DispatchersMonitor" private val dispatcher by lazy { Executors.newSingleThreadExecutor { Thread(it, TAG) }.asCoroutineDispatcher() } private val scope = CoroutineScope(dispatcher) fun init() { scope.coroutineContext.cancelChildren() if (!AppConfig.recordLog) { return } monitor(IO) monitor(Default) monitor(Main) } @OptIn(ExperimentalCoroutinesApi::class) private fun monitor(dispatcher: CoroutineDispatcher) { scope.launch { while (isActive) select { launch { withContext(dispatcher) { delay(3000) } }.onJoin {} onTimeout(5000) { LogUtils.d(TAG, "Dispatcher $dispatcher is timed out waiting for for 5000ms.") } } } } } ================================================ FILE: app/src/main/java/io/legado/app/help/EventMessage.kt ================================================ package io.legado.app.help import android.text.TextUtils @Suppress("unused") class EventMessage { var what: Int? = null var tag: String? = null var obj: Any? = null fun isFrom(tag: String): Boolean { return TextUtils.equals(this.tag, tag) } fun maybeFrom(vararg tags: String): Boolean { return listOf(*tags).contains(tag) } companion object { fun obtain(tag: String): EventMessage { val message = EventMessage() message.tag = tag return message } fun obtain(what: Int): EventMessage { val message = EventMessage() message.what = what return message } fun obtain(what: Int, obj: Any): EventMessage { val message = EventMessage() message.what = what message.obj = obj return message } fun obtain(tag: String, obj: Any): EventMessage { val message = EventMessage() message.tag = tag message.obj = obj return message } } } ================================================ FILE: app/src/main/java/io/legado/app/help/ExecutorService.kt ================================================ package io.legado.app.help import java.util.concurrent.ExecutorService import java.util.concurrent.Executors val globalExecutor: ExecutorService by lazy { Executors.newSingleThreadExecutor() } ================================================ FILE: app/src/main/java/io/legado/app/help/IntentData.kt ================================================ package io.legado.app.help object IntentData { private val bigData: MutableMap = mutableMapOf() @Synchronized fun put(key: String, data: Any?): String { data?.let { bigData[key] = data } return key } @Synchronized fun put(data: Any?): String { val key = System.currentTimeMillis().toString() data?.let { bigData[key] = data } return key } @Suppress("UNCHECKED_CAST") @Synchronized fun get(key: String?): T? { if (key == null) return null val data = bigData[key] bigData.remove(key) return data as? T } } ================================================ FILE: app/src/main/java/io/legado/app/help/IntentHelp.kt ================================================ package io.legado.app.help import android.content.Context import android.content.Intent import android.net.Uri import io.legado.app.R import io.legado.app.utils.toastOnUi import splitties.init.appCtx @Suppress("unused") object IntentHelp { fun getBrowserIntent(url: String): Intent { return getBrowserIntent(Uri.parse(url)) } fun getBrowserIntent(uri: Uri): Intent { val intent = Intent(Intent.ACTION_VIEW) intent.data = uri intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) if (intent.resolveActivity(appCtx.packageManager) == null) { return Intent.createChooser(intent, "请选择浏览器") } return intent } fun openTTSSetting() { //跳转到文字转语音设置界面 kotlin.runCatching { val intent = Intent() intent.action = "com.android.settings.TTS_SETTINGS" intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK appCtx.startActivity(intent) }.onFailure { appCtx.toastOnUi(R.string.tip_cannot_jump_setting_page) } } fun toInstallUnknown(context: Context) { kotlin.runCatching { val intent = Intent() intent.action = "android.settings.MANAGE_UNKNOWN_APP_SOURCES" intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK context.startActivity(intent) }.onFailure { context.toastOnUi("无法打开设置") } } } ================================================ FILE: app/src/main/java/io/legado/app/help/JsEncodeUtils.kt ================================================ package io.legado.app.help import android.util.Base64 import cn.hutool.crypto.digest.DigestUtil import cn.hutool.crypto.digest.HMac import cn.hutool.crypto.symmetric.SymmetricCrypto import io.legado.app.help.crypto.AsymmetricCrypto import io.legado.app.help.crypto.Sign import io.legado.app.help.crypto.SymmetricCryptoAndroid import io.legado.app.utils.MD5Utils /** * js加解密扩展类, 在js中通过java变量调用 * 添加方法,请更新文档/legado/app/src/main/assets/help/JsHelp.md */ @Suppress("unused") interface JsEncodeUtils { fun md5Encode(str: String): String { return MD5Utils.md5Encode(str) } fun md5Encode16(str: String): String { return MD5Utils.md5Encode16(str) } //******************对称加密解密************************// /** * 在js中这样使用 * java.createSymmetricCrypto(transformation, key, iv).decrypt(data) * java.createSymmetricCrypto(transformation, key, iv).decryptStr(data) * java.createSymmetricCrypto(transformation, key, iv).encrypt(data) * java.createSymmetricCrypto(transformation, key, iv).encryptBase64(data) * java.createSymmetricCrypto(transformation, key, iv).encryptHex(data) */ /* 调用SymmetricCrypto key为null时使用随机密钥*/ fun createSymmetricCrypto( transformation: String, key: ByteArray?, iv: ByteArray? ): SymmetricCrypto { val symmetricCrypto = SymmetricCryptoAndroid(transformation, key) return if (iv != null && iv.isNotEmpty()) symmetricCrypto.setIv(iv) else symmetricCrypto } fun createSymmetricCrypto( transformation: String, key: ByteArray ): SymmetricCrypto { return createSymmetricCrypto(transformation, key, null) } fun createSymmetricCrypto( transformation: String, key: String ): SymmetricCrypto { return createSymmetricCrypto(transformation, key, null) } fun createSymmetricCrypto( transformation: String, key: String, iv: String? ): SymmetricCrypto { return createSymmetricCrypto( transformation, key.encodeToByteArray(), iv?.encodeToByteArray() ) } //******************非对称加密解密************************// /* keys都为null时使用随机密钥 */ fun createAsymmetricCrypto( transformation: String ): AsymmetricCrypto { return AsymmetricCrypto(transformation) } //******************签名************************// fun createSign( algorithm: String ): Sign { return Sign(algorithm) } //******************对称加密解密old************************// /////AES /** * AES 解码为 ByteArray * @param str 传入的AES加密的数据 * @param key AES 解密的key * @param transformation AES加密的方式 * @param iv ECB模式的偏移向量 */ @Deprecated( "过于繁琐弃用", ReplaceWith("createSymmetricCrypto(transformation, key, iv).decrypt(str)") ) fun aesDecodeToByteArray( str: String, key: String, transformation: String, iv: String ): ByteArray? { return createSymmetricCrypto(transformation, key, iv).decrypt(str) } /** * AES 解码为 String * @param str 传入的AES加密的数据 * @param key AES 解密的key * @param transformation AES加密的方式 * @param iv ECB模式的偏移向量 */ @Deprecated( "过于繁琐弃用", ReplaceWith("createSymmetricCrypto(transformation, key, iv).decryptStr(str)") ) fun aesDecodeToString( str: String, key: String, transformation: String, iv: String ): String? { return createSymmetricCrypto(transformation, key, iv).decryptStr(str) } /** * AES解码为String,算法参数经过Base64加密 * * @param data 加密的字符串 * @param key Base64后的密钥 * @param mode 模式 * @param padding 补码方式 * @param iv Base64后的加盐 * @return 解密后的字符串 */ @Deprecated( "过于繁琐弃用", ReplaceWith("createSymmetricCrypto(transformation, key, iv).decryptStr(data)") ) fun aesDecodeArgsBase64Str( data: String, key: String, mode: String, padding: String, iv: String ): String? { return createSymmetricCrypto( "AES/${mode}/${padding}", Base64.decode(key, Base64.NO_WRAP), Base64.decode(iv, Base64.NO_WRAP) ).decryptStr(data) } /** * 已经base64的AES 解码为 ByteArray * @param str 传入的AES Base64加密的数据 * @param key AES 解密的key * @param transformation AES加密的方式 * @param iv ECB模式的偏移向量 */ @Deprecated( "过于繁琐弃用", ReplaceWith("createSymmetricCrypto(transformation, key, iv).decrypt(str)") ) fun aesBase64DecodeToByteArray( str: String, key: String, transformation: String, iv: String ): ByteArray? { return createSymmetricCrypto(transformation, key, iv).decrypt(str) } /** * 已经base64的AES 解码为 String * @param str 传入的AES Base64加密的数据 * @param key AES 解密的key * @param transformation AES加密的方式 * @param iv ECB模式的偏移向量 */ @Deprecated( "过于繁琐弃用", ReplaceWith("createSymmetricCrypto(transformation, key, iv).decryptStr(str)") ) fun aesBase64DecodeToString( str: String, key: String, transformation: String, iv: String ): String? { return createSymmetricCrypto(transformation, key, iv).decryptStr(str) } /** * 加密aes为ByteArray * @param data 传入的原始数据 * @param key AES加密的key * @param transformation AES加密的方式 * @param iv ECB模式的偏移向量 */ @Deprecated( "过于繁琐弃用", ReplaceWith("createSymmetricCrypto(transformation, key, iv).decrypt(data)") ) fun aesEncodeToByteArray( data: String, key: String, transformation: String, iv: String ): ByteArray? { return createSymmetricCrypto(transformation, key, iv).encrypt(data) } /** * 加密aes为String * @param data 传入的原始数据 * @param key AES加密的key * @param transformation AES加密的方式 * @param iv ECB模式的偏移向量 */ @Deprecated( "过于繁琐弃用", ReplaceWith("createSymmetricCrypto(transformation, key, iv).decryptStr(data)") ) fun aesEncodeToString( data: String, key: String, transformation: String, iv: String ): String? { return createSymmetricCrypto(transformation, key, iv).decryptStr(data) } /** * 加密aes后Base64化的ByteArray * @param data 传入的原始数据 * @param key AES加密的key * @param transformation AES加密的方式 * @param iv ECB模式的偏移向量 */ @Deprecated( "过于繁琐弃用", ReplaceWith("createSymmetricCrypto(transformation, key, iv).encryptBase64(data).toByteArray()") ) fun aesEncodeToBase64ByteArray( data: String, key: String, transformation: String, iv: String ): ByteArray? { return createSymmetricCrypto(transformation, key, iv).encryptBase64(data).toByteArray() } /** * 加密aes后Base64化的String * @param data 传入的原始数据 * @param key AES加密的key * @param transformation AES加密的方式 * @param iv ECB模式的偏移向量 */ @Deprecated( "过于繁琐弃用", ReplaceWith("createSymmetricCrypto(transformation, key, iv).encryptBase64(data)") ) fun aesEncodeToBase64String( data: String, key: String, transformation: String, iv: String ): String? { return createSymmetricCrypto(transformation, key, iv).encryptBase64(data) } /** * AES加密并转为Base64,算法参数经过Base64加密 * * @param data 被加密的字符串 * @param key Base64后的密钥 * @param mode 模式 * @param padding 补码方式 * @param iv Base64后的加盐 * @return 加密后的Base64 */ @Deprecated( "过于繁琐弃用", ReplaceWith("createSymmetricCrypto(transformation, key, iv).encryptBase64(data)") ) fun aesEncodeArgsBase64Str( data: String, key: String, mode: String, padding: String, iv: String ): String? { return createSymmetricCrypto("AES/${mode}/${padding}", key, iv).encryptBase64(data) } /////DES @Deprecated( "过于繁琐弃用", ReplaceWith("createSymmetricCrypto(transformation, key, iv).decryptStr(data)") ) fun desDecodeToString( data: String, key: String, transformation: String, iv: String ): String? { return createSymmetricCrypto(transformation, key, iv).decryptStr(data) } @Deprecated( "过于繁琐弃用", ReplaceWith("createSymmetricCrypto(transformation, key, iv).decryptStr(data)") ) fun desBase64DecodeToString( data: String, key: String, transformation: String, iv: String ): String? { return createSymmetricCrypto(transformation, key, iv).decryptStr(data) } @Deprecated( "过于繁琐弃用", ReplaceWith("createSymmetricCrypto(transformation, key, iv).encrypt(data)") ) fun desEncodeToString( data: String, key: String, transformation: String, iv: String ): String? { return String(createSymmetricCrypto(transformation, key, iv).encrypt(data)) } @Deprecated( "过于繁琐弃用", ReplaceWith("createSymmetricCrypto(transformation, key, iv).encryptBase64(data)") ) fun desEncodeToBase64String( data: String, key: String, transformation: String, iv: String ): String? { return createSymmetricCrypto(transformation, key, iv).encryptBase64(data) } //////3DES /** * 3DES解密 * * @param data 加密的字符串 * @param key 密钥 * @param mode 模式 * @param padding 补码方式 * @param iv 加盐 * @return 解密后的字符串 */ @Deprecated( "过于繁琐弃用", ReplaceWith("createSymmetricCrypto(transformation, key, iv).decryptStr(data)") ) fun tripleDESDecodeStr( data: String, key: String, mode: String, padding: String, iv: String ): String? { return createSymmetricCrypto("DESede/${mode}/${padding}", key, iv).decryptStr(data) } /** * 3DES解密,算法参数经过Base64加密 * * @param data 加密的字符串 * @param key Base64后的密钥 * @param mode 模式 * @param padding 补码方式 * @param iv Base64后的加盐 * @return 解密后的字符串 */ @Deprecated( "过于繁琐弃用", ReplaceWith("createSymmetricCrypto(transformation, key, iv).decryptStr(data)") ) fun tripleDESDecodeArgsBase64Str( data: String, key: String, mode: String, padding: String, iv: String ): String? { return createSymmetricCrypto( "DESede/${mode}/${padding}", Base64.decode(key, Base64.NO_WRAP), iv.encodeToByteArray() ).decryptStr(data) } /** * 3DES加密并转为Base64 * * @param data 被加密的字符串 * @param key 密钥 * @param mode 模式 * @param padding 补码方式 * @param iv 加盐 * @return 加密后的Base64 */ @Deprecated( "过于繁琐弃用", ReplaceWith("createSymmetricCrypto(transformation, key, iv).encryptBase64(data)") ) fun tripleDESEncodeBase64Str( data: String, key: String, mode: String, padding: String, iv: String ): String? { return createSymmetricCrypto("DESede/${mode}/${padding}", key, iv) .encryptBase64(data) } /** * 3DES加密并转为Base64,算法参数经过Base64加密 * * @param data 被加密的字符串 * @param key Base64后的密钥 * @param mode 模式 * @param padding 补码方式 * @param iv Base64后的加盐 * @return 加密后的Base64 */ @Deprecated( "过于繁琐弃用", ReplaceWith("createSymmetricCrypto(transformation, key, iv).encryptBase64(data)") ) fun tripleDESEncodeArgsBase64Str( data: String, key: String, mode: String, padding: String, iv: String ): String? { return createSymmetricCrypto( "DESede/${mode}/${padding}", Base64.decode(key, Base64.NO_WRAP), iv.encodeToByteArray() ).encryptBase64(data) } //******************消息摘要/散列消息鉴别码************************// /** * 生成摘要,并转为16进制字符串 * * @param data 被摘要数据 * @param algorithm 签名算法 * @return 16进制字符串 */ fun digestHex( data: String, algorithm: String, ): String { return DigestUtil.digester(algorithm).digestHex(data) } /** * 生成摘要,并转为Base64字符串 * * @param data 被摘要数据 * @param algorithm 签名算法 * @return Base64字符串 */ fun digestBase64Str( data: String, algorithm: String, ): String { return Base64.encodeToString(DigestUtil.digester(algorithm).digest(data), Base64.NO_WRAP) } /** * 生成散列消息鉴别码,并转为16进制字符串 * * @param data 被摘要数据 * @param algorithm 签名算法 * @param key 密钥 * @return 16进制字符串 */ @Suppress("FunctionName") fun HMacHex( data: String, algorithm: String, key: String ): String { return HMac(algorithm, key.toByteArray()).digestHex(data) } /** * 生成散列消息鉴别码,并转为Base64字符串 * * @param data 被摘要数据 * @param algorithm 签名算法 * @param key 密钥 * @return Base64字符串 */ @Suppress("FunctionName") fun HMacBase64( data: String, algorithm: String, key: String ): String { return Base64.encodeToString( HMac(algorithm, key.toByteArray()).digest(data), Base64.NO_WRAP ) } } ================================================ FILE: app/src/main/java/io/legado/app/help/JsExtensions.kt ================================================ package io.legado.app.help import android.webkit.WebSettings import androidx.annotation.Keep import cn.hutool.core.codec.Base64 import cn.hutool.core.util.HexUtil import com.script.rhino.rhinoContext import com.script.rhino.rhinoContextOrNull import io.legado.app.constant.AppConst import io.legado.app.constant.AppConst.dateFormat import io.legado.app.constant.AppLog 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.http.BackstageWebView import io.legado.app.help.http.CookieManager.cookieJarHeader import io.legado.app.help.http.CookieStore import io.legado.app.help.http.SSLHelper import io.legado.app.help.http.StrResponse import io.legado.app.help.source.SourceVerificationHelp 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.OpenUrlConfirmActivity import io.legado.app.utils.ArchiveUtils import io.legado.app.utils.ChineseUtils import io.legado.app.utils.EncoderUtils import io.legado.app.utils.EncodingDetect import io.legado.app.utils.FileUtils import io.legado.app.utils.GSON import io.legado.app.utils.HtmlFormatter import io.legado.app.utils.JsURL import io.legado.app.utils.MD5Utils import io.legado.app.utils.StringUtils import io.legado.app.utils.UrlUtil import io.legado.app.utils.compress.LibArchiveUtils import io.legado.app.utils.createFileReplace import io.legado.app.utils.externalCache import io.legado.app.utils.fromJsonObject import io.legado.app.utils.isAbsUrl import io.legado.app.utils.isMainThread import io.legado.app.utils.longToastOnUi import io.legado.app.utils.mapAsync import io.legado.app.utils.stackTraceStr import io.legado.app.utils.startActivity import io.legado.app.utils.toStringArray import io.legado.app.utils.toastOnUi import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.toList import kotlinx.coroutines.runBlocking import okio.use import org.jsoup.Connection import org.jsoup.Jsoup import splitties.init.appCtx import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.File import java.net.URLEncoder import java.nio.charset.Charset import java.security.MessageDigest import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.SimpleTimeZone import java.util.UUID import java.util.zip.ZipEntry import java.util.zip.ZipInputStream import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext /** * js扩展类, 在js中通过java变量调用 * 添加方法,请更新文档/legado/app/src/main/assets/help/JsHelp.md * 所有对于文件的读写删操作都是相对路径,只能操作阅读缓存内的文件 * /android/data/{package}/cache/... */ @Keep @Suppress("unused") interface JsExtensions : JsEncodeUtils { fun getSource(): BaseSource? private val context: CoroutineContext get() = rhinoContext.coroutineContext ?: EmptyCoroutineContext /** * 访问网络,返回String */ fun ajax(url: Any): String? { val urlStr = if (url is List<*>) { url.firstOrNull().toString() } else { url.toString() } val analyzeUrl = AnalyzeUrl(urlStr, source = getSource(), coroutineContext = context) return kotlin.runCatching { analyzeUrl.getStrResponse().body }.onFailure { rhinoContext.ensureActive() AppLog.put("ajax(${urlStr}) error\n${it.localizedMessage}", it) }.getOrElse { it.stackTraceStr } } /** * 并发访问网络 */ fun ajaxAll(urlList: Array): Array { return runBlocking(context) { urlList.asFlow().mapAsync(AppConfig.threadCount) { url -> val analyzeUrl = AnalyzeUrl( url, source = getSource(), coroutineContext = coroutineContext ) analyzeUrl.getStrResponseAwait() }.flowOn(IO).toList().toTypedArray() } } /** * 访问网络,返回Response */ fun connect(urlStr: String): StrResponse { val analyzeUrl = AnalyzeUrl( urlStr, source = getSource(), coroutineContext = context ) return kotlin.runCatching { analyzeUrl.getStrResponse() }.onFailure { rhinoContext.ensureActive() AppLog.put("connect(${urlStr}) error\n${it.localizedMessage}", it) }.getOrElse { StrResponse(analyzeUrl.url, it.stackTraceStr) } } fun connect(urlStr: String, header: String?): StrResponse { val headerMap = GSON.fromJsonObject>(header).getOrNull() val analyzeUrl = AnalyzeUrl( urlStr, headerMapF = headerMap, source = getSource(), coroutineContext = context ) return kotlin.runCatching { analyzeUrl.getStrResponse() }.onFailure { rhinoContext.ensureActive() AppLog.put("ajax($urlStr,$header) error\n${it.localizedMessage}", it) }.getOrElse { StrResponse(analyzeUrl.url, it.stackTraceStr) } } /** * 使用webView访问网络 * @param html 直接用webView载入的html, 如果html为空直接访问url * @param url html内如果有相对路径的资源不传入url访问不了 * @param js 用来取返回值的js语句, 没有就返回整个源代码 * @return 返回js获取的内容 */ fun webView(html: String?, url: String?, js: String?): String? { if (isMainThread) { error("webView must be called on a background thread") } return runBlocking(context) { BackstageWebView( url = url, html = html, javaScript = js, headerMap = getSource()?.getHeaderMap(true), tag = getSource()?.getKey() ).getStrResponse().body } } /** * 使用webView获取资源url */ fun webViewGetSource(html: String?, url: String?, js: String?, sourceRegex: String): String? { if (isMainThread) { error("webViewGetSource must be called on a background thread") } return runBlocking(context) { BackstageWebView( url = url, html = html, javaScript = js, headerMap = getSource()?.getHeaderMap(true), tag = getSource()?.getKey(), sourceRegex = sourceRegex ).getStrResponse().body } } /** * 使用webView获取跳转url */ fun webViewGetOverrideUrl( html: String?, url: String?, js: String?, overrideUrlRegex: String ): String? { if (isMainThread) { error("webViewGetOverrideUrl must be called on a background thread") } return runBlocking(context) { BackstageWebView( url = url, html = html, javaScript = js, headerMap = getSource()?.getHeaderMap(true), tag = getSource()?.getKey(), overrideUrlRegex = overrideUrlRegex ).getStrResponse().body } } /** * 使用内置浏览器打开链接,手动验证网站防爬 * @param url 要打开的链接 * @param title 浏览器页面的标题 */ fun startBrowser(url: String, title: String) { rhinoContext.ensureActive() SourceVerificationHelp.startBrowser(getSource(), url, title) } /** * 使用内置浏览器打开链接,并等待网页结果 */ 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) } /** * 打开图片验证码对话框,等待返回验证结果 */ fun getVerificationCode(imageUrl: String): String { rhinoContext.ensureActive() return SourceVerificationHelp.getVerificationResult(getSource(), imageUrl, "", false) } /** * 可从网络,本地文件(阅读私有数据目录相对路径)导入JavaScript脚本 */ fun importScript(path: String): String { val result = when { path.startsWith("http") -> cacheFile(path) else -> readTxtFile(path) } if (result.isBlank()) throw NoStackTraceException("$path 内容获取失败或者为空") return result } /** * 缓存以文本方式保存的文件 如.js .txt等 * @param urlStr 网络文件的链接 * @return 返回缓存后的文件内容 */ fun cacheFile(urlStr: String): String { return cacheFile(urlStr, 0) } /** * 缓存以文本方式保存的文件 如.js .txt等 * @param saveTime 缓存时间,单位:秒 */ fun cacheFile(urlStr: String, saveTime: Int): String { val key = md5Encode16(urlStr) val cachePath = CacheManager.get(key) return if ( cachePath.isNullOrBlank() || !getFile(cachePath).exists() ) { val path = downloadFile(urlStr) log("首次下载 $urlStr >> $path") CacheManager.put(key, path, saveTime) readTxtFile(path) } else { readTxtFile(cachePath) } } /** *js实现读取cookie */ fun getCookie(tag: String): String { return getCookie(tag, null) } fun getCookie(tag: String, key: String?): String { return if (key != null) { CookieStore.getKey(tag, key) } else { CookieStore.getCookie(tag) } } /** * 下载文件 * @param url 下载地址:可带参数type * @return 下载的文件相对路径 */ fun downloadFile(url: String): String { rhinoContext.ensureActive() val analyzeUrl = AnalyzeUrl(url, source = getSource(), coroutineContext = context) val type = analyzeUrl.type ?: UrlUtil.getSuffix(url) val path = FileUtils.getPath( File(FileUtils.getCachePath()), "${MD5Utils.md5Encode16(url)}.${type}" ) val file = File(path) file.delete() analyzeUrl.getInputStream().use { iStream -> file.createFileReplace() try { file.outputStream().buffered().use { oStream -> iStream.copyTo(oStream) } } catch (e: Throwable) { file.delete() throw e } } return path.substring(FileUtils.getCachePath().length) } /** * 实现16进制字符串转文件 * @param content 需要转成文件的16进制字符串 * @param url 通过url里的参数来判断文件类型 * @return 相对路径 */ @Deprecated( "Deprecated", ReplaceWith("downloadFile(url)") ) fun downloadFile(content: String, url: String): String { rhinoContext.ensureActive() val type = AnalyzeUrl(url, source = getSource(), coroutineContext = context).type ?: return "" val path = FileUtils.getPath( FileUtils.createFolderIfNotExist(FileUtils.getCachePath()), "${MD5Utils.md5Encode16(url)}.${type}" ) val file = File(path) file.createFileReplace() HexUtil.decodeHex(content).let { if (it.isNotEmpty()) { file.writeBytes(it) } } return path.substring(FileUtils.getCachePath().length) } /** * js实现重定向拦截,网络访问get */ fun get(urlStr: String, headers: Map): 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() Jsoup.connect(urlStr) .sslSocketFactory(SSLHelper.unsafeSSLSocketFactory) .ignoreContentType(true) .followRedirects(false) .headers(requestHeaders) .method(Connection.Method.GET) .execute() } return response } /** * js实现重定向拦截,网络访问head,不返回Response Body更省流量 */ fun head(urlStr: String, headers: Map): 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() Jsoup.connect(urlStr) .sslSocketFactory(SSLHelper.unsafeSSLSocketFactory) .ignoreContentType(true) .followRedirects(false) .headers(requestHeaders) .method(Connection.Method.HEAD) .execute() } return response } /** * 网络访问post */ fun post(urlStr: String, body: String, headers: Map): 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() Jsoup.connect(urlStr) .sslSocketFactory(SSLHelper.unsafeSSLSocketFactory) .ignoreContentType(true) .followRedirects(false) .requestBody(body) .headers(requestHeaders) .method(Connection.Method.POST) .execute() } return response } /* Str转ByteArray */ fun strToBytes(str: String): ByteArray { return str.toByteArray(charset("UTF-8")) } fun strToBytes(str: String, charset: String): ByteArray { return str.toByteArray(charset(charset)) } /* ByteArray转Str */ fun bytesToStr(bytes: ByteArray): String { return String(bytes, charset("UTF-8")) } fun bytesToStr(bytes: ByteArray, charset: String): String { return String(bytes, charset(charset)) } /** * js实现base64解码,不能删 */ fun base64Decode(str: String?): String { return Base64.decodeStr(str) } fun base64Decode(str: String?, charset: String): String { return Base64.decodeStr(str, charset(charset)) } fun base64Decode(str: String, flags: Int): String { return EncoderUtils.base64Decode(str, flags) } fun base64DecodeToByteArray(str: String?): ByteArray? { if (str.isNullOrBlank()) { return null } return EncoderUtils.base64DecodeToByteArray(str, 0) } fun base64DecodeToByteArray(str: String?, flags: Int): ByteArray? { if (str.isNullOrBlank()) { return null } return EncoderUtils.base64DecodeToByteArray(str, flags) } fun base64Encode(str: String): String? { return EncoderUtils.base64Encode(str, 2) } fun base64Encode(str: String, flags: Int): String? { return EncoderUtils.base64Encode(str, flags) } /* HexString 解码为字节数组 */ fun hexDecodeToByteArray(hex: String): ByteArray? { return HexUtil.decodeHex(hex) } /* hexString 解码为utf8String*/ fun hexDecodeToString(hex: String): String? { return HexUtil.decodeHexStr(hex) } /* utf8 编码为hexString */ fun hexEncodeToString(utf8: String): String? { return HexUtil.encodeHexStr(utf8) } /** * 格式化时间 */ fun timeFormatUTC(time: Long, format: String, sh: Int): String? { val utc = SimpleTimeZone(sh, "UTC") return SimpleDateFormat(format, Locale.getDefault()).run { timeZone = utc format(Date(time)) } } /** * 时间格式化 */ fun timeFormat(time: Long): String { return dateFormat.format(Date(time)) } fun encodeURI(str: String): String { return try { URLEncoder.encode(str, "UTF-8") } catch (e: Exception) { "" } } fun encodeURI(str: String, enc: String): String { return try { URLEncoder.encode(str, enc) } catch (e: Exception) { "" } } fun htmlFormat(str: String): String { return HtmlFormatter.formatKeepImg(str) } fun t2s(text: String): String { return ChineseUtils.t2s(text) } fun s2t(text: String): String { return ChineseUtils.s2t(text) } fun getWebViewUA(): String { return WebSettings.getDefaultUserAgent(appCtx) } //****************文件操作******************// /** * 获取本地文件 * @param path 相对路径 * @return File */ fun getFile(path: String): File { val cachePath = appCtx.externalCache.absolutePath val aPath = if (path.startsWith(File.separator)) { cachePath + path } else { cachePath + File.separator + path } val file = File(aPath) val safePath = appCtx.externalCache.parent!! if (!file.canonicalPath.startsWith(safePath)) { throw SecurityException("非法路径") } return file } fun readFile(path: String): ByteArray? { val file = getFile(path) if (file.exists()) { return file.readBytes() } return null } fun readTxtFile(path: String): String { val file = getFile(path) if (file.exists()) { val charsetName = EncodingDetect.getEncode(file) return String(file.readBytes(), charset(charsetName)) } return "" } fun readTxtFile(path: String, charsetName: String): String { val file = getFile(path) if (file.exists()) { return String(file.readBytes(), charset(charsetName)) } return "" } /** * 删除本地文件 */ fun deleteFile(path: String): Boolean { val file = getFile(path) return FileUtils.delete(file, true) } /** * js实现Zip压缩文件解压 * @param zipPath 相对路径 * @return 相对路径 */ fun unzipFile(zipPath: String): String { return unArchiveFile(zipPath) } /** * js实现7Zip压缩文件解压 * @param zipPath 相对路径 * @return 相对路径 */ fun un7zFile(zipPath: String): String { return unArchiveFile(zipPath) } /** * js实现Rar压缩文件解压 * @param zipPath 相对路径 * @return 相对路径 */ fun unrarFile(zipPath: String): String { return unArchiveFile(zipPath) } /** * js实现压缩文件解压 * @param zipPath 相对路径 * @return 相对路径 */ fun unArchiveFile(zipPath: String): String { if (zipPath.isEmpty()) return "" val zipFile = getFile(zipPath) return ArchiveUtils.deCompress(zipFile.absolutePath).let { ArchiveUtils.TEMP_FOLDER_NAME + File.separator + MD5Utils.md5Encode16(zipFile.name) } } /** * js实现文件夹内所有文本文件读取 * @param path 文件夹相对路径 * @return 所有文件字符串换行连接 */ fun getTxtInFolder(path: String): String { if (path.isEmpty()) return "" val folder = getFile(path) val contents = StringBuilder() folder.listFiles().let { if (it != null) { for (f in it) { val charsetName = EncodingDetect.getEncode(f) contents.append(String(f.readBytes(), charset(charsetName))) .append("\n") } contents.deleteCharAt(contents.length - 1) } } FileUtils.delete(folder.absolutePath) return contents.toString() } /** * 获取网络zip文件里面的数据 * @param url zip文件的链接或十六进制字符串 * @param path 所需获取文件在zip内的路径 * @return zip指定文件的数据 */ fun getZipStringContent(url: String, path: String): String { val byteArray = getZipByteArrayContent(url, path) ?: return "" val charsetName = EncodingDetect.getEncode(byteArray) return String(byteArray, Charset.forName(charsetName)) } fun getZipStringContent(url: String, path: String, charsetName: String): String { val byteArray = getZipByteArrayContent(url, path) ?: return "" return String(byteArray, Charset.forName(charsetName)) } /** * 获取网络zip文件里面的数据 * @param url zip文件的链接或十六进制字符串 * @param path 所需获取文件在zip内的路径 * @return zip指定文件的数据 */ fun getRarStringContent(url: String, path: String): String { val byteArray = getRarByteArrayContent(url, path) ?: return "" val charsetName = EncodingDetect.getEncode(byteArray) return String(byteArray, Charset.forName(charsetName)) } fun getRarStringContent(url: String, path: String, charsetName: String): String { val byteArray = getRarByteArrayContent(url, path) ?: return "" return String(byteArray, Charset.forName(charsetName)) } /** * 获取网络7zip文件里面的数据 * @param url 7zip文件的链接或十六进制字符串 * @param path 所需获取文件在7zip内的路径 * @return zip指定文件的数据 */ fun get7zStringContent(url: String, path: String): String { val byteArray = get7zByteArrayContent(url, path) ?: return "" val charsetName = EncodingDetect.getEncode(byteArray) return String(byteArray, Charset.forName(charsetName)) } fun get7zStringContent(url: String, path: String, charsetName: String): String { val byteArray = get7zByteArrayContent(url, path) ?: return "" return String(byteArray, Charset.forName(charsetName)) } /** * 获取网络zip文件里面的数据 * @param url zip文件的链接或十六进制字符串 * @param path 所需获取文件在zip内的路径 * @return zip指定文件的数据 */ fun getZipByteArrayContent(url: String, path: String): ByteArray? { val bytes = if (url.isAbsUrl()) { AnalyzeUrl(url, source = getSource(), coroutineContext = context).getByteArray() } else { HexUtil.decodeHex(url) } val bos = ByteArrayOutputStream() ZipInputStream(ByteArrayInputStream(bytes)).use { zis -> var entry: ZipEntry while (zis.nextEntry.also { entry = it } != null) { if (entry.name.equals(path)) { zis.use { it.copyTo(bos) } return bos.toByteArray() } entry = zis.nextEntry } } log("getZipContent 未发现内容") return null } /** * 获取网络Rar文件里面的数据 * @param url Rar文件的链接或十六进制字符串 * @param path 所需获取文件在Rar内的路径 * @return Rar指定文件的数据 */ fun getRarByteArrayContent(url: String, path: String): ByteArray? { val bytes = if (url.isAbsUrl()) { AnalyzeUrl(url, source = getSource(), coroutineContext = context).getByteArray() } else { HexUtil.decodeHex(url) } return ByteArrayInputStream(bytes).use { LibArchiveUtils.getByteArrayContent(it, path) } } /** * 获取网络7zip文件里面的数据 * @param url 7zip文件的链接或十六进制字符串 * @param path 所需获取文件在7zip内的路径 * @return 7zip指定文件的数据 */ fun get7zByteArrayContent(url: String, path: String): ByteArray? { val bytes = if (url.isAbsUrl()) { AnalyzeUrl(url, source = getSource(), coroutineContext = context).getByteArray() } else { HexUtil.decodeHex(url) } return ByteArrayInputStream(bytes).use { LibArchiveUtils.getByteArrayContent(it, path) } } //******************文件操作************************// /** * 解析字体Base64数据,返回字体解析类 */ @Deprecated( "Deprecated", ReplaceWith("queryTTF(data)") ) fun queryBase64TTF(data: String?): QueryTTF? { log("queryBase64TTF(String)方法已过时,并将在未来删除;请无脑使用queryTTF(Any)替代,新方法支持传入 url、本地文件、base64、ByteArray 自动判断&自动缓存,特殊情况需禁用缓存请传入第二可选参数false:Boolean") return queryTTF(data) } /** * 返回字体解析类 * @param data 支持url,本地文件,base64,ByteArray,自动判断,自动缓存 * @param useCache 可选开关缓存,不传入该值默认开启缓存 */ @OptIn(ExperimentalStdlibApi::class) fun queryTTF(data: Any?, useCache: Boolean): QueryTTF? { try { var key: String? = null var qTTF: QueryTTF? when (data) { is String -> { if (useCache) { key = MessageDigest.getInstance("SHA-256").digest(data.toByteArray()) .toHexString() qTTF = AppCacheManager.getQueryTTF(key) if (qTTF != null) return qTTF } val font: ByteArray? = when { data.isAbsUrl() -> AnalyzeUrl( data, source = getSource(), coroutineContext = context ).getByteArray() else -> base64DecodeToByteArray(data) } font ?: return null qTTF = QueryTTF(font) } is ByteArray -> { if (useCache) { key = MessageDigest.getInstance("SHA-256").digest(data).toHexString() qTTF = AppCacheManager.getQueryTTF(key) if (qTTF != null) return qTTF } qTTF = QueryTTF(data) } else -> return null } if (key != null) AppCacheManager.put(key, qTTF) return qTTF } catch (e: Exception) { AppLog.put("[queryTTF] 获取字体处理类出错", e) throw e } } fun queryTTF(data: Any?): QueryTTF? { return queryTTF(data, true) } /** * @param text 包含错误字体的内容 * @param errorQueryTTF 错误的字体 * @param correctQueryTTF 正确的字体 * @param filter 删除 errorQueryTTF 中不存在的字符 */ fun replaceFont( text: String, errorQueryTTF: QueryTTF?, correctQueryTTF: QueryTTF?, filter: Boolean ): String { if (errorQueryTTF == null || correctQueryTTF == null) return text val contentArray = text.toStringArray() //这里不能用toCharArray,因为有些文字占多个字节 val intArray = IntArray(1) contentArray.forEachIndexed { index, s -> val oldCode = s.codePointAt(0) // 忽略正常的空白字符 if (errorQueryTTF.isBlankUnicode(oldCode)) { return@forEachIndexed } // 删除轮廓数据不存在的字符 var glyf = errorQueryTTF.getGlyfByUnicode(oldCode) // 轮廓数据不存在 if (errorQueryTTF.getGlyfIdByUnicode(oldCode) == 0) glyf = null // 轮廓数据指向保留索引0 if (filter && (glyf == null)) { contentArray[index] = "" return@forEachIndexed } // 使用轮廓数据反查Unicode val code = correctQueryTTF.getUnicodeByGlyf(glyf) if (code != 0) { intArray[0] = code contentArray[index] = String(intArray, 0, 1) } } return contentArray.joinToString("") } /** * @param text 包含错误字体的内容 * @param errorQueryTTF 错误的字体 * @param correctQueryTTF 正确的字体 */ fun replaceFont( text: String, errorQueryTTF: QueryTTF?, correctQueryTTF: QueryTTF? ): String { return replaceFont(text, errorQueryTTF, correctQueryTTF, false) } /** * 章节数转数字 */ fun toNumChapter(s: String?): String? { s ?: return null val matcher = AppPattern.titleNumPattern.matcher(s) if (matcher.find()) { val intStr = StringUtils.stringToInt(matcher.group(2)) return "${matcher.group(1)}${intStr}${matcher.group(3)}" } return s } fun toURL(urlStr: String): JsURL { return JsURL(urlStr) } fun toURL(url: String, baseUrl: String? = null): JsURL { return JsURL(url, baseUrl) } /** * 弹窗提示 */ fun toast(msg: Any?) { rhinoContext.ensureActive() appCtx.toastOnUi("${getSource()?.getTag()}: ${msg.toString()}") } /** * 弹窗提示 停留时间较长 */ fun longToast(msg: Any?) { rhinoContext.ensureActive() appCtx.longToastOnUi("${getSource()?.getTag()}: ${msg.toString()}") } /** * 输出调试日志 */ fun log(msg: Any?): Any? { rhinoContextOrNull?.ensureActive() getSource()?.let { Debug.log(it.getKey(), msg.toString()) } ?: Debug.log(msg.toString()) AppLog.putDebug("${getSource()?.getTag() ?: "源"}调试输出: $msg") return msg } /** * 输出对象类型 */ fun logType(any: Any?) { if (any == null) { log("null") } else { log(any.javaClass.name) } } /** * 生成UUID */ fun randomUUID(): String { return UUID.randomUUID().toString() } fun androidId(): String { return AppConst.androidId } fun openUrl(url: String) { openUrl(url, null) } // 新增 mimeType 参数,默认为 null(保持兼容性) fun openUrl(url: String, mimeType: String? = null) { require(url.length < 64 * 1024) { "openUrl parameter url too long" } rhinoContext.ensureActive() val source = getSource() ?: throw NoStackTraceException("openUrl source cannot be null") appCtx.startActivity { putExtra("uri", url) putExtra("mimeType", mimeType) putExtra("sourceOrigin", source.getKey()) putExtra("sourceName", source.getTag()) putExtra("sourceType", source.getSourceType()) } } } ================================================ FILE: app/src/main/java/io/legado/app/help/LauncherIconHelp.kt ================================================ package io.legado.app.help import android.content.ComponentName import android.content.pm.PackageManager import android.os.Build import io.legado.app.R import io.legado.app.ui.welcome.* import io.legado.app.utils.toastOnUi import splitties.init.appCtx /** * Created by GKF on 2018/2/27. * 更换图标 */ object LauncherIconHelp { private val packageManager: PackageManager = appCtx.packageManager private val componentNames = arrayListOf( ComponentName(appCtx, Launcher1::class.java.name), ComponentName(appCtx, Launcher2::class.java.name), ComponentName(appCtx, Launcher3::class.java.name), ComponentName(appCtx, Launcher4::class.java.name), ComponentName(appCtx, Launcher5::class.java.name), ComponentName(appCtx, Launcher6::class.java.name) ) fun changeIcon(icon: String?) { if (icon.isNullOrEmpty()) return if (Build.VERSION.SDK_INT < 26) { appCtx.toastOnUi(R.string.change_icon_error) return } var hasEnabled = false componentNames.forEach { if (icon.equals(it.className.substringAfterLast("."), true)) { hasEnabled = true //启用 packageManager.setComponentEnabledSetting( it, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP ) } else { //禁用 packageManager.setComponentEnabledSetting( it, PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP ) } } if (hasEnabled) { packageManager.setComponentEnabledSetting( ComponentName(appCtx, WelcomeActivity::class.java.name), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP ) } else { packageManager.setComponentEnabledSetting( ComponentName(appCtx, WelcomeActivity::class.java.name), PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP ) } } } ================================================ FILE: app/src/main/java/io/legado/app/help/LayoutManager.kt ================================================ package io.legado.app.help import androidx.annotation.IntDef import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.StaggeredGridLayoutManager @Suppress("unused") object LayoutManager { interface LayoutManagerFactory { fun create(recyclerView: RecyclerView): RecyclerView.LayoutManager } @IntDef(LinearLayoutManager.HORIZONTAL, LinearLayoutManager.VERTICAL) @Retention(AnnotationRetention.SOURCE) annotation class Orientation fun linear(): LayoutManagerFactory { return object : LayoutManagerFactory { override fun create(recyclerView: RecyclerView): RecyclerView.LayoutManager { return LinearLayoutManager(recyclerView.context) } } } fun linear(@Orientation orientation: Int, reverseLayout: Boolean): LayoutManagerFactory { return object : LayoutManagerFactory { override fun create(recyclerView: RecyclerView): RecyclerView.LayoutManager { return LinearLayoutManager(recyclerView.context, orientation, reverseLayout) } } } fun grid(spanCount: Int): LayoutManagerFactory { return object : LayoutManagerFactory { override fun create(recyclerView: RecyclerView): RecyclerView.LayoutManager { return GridLayoutManager(recyclerView.context, spanCount) } } } fun grid( spanCount: Int, @Orientation orientation: Int, reverseLayout: Boolean ): LayoutManagerFactory { return object : LayoutManagerFactory { override fun create(recyclerView: RecyclerView): RecyclerView.LayoutManager { return GridLayoutManager( recyclerView.context, spanCount, orientation, reverseLayout ) } } } fun staggeredGrid(spanCount: Int, @Orientation orientation: Int): LayoutManagerFactory { return object : LayoutManagerFactory { override fun create(recyclerView: RecyclerView): RecyclerView.LayoutManager { return StaggeredGridLayoutManager(spanCount, orientation) } } } } ================================================ FILE: app/src/main/java/io/legado/app/help/LifecycleHelp.kt ================================================ package io.legado.app.help import android.app.Activity import android.app.Application import android.os.Bundle import io.legado.app.base.BaseService import io.legado.app.utils.LogUtils import java.lang.ref.WeakReference /** * Activity管理器,管理项目中Activity的状态 */ @Suppress("unused") object LifecycleHelp : Application.ActivityLifecycleCallbacks { private const val TAG = "LifecycleHelp" private val activities: MutableList> = arrayListOf() private val services: MutableList> = arrayListOf() private var appFinishedListener: (() -> Unit)? = null fun activitySize(): Int { return activities.size } /** * 判断指定Activity是否存在 */ fun isExistActivity(activityClass: Class<*>): Boolean { activities.forEach { item -> if (item.get()?.javaClass == activityClass) { return true } } return false } /** * 关闭指定 activity(class) */ fun finishActivity(vararg activityClasses: Class<*>) { val waitFinish = ArrayList>() for (temp in activities) { for (activityClass in activityClasses) { if (temp.get()?.javaClass == activityClass) { waitFinish.add(temp) break } } } waitFinish.forEach { it.get()?.finish() } } fun setOnAppFinishedListener(appFinishedListener: (() -> Unit)) { this.appFinishedListener = appFinishedListener } override fun onActivityPaused(activity: Activity) { LogUtils.d(TAG, "${activity::class.simpleName} onPause") } override fun onActivityResumed(activity: Activity) { LogUtils.d(TAG, "${activity::class.simpleName} onResume") } override fun onActivityStarted(activity: Activity) { LogUtils.d(TAG, "${activity::class.simpleName} onStart") } override fun onActivityDestroyed(activity: Activity) { LogUtils.d(TAG, "${activity::class.simpleName} onDestroy") for (temp in activities) { if (temp.get() != null && temp.get() === activity) { activities.remove(temp) if (services.size == 0 && activities.size == 0) { onAppFinished() } break } } } override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) { LogUtils.d(TAG, "${activity::class.simpleName} onSaveInstanceState") } override fun onActivityStopped(activity: Activity) { LogUtils.d(TAG, "${activity::class.simpleName} onStop") } override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { LogUtils.d(TAG, "${activity::class.simpleName} onCreate") activities.add(WeakReference(activity)) } @Synchronized fun onServiceCreate(service: BaseService) { LogUtils.d(TAG, "${service::class.simpleName} onCreate") services.add(WeakReference(service)) } @Synchronized fun onServiceDestroy(service: BaseService) { LogUtils.d(TAG, "${service::class.simpleName} onDestroy") for (temp in services) { if (temp.get() != null && temp.get() === service) { services.remove(temp) if (services.size == 0 && activities.size == 0) { onAppFinished() } break } } } private fun onAppFinished() { appFinishedListener?.invoke() } } ================================================ FILE: app/src/main/java/io/legado/app/help/MediaHelp.kt ================================================ package io.legado.app.help import android.content.Context import android.media.AudioManager import android.media.MediaPlayer import android.support.v4.media.session.PlaybackStateCompat import androidx.media.AudioAttributesCompat import androidx.media.AudioFocusRequestCompat import androidx.media.AudioManagerCompat import io.legado.app.R import splitties.systemservices.audioManager object MediaHelp { const val MEDIA_SESSION_ACTIONS = (PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or PlaybackStateCompat.ACTION_REWIND or PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_STOP or PlaybackStateCompat.ACTION_FAST_FORWARD or PlaybackStateCompat.ACTION_SKIP_TO_NEXT or PlaybackStateCompat.ACTION_SEEK_TO or PlaybackStateCompat.ACTION_SET_RATING or PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH or PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM or PlaybackStateCompat.ACTION_PLAY_FROM_URI or PlaybackStateCompat.ACTION_PREPARE or PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID or PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH or PlaybackStateCompat.ACTION_PREPARE_FROM_URI or PlaybackStateCompat.ACTION_SET_REPEAT_MODE or PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE or PlaybackStateCompat.ACTION_SET_CAPTIONING_ENABLED) fun buildAudioFocusRequestCompat( audioFocusChangeListener: AudioManager.OnAudioFocusChangeListener ): AudioFocusRequestCompat { val mPlaybackAttributes = AudioAttributesCompat.Builder() .setUsage(AudioAttributesCompat.USAGE_MEDIA) .setContentType(AudioAttributesCompat.CONTENT_TYPE_MUSIC) .build() return AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN) .setAudioAttributes(mPlaybackAttributes) .setOnAudioFocusChangeListener(audioFocusChangeListener) .build() } /** * @return 音频焦点 */ fun requestFocus(focusRequest: AudioFocusRequestCompat): Boolean { val request = AudioManagerCompat.requestAudioFocus(audioManager, focusRequest) return request == AudioManager.AUDIOFOCUS_REQUEST_GRANTED } /** * 播放静音音频,用来获取音频焦点 */ fun playSilentSound(mContext: Context) { kotlin.runCatching { // Stupid Android 8 "Oreo" hack to make media buttons work val mMediaPlayer = MediaPlayer.create(mContext, R.raw.silent_sound) mMediaPlayer.setOnCompletionListener { mMediaPlayer.release() } mMediaPlayer.start() } } } ================================================ FILE: app/src/main/java/io/legado/app/help/PaintPool.kt ================================================ package io.legado.app.help import android.graphics.Paint import io.legado.app.utils.objectpool.BaseSafeObjectPool object PaintPool : BaseSafeObjectPool(8) { private val emptyPaint = Paint() override fun create(): Paint = Paint() override fun recycle(target: Paint) { target.set(emptyPaint) super.recycle(target) } } ================================================ FILE: app/src/main/java/io/legado/app/help/README.md ================================================ # 放置一些帮助类 ================================================ FILE: app/src/main/java/io/legado/app/help/ReplaceAnalyzer.kt ================================================ package io.legado.app.help import io.legado.app.data.entities.ReplaceRule import io.legado.app.exception.NoStackTraceException import io.legado.app.utils.* object ReplaceAnalyzer { fun jsonToReplaceRules(json: String): Result> { return kotlin.runCatching { val replaceRules = mutableListOf() val items: List> = jsonPath.parse(json).read("$") for (item in items) { val jsonItem = jsonPath.parse(item) jsonToReplaceRule(jsonItem.jsonString()).getOrThrow().let { if (it.isValid()) { replaceRules.add(it) } } } replaceRules } } fun jsonToReplaceRule(json: String): Result { return runCatching { val replaceRule: ReplaceRule? = GSON.fromJsonObject(json.trim()).getOrNull() if (replaceRule == null || replaceRule.pattern.isEmpty()) { val jsonItem = jsonPath.parse(json.trim()) val rule = ReplaceRule() rule.id = jsonItem.readLong("$.id") ?: System.currentTimeMillis() rule.pattern = jsonItem.readString("$.regex") ?: "" if (rule.pattern.isEmpty()) throw NoStackTraceException("格式不对") rule.name = jsonItem.readString("$.replaceSummary") ?: "" rule.replacement = jsonItem.readString("$.replacement") ?: "" rule.isRegex = jsonItem.readBool("$.isRegex") == true rule.scope = jsonItem.readString("$.useTo") rule.isEnabled = jsonItem.readBool("$.enable") == true rule.order = jsonItem.readInt("$.serialNumber") ?: 0 return@runCatching rule } return@runCatching replaceRule } } } ================================================ FILE: app/src/main/java/io/legado/app/help/RuleBigDataHelp.kt ================================================ package io.legado.app.help import io.legado.app.data.appDb import io.legado.app.utils.FileUtils import io.legado.app.utils.MD5Utils import io.legado.app.utils.externalFiles import io.legado.app.utils.getFile import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.withContext import splitties.init.appCtx import java.io.File object RuleBigDataHelp { private val ruleDataDir = FileUtils.createFolderIfNotExist(appCtx.externalFiles, "ruleData") private val bookData = FileUtils.createFolderIfNotExist(ruleDataDir, "book") private val rssData = FileUtils.createFolderIfNotExist(ruleDataDir, "rss") suspend fun clearInvalid() { withContext(IO) { bookData.listFiles()?.forEach { if (it.isFile) { FileUtils.delete(it) } else { val bookUrlFile = it.getFile("bookUrl.txt") if (!bookUrlFile.exists()) { FileUtils.delete(it) } else { val bookUrl = bookUrlFile.readText() if (!appDb.bookDao.has(bookUrl)) { FileUtils.delete(it) } } } } rssData.listFiles()?.forEach { if (it.isFile) { FileUtils.delete(it) } else { val originFile = it.getFile("origin.txt") if (!originFile.exists()) { FileUtils.delete(it) } else { val origin = originFile.readText() if (!appDb.rssSourceDao.has(origin)) { FileUtils.delete(it) } } } } } } fun putBookVariable(bookUrl: String, key: String, value: String?) { val md5BookUrl = MD5Utils.md5Encode(bookUrl) val md5Key = MD5Utils.md5Encode(key) if (value == null) { FileUtils.delete(FileUtils.getPath(bookData, md5BookUrl, "$md5Key.txt"), true) } else { val valueFile = FileUtils.createFileIfNotExist(bookData, md5BookUrl, "$md5Key.txt") valueFile.writeText(value) val bookUrlFile = File(FileUtils.getPath(bookData, md5BookUrl, "bookUrl.txt")) if (!bookUrlFile.exists()) { bookUrlFile.writeText(bookUrl) } } } fun getBookVariable(bookUrl: String, key: String?): String? { val md5BookUrl = MD5Utils.md5Encode(bookUrl) val md5Key = MD5Utils.md5Encode(key) val file = File(FileUtils.getPath(bookData, md5BookUrl, "$md5Key.txt")) if (file.exists()) { return file.readText() } return null } fun hasBookVariable(bookUrl: String, key: String): Boolean { val md5BookUrl = MD5Utils.md5Encode(bookUrl) val md5Key = MD5Utils.md5Encode(key) val file = File(FileUtils.getPath(bookData, md5BookUrl, "$md5Key.txt")) return file.exists() } fun putChapterVariable(bookUrl: String, chapterUrl: String, key: String, value: String?) { val md5BookUrl = MD5Utils.md5Encode(bookUrl) val md5ChapterUrl = MD5Utils.md5Encode(chapterUrl) val md5Key = MD5Utils.md5Encode(key) if (value == null) { FileUtils.delete(FileUtils.getPath(bookData, md5BookUrl, md5ChapterUrl, "$md5Key.txt")) } else { val valueFile = FileUtils.createFileIfNotExist(bookData, md5BookUrl, md5ChapterUrl, "$md5Key.txt") valueFile.writeText(value) val bookUrlFile = File(FileUtils.getPath(bookData, md5BookUrl, "bookUrl.txt")) if (!bookUrlFile.exists()) { bookUrlFile.writeText(bookUrl) } } } fun getChapterVariable(bookUrl: String, chapterUrl: String, key: String): String? { val md5BookUrl = MD5Utils.md5Encode(bookUrl) val md5ChapterUrl = MD5Utils.md5Encode(chapterUrl) val md5Key = MD5Utils.md5Encode(key) val file = File(FileUtils.getPath(bookData, md5BookUrl, md5ChapterUrl, "$md5Key.txt")) if (file.exists()) { return file.readText() } return null } fun putRssVariable(origin: String, link: String, key: String, value: String?) { val md5Origin = MD5Utils.md5Encode(origin) val md5Link = MD5Utils.md5Encode(link) val md5Key = MD5Utils.md5Encode(key) val filePath = FileUtils.getPath(rssData, md5Origin, md5Link, "$md5Key.txt") if (value == null) { FileUtils.delete(filePath) } else { val valueFile = FileUtils.createFileIfNotExist(filePath) valueFile.writeText(value) val originFile = File(FileUtils.getPath(rssData, md5Origin, "origin.txt")) if (!originFile.exists()) { originFile.writeText(origin) } val linFile = File(FileUtils.getPath(rssData, md5Origin, md5Link, "origin.txt")) if (!linFile.exists()) { linFile.writeText(link) } } } fun getRssVariable(origin: String, link: String, key: String): String? { val md5Origin = MD5Utils.md5Encode(origin) val md5Link = MD5Utils.md5Encode(link) val md5Key = MD5Utils.md5Encode(key) val filePath = FileUtils.getPath(rssData, md5Origin, md5Link, "$md5Key.txt") val file = File(filePath) if (file.exists()) { return file.readText() } return null } } ================================================ FILE: app/src/main/java/io/legado/app/help/RuleComplete.kt ================================================ package io.legado.app.help @Suppress("RegExpRedundantEscape") object RuleComplete { // 需要补全 private val needComplete = Regex( """(?\&{2}|%%|\|{2}|$)""" ) // 不能补全 存在js/json/{{xx}}的复杂情况 private val notComplete = Regex("""^:|^##|\{\{|@js:||@Json:|\$\.""") // 修正从图片获取信息 private val fixImgInfo = Regex("""(?<=(^|tag\.|[\+/@>~| &]))img(?(\[@?.+\]|\.[-\w]+)?)[@/]+text(\(\))?(?\&{2}|%%|\|{2}|$)""") private val isXpath = Regex("^//|^@Xpath:") /** * 对简单规则进行补全,简化部分书源规则的编写 * 对JSOUP/XPath/CSS规则生效 * @author 希弥 * @return 补全后的规则 或 原规则 * @param rules 需要补全的规则 * @param preRule 预处理规则或列表规则 * @param type 补全结果的类型,可选的值有: * 1 文字(默认) * 2 链接 * 3 图片 */ fun autoComplete( rules: String?, preRule: String? = null, type: Int = 1 ): String? { if (rules.isNullOrEmpty() || rules.contains(notComplete) || preRule?.contains(notComplete) == true) { return rules } /** 尾部##分割的正则或由,分割的参数 */ val tailStr: String /** 分割字符 */ val splitStr: String /** 用于获取文字时添加的规则 */ val textRule: String /** 用于获取链接时添加的规则 */ val linkRule: String /** 用于获取图片时添加的规则 */ val imgRule: String /** 用于获取图片alt属性时添加的规则 */ val imgText: String // 分离尾部规则 val regexSplit = rules.split("""##|,\{""".toRegex(), 2) val cleanedRule = regexSplit[0] if (regexSplit.size > 1) { splitStr = """##|,\{""".toRegex().find(rules)?.value ?: "" tailStr = splitStr + regexSplit[1] } else { tailStr = "" } if (cleanedRule.contains(isXpath)) { textRule = "//text()\${seq}" linkRule = "//@href\${seq}" imgRule = "//@src\${seq}" imgText = "img\${at}/@alt\${seq}" } else { textRule = "@text\${seq}" linkRule = "@href\${seq}" imgRule = "@src\${seq}" imgText = "img\${at}@alt\${seq}" } return when (type) { 1 -> needComplete.replace(cleanedRule, textRule).replace(fixImgInfo, imgText) + tailStr 2 -> needComplete.replace(cleanedRule, linkRule) + tailStr 3 -> needComplete.replace(cleanedRule, imgRule) + tailStr else -> rules } } } ================================================ FILE: app/src/main/java/io/legado/app/help/TTS.kt ================================================ package io.legado.app.help import android.speech.tts.TextToSpeech import android.speech.tts.UtteranceProgressListener import io.legado.app.R import io.legado.app.constant.AppLog import io.legado.app.utils.buildMainHandler import io.legado.app.utils.splitNotBlank import io.legado.app.utils.toastOnUi import splitties.init.appCtx class TTS { private val handler by lazy { buildMainHandler() } private val tag = "legado_tts" private val clearTtsRunnable = Runnable { clearTts() } private var speakStateListener: SpeakStateListener? = null private var textToSpeech: TextToSpeech? = null private var text: String? = null private var onInit = false private val initListener by lazy { InitListener() } private val utteranceListener by lazy { TTSUtteranceListener() } val isSpeaking: Boolean get() { return textToSpeech?.isSpeaking ?: false } @Suppress("unused") fun setSpeakStateListener(speakStateListener: SpeakStateListener) { this.speakStateListener = speakStateListener } @Suppress("unused") fun removeSpeakStateListener() { speakStateListener = null } @Synchronized fun speak(text: String) { handler.removeCallbacks(clearTtsRunnable) this.text = text if (onInit) { return } if (textToSpeech == null) { onInit = true textToSpeech = TextToSpeech(appCtx, initListener) } else { addTextToSpeakList() } } fun stop() { textToSpeech?.stop() } @Synchronized fun clearTts() { textToSpeech?.let { tts -> tts.stop() tts.shutdown() } textToSpeech = null } private fun addTextToSpeakList() { val tts = textToSpeech ?: return kotlin.runCatching { var result = tts.speak("", TextToSpeech.QUEUE_FLUSH, null, null) if (result == TextToSpeech.ERROR) { clearTts() textToSpeech = TextToSpeech(appCtx, initListener) return } text?.splitNotBlank("\n")?.forEachIndexed { i, s -> result = tts.speak(s, TextToSpeech.QUEUE_ADD, null, tag + i) if (result == TextToSpeech.ERROR) { AppLog.put("tts朗读出错:$text") } } }.onFailure { AppLog.put("tts朗读出错", it) appCtx.toastOnUi(it.localizedMessage) } } /** * 初始化监听 */ private inner class InitListener : TextToSpeech.OnInitListener { override fun onInit(status: Int) { if (status == TextToSpeech.SUCCESS) { textToSpeech?.setOnUtteranceProgressListener(utteranceListener) addTextToSpeakList() } else { appCtx.toastOnUi(R.string.tts_init_failed) } onInit = false } } /** * 朗读监听 */ private inner class TTSUtteranceListener : UtteranceProgressListener() { override fun onStart(utteranceId: String?) { //开始朗读取消释放资源任务 handler.removeCallbacks(clearTtsRunnable) speakStateListener?.onStart() } override fun onDone(utteranceId: String?) { //一分钟没有朗读释放资源 handler.postDelayed(clearTtsRunnable, 60000L) speakStateListener?.onDone() } @Deprecated("Deprecated in Java") override fun onError(utteranceId: String?) { //Deprecated } } interface SpeakStateListener { fun onStart() fun onDone() } } ================================================ FILE: app/src/main/java/io/legado/app/help/book/BookContent.kt ================================================ package io.legado.app.help.book import io.legado.app.data.entities.ReplaceRule data class BookContent( val sameTitleRemoved: Boolean, val textList: List, //起效的替换规则 val effectiveReplaceRules: List? ) { override fun toString(): String { return textList.joinToString("\n") } } ================================================ FILE: app/src/main/java/io/legado/app/help/book/BookExtensions.kt ================================================ @file:Suppress("unused") package io.legado.app.help.book import android.net.Uri import androidx.core.net.toUri import com.script.buildScriptBindings import com.script.rhino.RhinoScriptEngine import io.legado.app.constant.AppLog import io.legado.app.constant.AppPattern import io.legado.app.constant.BookSourceType import io.legado.app.constant.BookType import io.legado.app.data.appDb import io.legado.app.data.entities.BaseBook import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookSource import io.legado.app.exception.NoStackTraceException import io.legado.app.help.RuleBigDataHelp import io.legado.app.help.config.AppConfig import io.legado.app.model.localBook.LocalBook import io.legado.app.utils.FileDoc import io.legado.app.utils.GSON import io.legado.app.utils.MD5Utils import io.legado.app.utils.exists import io.legado.app.utils.find import io.legado.app.utils.inputStream import io.legado.app.utils.isUri import io.legado.app.utils.normalizeFileName import io.legado.app.utils.toastOnUi import splitties.init.appCtx import java.io.File import java.time.LocalDate import java.time.Period.between import java.util.concurrent.ConcurrentHashMap import kotlin.math.max import kotlin.math.min val Book.isAudio: Boolean get() = isType(BookType.audio) val Book.isImage: Boolean get() = isType(BookType.image) val Book.isLocal: Boolean get() { if (type == 0) { return origin == BookType.localTag || origin.startsWith(BookType.webDavTag) } return isType(BookType.local) } val Book.isLocalTxt: Boolean get() = isLocal && originName.endsWith(".txt", true) val Book.isEpub: Boolean get() = isLocal && originName.endsWith(".epub", true) val Book.isUmd: Boolean get() = isLocal && originName.endsWith(".umd", true) val Book.isPdf: Boolean get() = isLocal && originName.endsWith(".pdf", true) val Book.isMobi: Boolean get() = isLocal && (originName.endsWith(".mobi", true) || originName.endsWith(".azw3", true) || originName.endsWith(".azw", true)) val Book.isOnLineTxt: Boolean get() = !isLocal && isType(BookType.text) val Book.isWebFile: Boolean get() = isType(BookType.webFile) val Book.isUpError: Boolean get() = isType(BookType.updateError) val Book.isArchive: Boolean get() = isType(BookType.archive) val Book.isNotShelf: Boolean get() = isType(BookType.notShelf) val Book.archiveName: String get() { if (!isArchive) throw NoStackTraceException("Book is not deCompressed from archive") // local_book::archive.rar // webDav::https://...../archive.rar return origin.substringAfter("::").substringAfterLast("/") } fun Book.contains(word: String?): Boolean { if (word.isNullOrEmpty()) { return true } return name.contains(word) || author.contains(word) || originName.contains(word) || origin.contains(word) || kind?.contains(word) == true || intro?.contains(word) == true } private val localUriCache by lazy { ConcurrentHashMap() } fun Book.getLocalUri(): Uri { if (!isLocal) { throw NoStackTraceException("不是本地书籍") } var uri = localUriCache[bookUrl] if (uri != null) { return uri } uri = if (bookUrl.isUri()) { bookUrl.toUri() } else { Uri.fromFile(File(bookUrl)) } //先检测uri是否有效,这个比较快 uri.inputStream(appCtx).getOrNull()?.use { localUriCache[bookUrl] = uri }?.let { return uri } //不同的设备书籍保存路径可能不一样, uri无效时尝试寻找当前保存路径下的文件 val defaultBookDir = AppConfig.defaultBookTreeUri val importBookDir = AppConfig.importBookPath // 查找书籍保存目录 if (!defaultBookDir.isNullOrBlank()) { val treeUri = defaultBookDir.toUri() val treeFileDoc = FileDoc.fromUri(treeUri, true) if (!treeFileDoc.exists()) { appCtx.toastOnUi("书籍保存目录失效,请重新设置!") } else { val fileDoc = treeFileDoc.find(originName, 5, 100) if (fileDoc != null) { localUriCache[bookUrl] = fileDoc.uri //更新bookUrl 重启不用再找一遍 bookUrl = fileDoc.toString() save() return fileDoc.uri } } } // 查找添加本地选择的目录 if (!importBookDir.isNullOrBlank() && defaultBookDir != importBookDir) { val treeUri = if (importBookDir.isUri()) { importBookDir.toUri() } else { Uri.fromFile(File(importBookDir)) } val treeFileDoc = FileDoc.fromUri(treeUri, true) val fileDoc = treeFileDoc.find(originName, 5, 100) if (fileDoc != null) { localUriCache[bookUrl] = fileDoc.uri bookUrl = fileDoc.toString() save() return fileDoc.uri } } localUriCache[bookUrl] = uri return uri } fun Book.getArchiveUri(): Uri? { val defaultBookDir = AppConfig.defaultBookTreeUri return if (isArchive && !defaultBookDir.isNullOrBlank()) { FileDoc.fromUri(defaultBookDir.toUri(), true) .find(archiveName)?.uri } else { null } } fun Book.cacheLocalUri(uri: Uri) { localUriCache[bookUrl] = uri } fun Book.removeLocalUriCache() { localUriCache.remove(bookUrl) } fun Book.getRemoteUrl(): String? { if (origin.startsWith(BookType.webDavTag)) { return origin.substring(BookType.webDavTag.length) } return null } fun Book.setType(@BookType.Type vararg types: Int) { type = 0 addType(*types) } fun Book.addType(@BookType.Type vararg types: Int) { types.forEach { type = type or it } } fun Book.removeType(@BookType.Type vararg types: Int) { types.forEach { type = type and it.inv() } } fun Book.removeAllBookType() { removeType(BookType.allBookType) } fun Book.clearType() { type = 0 } fun Book.isType(@BookType.Type bookType: Int): Boolean = type and bookType > 0 fun Book.upType() { if (type < 8) { type = when (type) { BookSourceType.image -> BookType.image BookSourceType.audio -> BookType.audio BookSourceType.file -> BookType.webFile else -> BookType.text } if (origin == BookType.localTag || origin.startsWith(BookType.webDavTag)) { type = type or BookType.local } } } fun Book.sync(oldBook: Book) { val curBook = appDb.bookDao.getBook(oldBook.bookUrl)!! durChapterTime = curBook.durChapterTime durChapterPos = curBook.durChapterPos if (durChapterIndex != curBook.durChapterIndex) { durChapterIndex = curBook.durChapterIndex val replaceRules = ContentProcessor.get(this).getTitleReplaceRules() appDb.bookChapterDao.getChapter(bookUrl, durChapterIndex)?.let { durChapterTitle = it.getDisplayTitle(replaceRules, getUseReplaceRule()) } } canUpdate = curBook.canUpdate readConfig = curBook.readConfig } fun Book.update() { appDb.bookDao.update(this) } fun Book.primaryStr(): String { return origin + bookUrl } fun Book.updateTo(newBook: Book): Book { newBook.durChapterIndex = durChapterIndex newBook.durChapterTitle = durChapterTitle newBook.durChapterPos = durChapterPos newBook.durChapterTime = durChapterTime newBook.group = group newBook.order = order newBook.customCoverUrl = customCoverUrl newBook.customIntro = customIntro newBook.customTag = customTag newBook.canUpdate = canUpdate newBook.readConfig = readConfig val variableMap = variableMap.toMutableMap() variableMap.keys.removeIf { newBook.hasVariable(it) } newBook.variableMap.putAll(variableMap) newBook.variable = GSON.toJson(newBook.variableMap) return newBook } fun Book.hasVariable(key: String): Boolean { return variableMap.contains(key) || RuleBigDataHelp.hasBookVariable(bookUrl, key) } fun Book.getFolderNameNoCache(): String { return name.replace(AppPattern.fileNameRegex, "").let { it.substring(0, min(9, it.length)) + MD5Utils.md5Encode16(bookUrl) } } fun Book.getBookSource(): BookSource? { return appDb.bookSourceDao.getBookSource(origin) } fun Book.isLocalModified(): Boolean { return isLocal && LocalBook.getLastModified(this).getOrDefault(0L) > latestChapterTime } fun Book.releaseHtmlData() { infoHtml = null tocHtml = null } fun Book.isSameNameAuthor(other: Any?): Boolean { if (other is BaseBook) { return name == other.name && author == other.author } return false } fun Book.getExportFileName(suffix: String): String { val jsStr = AppConfig.bookExportFileName if (jsStr.isNullOrBlank()) { return "$name 作者:${getRealAuthor()}.$suffix" } val bindings = buildScriptBindings { bindings -> bindings["epubIndex"] = ""// 兼容老版本,修复可能存在的错误 bindings["name"] = name bindings["author"] = getRealAuthor() } return kotlin.runCatching { RhinoScriptEngine.eval(jsStr, bindings).toString() + "." + suffix }.onFailure { AppLog.put("导出书名规则错误,使用默认规则\n${it.localizedMessage}", it) }.getOrDefault("$name 作者:${getRealAuthor()}.$suffix") } /** * 获取分割文件后的文件名 */ fun Book.getExportFileName( suffix: String, epubIndex: Int, jsStr: String? = AppConfig.episodeExportFileName ): String { // 默认规则 val default = "$name 作者:${getRealAuthor()} [${epubIndex}].$suffix" if (jsStr.isNullOrBlank()) { return default } val bindings = buildScriptBindings { bindings -> bindings["name"] = name bindings["author"] = getRealAuthor() bindings["epubIndex"] = epubIndex } return kotlin.runCatching { RhinoScriptEngine.eval(jsStr, bindings).toString() + "." + suffix }.onFailure { AppLog.put("导出书名规则错误,使用默认规则\n${it.localizedMessage}", it) }.getOrDefault(default).normalizeFileName() } // 根据当前日期计算章节总数 fun Book.simulatedTotalChapterNum(): Int { return if (readSimulating()) { val currentDate = LocalDate.now() val daysPassed = between(config.startDate, currentDate).days + 1 // 计算当前应该解锁到哪一章 val chaptersToUnlock = max(0, (config.startChapter ?: 0) + (daysPassed * config.dailyChapters)) min(totalChapterNum, chaptersToUnlock) } else { totalChapterNum } } fun Book.readSimulating(): Boolean { return config.readSimulating } fun tryParesExportFileName(jsStr: String): Boolean { val bindings = buildScriptBindings { bindings -> bindings["name"] = "name" bindings["author"] = "author" bindings["epubIndex"] = "epubIndex" } return runCatching { RhinoScriptEngine.eval(jsStr, bindings) true }.getOrDefault(false) } ================================================ FILE: app/src/main/java/io/legado/app/help/book/BookHelp.kt ================================================ package io.legado.app.help.book import android.graphics.BitmapFactory import android.os.ParcelFileDescriptor import androidx.documentfile.provider.DocumentFile import com.script.rhino.runScriptWithContext import io.legado.app.constant.AppLog import io.legado.app.constant.AppPattern import io.legado.app.constant.EventBus import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookSource import io.legado.app.help.config.AppConfig import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.model.localBook.LocalBook import io.legado.app.utils.ArchiveUtils import io.legado.app.utils.FileUtils import io.legado.app.utils.ImageUtils import io.legado.app.utils.MD5Utils import io.legado.app.utils.NetworkUtils import io.legado.app.utils.StringUtils import io.legado.app.utils.SvgUtils import io.legado.app.utils.UrlUtil import io.legado.app.utils.createFileIfNotExist import io.legado.app.utils.exists import io.legado.app.utils.externalFiles import io.legado.app.utils.getFile import io.legado.app.utils.isContentScheme import io.legado.app.utils.onEachParallel import io.legado.app.utils.postEvent import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.flow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.withContext import org.apache.commons.text.similarity.JaccardSimilarity import splitties.init.appCtx import java.io.ByteArrayInputStream import java.io.File import java.io.FileNotFoundException import java.io.FileOutputStream import java.io.IOException import java.util.concurrent.ConcurrentHashMap import java.util.regex.Pattern import java.util.zip.ZipFile import kotlin.coroutines.coroutineContext import kotlin.math.abs import kotlin.math.max import kotlin.math.min @Suppress("unused", "ConstPropertyName") object BookHelp { private val downloadDir: File = appCtx.externalFiles private const val cacheFolderName = "book_cache" private const val cacheImageFolderName = "images" private const val cacheEpubFolderName = "epub" private val downloadImages = ConcurrentHashMap() val cachePath = FileUtils.getPath(downloadDir, cacheFolderName) fun clearCache() { FileUtils.delete( FileUtils.getPath(downloadDir, cacheFolderName) ) } fun clearCache(book: Book) { val filePath = FileUtils.getPath(downloadDir, cacheFolderName, book.getFolderName()) FileUtils.delete(filePath) } fun updateCacheFolder(oldBook: Book, newBook: Book) { val oldFolderName = oldBook.getFolderNameNoCache() val newFolderName = newBook.getFolderNameNoCache() if (oldFolderName == newFolderName) return val oldFolderPath = FileUtils.getPath( downloadDir, cacheFolderName, oldFolderName ) val newFolderPath = FileUtils.getPath( downloadDir, cacheFolderName, newFolderName ) FileUtils.move(oldFolderPath, newFolderPath) } /** * 清除已删除书的缓存 解压缓存 */ suspend fun clearInvalidCache() { withContext(IO) { val bookFolderNames = hashSetOf() val originNames = hashSetOf() appDb.bookDao.all.forEach { clearComicCache(it) bookFolderNames.add(it.getFolderName()) if (it.isEpub) originNames.add(it.originName) } downloadDir.getFile(cacheFolderName) .listFiles()?.forEach { bookFile -> if (!bookFolderNames.contains(bookFile.name)) { FileUtils.delete(bookFile.absolutePath) } } downloadDir.getFile(cacheEpubFolderName) .listFiles()?.forEach { epubFile -> if (!originNames.contains(epubFile.name)) { FileUtils.delete(epubFile.absolutePath) } } FileUtils.delete(ArchiveUtils.TEMP_PATH) val filesDir = appCtx.filesDir FileUtils.delete("$filesDir/shareBookSource.json") FileUtils.delete("$filesDir/shareRssSource.json") FileUtils.delete("$filesDir/books.json") } } //清除已经看过的漫画数据 private fun clearComicCache(book: Book) { //只处理漫画 //为0的时候,不清除已缓存数据 if (!book.isImage || AppConfig.imageRetainNum == 0) { return } //向前保留设定数量,向后保留预下载数量 val startIndex = book.durChapterIndex - AppConfig.imageRetainNum val endIndex = book.durChapterIndex + AppConfig.preDownloadNum val chapterList = appDb.bookChapterDao.getChapterList(book.bookUrl, startIndex, endIndex) val imgNames = hashSetOf() //获取需要保留章节的图片信息 chapterList.forEach { val content = getContent(book, it) if (content != null) { val matcher = AppPattern.imgPattern.matcher(content) while (matcher.find()) { val src = matcher.group(1) ?: continue val mSrc = NetworkUtils.getAbsoluteURL(it.url, src) imgNames.add("${MD5Utils.md5Encode16(mSrc)}.${getImageSuffix(mSrc)}") } } } downloadDir.getFile( cacheFolderName, book.getFolderName(), cacheImageFolderName ).listFiles()?.forEach { imgFile -> if (!imgNames.contains(imgFile.name)) { imgFile.delete() } } } suspend fun saveContent( bookSource: BookSource, book: Book, bookChapter: BookChapter, content: String ) { try { saveText(book, bookChapter, content) //saveImages(bookSource, book, bookChapter, content) postEvent(EventBus.SAVE_CONTENT, Pair(book, bookChapter)) } catch (e: Exception) { e.printStackTrace() AppLog.put("保存正文失败 ${book.name} ${bookChapter.title}", e) } } fun saveText( book: Book, bookChapter: BookChapter, content: String ) { if (content.isEmpty()) return //保存文本 FileUtils.createFileIfNotExist( downloadDir, cacheFolderName, book.getFolderName(), bookChapter.getFileName(), ).writeText(content) if (book.isOnLineTxt && AppConfig.tocCountWords) { val wordCount = StringUtils.wordCountFormat(content.length) bookChapter.wordCount = wordCount appDb.bookChapterDao.upWordCount(bookChapter.bookUrl, bookChapter.url, wordCount) } } fun flowImages(bookChapter: BookChapter, content: String): Flow { return flow { val matcher = AppPattern.imgPattern.matcher(content) while (matcher.find()) { val src = matcher.group(1) ?: continue val mSrc = NetworkUtils.getAbsoluteURL(bookChapter.url, src) emit(mSrc) } } } suspend fun saveImages( bookSource: BookSource, book: Book, bookChapter: BookChapter, content: String, concurrency: Int = AppConfig.threadCount ) = coroutineScope { flowImages(bookChapter, content).onEachParallel(concurrency) { mSrc -> saveImage(bookSource, book, mSrc, bookChapter) }.collect() } suspend fun saveImage( bookSource: BookSource?, book: Book, src: String, chapter: BookChapter? = null ) { if (isImageExist(book, src)) { return } val mutex = synchronized(this) { downloadImages.getOrPut(src) { Mutex() } } mutex.lock() try { if (isImageExist(book, src)) { return } val analyzeUrl = AnalyzeUrl( src, source = bookSource, coroutineContext = coroutineContext ) val bytes = analyzeUrl.getByteArrayAwait() //某些图片被加密,需要进一步解密 runScriptWithContext { ImageUtils.decode( src, bytes, isCover = false, bookSource, book ) }?.let { if (!checkImage(it)) { // 如果部分图片失效,每次进入正文都会花很长时间再次获取图片数据 // 所以无论如何都要将数据写入到文件里 // throw NoStackTraceException("数据异常") AppLog.put("${book.name} ${chapter?.title} 图片 $src 下载错误 数据异常") } writeImage(book, src, it) } } catch (e: Exception) { coroutineContext.ensureActive() val msg = "${book.name} ${chapter?.title} 图片 $src 下载失败\n${e.localizedMessage}" AppLog.put(msg, e) } finally { downloadImages.remove(src) mutex.unlock() } } fun getImage(book: Book, src: String): File { return downloadDir.getFile( cacheFolderName, book.getFolderName(), cacheImageFolderName, "${MD5Utils.md5Encode16(src)}.${getImageSuffix(src)}" ) } @Synchronized fun writeImage(book: Book, src: String, bytes: ByteArray) { getImage(book, src).createFileIfNotExist().writeBytes(bytes) } @Synchronized fun isImageExist(book: Book, src: String): Boolean { return getImage(book, src).exists() } fun getImageSuffix(src: String): String { return UrlUtil.getSuffix(src, "jpg") } @Throws(IOException::class, FileNotFoundException::class) fun getEpubFile(book: Book): ZipFile { val uri = book.getLocalUri() if (uri.isContentScheme()) { FileUtils.createFolderIfNotExist(downloadDir, cacheEpubFolderName) val path = FileUtils.getPath(downloadDir, cacheEpubFolderName, book.originName) val file = File(path) val doc = DocumentFile.fromSingleUri(appCtx, uri) ?: throw IOException("文件不存在") if (!file.exists() || doc.lastModified() > book.latestChapterTime) { LocalBook.getBookInputStream(book).use { inputStream -> FileOutputStream(file).use { outputStream -> inputStream.copyTo(outputStream) } } } return ZipFile(file) } return ZipFile(uri.path) } /** * 获取本地书籍文件的ParcelFileDescriptor * * @param book * @return */ @Throws(IOException::class, FileNotFoundException::class) fun getBookPFD(book: Book): ParcelFileDescriptor? { val uri = book.getLocalUri() return if (uri.isContentScheme()) { appCtx.contentResolver.openFileDescriptor(uri, "r") } else { ParcelFileDescriptor.open(File(uri.path!!), ParcelFileDescriptor.MODE_READ_ONLY) } } fun getChapterFiles(book: Book): HashSet { val fileNames = hashSetOf() if (book.isLocalTxt) { return fileNames } FileUtils.createFolderIfNotExist( downloadDir, subDirs = arrayOf(cacheFolderName, book.getFolderName()) ).list()?.let { fileNames.addAll(it) } return fileNames } /** * 检测该章节是否下载 */ fun hasContent(book: Book, bookChapter: BookChapter): Boolean { return if (book.isLocalTxt || (bookChapter.isVolume && bookChapter.url.startsWith(bookChapter.title)) ) { true } else { downloadDir.exists( cacheFolderName, book.getFolderName(), bookChapter.getFileName() ) } } /** * 检测图片是否下载 */ fun hasImageContent(book: Book, bookChapter: BookChapter): Boolean { if (!hasContent(book, bookChapter)) { return false } var ret = true val op = BitmapFactory.Options() op.inJustDecodeBounds = true getContent(book, bookChapter)?.let { val matcher = AppPattern.imgPattern.matcher(it) while (matcher.find()) { val src = matcher.group(1)!! val image = getImage(book, src) if (!image.exists()) { ret = false continue } BitmapFactory.decodeFile(image.absolutePath, op) if (op.outWidth < 1 && op.outHeight < 1) { if (SvgUtils.getSize(image.absolutePath) != null) { continue } ret = false image.delete() } } } return ret } private fun checkImage(bytes: ByteArray): Boolean { val op = BitmapFactory.Options() op.inJustDecodeBounds = true BitmapFactory.decodeByteArray(bytes, 0, bytes.size, op) if (op.outWidth < 1 && op.outHeight < 1) { return SvgUtils.getSize(ByteArrayInputStream(bytes)) != null } return true } /** * 读取章节内容 */ fun getContent(book: Book, bookChapter: BookChapter): String? { val file = downloadDir.getFile( cacheFolderName, book.getFolderName(), bookChapter.getFileName() ) if (file.exists()) { val string = file.readText() if (string.isEmpty()) { return null } return string } if (book.isLocal) { val string = LocalBook.getContent(book, bookChapter) if (string != null && book.isEpub) { saveText(book, bookChapter, string) } return string } return null } /** * 删除章节内容 */ fun delContent(book: Book, bookChapter: BookChapter) { FileUtils.createFileIfNotExist( downloadDir, cacheFolderName, book.getFolderName(), bookChapter.getFileName() ).delete() } /** * 设置是否禁用正文的去除重复标题,针对单个章节 */ fun setRemoveSameTitle(book: Book, bookChapter: BookChapter, removeSameTitle: Boolean) { val fileName = bookChapter.getFileName("nr") val contentProcessor = ContentProcessor.get(book) if (removeSameTitle) { val path = FileUtils.getPath( downloadDir, cacheFolderName, book.getFolderName(), fileName ) contentProcessor.removeSameTitleCache.remove(fileName) File(path).delete() } else { FileUtils.createFileIfNotExist( downloadDir, cacheFolderName, book.getFolderName(), fileName ) contentProcessor.removeSameTitleCache.add(fileName) } } /** * 获取是否去除重复标题 */ fun removeSameTitle(book: Book, bookChapter: BookChapter): Boolean { val path = FileUtils.getPath( downloadDir, cacheFolderName, book.getFolderName(), bookChapter.getFileName("nr") ) return !File(path).exists() } /** * 格式化书名 */ fun formatBookName(name: String): String { return name .replace(AppPattern.nameRegex, "") .trim { it <= ' ' } } /** * 格式化作者 */ fun formatBookAuthor(author: String): String { return author .replace(AppPattern.authorRegex, "") .trim { it <= ' ' } } private val jaccardSimilarity by lazy { JaccardSimilarity() } /** * 根据目录名获取当前章节 */ fun getDurChapter( oldDurChapterIndex: Int, oldDurChapterName: String?, newChapterList: List, oldChapterListSize: Int = 0 ): Int { if (oldDurChapterIndex <= 0) return 0 if (newChapterList.isEmpty()) return oldDurChapterIndex val oldChapterNum = getChapterNum(oldDurChapterName) val oldName = getPureChapterName(oldDurChapterName) val newChapterSize = newChapterList.size val durIndex = if (oldChapterListSize == 0) oldDurChapterIndex else oldDurChapterIndex * oldChapterListSize / newChapterSize val min = max(0, min(oldDurChapterIndex, durIndex) - 10) val max = min(newChapterSize - 1, max(oldDurChapterIndex, durIndex) + 10) var nameSim = 0.0 var newIndex = 0 var newNum = 0 if (oldName.isNotEmpty()) { for (i in min..max) { val newName = getPureChapterName(newChapterList[i].title) val temp = jaccardSimilarity.apply(oldName, newName) if (temp > nameSim) { nameSim = temp newIndex = i } } } if (nameSim < 0.96 && oldChapterNum > 0) { for (i in min..max) { val temp = getChapterNum(newChapterList[i].title) if (temp == oldChapterNum) { newNum = temp newIndex = i break } else if (abs(temp - oldChapterNum) < abs(newNum - oldChapterNum)) { newNum = temp newIndex = i } } } return if (nameSim > 0.96 || abs(newNum - oldChapterNum) < 1) { newIndex } else { min(max(0, newChapterList.size - 1), oldDurChapterIndex) } } fun getDurChapter( oldBook: Book, newChapterList: List ): Int { return oldBook.run { getDurChapter(durChapterIndex, durChapterTitle, newChapterList, totalChapterNum) } } private val chapterNamePattern1 by lazy { Pattern.compile( ".*?第([\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+)[章节篇回集话]" ) } @Suppress("RegExpSimplifiable") private val chapterNamePattern2 by lazy { Pattern.compile( "^(?:[\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+[,:、])*([\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+)(?:[,:、]|\\.[^\\d])" ) } private val regexA by lazy { return@lazy "\\s".toRegex() } private fun getChapterNum(chapterName: String?): Int { chapterName ?: return -1 val chapterName1 = StringUtils.fullToHalf(chapterName).replace(regexA, "") return StringUtils.stringToInt( ( chapterNamePattern1.matcher(chapterName1).takeIf { it.find() } ?: chapterNamePattern2.matcher(chapterName1).takeIf { it.find() } )?.group(1) ?: "-1" ) } private val regexOther by lazy { // 所有非字母数字中日韩文字 CJK区+扩展A-F区 @Suppress("RegExpDuplicateCharacterInClass") return@lazy "[^\\w\\u4E00-\\u9FEF〇\\u3400-\\u4DBF\\u20000-\\u2A6DF\\u2A700-\\u2EBEF]".toRegex() } @Suppress("RegExpUnnecessaryNonCapturingGroup", "RegExpSimplifiable") private val regexB by lazy { //章节序号,排除处于结尾的状况,避免将章节名替换为空字串 return@lazy "^.*?第(?:[\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+)[章节篇回集话](?!$)|^(?:[\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+[,:、])*(?:[\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+)(?:[,:、](?!$)|\\.(?=[^\\d]))".toRegex() } private val regexC by lazy { //前后附加内容,整个章节名都在括号中时只剔除首尾括号,避免将章节名替换为空字串 return@lazy "(?!^)(?:[〖【《〔\\[{(][^〖【《〔\\[{()〕》】〗\\]}]+)?[)〕》】〗\\]}]$|^[〖【《〔\\[{(](?:[^〖【《〔\\[{()〕》】〗\\]}]+[〕》】〗\\]})])?(?!$)".toRegex() } private fun getPureChapterName(chapterName: String?): String { return if (chapterName == null) "" else StringUtils.fullToHalf(chapterName) .replace(regexA, "") .replace(regexB, "") .replace(regexC, "") .replace(regexOther, "") } } ================================================ FILE: app/src/main/java/io/legado/app/help/book/ContentHelp.kt ================================================ package io.legado.app.help.book import java.util.regex.Pattern import kotlin.math.max import kotlin.math.min @Suppress("SameParameterValue", "RegExpRedundantEscape") object ContentHelp { /** * 段落重排算法入口。把整篇内容输入,连接错误的分段,再把每个段落调用其他方法重新切分 * * @param content 正文 * @param chapterName 标题 * @return */ fun reSegment(content: String, chapterName: String): String { var content1 = content val dict = makeDict(content1) var p = content1 .replace(""".toRegex(), "“") .replace("[::]['\"‘”“]+".toRegex(), ":“") .replace("[\"”“]+\\s*[\"”“][\\s\"”“]*".toRegex(), "”\n“") .split("\n(\\s*)".toRegex()).toTypedArray() //初始化StringBuilder的长度,在原content的长度基础上做冗余 var buffer = StringBuilder((content1.length * 1.15).toInt()) // 章节的文本格式为章节标题-空行-首段,所以处理段落时需要略过第一行文本。 buffer.append(" ") if (chapterName.trim { it <= ' ' } != p[0].trim { it <= ' ' }) { // 去除段落内空格。unicode 3000 象形字间隔(中日韩符号和标点),不包含在\s内 buffer.append(p[0].replace("[\u3000\\s]+".toRegex(), "")) } //如果原文存在分段错误,需要把段落重新黏合 for (i in 1 until p.size) { if (match(MARK_SENTENCES_END, buffer.last()) || (match(MARK_QUOTATION_RIGHT, buffer.last()) && match(MARK_SENTENCES_END, buffer[buffer.lastIndex - 1])) ) { buffer.append("\n") } // 段落开头以外的地方不应该有空格 // 去除段落内空格。unicode 3000 象形字间隔(中日韩符号和标点),不包含在\s内 buffer.append(p[i].replace("[\u3000\\s]".toRegex(), "")) } // 预分段预处理 // ”“处理为”\n“。 // ”。“处理为”。\n“。不考虑“?” “!”的情况。 // ”。xxx处理为 ”。\n xxx p = buffer.toString() .replace("[\"”“]+\\s*[\"”“]+".toRegex(), "”\n“") .replace("[\"”“]+(?。!?!~)[\"”“]+".toRegex(), "”$1\n“") .replace("[\"”“]+(?。!?!~)([^\"”“])".toRegex(), "”$1\n$2") .replace( "([问说喊唱叫骂道着答])[\\.。]".toRegex(), "$1。\n" ) .split("\n".toRegex()).toTypedArray() buffer = StringBuilder((content1.length * 1.15).toInt()) for (s in p) { buffer.append("\n") buffer.append(findNewLines(s, dict)) } buffer = reduceLength(buffer) content1 = (buffer.toString() // 处理章节头部空格和换行 .replaceFirst("^\\s+".toRegex(), "") .replace("\\s*[\"”“]+\\s*[\"”“][\\s\"”“]*".toRegex(), "”\n“") .replace("[::][”“\"\\s]+".toRegex(), ":“") .replace("\n[\"“”]([^\n\"“”]+)([,:,:][\"”“])([^\n\"“”]+)".toRegex(), "\n$1:“$3") .replace("\n(\\s*)".toRegex(), "\n")) return content1 } /** * 强制切分,减少段落内的句子 * 如果连续2对引号的段落没有提示语,进入对话模式。最后一对引号后强制切分段落 * 如果引号内的内容长于5句,可能引号状态有误,随机分段 * 如果引号外的内容长于3句,随机分段 * * @param str * @return */ private fun reduceLength(str: StringBuilder): StringBuilder { val p = str.toString().split("\n".toRegex()).toTypedArray() val l = p.size val b = BooleanArray(l) for (i in 0 until l) { b[i] = p[i].matches(PARAGRAPH_DIAGLOG) } var dialogue = 0 for (i in 0 until l) { if (b[i]) { if (dialogue < 0) dialogue = 1 else if (dialogue < 2) dialogue++ } else { if (dialogue > 1) { p[i] = splitQuote(p[i]) dialogue-- } else if (dialogue > 0 && i < l - 2) { if (b[i + 1]) p[i] = splitQuote(p[i]) } } } val string = StringBuilder() for (i in 0 until l) { string.append('\n') string.append(p[i]) //System.out.print(" "+b[i]); } //System.out.println(" " + str); return string } // 强制切分进入对话模式后,未构成 “xxx” 形式的段落 private fun splitQuote(str: String): String { val length = str.length if (length < 3) return str if (match(MARK_QUOTATION, str[0])) { val i = seekIndex(str, MARK_QUOTATION, 1, length - 2, true) + 1 if (i > 1) if (!match(MARK_QUOTATION_BEFORE, str[i - 1])) { return "${str.take(i)}\n${str.substring(i)}" } } else if (match(MARK_QUOTATION, str[length - 1])) { val i = length - 1 - seekIndex(str, MARK_QUOTATION, 1, length - 2, false) if (i > 1) { if (!match(MARK_QUOTATION_BEFORE, str[i - 1])) { return "${str.take(i)}\n${str.substring(i)}" } } } return str } /** * 计算随机插入换行符的位置。 * @param str 字符串 * @param offset 传回的结果需要叠加的偏移量 * @param min 最低几个句子,随机插入换行 * @param gain 倍率。每个句子插入换行的数学期望 = 1 / gain , gain越大越不容易插入换行 * @return */ private fun forceSplit( str: String, offset: Int, min: Int, gain: Int, tigger: Int ): ArrayList { val result = ArrayList() val arrayEnd = seekIndexes(str, MARK_SENTENCES_END_P, 0, str.length - 2, true) val arrayMid = seekIndexes(str, MARK_SENTENCES_MID, 0, str.length - 2, true) if (arrayEnd.size < tigger && arrayMid.size < tigger * 3) return result var j = 0 var i = min while (i < arrayEnd.size) { var k = 0 while (j < arrayMid.size) { if (arrayMid[j] < arrayEnd[i]) k++ j++ } if (Math.random() * gain < 0.8 + k / 2.5) { result.add(arrayEnd[i] + offset) i = max(i + min, i) } i++ } return result } // 对内容重新划分段落.输入参数str已经使用换行符预分割 private fun findNewLines(str: String, dict: List): String { val string = StringBuilder(str) // 标记string中每个引号的位置.特别的,用引号进行列举时视为只有一对引号。 如:“锅”、“碗”视为“锅、碗”,从而避免误断句。 val arrayQuote: MutableList = ArrayList() // 标记插入换行符的位置,int为插入位置(str的char下标) var insN = ArrayList() //mod[i]标记str的每一段处于引号内还是引号外。范围: str.substring( array_quote.get(i), array_quote.get(i+1) )的状态。 //长度:array_quote.size(),但是初始化时未预估占用的长度,用空间换时间 //0未知,正数引号内,负数引号外。 //如果相邻的两个标记都为+1,那么需要增加1个引号。 //引号内不进行断句 val mod = IntArray(str.length) var waitClose = false for (i in str.indices) { val c = str[i] if (match(MARK_QUOTATION, c)) { val size = arrayQuote.size // 把“xxx”、“yy”合并为“xxx_yy”进行处理 if (size > 0) { val quotePre = arrayQuote[size - 1] if (i - quotePre == 2) { var remove = false if (waitClose) { if (match(",,、/", str[i - 1])) { // 考虑出现“和”这种特殊情况 remove = true } } else if (match(",,、/和与或", str[i - 1])) { remove = true } if (remove) { string.setCharAt(i, '“') string.setCharAt(i - 2, '”') arrayQuote.removeAt(size - 1) mod[size - 1] = 1 mod[size] = -1 continue } } } arrayQuote.add(i) // 为xxx:“xxx”做标记 if (i > 1) { // 当前发言的正引号的前一个字符 val charB1 = str[i - 1] // 上次发言的正引号的前一个字符 var charB2 = 0.toChar() if (match(MARK_QUOTATION_BEFORE, charB1)) { // 如果不是第一处引号,寻找上一处断句,进行分段 if (arrayQuote.size > 1) { val lastQuote = arrayQuote[arrayQuote.size - 2] var p = 0 if (charB1 == ',' || charB1 == ',') { if (arrayQuote.size > 2) { p = arrayQuote[arrayQuote.size - 3] if (p > 0) { charB2 = str[p - 1] } } } //if(char_b2=='.' || char_b2=='。') if (match(MARK_SENTENCES_END_P, charB2)) { insN.add(p - 1) } else if (!match("的", charB2)) { val lastEnd = seekLast(str, MARK_SENTENCES_END, i, lastQuote) if (lastEnd > 0) insN.add(lastEnd) else insN.add(lastQuote) } } waitClose = true mod[size] = 1 if (size > 0) { mod[size - 1] = -1 if (size > 1) { mod[size - 2] = 1 } } } else if (waitClose) { run { waitClose = false insN.add(i) } } } } } val size = arrayQuote.size //标记循环状态,此位置前的引号是否已经配对 var opend = false if (size > 0) { //第1次遍历array_quote,令其元素的值不为0 for (i in 0 until size) { if (mod[i] > 0) { opend = true } else if (mod[i] < 0) { //连续2个反引号表明存在冲突,强制把前一个设为正引号 if (!opend) { if (i > 0) mod[i] = 3 } opend = false } else { opend = !opend if (opend) mod[i] = 2 else mod[i] = -2 } } // 修正,断尾必须封闭引号 if (opend) { if (arrayQuote[size - 1] - string.length > -3) { //if((match(MARK_QUOTATION,string.charAt(string.length()-1)) || match(MARK_QUOTATION,string.charAt(string.length()-2)))){ if (size > 1) mod[size - 2] = 4 // 0<=i=1 mod[size - 1] = -4 } else if (!match(MARK_SENTENCES_SAY, string[string.length - 2])) string.append( "”" ) } //第2次循环,mod[i]由负变正时,前1字符如果是句末,需要插入换行 var loop2Mod1 = -1 //上一个引号跟随内容的状态 var loop2Mod2: Int //当前引号跟随内容的状态 var i = 0 var j = arrayQuote[0] - 1 //当前引号前一字符的序号 if (j < 0) { i = 1 loop2Mod1 = 0 } while (i < size) { j = arrayQuote[i] - 1 loop2Mod2 = mod[i] if (loop2Mod1 < 0 && loop2Mod2 > 0) { if (match(MARK_SENTENCES_END, string[j])) insN.add(j) } loop2Mod1 = loop2Mod2 i++ } } //第3次循环,匹配并插入换行。 //"xxxx" xxxx。\n xxx“xxxx” //未实现 // 使用字典验证ins_n , 避免插入不必要的换行。 // 由于目前没有插入、的列表,无法解决 “xx”、“xx”“xx” 被插入换行的问题 val insN1 = ArrayList() for (i in insN) { if (match("\"'”“", string[i])) { val start: Int = seekLast( str, "\"'”“", i - 1, i - WORD_MAX_LENGTH ) if (start > 0) { val word = str.substring(start + 1, i) if (dict.contains(word)) { //System.out.println("使用字典验证 跳过\tins_n=" + i + " word=" + word); //引号内如果是字典词条,后方不插入换行符(前方不需要优化) continue } else { //System.out.println("使用字典验证 插入\tins_n=" + i + " word=" + word); if (match("的地得", str[start])) { //xx的“xx”,后方不插入换行符(前方不需要优化) continue } } } } insN1.add(i) } insN = insN1 // 随机在句末插入换行符 insN = ArrayList(HashSet(insN)) insN.sort() run { var subs: String var j = 0 var progress = 0 var nextLine = -1 if (insN.isNotEmpty()) nextLine = insN[j] var gain = 3 var min = 0 var trigger = 2 for (i in arrayQuote.indices) { val qutoe = arrayQuote[i] if (qutoe > 0) { gain = 4 min = 2 trigger = 4 } else { gain = 3 min = 0 trigger = 2 } // 把引号前的换行符与内容相间插入 while (j < insN.size) { // 如果下一个换行符在当前引号前,那么需要此次处理.如果紧挨当前引号,需要考虑插入引号的情况 if (nextLine >= qutoe) break nextLine = insN[j] if (progress < nextLine) { subs = string.substring(progress, nextLine) insN.addAll(forceSplit(subs, progress, min, gain, trigger)) progress = nextLine + 1 } j++ } if (progress < qutoe) { subs = string.substring(progress, qutoe + 1) insN.addAll(forceSplit(subs, progress, min, gain, trigger)) progress = qutoe + 1 } } while (j < insN.size) { nextLine = insN[j] if (progress < nextLine) { subs = string.substring(progress, nextLine) insN.addAll(forceSplit(subs, progress, min, gain, trigger)) progress = nextLine + 1 } j++ } if (progress < string.length) { subs = string.substring(progress, string.length) insN.addAll(forceSplit(subs, progress, min, gain, trigger)) } } // 根据段落状态修正引号方向、计算需要插入引号的位置 // ins_quote跟随array_quote ins_quote[i]!=0,则array_quote.get(i)的引号前需要前插入'”' val insQuote = BooleanArray(size) opend = false for (i in 0 until size) { val p = arrayQuote[i] if (mod[i] > 0) { string.setCharAt(p, '“') if (opend) insQuote[i] = true opend = true } else if (mod[i] < 0) { string.setCharAt(p, '”') opend = false } else { opend = !opend if (opend) string.setCharAt(p, '“') else string.setCharAt(p, '”') } } insN = ArrayList(HashSet(insN)) insN.sort() // 完成字符串拼接(从string复制、插入引号和换行 // ins_quote 在引号前插入一个引号。 ins_quote[i]!=0,则array_quote.get(i)的引号前需要前插入'”' // ins_n 插入换行。数组的值表示插入换行符的位置 val buffer = StringBuilder((str.length * 1.15).toInt()) var j = 0 var progress = 0 var nextLine = -1 if (insN.isNotEmpty()) nextLine = insN[j] for (i in arrayQuote.indices) { val quote = arrayQuote[i] // 把引号前的换行符与内容相间插入 while (j < insN.size) { // 如果下一个换行符在当前引号前,那么需要此次处理.如果紧挨当前引号,需要考虑插入引号的情况 if (nextLine >= quote) break nextLine = insN[j] buffer.append(string, progress, nextLine + 1) buffer.append('\n') progress = nextLine + 1 j++ } if (progress < quote) { buffer.append(string, progress, quote + 1) progress = quote + 1 } if (insQuote[i] && buffer.length > 2) { if (buffer[buffer.length - 1] == '\n') buffer.append('“') else buffer.insert( buffer.length - 1, "”\n" ) } } while (j < insN.size) { nextLine = insN[j] if (progress <= nextLine) { buffer.append(string, progress, nextLine + 1) buffer.append('\n') progress = nextLine + 1 } j++ } if (progress < string.length) { buffer.append(string, progress, string.length) } return buffer.toString() } /** * 从字符串提取引号包围,且不止出现一次的内容为字典 * * @param str * @return 词条列表 */ private fun makeDict(str: String): List { // 引号中间不包含任何标点 val patten = Pattern.compile( """ (?<=["'”“])([^ \p{P}]{1,$WORD_MAX_LENGTH})(?=["'”“]) """.trimIndent() ) //Pattern patten = Pattern.compile("(?<=[\"'”“])([^\n\"'”“]{1,16})(?=[\"'”“])"); val matcher = patten.matcher(str) val cache: MutableList = ArrayList() val dict: MutableList = ArrayList() while (matcher.find()) { val word = matcher.group() if (cache.contains(word)) { if (!dict.contains(word)) dict.add(word) } else cache.add(word) } return dict } /** * 计算匹配到字典的每个字符的位置 * * @param str 待匹配的字符串 * @param key 字典 * @param from 从字符串的第几个字符开始匹配 * @param to 匹配到第几个字符结束 * @param inOrder 是否按照从前向后的顺序匹配 * @return 返回距离构成的ArrayList */ private fun seekIndexes( str: String, key: String, from: Int, to: Int, inOrder: Boolean ): ArrayList { val list = ArrayList() if (str.length - from < 1) return list var i = 0 if (from > 0) i = from var t = str.length if (to > 0) t = min(t, to) var c: Char while (i < t) { c = if (inOrder) str[i] else str[str.length - i - 1] if (key.indexOf(c) != -1) { if (list.isNotEmpty() && i - list.last() == 1) { list[list.lastIndex] = i } else { list.add(i) } } i++ } return list } /** * 计算字符串最后出现与字典中字符匹配的位置 * * @param str 数据字符串 * @param key 字典字符串 * @param from 从哪个字符开始匹配,默认最末位 * @param to 匹配到哪个字符(不包含此字符)默认0 * @return 位置(正向计算) */ private fun seekLast(str: String, key: String, from: Int, to: Int): Int { if (str.length - from < 1) return -1 var i = str.lastIndex if (from < i && i > 0) i = from var t = 0 if (to > 0) t = to var c: Char while (i > t) { c = str[i] if (key.indexOf(c) != -1) { return i } i-- } return -1 } /** * 计算字符串与字典中字符的最短距离 * * @param str 数据字符串 * @param key 字典字符串 * @param from 从哪个字符开始匹配,默认0 * @param to 匹配到哪个字符(不包含此字符)默认匹配到最末位 * @param inOrder 是否从正向开始匹配 * @return 返回最短距离, 注意不是str的char的下标 */ private fun seekIndex(str: String, key: String, from: Int, to: Int, inOrder: Boolean): Int { if (str.length - from < 1) return -1 var i = 0 if (from > 0) i = from var t = str.length if (to > 0) t = min(t, to) var c: Char while (i < t) { c = if (inOrder) str[i] else str[str.length - i - 1] if (key.indexOf(c) != -1) { return i } i++ } return -1 } /* 搜寻引号并进行分段。处理了一、二、五三类常见情况 参照百科词条[引号#应用示例](https://baike.baidu.com/item/%E5%BC%95%E5%8F%B7/998963?#5)对引号内容进行矫正并分句。 一、完整引用说话内容,在反引号内侧有断句标点。例如: 1) 丫姑折断几枝扔下来,边叫我的小名儿边说:“先喂饱你!” 2)“哎呀,真是美极了!”皇帝说,“我十分满意!” 3)“怕什么!海的美就在这里!”我说道。 二、部分引用,在反引号外侧有断句标点: 4)适当地改善自己的生活,岂但“你管得着吗”,而且是顺乎天理,合乎人情的。 5)现代画家徐悲鸿笔下的马,正如有的评论家所说的那样,“形神兼备,充满生机”。 6)唐朝的张嘉贞说它“制造奇特,人不知其所为”。 三、一段接着一段地直接引用时,中间段落只在段首用起引号,该段段尾却不用引回号。但是正统文学不在考虑范围内。 四、引号里面又要用引号时,外面一层用双引号,里面一层用单引号。暂时不需要考虑 五、反语和强调,周围没有断句符号。 */ // 句子结尾的标点。因为引号可能存在误判,不包含引号。 private const val MARK_SENTENCES_END = "?。!?!~" private const val MARK_SENTENCES_END_P = ".?。!?!~" // 句中标点,由于某些网站常把“,”写为".",故英文句点按照句中标点判断 private const val MARK_SENTENCES_MID = ".,、,—…" private const val MARK_SENTENCES_SAY = "问说喊唱叫骂道着答" // XXX说:“”的冒号 private const val MARK_QUOTATION_BEFORE = ",:,:" // 引号 private const val MARK_QUOTATION = "\"“”" private const val MARK_QUOTATION_RIGHT = "\"”" private val PARAGRAPH_DIAGLOG = "^[\"”“][^\"”“]+[\"”“]$".toRegex() // 限制字典的长度 private const val WORD_MAX_LENGTH = 16 private fun match(rule: String, chr: Char): Boolean { return rule.indexOf(chr) != -1 } } ================================================ FILE: app/src/main/java/io/legado/app/help/book/ContentProcessor.kt ================================================ package io.legado.app.help.book import android.os.Build import io.legado.app.constant.AppLog import io.legado.app.constant.AppPattern.spaceRegex import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.ReplaceRule import io.legado.app.exception.RegexTimeoutException import io.legado.app.help.config.AppConfig import io.legado.app.help.config.ReadBookConfig import io.legado.app.utils.ChineseUtils import io.legado.app.utils.escapeRegex import io.legado.app.utils.replace import io.legado.app.utils.stackTraceStr import io.legado.app.utils.toastOnUi import kotlinx.coroutines.CancellationException import splitties.init.appCtx import java.lang.ref.WeakReference import java.util.concurrent.CopyOnWriteArrayList import java.util.regex.Pattern class ContentProcessor private constructor( private val bookName: String, private val bookOrigin: String ) { companion object { private val processors = hashMapOf>() private val isAndroid8 = Build.VERSION.SDK_INT in 26..27 fun get(book: Book) = get(book.name, book.origin) fun get(bookName: String, bookOrigin: String): ContentProcessor { val processorWr = processors[bookName + bookOrigin] var processor: ContentProcessor? = processorWr?.get() if (processor == null) { processor = ContentProcessor(bookName, bookOrigin) processors[bookName + bookOrigin] = WeakReference(processor) } return processor } fun upReplaceRules() { processors.forEach { it.value.get()?.upReplaceRules() } } } private val titleReplaceRules = CopyOnWriteArrayList() private val contentReplaceRules = CopyOnWriteArrayList() val removeSameTitleCache = hashSetOf() init { upReplaceRules() upRemoveSameTitle() } fun upReplaceRules() { titleReplaceRules.run { clear() addAll(appDb.replaceRuleDao.findEnabledByTitleScope(bookName, bookOrigin)) } contentReplaceRules.run { clear() addAll(appDb.replaceRuleDao.findEnabledByContentScope(bookName, bookOrigin)) } } private fun upRemoveSameTitle() { val book = appDb.bookDao.getBookByOrigin(bookName, bookOrigin) ?: return removeSameTitleCache.clear() val files = BookHelp.getChapterFiles(book).filter { it.endsWith("nr") } removeSameTitleCache.addAll(files) } fun getTitleReplaceRules(): List { return titleReplaceRules } @Suppress("MemberVisibilityCanBePrivate") fun getContentReplaceRules(): List { return contentReplaceRules } fun getContent( book: Book, chapter: BookChapter, content: String, includeTitle: Boolean = true, useReplace: Boolean = true, chineseConvert: Boolean = true, reSegment: Boolean = true ): BookContent { var mContent = content var sameTitleRemoved = false var effectiveReplaceRules: ArrayList? = null if (content != "null") { //去除重复标题 val fileName = chapter.getFileName("nr") if (!removeSameTitleCache.contains(fileName)) try { val name = Pattern.quote(book.name) var title = chapter.title.escapeRegex().replace(spaceRegex, "\\\\s*") var matcher = Pattern.compile("^(\\s|\\p{P}|${name})*${title}(\\s)*") .matcher(mContent) if (matcher.find()) { mContent = mContent.substring(matcher.end()) sameTitleRemoved = true } else if (useReplace && book.getUseReplaceRule()) { title = Pattern.quote( chapter.getDisplayTitle( titleReplaceRules, chineseConvert = false ) ) matcher = Pattern.compile("^(\\s|\\p{P}|${name})*${title}(\\s)*") .matcher(mContent) if (matcher.find()) { mContent = mContent.substring(matcher.end()) sameTitleRemoved = true } } } catch (e: Exception) { AppLog.put("去除重复标题出错\n${e.localizedMessage}", e) } if (reSegment && book.getReSegment()) { //重新分段 mContent = ContentHelp.reSegment(mContent, chapter.title) } if (chineseConvert) { //简繁转换 try { when (AppConfig.chineseConverterType) { 1 -> mContent = ChineseUtils.t2s(mContent) 2 -> mContent = ChineseUtils.s2t(mContent) } } catch (_: Exception) { appCtx.toastOnUi("简繁转换出错") } } if (useReplace && book.getUseReplaceRule()) { //替换 effectiveReplaceRules = arrayListOf() mContent = mContent.lines().joinToString("\n") { it.trim() } getContentReplaceRules().forEach { item -> if (item.pattern.isEmpty()) { return@forEach } try { val tmp = if (item.isRegex) { mContent.replace( item.regex, item.replacement, item.getValidTimeoutMillisecond() ) } else { mContent.replace(item.pattern, item.replacement) } if (mContent != tmp) { effectiveReplaceRules.add(item) mContent = tmp } } catch (e: RegexTimeoutException) { item.isEnabled = false appDb.replaceRuleDao.update(item) mContent = item.name + e.stackTraceStr } catch (_: CancellationException) { } catch (e: Exception) { AppLog.put("替换净化: 规则 ${item.name}替换出错.\n${mContent}", e) appCtx.toastOnUi("替换净化: 规则 ${item.name}替换出错") } } } } if (includeTitle) { //重新添加标题 mContent = chapter.getDisplayTitle( getTitleReplaceRules(), useReplace = useReplace && book.getUseReplaceRule() ) + "\n" + mContent } if (isAndroid8) { mContent = mContent.replace('\u00A0', ' ') } val contents = arrayListOf() mContent.split("\n").forEach { str -> val paragraph = str.trim { it.code <= 0x20 || it == ' ' } if (paragraph.isNotEmpty()) { if (contents.isEmpty() && includeTitle) { contents.add(paragraph) } else { contents.add("${ReadBookConfig.paragraphIndent}$paragraph") } } } return BookContent(sameTitleRemoved, contents, effectiveReplaceRules) } } ================================================ FILE: app/src/main/java/io/legado/app/help/config/AppConfig.kt ================================================ package io.legado.app.help.config import android.content.SharedPreferences import android.os.Build import io.legado.app.BuildConfig import io.legado.app.constant.AppConst import io.legado.app.constant.PreferKey import io.legado.app.data.appDb import io.legado.app.utils.canvasrecorder.CanvasRecorderFactory import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.getPrefInt import io.legado.app.utils.getPrefLong import io.legado.app.utils.getPrefString import io.legado.app.utils.isNightMode import io.legado.app.utils.putPrefBoolean import io.legado.app.utils.putPrefInt import io.legado.app.utils.putPrefLong import io.legado.app.utils.putPrefString import io.legado.app.utils.removePref import io.legado.app.utils.sysConfiguration import io.legado.app.utils.toastOnUi import splitties.init.appCtx @Suppress("MemberVisibilityCanBePrivate", "ConstPropertyName") object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener { val isCronet = appCtx.getPrefBoolean(PreferKey.cronet) var useAntiAlias = appCtx.getPrefBoolean(PreferKey.antiAlias) var userAgent: String = getPrefUserAgent() var isEInkMode = appCtx.getPrefString(PreferKey.themeMode) == "3" var clickActionTL = appCtx.getPrefInt(PreferKey.clickActionTL, 2) var clickActionTC = appCtx.getPrefInt(PreferKey.clickActionTC, 2) var clickActionTR = appCtx.getPrefInt(PreferKey.clickActionTR, 1) var clickActionML = appCtx.getPrefInt(PreferKey.clickActionML, 2) var clickActionMC = appCtx.getPrefInt(PreferKey.clickActionMC, 0) var clickActionMR = appCtx.getPrefInt(PreferKey.clickActionMR, 1) var clickActionBL = appCtx.getPrefInt(PreferKey.clickActionBL, 2) var clickActionBC = appCtx.getPrefInt(PreferKey.clickActionBC, 1) var clickActionBR = appCtx.getPrefInt(PreferKey.clickActionBR, 1) var themeMode = appCtx.getPrefString(PreferKey.themeMode, "0") var useDefaultCover = appCtx.getPrefBoolean(PreferKey.useDefaultCover, false) var optimizeRender = CanvasRecorderFactory.isSupport && appCtx.getPrefBoolean(PreferKey.optimizeRender, false) var recordLog = appCtx.getPrefBoolean(PreferKey.recordLog) override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { when (key) { PreferKey.themeMode -> { themeMode = appCtx.getPrefString(PreferKey.themeMode, "0") isEInkMode = themeMode == "3" } PreferKey.clickActionTL -> clickActionTL = appCtx.getPrefInt(PreferKey.clickActionTL, 2) PreferKey.clickActionTC -> clickActionTC = appCtx.getPrefInt(PreferKey.clickActionTC, 2) PreferKey.clickActionTR -> clickActionTR = appCtx.getPrefInt(PreferKey.clickActionTR, 1) PreferKey.clickActionML -> clickActionML = appCtx.getPrefInt(PreferKey.clickActionML, 2) PreferKey.clickActionMC -> clickActionMC = appCtx.getPrefInt(PreferKey.clickActionMC, 0) PreferKey.clickActionMR -> clickActionMR = appCtx.getPrefInt(PreferKey.clickActionMR, 1) PreferKey.clickActionBL -> clickActionBL = appCtx.getPrefInt(PreferKey.clickActionBL, 2) PreferKey.clickActionBC -> clickActionBC = appCtx.getPrefInt(PreferKey.clickActionBC, 1) PreferKey.clickActionBR -> clickActionBR = appCtx.getPrefInt(PreferKey.clickActionBR, 1) PreferKey.readBodyToLh -> ReadBookConfig.readBodyToLh = appCtx.getPrefBoolean(PreferKey.readBodyToLh, true) PreferKey.useZhLayout -> ReadBookConfig.useZhLayout = appCtx.getPrefBoolean(PreferKey.useZhLayout) PreferKey.userAgent -> userAgent = getPrefUserAgent() PreferKey.antiAlias -> useAntiAlias = appCtx.getPrefBoolean(PreferKey.antiAlias) PreferKey.useDefaultCover -> useDefaultCover = appCtx.getPrefBoolean(PreferKey.useDefaultCover, false) PreferKey.optimizeRender -> optimizeRender = CanvasRecorderFactory.isSupport && appCtx.getPrefBoolean(PreferKey.optimizeRender, false) PreferKey.recordLog -> recordLog = appCtx.getPrefBoolean(PreferKey.recordLog) } } var isNightTheme: Boolean get() = when (themeMode) { "1" -> false "2" -> true "3" -> false else -> sysConfiguration.isNightMode } set(value) { if (isNightTheme != value) { if (value) { appCtx.putPrefString(PreferKey.themeMode, "2") } else { appCtx.putPrefString(PreferKey.themeMode, "1") } } } var showUnread: Boolean get() = appCtx.getPrefBoolean(PreferKey.showUnread, true) set(value) { appCtx.putPrefBoolean(PreferKey.showUnread, value) } var showLastUpdateTime: Boolean get() = appCtx.getPrefBoolean(PreferKey.showLastUpdateTime, false) set(value) { appCtx.putPrefBoolean(PreferKey.showLastUpdateTime, value) } var showWaitUpCount: Boolean get() = appCtx.getPrefBoolean(PreferKey.showWaitUpCount, false) set(value) { appCtx.putPrefBoolean(PreferKey.showWaitUpCount, value) } var readBrightness: Int get() = if (isNightTheme) { appCtx.getPrefInt(PreferKey.nightBrightness, 100) } else { appCtx.getPrefInt(PreferKey.brightness, 100) } set(value) { if (isNightTheme) { appCtx.putPrefInt(PreferKey.nightBrightness, value) } else { appCtx.putPrefInt(PreferKey.brightness, value) } } val textSelectAble: Boolean get() = appCtx.getPrefBoolean(PreferKey.textSelectAble, true) val isTransparentStatusBar: Boolean get() = appCtx.getPrefBoolean(PreferKey.transparentStatusBar, true) val immNavigationBar: Boolean get() = appCtx.getPrefBoolean(PreferKey.immNavigationBar, true) val screenOrientation: String? get() = appCtx.getPrefString(PreferKey.screenOrientation) var bookGroupStyle: Int get() = appCtx.getPrefInt(PreferKey.bookGroupStyle, 0) set(value) { appCtx.putPrefInt(PreferKey.bookGroupStyle, value) } var bookshelfLayout: Int get() = appCtx.getPrefInt(PreferKey.bookshelfLayout, 0) set(value) { appCtx.putPrefInt(PreferKey.bookshelfLayout, value) } var saveTabPosition: Int get() = appCtx.getPrefInt(PreferKey.saveTabPosition, 0) set(value) { appCtx.putPrefInt(PreferKey.saveTabPosition, value) } var bookExportFileName: String? get() = appCtx.getPrefString(PreferKey.bookExportFileName) set(value) { appCtx.putPrefString(PreferKey.bookExportFileName, value) } // 保存 自定义导出章节模式 文件名js表达式 var episodeExportFileName: String? get() = appCtx.getPrefString(PreferKey.episodeExportFileName, "") set(value) { appCtx.putPrefString(PreferKey.episodeExportFileName, value) } var bookImportFileName: String? get() = appCtx.getPrefString(PreferKey.bookImportFileName) set(value) { appCtx.putPrefString(PreferKey.bookImportFileName, value) } var backupPath: String? get() = appCtx.getPrefString(PreferKey.backupPath) set(value) { if (value.isNullOrEmpty()) { appCtx.removePref(PreferKey.backupPath) } else { appCtx.putPrefString(PreferKey.backupPath, value) } } // 书籍保存位置 var defaultBookTreeUri: String? get() = appCtx.getPrefString(PreferKey.defaultBookTreeUri) set(value) { if (value.isNullOrEmpty()) { appCtx.removePref(PreferKey.defaultBookTreeUri) } else { appCtx.putPrefString(PreferKey.defaultBookTreeUri, value) } } val showDiscovery: Boolean get() = appCtx.getPrefBoolean(PreferKey.showDiscovery, true) val showRSS: Boolean get() = appCtx.getPrefBoolean(PreferKey.showRss, true) val autoRefreshBook: Boolean get() = appCtx.getPrefBoolean(PreferKey.autoRefresh) var enableReview: Boolean get() = BuildConfig.DEBUG && appCtx.getPrefBoolean(PreferKey.enableReview, false) set(value) { appCtx.putPrefBoolean(PreferKey.enableReview, value) } var threadCount: Int get() = appCtx.getPrefInt(PreferKey.threadCount, 16) set(value) { appCtx.putPrefInt(PreferKey.threadCount, value) } var remoteServerId: Long get() = appCtx.getPrefLong(PreferKey.remoteServerId) set(value) { appCtx.putPrefLong(PreferKey.remoteServerId, value) } // 添加本地选择的目录 var importBookPath: String? get() = appCtx.getPrefString("importBookPath") set(value) { if (value == null) { appCtx.removePref("importBookPath") } else { appCtx.putPrefString("importBookPath", value) } } var ttsFlowSys: Boolean get() = appCtx.getPrefBoolean(PreferKey.ttsFollowSys, true) set(value) { appCtx.putPrefBoolean(PreferKey.ttsFollowSys, value) } val noAnimScrollPage: Boolean get() = appCtx.getPrefBoolean(PreferKey.noAnimScrollPage, false) const val defaultSpeechRate = 5 var ttsSpeechRate: Int get() = appCtx.getPrefInt(PreferKey.ttsSpeechRate, defaultSpeechRate) set(value) { appCtx.putPrefInt(PreferKey.ttsSpeechRate, value) } var ttsTimer: Int get() = appCtx.getPrefInt(PreferKey.ttsTimer, 0) set(value) { appCtx.putPrefInt(PreferKey.ttsTimer, value) } val speechRatePlay: Int get() = if (ttsFlowSys) defaultSpeechRate else ttsSpeechRate var chineseConverterType: Int get() = appCtx.getPrefInt(PreferKey.chineseConverterType) set(value) { appCtx.putPrefInt(PreferKey.chineseConverterType, value) } var systemTypefaces: Int get() = appCtx.getPrefInt(PreferKey.systemTypefaces) set(value) { appCtx.putPrefInt(PreferKey.systemTypefaces, value) } var elevation: Int get() = if (isEInkMode) 0 else appCtx.getPrefInt( PreferKey.barElevation, AppConst.sysElevation ) set(value) { appCtx.putPrefInt(PreferKey.barElevation, value) } var readUrlInBrowser: Boolean get() = appCtx.getPrefBoolean(PreferKey.readUrlOpenInBrowser) set(value) { appCtx.putPrefBoolean(PreferKey.readUrlOpenInBrowser, value) } var exportCharset: String get() { val c = appCtx.getPrefString(PreferKey.exportCharset) if (c.isNullOrBlank()) { return "UTF-8" } return c } set(value) { appCtx.putPrefString(PreferKey.exportCharset, value) } var exportUseReplace: Boolean get() = appCtx.getPrefBoolean(PreferKey.exportUseReplace, true) set(value) { appCtx.putPrefBoolean(PreferKey.exportUseReplace, value) } var exportToWebDav: Boolean get() = appCtx.getPrefBoolean(PreferKey.exportToWebDav) set(value) { appCtx.putPrefBoolean(PreferKey.exportToWebDav, value) } var exportNoChapterName: Boolean get() = appCtx.getPrefBoolean(PreferKey.exportNoChapterName) set(value) { appCtx.putPrefBoolean(PreferKey.exportNoChapterName, value) } // 是否启用自定义导出 default->false var enableCustomExport: Boolean get() = appCtx.getPrefBoolean(PreferKey.enableCustomExport, false) set(value) { appCtx.putPrefBoolean(PreferKey.enableCustomExport, value) } var exportType: Int get() = appCtx.getPrefInt(PreferKey.exportType) set(value) { appCtx.putPrefInt(PreferKey.exportType, value) } var exportPictureFile: Boolean get() = appCtx.getPrefBoolean(PreferKey.exportPictureFile, false) set(value) { appCtx.putPrefBoolean(PreferKey.exportPictureFile, value) } var parallelExportBook: Boolean get() = appCtx.getPrefBoolean(PreferKey.parallelExportBook, false) set(value) { appCtx.putPrefBoolean(PreferKey.parallelExportBook, value) } var changeSourceCheckAuthor: Boolean get() = appCtx.getPrefBoolean(PreferKey.changeSourceCheckAuthor) set(value) { appCtx.putPrefBoolean(PreferKey.changeSourceCheckAuthor, value) } var ttsEngine: String? get() = appCtx.getPrefString(PreferKey.ttsEngine) set(value) { appCtx.putPrefString(PreferKey.ttsEngine, value) } var webPort: Int get() = appCtx.getPrefInt(PreferKey.webPort, 1122) set(value) { appCtx.putPrefInt(PreferKey.webPort, value) } var tocUiUseReplace: Boolean get() = appCtx.getPrefBoolean(PreferKey.tocUiUseReplace) set(value) { appCtx.putPrefBoolean(PreferKey.tocUiUseReplace, value) } var tocCountWords: Boolean get() = appCtx.getPrefBoolean(PreferKey.tocCountWords, true) set(value) { appCtx.putPrefBoolean(PreferKey.tocCountWords, value) } var enableReadRecord: Boolean get() = appCtx.getPrefBoolean(PreferKey.enableReadRecord, true) set(value) { appCtx.putPrefBoolean(PreferKey.enableReadRecord, value) } val autoChangeSource: Boolean get() = appCtx.getPrefBoolean(PreferKey.autoChangeSource, true) var changeSourceLoadInfo: Boolean get() = appCtx.getPrefBoolean(PreferKey.changeSourceLoadInfo) set(value) { appCtx.putPrefBoolean(PreferKey.changeSourceLoadInfo, value) } var changeSourceLoadToc: Boolean get() = appCtx.getPrefBoolean(PreferKey.changeSourceLoadToc) set(value) { appCtx.putPrefBoolean(PreferKey.changeSourceLoadToc, value) } var changeSourceLoadWordCount: Boolean get() = appCtx.getPrefBoolean(PreferKey.changeSourceLoadWordCount) set(value) { appCtx.putPrefBoolean(PreferKey.changeSourceLoadWordCount, value) } var openBookInfoByClickTitle: Boolean get() = appCtx.getPrefBoolean(PreferKey.openBookInfoByClickTitle, true) set(value) { appCtx.putPrefBoolean(PreferKey.openBookInfoByClickTitle, value) } var showBookshelfFastScroller: Boolean get() = appCtx.getPrefBoolean(PreferKey.showBookshelfFastScroller, false) set(value) { appCtx.putPrefBoolean(PreferKey.showBookshelfFastScroller, value) } var contentSelectSpeakMod: Int get() = appCtx.getPrefInt(PreferKey.contentSelectSpeakMod) set(value) { appCtx.putPrefInt(PreferKey.contentSelectSpeakMod, value) } var batchChangeSourceDelay: Int get() = appCtx.getPrefInt(PreferKey.batchChangeSourceDelay) set(value) { appCtx.putPrefInt(PreferKey.batchChangeSourceDelay, value) } val importKeepName get() = appCtx.getPrefBoolean(PreferKey.importKeepName) val importKeepGroup get() = appCtx.getPrefBoolean(PreferKey.importKeepGroup) var importKeepEnable: Boolean get() = appCtx.getPrefBoolean(PreferKey.importKeepEnable, false) set(value) { appCtx.putPrefBoolean(PreferKey.importKeepEnable, value) } var previewImageByClick: Boolean get() = appCtx.getPrefBoolean(PreferKey.previewImageByClick, false) set(value) { appCtx.putPrefBoolean(PreferKey.previewImageByClick, value) } var preDownloadNum get() = appCtx.getPrefInt(PreferKey.preDownloadNum, 10) set(value) { appCtx.putPrefInt(PreferKey.preDownloadNum, value) } val syncBookProgress get() = appCtx.getPrefBoolean(PreferKey.syncBookProgress, true) val syncBookProgressPlus get() = appCtx.getPrefBoolean(PreferKey.syncBookProgressPlus, false) val mediaButtonOnExit get() = appCtx.getPrefBoolean("mediaButtonOnExit", true) val readAloudByMediaButton get() = appCtx.getPrefBoolean(PreferKey.readAloudByMediaButton, false) val replaceEnableDefault get() = appCtx.getPrefBoolean(PreferKey.replaceEnableDefault, true) val webDavDir get() = appCtx.getPrefString(PreferKey.webDavDir, "legado") val webDavDeviceName get() = appCtx.getPrefString(PreferKey.webDavDeviceName, Build.MODEL) val recordHeapDump get() = appCtx.getPrefBoolean(PreferKey.recordHeapDump, false) val loadCoverOnlyWifi get() = appCtx.getPrefBoolean(PreferKey.loadCoverOnlyWifi, false) val showAddToShelfAlert get() = appCtx.getPrefBoolean(PreferKey.showAddToShelfAlert, true) val ignoreAudioFocus get() = appCtx.getPrefBoolean(PreferKey.ignoreAudioFocus, false) var pauseReadAloudWhilePhoneCalls get() = appCtx.getPrefBoolean(PreferKey.pauseReadAloudWhilePhoneCalls, false) set(value) = appCtx.putPrefBoolean(PreferKey.pauseReadAloudWhilePhoneCalls, value) val onlyLatestBackup get() = appCtx.getPrefBoolean(PreferKey.onlyLatestBackup, true) val autoCheckNewBackup get() = appCtx.getPrefBoolean(PreferKey.autoCheckNewBackup, true) val defaultHomePage get() = appCtx.getPrefString(PreferKey.defaultHomePage, "bookshelf") val updateToVariant get() = appCtx.getPrefString(PreferKey.updateToVariant, "default_version") val streamReadAloudAudio get() = appCtx.getPrefBoolean(PreferKey.streamReadAloudAudio, false) val doublePageHorizontal: String? get() = appCtx.getPrefString(PreferKey.doublePageHorizontal) val progressBarBehavior: String? get() = appCtx.getPrefString(PreferKey.progressBarBehavior, "page") val keyPageOnLongPress get() = appCtx.getPrefBoolean(PreferKey.keyPageOnLongPress, false) val volumeKeyPage get() = appCtx.getPrefBoolean(PreferKey.volumeKeyPage, true) val volumeKeyPageOnPlay get() = appCtx.getPrefBoolean(PreferKey.volumeKeyPageOnPlay, true) val mouseWheelPage get() = appCtx.getPrefBoolean(PreferKey.mouseWheelPage, true) val paddingDisplayCutouts get() = appCtx.getPrefBoolean(PreferKey.paddingDisplayCutouts, false) var searchScope: String get() = appCtx.getPrefString("searchScope") ?: "" set(value) { appCtx.putPrefString("searchScope", value) } var searchGroup: String get() = appCtx.getPrefString("searchGroup") ?: "" set(value) { appCtx.putPrefString("searchGroup", value) } var pageTouchSlop: Int get() = appCtx.getPrefInt(PreferKey.pageTouchSlop, 0) set(value) { appCtx.putPrefInt(PreferKey.pageTouchSlop, value) } var bookshelfSort: Int get() = appCtx.getPrefInt(PreferKey.bookshelfSort, 0) set(value) { appCtx.putPrefInt(PreferKey.bookshelfSort, value) } fun getBookSortByGroupId(groupId: Long): Int { return appDb.bookGroupDao.getByID(groupId)?.getRealBookSort() ?: bookshelfSort } private fun getPrefUserAgent(): String { val ua = appCtx.getPrefString(PreferKey.userAgent) if (ua.isNullOrBlank()) { return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/" + BuildConfig.Cronet_Main_Version + " Safari/537.36" } return ua } var bitmapCacheSize: Int get() = appCtx.getPrefInt(PreferKey.bitmapCacheSize, 50) set(value) { appCtx.putPrefInt(PreferKey.bitmapCacheSize, value) } var imageRetainNum: Int get() = appCtx.getPrefInt(PreferKey.imageRetainNum, 0) set(value) { appCtx.putPrefInt(PreferKey.imageRetainNum, value) } var showReadTitleBarAddition: Boolean get() = appCtx.getPrefBoolean(PreferKey.showReadTitleAddition, true) set(value) { appCtx.putPrefBoolean(PreferKey.showReadTitleAddition, value) } var readBarStyleFollowPage: Boolean get() = appCtx.getPrefBoolean(PreferKey.readBarStyleFollowPage, false) set(value) { appCtx.putPrefBoolean(PreferKey.readBarStyleFollowPage, value) } var sourceEditMaxLine: Int get() { val maxLine = appCtx.getPrefInt(PreferKey.sourceEditMaxLine, Int.MAX_VALUE) if (maxLine < 10) { return Int.MAX_VALUE } return maxLine } set(value) { appCtx.putPrefInt(PreferKey.sourceEditMaxLine, value) } var audioPlayUseWakeLock: Boolean get() = appCtx.getPrefBoolean(PreferKey.audioPlayWakeLock) set(value) { appCtx.putPrefBoolean(PreferKey.audioPlayWakeLock, value) } var brightnessVwPos: Boolean get() = appCtx.getPrefBoolean(PreferKey.brightnessVwPos) set(value) { appCtx.putPrefBoolean(PreferKey.brightnessVwPos, value) } fun detectClickArea() { if (clickActionTL * clickActionTC * clickActionTR * clickActionML * clickActionMC * clickActionMR * clickActionBL * clickActionBC * clickActionBR != 0 ) { appCtx.putPrefInt(PreferKey.clickActionMC, 0) appCtx.toastOnUi("当前没有配置菜单区域,自动恢复中间区域为菜单.") } } //跳转到漫画界面不使用富文本模式 val showMangaUi: Boolean get() = appCtx.getPrefBoolean(PreferKey.showMangaUi, true) //禁用漫画缩放 var disableMangaScale: Boolean get() = appCtx.getPrefBoolean(PreferKey.disableMangaScale, true) set(value) { appCtx.putPrefBoolean(PreferKey.disableMangaScale, value) } var disableMangaPageAnim: Boolean get() = appCtx.getPrefBoolean(PreferKey.disableMangaPageAnim, false) set(value) { appCtx.putPrefBoolean(PreferKey.disableMangaPageAnim, value) } //漫画预加载数量 var mangaPreDownloadNum get() = appCtx.getPrefInt(PreferKey.mangaPreDownloadNum, 10) set(value) { appCtx.putPrefInt(PreferKey.mangaPreDownloadNum, value) } //点击翻页 var disableClickScroll get() = appCtx.getPrefBoolean(PreferKey.disableClickScroll, false) set(value) { appCtx.putPrefBoolean(PreferKey.disableClickScroll, value) } //漫画滚动速度 var mangaAutoPageSpeed get() = appCtx.getPrefInt(PreferKey.mangaAutoPageSpeed, 3) set(value) { appCtx.putPrefInt(PreferKey.mangaAutoPageSpeed, value) } //漫画页脚配置 var mangaFooterConfig get() = appCtx.getPrefString(PreferKey.mangaFooterConfig, "") set(value) { appCtx.putPrefString(PreferKey.mangaFooterConfig, value) } //漫画水平滚动 var enableMangaHorizontalScroll get() = appCtx.getPrefBoolean(PreferKey.enableMangaHorizontalScroll, false) set(value) { appCtx.putPrefBoolean(PreferKey.enableMangaHorizontalScroll, value) } var mangaColorFilter get() = appCtx.getPrefString(PreferKey.mangaColorFilter, "") set(value) { appCtx.putPrefString(PreferKey.mangaColorFilter, value) } //禁用漫画内标题 var hideMangaTitle get() = appCtx.getPrefBoolean(PreferKey.hideMangaTitle, false) set(value) { appCtx.putPrefBoolean(PreferKey.hideMangaTitle, value) } //开启墨水屏模式 var enableMangaEInk get() = appCtx.getPrefBoolean(PreferKey.enableMangaEInk, false) set(value) { appCtx.putPrefBoolean(PreferKey.enableMangaEInk, value) } var mangaEInkThreshold get() = appCtx.getPrefInt(PreferKey.mangaEInkThreshold, 150) set(value) { appCtx.putPrefInt(PreferKey.mangaEInkThreshold, value) } var disableHorizontalPageSnap get() = appCtx.getPrefBoolean(PreferKey.disableHorizontalPageSnap, false) set(value) { appCtx.putPrefBoolean(PreferKey.disableHorizontalPageSnap, value) } var enableMangaGray get() = appCtx.getPrefBoolean(PreferKey.enableMangaGray, false) set(value) { appCtx.putPrefBoolean(PreferKey.enableMangaGray, value) } var welcomeImage get() = appCtx.getPrefString(PreferKey.welcomeImage) set(value) { appCtx.putPrefString(PreferKey.welcomeImage, value) } var welcomeShowText get() = appCtx.getPrefBoolean(PreferKey.welcomeShowText, true) set(value) { appCtx.putPrefBoolean(PreferKey.welcomeShowText, value) } var welcomeShowIcon get() = appCtx.getPrefBoolean(PreferKey.welcomeShowIcon, true) set(value) { appCtx.putPrefBoolean(PreferKey.welcomeShowIcon, value) } var welcomeImageDark get() = appCtx.getPrefString(PreferKey.welcomeImageDark) set(value) { appCtx.putPrefString(PreferKey.welcomeImageDark, value) } var welcomeShowTextDark get() = appCtx.getPrefBoolean(PreferKey.welcomeShowTextDark, true) set(value) { appCtx.putPrefBoolean(PreferKey.welcomeShowTextDark, value) } var welcomeShowIconDark get() = appCtx.getPrefBoolean(PreferKey.welcomeShowIconDark, true) set(value) { appCtx.putPrefBoolean(PreferKey.welcomeShowIconDark, value) } } ================================================ FILE: app/src/main/java/io/legado/app/help/config/LocalConfig.kt ================================================ package io.legado.app.help.config import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit import io.legado.app.utils.getBoolean import io.legado.app.utils.putBoolean import io.legado.app.utils.putLong import io.legado.app.utils.putString import io.legado.app.utils.remove import splitties.init.appCtx @Suppress("ConstPropertyName") object LocalConfig : SharedPreferences by appCtx.getSharedPreferences("local", Context.MODE_PRIVATE) { private const val versionCodeKey = "appVersionCode" /** * 本地密码,用来对需要备份的敏感信息加密,如 webdav 配置等 */ var password: String? get() = getString("password", null) set(value) { if (value != null) { putString("password", value) } else { remove("password") } } var lastBackup: Long get() = getLong("lastBackup", 0) set(value) { putLong("lastBackup", value) } var privacyPolicyOk: Boolean get() = getBoolean("privacyPolicyOk") set(value) { putBoolean("privacyPolicyOk", value) } val readHelpVersionIsLast: Boolean get() = isLastVersion(1, "readHelpVersion", "firstRead") val backupHelpVersionIsLast: Boolean get() = isLastVersion(1, "backupHelpVersion", "firstBackup") val readMenuHelpVersionIsLast: Boolean get() = isLastVersion(1, "readMenuHelpVersion", "firstReadMenu") val bookSourcesHelpVersionIsLast: Boolean get() = isLastVersion(1, "bookSourceHelpVersion", "firstOpenBookSources") val webDavBookHelpVersionIsLast: Boolean get() = isLastVersion(1, "webDavBookHelpVersion", "firstOpenWebDavBook") val ruleHelpVersionIsLast: Boolean get() = isLastVersion(1, "ruleHelpVersion") val needUpHttpTTS: Boolean get() = !isLastVersion(6, "httpTtsVersion") val needUpTxtTocRule: Boolean get() = !isLastVersion(3, "txtTocRuleVersion") val needUpRssSources: Boolean get() = !isLastVersion(6, "rssSourceVersion") val needUpDictRule: Boolean get() = !isLastVersion(2, "needUpDictRule") var versionCode get() = getLong(versionCodeKey, 0) set(value) { edit { putLong(versionCodeKey, value) } } val isFirstOpenApp: Boolean get() { val value = getBoolean("firstOpen", true) if (value) { edit { putBoolean("firstOpen", false) } } return value } @Suppress("SameParameterValue") private fun isLastVersion( lastVersion: Int, versionKey: String, firstOpenKey: String? = null ): Boolean { var version = getInt(versionKey, 0) if (version == 0 && firstOpenKey != null) { if (!getBoolean(firstOpenKey, true)) { version = 1 } } if (version < lastVersion) { edit { putInt(versionKey, lastVersion) } return false } return true } var bookInfoDeleteAlert: Boolean get() = getBoolean("bookInfoDeleteAlert", true) set(value) { putBoolean("bookInfoDeleteAlert", value) } var deleteBookOriginal: Boolean get() = getBoolean("deleteBookOriginal") set(value) { putBoolean("deleteBookOriginal", value) } var appCrash: Boolean get() = getBoolean("appCrash") set(value) { putBoolean("appCrash", value) } } ================================================ FILE: app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt ================================================ package io.legado.app.help.config import android.graphics.Color import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import androidx.annotation.Keep import androidx.core.graphics.toColorInt import io.legado.app.R import io.legado.app.constant.AppLog import io.legado.app.constant.PageAnim import io.legado.app.constant.PreferKey import io.legado.app.help.DefaultData import io.legado.app.help.coroutine.Coroutine import io.legado.app.utils.BitmapUtils import io.legado.app.utils.FileUtils import io.legado.app.utils.GSON import io.legado.app.utils.compress.ZipUtils import io.legado.app.utils.createFolderReplace import io.legado.app.utils.externalCache import io.legado.app.utils.externalFiles import io.legado.app.utils.fromJsonArray import io.legado.app.utils.fromJsonObject import io.legado.app.utils.getCompatColor import io.legado.app.utils.getFile import io.legado.app.utils.getMeanColor import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.getPrefInt import io.legado.app.utils.hexString import io.legado.app.utils.printOnDebug import io.legado.app.utils.putPrefBoolean import io.legado.app.utils.putPrefInt import io.legado.app.utils.resizeAndRecycle import splitties.init.appCtx import java.io.File /** * 阅读界面配置 */ @Suppress("ConstPropertyName") @Keep object ReadBookConfig { const val configFileName = "readConfig.json" const val shareConfigFileName = "shareReadConfig.json" val configFilePath = FileUtils.getPath(appCtx.filesDir, configFileName) val shareConfigFilePath = FileUtils.getPath(appCtx.filesDir, shareConfigFileName) val configList: ArrayList = arrayListOf() lateinit var shareConfig: Config var durConfig get() = getConfig(styleSelect) set(value) { configList[styleSelect] = value if (shareLayout) { shareConfig = value } } var isComic: Boolean = false var bg: Drawable? = null var bgMeanColor: Int = 0 val textColor: Int get() = durConfig.curTextColor() init { initConfigs() initShareConfig() } @Synchronized fun getConfig(index: Int): Config { if (configList.size < 5) { resetAll() } return configList.getOrNull(index) ?: configList[0] } fun initConfigs() { val configFile = File(configFilePath) var configs: List? = null if (configFile.exists()) { try { val json = configFile.readText() configs = GSON.fromJsonArray(json).getOrThrow() } catch (e: Exception) { AppLog.put("读取排版配置文件出错", e) } } (configs ?: DefaultData.readConfigs).let { configList.clear() configList.addAll(it) } } fun initShareConfig() { val configFile = File(shareConfigFilePath) var c: Config? = null if (configFile.exists()) { try { val json = configFile.readText() c = GSON.fromJsonObject(json).getOrThrow() } catch (e: Exception) { e.printOnDebug() } } shareConfig = c ?: configList.getOrNull(5) ?: Config() } fun upBg(width: Int, height: Int) { val drawable = durConfig.curBgDrawable(width, height) if (drawable is BitmapDrawable && drawable.bitmap != null) { bgMeanColor = drawable.bitmap.getMeanColor() } else if (drawable is ColorDrawable) { bgMeanColor = drawable.color } val tmp = bg bg = drawable (tmp as? BitmapDrawable)?.bitmap?.recycle() } fun save() { Coroutine.async { synchronized(this) { GSON.toJson(configList).let { FileUtils.delete(configFilePath) FileUtils.createFileIfNotExist(configFilePath).writeText(it) } GSON.toJson(shareConfig).let { FileUtils.delete(shareConfigFilePath) FileUtils.createFileIfNotExist(shareConfigFilePath).writeText(it) } } } } fun getAllPicBgStr(): ArrayList { val list = arrayListOf() configList.forEach { if (it.bgType == 2) { list.add(it.bgStr) } if (it.bgTypeNight == 2) { list.add(it.bgStrNight) } if (it.bgTypeEInk == 2) { list.add(it.bgStrEInk) } } return list } fun deleteDur(): Boolean { if (configList.size > 5) { val removeIndex = styleSelect configList.removeAt(removeIndex) if (removeIndex <= readStyleSelect) { readStyleSelect -= 1 } if (removeIndex <= comicStyleSelect) { comicStyleSelect -= 1 } return true } return false } fun clearBgAndCache() { val bgs = hashSetOf() configList.forEach { config -> repeat(3) { config.getBgPath(it)?.let { path -> bgs.add(path) } } } appCtx.externalFiles.getFile("bg").listFiles()?.forEach { if (!bgs.contains(it.absolutePath)) { it.delete() } } FileUtils.delete(appCtx.externalCache.getFile("readConfig")) val configZipPath = FileUtils.getPath(appCtx.externalCache, "readConfig.zip") FileUtils.delete(configZipPath) } private fun resetAll() { DefaultData.readConfigs.let { configList.clear() configList.addAll(it) save() } } //配置写入读取 var readBodyToLh = appCtx.getPrefBoolean(PreferKey.readBodyToLh, true) var autoReadSpeed = appCtx.getPrefInt(PreferKey.autoReadSpeed, 10) set(value) { field = value appCtx.putPrefInt(PreferKey.autoReadSpeed, value) } var styleSelect: Int get() = if (isComic) comicStyleSelect else readStyleSelect set(value) { if (isComic) { comicStyleSelect = value } else { readStyleSelect = value } } var readStyleSelect = appCtx.getPrefInt(PreferKey.readStyleSelect) set(value) { field = value if (appCtx.getPrefInt(PreferKey.readStyleSelect) != value) { appCtx.putPrefInt(PreferKey.readStyleSelect, value) } } var comicStyleSelect = appCtx.getPrefInt(PreferKey.comicStyleSelect, readStyleSelect) set(value) { field = value if (appCtx.getPrefInt(PreferKey.comicStyleSelect) != value) { appCtx.putPrefInt(PreferKey.comicStyleSelect, value) } } var shareLayout = appCtx.getPrefBoolean(PreferKey.shareLayout) set(value) { field = value if (appCtx.getPrefBoolean(PreferKey.shareLayout) != value) { appCtx.putPrefBoolean(PreferKey.shareLayout, value) } } /** * 两端对齐 */ val textFullJustify get() = appCtx.getPrefBoolean(PreferKey.textFullJustify, true) /** * 底部对齐 */ val textBottomJustify get() = appCtx.getPrefBoolean(PreferKey.textBottomJustify, true) var hideStatusBar = appCtx.getPrefBoolean(PreferKey.hideStatusBar) var hideNavigationBar = appCtx.getPrefBoolean(PreferKey.hideNavigationBar) var useZhLayout = appCtx.getPrefBoolean(PreferKey.useZhLayout) val config get() = if (shareLayout) shareConfig else durConfig var bgAlpha: Int get() = config.bgAlpha set(value) { config.bgAlpha = value } var pageAnim: Int get() = config.curPageAnim() set(@PageAnim.Anim value) { config.setCurPageAnim(value) } var textFont: String get() = config.textFont set(value) { config.textFont = value } var textBold: Int get() = config.textBold set(value) { config.textBold = value } var textSize: Int get() = config.textSize set(value) { config.textSize = value } var letterSpacing: Float get() = config.letterSpacing set(value) { config.letterSpacing = value } var lineSpacingExtra: Int get() = config.lineSpacingExtra set(value) { config.lineSpacingExtra = value } var paragraphSpacing: Int get() = config.paragraphSpacing set(value) { config.paragraphSpacing = value } /** * 标题位置 0:居左 1:居中 2:隐藏 */ var titleMode: Int get() = config.titleMode set(value) { config.titleMode = value } var titleSize: Int get() = config.titleSize set(value) { config.titleSize = value } /** * 是否标题居中 */ val isMiddleTitle get() = titleMode == 1 var titleTopSpacing: Int get() = config.titleTopSpacing set(value) { config.titleTopSpacing = value } var titleBottomSpacing: Int get() = config.titleBottomSpacing set(value) { config.titleBottomSpacing = value } var paragraphIndent: String get() = config.paragraphIndent set(value) { config.paragraphIndent = value } var underline: Boolean get() = config.underline set(value) { config.underline = value } var paddingBottom: Int get() = config.paddingBottom set(value) { config.paddingBottom = value } var paddingLeft: Int get() = config.paddingLeft set(value) { config.paddingLeft = value } var paddingRight: Int get() = config.paddingRight set(value) { config.paddingRight = value } var paddingTop: Int get() = config.paddingTop set(value) { config.paddingTop = value } var headerPaddingBottom: Int get() = config.headerPaddingBottom set(value) { config.headerPaddingBottom = value } var headerPaddingLeft: Int get() = config.headerPaddingLeft set(value) { config.headerPaddingLeft = value } var headerPaddingRight: Int get() = config.headerPaddingRight set(value) { config.headerPaddingRight = value } var headerPaddingTop: Int get() = config.headerPaddingTop set(value) { config.headerPaddingTop = value } var footerPaddingBottom: Int get() = config.footerPaddingBottom set(value) { config.footerPaddingBottom = value } var footerPaddingLeft: Int get() = config.footerPaddingLeft set(value) { config.footerPaddingLeft = value } var footerPaddingRight: Int get() = config.footerPaddingRight set(value) { config.footerPaddingRight = value } var footerPaddingTop: Int get() = config.footerPaddingTop set(value) { config.footerPaddingTop = value } var showHeaderLine: Boolean get() = config.showHeaderLine set(value) { config.showHeaderLine = value } var showFooterLine: Boolean get() = config.showFooterLine set(value) { config.showFooterLine = value } fun getExportConfig(): Config { val exportConfig = durConfig.copy() if (shareLayout) { exportConfig.textFont = shareConfig.textFont exportConfig.textBold = shareConfig.textBold exportConfig.textSize = shareConfig.textSize exportConfig.letterSpacing = shareConfig.letterSpacing exportConfig.lineSpacingExtra = shareConfig.lineSpacingExtra exportConfig.paragraphSpacing = shareConfig.paragraphSpacing exportConfig.titleMode = shareConfig.titleMode exportConfig.titleSize = shareConfig.titleSize exportConfig.titleTopSpacing = shareConfig.titleTopSpacing exportConfig.titleBottomSpacing = shareConfig.titleBottomSpacing exportConfig.paddingBottom = shareConfig.paddingBottom exportConfig.paddingLeft = shareConfig.paddingLeft exportConfig.paddingRight = shareConfig.paddingRight exportConfig.paddingTop = shareConfig.paddingTop exportConfig.headerPaddingBottom = shareConfig.headerPaddingBottom exportConfig.headerPaddingLeft = shareConfig.headerPaddingLeft exportConfig.headerPaddingRight = shareConfig.headerPaddingRight exportConfig.headerPaddingTop = shareConfig.headerPaddingTop exportConfig.footerPaddingBottom = shareConfig.footerPaddingBottom exportConfig.footerPaddingLeft = shareConfig.footerPaddingLeft exportConfig.footerPaddingRight = shareConfig.footerPaddingRight exportConfig.footerPaddingTop = shareConfig.footerPaddingTop exportConfig.showHeaderLine = shareConfig.showHeaderLine exportConfig.showFooterLine = shareConfig.showFooterLine exportConfig.tipHeaderLeft = shareConfig.tipHeaderLeft exportConfig.tipHeaderMiddle = shareConfig.tipHeaderMiddle exportConfig.tipHeaderRight = shareConfig.tipHeaderRight exportConfig.tipFooterLeft = shareConfig.tipFooterLeft exportConfig.tipFooterMiddle = shareConfig.tipFooterMiddle exportConfig.tipFooterRight = shareConfig.tipFooterRight exportConfig.tipColor = shareConfig.tipColor exportConfig.headerMode = shareConfig.headerMode exportConfig.footerMode = shareConfig.footerMode } return exportConfig } fun import(byteArray: ByteArray): Config { val configZipPath = FileUtils.getPath(appCtx.externalCache, "readConfig.zip") FileUtils.delete(configZipPath) val zipFile = FileUtils.createFileIfNotExist(configZipPath) zipFile.writeBytes(byteArray) val configDir = appCtx.externalCache.getFile("readConfig") configDir.createFolderReplace() ZipUtils.unZipToPath(zipFile, configDir) val configFile = configDir.getFile(configFileName) val config: Config = GSON.fromJsonObject(configFile.readText()).getOrThrow() if (config.textFont.isNotEmpty()) { val fontName = config.textFont val fontPath = FileUtils.getPath(appCtx.externalFiles, "font", fontName) val fontFile = configDir.getFile(fontName) if (fontFile.exists()) { if (!FileUtils.exist(fontPath)) { fontFile.copyTo(File(fontPath)) } config.textFont = fontPath } else { config.textFont = "" } } if (config.bgType == 2) { val bgName = FileUtils.getName(config.bgStr) config.bgStr = bgName val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName) if (!FileUtils.exist(bgPath)) { val bgFile = configDir.getFile(bgName) if (bgFile.exists()) { bgFile.copyTo(File(bgPath)) } } config.bgStr = bgPath } else if (config.bgType == 0) { config.bgStr.toColorInt() } if (config.bgTypeNight == 2) { val bgName = FileUtils.getName(config.bgStrNight) config.bgStrNight = bgName val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName) if (!FileUtils.exist(bgPath)) { val bgFile = configDir.getFile(bgName) if (bgFile.exists()) { bgFile.copyTo(File(bgPath)) } } config.bgStrNight = bgPath } else if (config.bgTypeNight == 0) { config.bgStrNight.toColorInt() } if (config.bgTypeEInk == 2) { val bgName = FileUtils.getName(config.bgStrEInk) config.bgStrEInk = bgName val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName) if (!FileUtils.exist(bgPath)) { val bgFile = configDir.getFile(bgName) if (bgFile.exists()) { bgFile.copyTo(File(bgPath)) } } config.bgStrEInk = bgPath } else if (config.bgTypeEInk == 0) { config.bgStrEInk.toColorInt() } config.curTextColor() return config } @Keep data class Config( var name: String = "", var bgStr: String = "#EEEEEE",//白天背景 var bgStrNight: String = "#000000",//夜间背景 var bgStrEInk: String = "#FFFFFF",//EInk背景 var bgAlpha: Int = 100,//背景透明度 var bgType: Int = 0,//白天背景类型 0:颜色, 1:assets图片, 2其它图片 var bgTypeNight: Int = 0,//夜间背景类型 var bgTypeEInk: Int = 0,//EInk背景类型 private var darkStatusIcon: Boolean = true,//白天是否暗色状态栏 private var darkStatusIconNight: Boolean = false,//晚上是否暗色状态栏 private var darkStatusIconEInk: Boolean = true, private var textColor: String = "#3E3D3B",//白天文字颜色 private var textColorNight: String = "#ADADAD",//夜间文字颜色 private var textColorEInk: String = "#000000", private var pageAnim: Int = 0,//翻页动画 private var pageAnimEInk: Int = 4, var textFont: String = "",//字体 var textBold: Int = 0,//是否粗体字 0:正常, 1:粗体, 2:细体 var textSize: Int = 20,//文字大小 var letterSpacing: Float = 0.1f,//字间距 var lineSpacingExtra: Int = 12,//行间距 var paragraphSpacing: Int = 2,//段距 var titleMode: Int = 0,//标题位置 0:居左 1:居中 2:隐藏 var titleSize: Int = 0, var titleTopSpacing: Int = 0, var titleBottomSpacing: Int = 0, var paragraphIndent: String = "  ",//段落缩进 var underline: Boolean = false, //下划线 var paddingBottom: Int = 6, var paddingLeft: Int = 16, var paddingRight: Int = 16, var paddingTop: Int = 6, var headerPaddingBottom: Int = 0, var headerPaddingLeft: Int = 16, var headerPaddingRight: Int = 16, var headerPaddingTop: Int = 0, var footerPaddingBottom: Int = 6, var footerPaddingLeft: Int = 16, var footerPaddingRight: Int = 16, var footerPaddingTop: Int = 6, var showHeaderLine: Boolean = false, var showFooterLine: Boolean = true, var tipHeaderLeft: Int = ReadTipConfig.time, var tipHeaderMiddle: Int = ReadTipConfig.none, var tipHeaderRight: Int = ReadTipConfig.battery, var tipFooterLeft: Int = ReadTipConfig.chapterTitle, var tipFooterMiddle: Int = ReadTipConfig.none, var tipFooterRight: Int = ReadTipConfig.pageAndTotal, var tipColor: Int = 0, var tipDividerColor: Int = -1, var headerMode: Int = 0, var footerMode: Int = 0 ) { @Transient private var textColorIntEInk = -1 @Transient private var textColorIntNight = -1 @Transient private var textColorInt = -1 @Transient private var initColorInt = false private fun initColorInt() { textColorIntEInk = Color.parseColor(textColorEInk) textColorIntNight = Color.parseColor(textColorNight) textColorInt = Color.parseColor(textColor) initColorInt = true } fun setCurTextColor(color: Int) { when { AppConfig.isEInkMode -> { textColorEInk = "#${color.hexString}" textColorIntEInk = color } AppConfig.isNightTheme -> { textColorNight = "#${color.hexString}" textColorIntNight = color } else -> { textColor = "#${color.hexString}" textColorInt = color } } } fun curTextColor(): Int { if (!initColorInt) { initColorInt() } return when { AppConfig.isEInkMode -> textColorIntEInk AppConfig.isNightTheme -> textColorIntNight else -> textColorInt } } fun setCurStatusIconDark(isDark: Boolean) { when { AppConfig.isEInkMode -> darkStatusIconEInk = isDark AppConfig.isNightTheme -> darkStatusIconNight = isDark else -> darkStatusIcon = isDark } } fun curStatusIconDark(): Boolean { return when { AppConfig.isEInkMode -> darkStatusIconEInk AppConfig.isNightTheme -> darkStatusIconNight else -> darkStatusIcon } } fun setCurPageAnim(@PageAnim.Anim anim: Int) { when { AppConfig.isEInkMode -> pageAnimEInk = anim else -> pageAnim = anim } } fun curPageAnim(): Int { return when { AppConfig.isEInkMode -> pageAnimEInk else -> pageAnim } } fun setCurBg(bgType: Int, bg: String) { when { AppConfig.isEInkMode -> { bgTypeEInk = bgType bgStrEInk = bg } AppConfig.isNightTheme -> { bgTypeNight = bgType bgStrNight = bg } else -> { this.bgType = bgType bgStr = bg } } } fun curBgStr(): String { return when { AppConfig.isEInkMode -> bgStrEInk AppConfig.isNightTheme -> bgStrNight else -> bgStr } } fun curBgType(): Int { return when { AppConfig.isEInkMode -> bgTypeEInk AppConfig.isNightTheme -> bgTypeNight else -> bgType } } fun curBgDrawable(width: Int, height: Int): Drawable { if (width == 0 || height == 0) { return ColorDrawable(appCtx.getCompatColor(R.color.background)) } var bgDrawable: Drawable? = null val resources = appCtx.resources try { bgDrawable = when (curBgType()) { 0 -> ColorDrawable(Color.parseColor(curBgStr())) 1 -> { val path = "bg" + File.separator + curBgStr() val bitmap = BitmapUtils.decodeAssetsBitmap(appCtx, path, width, height) BitmapDrawable(resources, bitmap?.resizeAndRecycle(width, height)) } else -> { val path = curBgStr().let { if (it.contains(File.separator)) it else FileUtils.getPath(appCtx.externalFiles, "bg", curBgStr()) } val bitmap = BitmapUtils.decodeBitmap(path, width, height) BitmapDrawable(resources, bitmap?.resizeAndRecycle(width, height)) } } } catch (e: OutOfMemoryError) { e.printOnDebug() } catch (e: Exception) { e.printOnDebug() } return bgDrawable ?: ColorDrawable(appCtx.getCompatColor(R.color.background)) } fun getBgPath(bgIndex: Int): String? { val bgType = when (bgIndex) { 0 -> bgType 1 -> bgTypeNight 2 -> bgTypeEInk else -> error("unknown bgIndex: $bgIndex") } if (bgType != 2) { return null } val bgStr = when (bgIndex) { 0 -> bgStr 1 -> bgStrNight 2 -> bgStrEInk else -> error("unknown bgIndex: $bgIndex") } val path = if (bgStr.contains(File.separator)) { bgStr } else { FileUtils.getPath(appCtx.externalFiles, "bg", bgStr) } return path } } } ================================================ FILE: app/src/main/java/io/legado/app/help/config/ReadTipConfig.kt ================================================ package io.legado.app.help.config import android.content.Context import io.legado.app.R import splitties.init.appCtx @Suppress("ConstPropertyName") object ReadTipConfig { const val none = 0 const val chapterTitle = 1 const val time = 2 const val battery = 3 const val batteryPercentage = 10 const val page = 4 const val totalProgress = 5 const val pageAndTotal = 6 const val bookName = 7 const val timeBattery = 8 const val timeBatteryPercentage = 9 const val totalProgress1 = 11 val tipValues = arrayOf( none, bookName, chapterTitle, time, battery, batteryPercentage, page, totalProgress, totalProgress1, pageAndTotal, timeBattery, timeBatteryPercentage ) val tipNames get() = appCtx.resources.getStringArray(R.array.read_tip).toList() val tipColorNames get() = appCtx.resources.getStringArray(R.array.tip_color).toList() val tipDividerColorNames get() = appCtx.resources.getStringArray(R.array.tip_divider_color).toList() var tipHeaderLeft: Int get() = ReadBookConfig.config.tipHeaderLeft set(value) { ReadBookConfig.config.tipHeaderLeft = value } var tipHeaderMiddle: Int get() = ReadBookConfig.config.tipHeaderMiddle set(value) { ReadBookConfig.config.tipHeaderMiddle = value } var tipHeaderRight: Int get() = ReadBookConfig.config.tipHeaderRight set(value) { ReadBookConfig.config.tipHeaderRight = value } var tipFooterLeft: Int get() = ReadBookConfig.config.tipFooterLeft set(value) { ReadBookConfig.config.tipFooterLeft = value } var tipFooterMiddle: Int get() = ReadBookConfig.config.tipFooterMiddle set(value) { ReadBookConfig.config.tipFooterMiddle = value } var tipFooterRight: Int get() = ReadBookConfig.config.tipFooterRight set(value) { ReadBookConfig.config.tipFooterRight = value } var headerMode: Int get() = ReadBookConfig.config.headerMode set(value) { ReadBookConfig.config.headerMode = value } var footerMode: Int get() = ReadBookConfig.config.footerMode set(value) { ReadBookConfig.config.footerMode = value } var tipColor: Int get() = ReadBookConfig.config.tipColor set(value) { ReadBookConfig.config.tipColor = value } var tipDividerColor: Int get() = ReadBookConfig.config.tipDividerColor set(value) { ReadBookConfig.config.tipDividerColor = value } fun getHeaderModes(context: Context): LinkedHashMap { return linkedMapOf( Pair(0, context.getString(R.string.hide_when_status_bar_show)), Pair(1, context.getString(R.string.show)), Pair(2, context.getString(R.string.hide)) ) } fun getFooterModes(context: Context): LinkedHashMap { return linkedMapOf( Pair(0, context.getString(R.string.show)), Pair(1, context.getString(R.string.hide)) ) } } ================================================ FILE: app/src/main/java/io/legado/app/help/config/SourceConfig.kt ================================================ package io.legado.app.help.config import android.content.Context.MODE_PRIVATE import androidx.core.content.edit import splitties.init.appCtx object SourceConfig { private val sp = appCtx.getSharedPreferences("SourceConfig", MODE_PRIVATE) fun setBookScore(origin: String, name: String, author: String, score: Int) { sp.edit { val preScore = getBookScore(origin, name, author) var newScore = score if (preScore != 0) { newScore = score - preScore } putInt(origin, getSourceScore(origin) + newScore) putInt("${origin}_${name}_${author}", score) } } fun getBookScore(origin: String, name: String, author: String): Int { return sp.getInt("${origin}_${name}_${author}", 0) } fun getSourceScore(origin: String): Int { return sp.getInt(origin, 0) } fun removeSource(origin: String) { sp.all.keys.filter { it.startsWith(origin) }.let { sp.edit { it.forEach { remove(it) } } } } } ================================================ FILE: app/src/main/java/io/legado/app/help/config/ThemeConfig.kt ================================================ package io.legado.app.help.config import android.content.Context import android.graphics.Bitmap import android.graphics.Color import android.util.DisplayMetrics import androidx.annotation.Keep import androidx.appcompat.app.AppCompatDelegate import androidx.core.graphics.toColorInt import io.legado.app.R import io.legado.app.constant.AppLog import io.legado.app.constant.EventBus import io.legado.app.constant.PreferKey import io.legado.app.constant.Theme import io.legado.app.help.DefaultData import io.legado.app.lib.theme.ThemeStore import io.legado.app.model.BookCover import io.legado.app.utils.BitmapUtils import io.legado.app.utils.ColorUtils import io.legado.app.utils.FileUtils import io.legado.app.utils.GSON import io.legado.app.utils.externalFiles import io.legado.app.utils.fromJsonArray import io.legado.app.utils.fromJsonObject import io.legado.app.utils.getCompatColor import io.legado.app.utils.getFile import io.legado.app.utils.getPrefInt import io.legado.app.utils.getPrefString import io.legado.app.utils.hexString import io.legado.app.utils.postEvent import io.legado.app.utils.printOnDebug import io.legado.app.utils.putPrefInt import io.legado.app.utils.stackBlur import splitties.init.appCtx import java.io.File @Keep object ThemeConfig { const val configFileName = "themeConfig.json" val configFilePath = FileUtils.getPath(appCtx.filesDir, configFileName) val configList: ArrayList by lazy { val cList = getConfigs() ?: DefaultData.themeConfigs ArrayList(cList) } fun getTheme() = when { AppConfig.isEInkMode -> Theme.EInk AppConfig.isNightTheme -> Theme.Dark else -> Theme.Light } fun isDarkTheme(): Boolean { return getTheme() == Theme.Dark } fun applyDayNight(context: Context) { applyTheme(context) initNightMode() BookCover.upDefaultCover() postEvent(EventBus.RECREATE, "") } fun applyDayNightInit(context: Context) { applyTheme(context) initNightMode() } private fun initNightMode() { val targetMode = if (AppConfig.isNightTheme) { AppCompatDelegate.MODE_NIGHT_YES } else { AppCompatDelegate.MODE_NIGHT_NO } AppCompatDelegate.setDefaultNightMode(targetMode) } fun getBgImage(context: Context, metrics: DisplayMetrics): Bitmap? { val bgCfg = when (getTheme()) { Theme.Light -> Pair( context.getPrefString(PreferKey.bgImage), context.getPrefInt(PreferKey.bgImageBlurring, 0) ) Theme.Dark -> Pair( context.getPrefString(PreferKey.bgImageN), context.getPrefInt(PreferKey.bgImageNBlurring, 0) ) else -> null } ?: return null if (bgCfg.first.isNullOrBlank()) return null val bgImage = BitmapUtils .decodeBitmap(bgCfg.first!!, metrics.widthPixels, metrics.heightPixels) if (bgCfg.second == 0) { return bgImage } return bgImage?.stackBlur(bgCfg.second) } fun upConfig() { getConfigs()?.forEach { config -> addConfig(config) } } fun save() { val json = GSON.toJson(configList) FileUtils.delete(configFilePath) FileUtils.createFileIfNotExist(configFilePath).writeText(json) } fun delConfig(index: Int) { configList.removeAt(index) save() } fun addConfig(json: String): Boolean { GSON.fromJsonObject(json.trim { it < ' ' }).getOrNull() ?.let { if (validateConfig(it)) { addConfig(it) return true } } return false } fun addConfig(newConfig: Config) { if (!validateConfig(newConfig)) { return } configList.forEachIndexed { index, config -> if (newConfig.themeName == config.themeName) { configList[index] = newConfig return } } configList.add(newConfig) save() } private fun validateConfig(config: Config): Boolean { try { config.primaryColor.toColorInt() config.accentColor.toColorInt() config.backgroundColor.toColorInt() config.bottomBackground.toColorInt() return true } catch (_: Exception) { return false } } private fun getConfigs(): List? { val configFile = File(configFilePath) if (configFile.exists()) { kotlin.runCatching { val json = configFile.readText() return GSON.fromJsonArray(json).getOrThrow() }.onFailure { it.printOnDebug() } } return null } fun applyConfig(context: Context, config: Config) { try { val primary = Color.parseColor(config.primaryColor) val accent = Color.parseColor(config.accentColor) val background = Color.parseColor(config.backgroundColor) val bBackground = Color.parseColor(config.bottomBackground) if (config.isNightTheme) { context.putPrefInt(PreferKey.cNPrimary, primary) context.putPrefInt(PreferKey.cNAccent, accent) context.putPrefInt(PreferKey.cNBackground, background) context.putPrefInt(PreferKey.cNBBackground, bBackground) } else { context.putPrefInt(PreferKey.cPrimary, primary) context.putPrefInt(PreferKey.cAccent, accent) context.putPrefInt(PreferKey.cBackground, background) context.putPrefInt(PreferKey.cBBackground, bBackground) } AppConfig.isNightTheme = config.isNightTheme applyDayNight(context) } catch (e: Exception) { AppLog.put("设置主题出错\n$e", e, true) } } fun saveDayTheme(context: Context, name: String) { val primary = context.getPrefInt(PreferKey.cPrimary, context.getCompatColor(R.color.md_brown_500)) val accent = context.getPrefInt(PreferKey.cAccent, context.getCompatColor(R.color.md_red_600)) val background = context.getPrefInt(PreferKey.cBackground, context.getCompatColor(R.color.md_grey_100)) val bBackground = context.getPrefInt(PreferKey.cBBackground, context.getCompatColor(R.color.md_grey_200)) val config = Config( themeName = name, isNightTheme = false, primaryColor = "#${primary.hexString}", accentColor = "#${accent.hexString}", backgroundColor = "#${background.hexString}", bottomBackground = "#${bBackground.hexString}" ) addConfig(config) } fun saveNightTheme(context: Context, name: String) { val primary = context.getPrefInt( PreferKey.cNPrimary, context.getCompatColor(R.color.md_blue_grey_600) ) val accent = context.getPrefInt( PreferKey.cNAccent, context.getCompatColor(R.color.md_deep_orange_800) ) val background = context.getPrefInt(PreferKey.cNBackground, context.getCompatColor(R.color.md_grey_900)) val bBackground = context.getPrefInt(PreferKey.cNBBackground, context.getCompatColor(R.color.md_grey_850)) val config = Config( themeName = name, isNightTheme = true, primaryColor = "#${primary.hexString}", accentColor = "#${accent.hexString}", backgroundColor = "#${background.hexString}", bottomBackground = "#${bBackground.hexString}" ) addConfig(config) } /** * 更新主题 */ fun applyTheme(context: Context) = with(context) { when { AppConfig.isEInkMode -> { ThemeStore.editTheme(this) .primaryColor(Color.WHITE) .accentColor(Color.BLACK) .backgroundColor(Color.WHITE) .bottomBackground(Color.WHITE) .apply() } AppConfig.isNightTheme -> { val primary = getPrefInt(PreferKey.cNPrimary, getCompatColor(R.color.md_blue_grey_600)) val accent = getPrefInt(PreferKey.cNAccent, getCompatColor(R.color.md_deep_orange_800)) var background = getPrefInt(PreferKey.cNBackground, getCompatColor(R.color.md_grey_900)) if (ColorUtils.isColorLight(background)) { background = getCompatColor(R.color.md_grey_900) putPrefInt(PreferKey.cNBackground, background) } val bBackground = getPrefInt(PreferKey.cNBBackground, getCompatColor(R.color.md_grey_850)) ThemeStore.editTheme(this) .primaryColor(ColorUtils.withAlpha(primary, 1f)) .accentColor(ColorUtils.withAlpha(accent, 1f)) .backgroundColor(ColorUtils.withAlpha(background, 1f)) .bottomBackground(ColorUtils.withAlpha(bBackground, 1f)) .apply() } else -> { val primary = getPrefInt(PreferKey.cPrimary, getCompatColor(R.color.md_brown_500)) val accent = getPrefInt(PreferKey.cAccent, getCompatColor(R.color.md_red_600)) var background = getPrefInt(PreferKey.cBackground, getCompatColor(R.color.md_grey_100)) if (!ColorUtils.isColorLight(background)) { background = getCompatColor(R.color.md_grey_100) putPrefInt(PreferKey.cBackground, background) } val bBackground = getPrefInt(PreferKey.cBBackground, getCompatColor(R.color.md_grey_200)) ThemeStore.editTheme(this) .primaryColor(ColorUtils.withAlpha(primary, 1f)) .accentColor(ColorUtils.withAlpha(accent, 1f)) .backgroundColor(ColorUtils.withAlpha(background, 1f)) .bottomBackground(ColorUtils.withAlpha(bBackground, 1f)) .apply() } } } fun clearBg() { val bgImagePath = appCtx.getPrefString(PreferKey.bgImage) appCtx.externalFiles.getFile(PreferKey.bgImage).listFiles()?.forEach { if (it.absolutePath != bgImagePath) { it.delete() } } val bgImageNPath = appCtx.getPrefString(PreferKey.bgImageN) appCtx.externalFiles.getFile(PreferKey.bgImageN).listFiles()?.forEach { if (it.absolutePath != bgImageNPath) { it.delete() } } } @Keep data class Config( var themeName: String, var isNightTheme: Boolean, var primaryColor: String, var accentColor: String, var backgroundColor: String, var bottomBackground: String ) { override fun hashCode(): Int { return GSON.toJson(this).hashCode() } override fun equals(other: Any?): Boolean { other ?: return false if (other is Config) { return other.themeName == themeName && other.isNightTheme == isNightTheme && other.primaryColor == primaryColor && other.accentColor == accentColor && other.backgroundColor == backgroundColor && other.bottomBackground == bottomBackground } return false } } } ================================================ FILE: app/src/main/java/io/legado/app/help/coroutine/ActivelyCancelException.kt ================================================ package io.legado.app.help.coroutine import kotlin.coroutines.cancellation.CancellationException class ActivelyCancelException : CancellationException() { override fun fillInStackTrace(): Throwable { stackTrace = emptyArray() return this } } ================================================ FILE: app/src/main/java/io/legado/app/help/coroutine/CompositeCoroutine.kt ================================================ package io.legado.app.help.coroutine @Suppress("unused") class CompositeCoroutine : CoroutineContainer { private var resources: HashSet>? = null val size: Int get() = resources?.size ?: 0 val isEmpty: Boolean get() = size == 0 constructor() constructor(vararg coroutines: Coroutine<*>) { this.resources = hashSetOf(*coroutines) } constructor(coroutines: Iterable>) { this.resources = hashSetOf() for (d in coroutines) { this.resources?.add(d) } } override fun add(coroutine: Coroutine<*>): Boolean { synchronized(this) { var set: HashSet>? = resources if (resources == null) { set = hashSetOf() resources = set } return set!!.add(coroutine) } } override fun addAll(vararg coroutines: Coroutine<*>): Boolean { synchronized(this) { var set: HashSet>? = resources if (resources == null) { set = hashSetOf() resources = set } for (coroutine in coroutines) { val add = set!!.add(coroutine) if (!add) { return false } } } return true } override fun remove(coroutine: Coroutine<*>): Boolean { if (delete(coroutine)) { coroutine.cancel() return true } return false } override fun delete(coroutine: Coroutine<*>): Boolean { synchronized(this) { val set = resources if (set == null || !set.remove(coroutine)) { return false } } return true } override fun clear() { val set: HashSet>? synchronized(this) { set = resources resources = null } set?.forEachIndexed { _, coroutine -> coroutine.cancel() } } } ================================================ FILE: app/src/main/java/io/legado/app/help/coroutine/Coroutine.kt ================================================ package io.legado.app.help.coroutine import io.legado.app.utils.printOnDebug import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.DisposableHandle import kotlinx.coroutines.Job import kotlinx.coroutines.MainScope import kotlinx.coroutines.ensureActive import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.plus import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import kotlin.coroutines.CoroutineContext /** * 链式协程 * 注意:如果协程太快完成,回调会不执行 */ @Suppress("unused", "MemberVisibilityCanBePrivate") class Coroutine( private val scope: CoroutineScope, context: CoroutineContext = Dispatchers.IO, private val startOption: CoroutineStart = CoroutineStart.DEFAULT, private val executeContext: CoroutineContext = Dispatchers.Main, private val semaphore: Semaphore? = null, block: suspend CoroutineScope.() -> T ) { companion object { private val DEFAULT = MainScope() fun async( scope: CoroutineScope = DEFAULT, context: CoroutineContext = Dispatchers.IO, start: CoroutineStart = CoroutineStart.DEFAULT, executeContext: CoroutineContext = Dispatchers.Main, semaphore: Semaphore? = null, block: suspend CoroutineScope.() -> T ): Coroutine { return Coroutine(scope, context, start, executeContext, semaphore, block) } } private val job: Job private var start: VoidCallback? = null private var success: Callback? = null private var error: Callback? = null private var finally: VoidCallback? = null private var cancel: VoidCallback? = null private var timeMillis: Long? = null private var errorReturn: Result? = null val isCancelled: Boolean get() = job.isCancelled val isActive: Boolean get() = job.isActive val isCompleted: Boolean get() = job.isCompleted init { this.job = executeInternal(context, block) } fun timeout(timeMillis: () -> Long): Coroutine { this.timeMillis = timeMillis() return this@Coroutine } fun timeout(timeMillis: Long): Coroutine { this.timeMillis = timeMillis return this@Coroutine } fun onErrorReturn(value: () -> T?): Coroutine { this.errorReturn = Result(value()) return this@Coroutine } fun onErrorReturn(value: T?): Coroutine { this.errorReturn = Result(value) return this@Coroutine } fun onStart( context: CoroutineContext? = null, block: (suspend CoroutineScope.() -> Unit) ): Coroutine { this.start = VoidCallback(context, block) return this@Coroutine } fun onSuccess( context: CoroutineContext? = null, block: suspend CoroutineScope.(T) -> Unit ): Coroutine { this.success = Callback(context, block) return this@Coroutine } fun onError( context: CoroutineContext? = null, block: suspend CoroutineScope.(Throwable) -> Unit ): Coroutine { this.error = Callback(context, block) return this@Coroutine } /** * 如果协程被取消,不执行 */ fun onFinally( context: CoroutineContext? = null, block: suspend CoroutineScope.() -> Unit ): Coroutine { this.finally = VoidCallback(context, block) return this@Coroutine } fun onCancel( context: CoroutineContext? = null, block: suspend CoroutineScope.() -> Unit ): Coroutine { this.cancel = VoidCallback(context, block) job.invokeOnCompletion { if (it is CancellationException && it !is ActivelyCancelException) { cancel() } } return this@Coroutine } //取消当前任务 fun cancel(cause: ActivelyCancelException = ActivelyCancelException()) { if (!job.isCancelled) { job.cancel(cause) } cancel?.let { DEFAULT.launch(executeContext) { if (null == it.context) { it.block.invoke(this) } else { withContext(it.context) { it.block.invoke(this) } } } } } fun invokeOnCompletion(handler: CompletionHandler): DisposableHandle { return job.invokeOnCompletion(handler) } fun start() { job.start() } private fun executeInternal( context: CoroutineContext, block: suspend CoroutineScope.() -> T ): Job { return (scope.plus(executeContext)).launch(start = startOption) { semaphore?.acquire() try { start?.let { dispatchVoidCallback(this, it) } ensureActive() val value = executeBlock(this, context, timeMillis ?: 0L, block) ensureActive() success?.let { dispatchCallback(this, value, it) } } catch (e: Throwable) { e.printOnDebug() val consume: Boolean = errorReturn?.value?.let { value -> success?.let { dispatchCallback(this, value, it) } true } ?: false if (!consume) { error?.let { dispatchCallback(this, e, it) } } } finally { try { finally?.let { dispatchVoidCallback(this, it) } } finally { semaphore?.release() } } } } private suspend inline fun dispatchVoidCallback(scope: CoroutineScope, callback: VoidCallback) { if (null == callback.context) { callback.block.invoke(scope) } else { withContext(callback.context) { callback.block.invoke(this) } } } private suspend inline fun dispatchCallback( scope: CoroutineScope, value: R, callback: Callback ) { if (!scope.isActive) return if (null == callback.context) { callback.block.invoke(scope, value) } else { withContext(callback.context) { callback.block.invoke(this, value) } } } private suspend inline fun executeBlock( scope: CoroutineScope, context: CoroutineContext, timeMillis: Long, noinline block: suspend CoroutineScope.() -> T ): T { return withContext(context) { if (timeMillis > 0L) withTimeout(timeMillis) { block() } else { block() } } } private data class Result(val value: T?) private class VoidCallback( val context: CoroutineContext?, val block: suspend CoroutineScope.() -> Unit ) private class Callback( val context: CoroutineContext?, val block: suspend CoroutineScope.(VALUE) -> Unit ) } ================================================ FILE: app/src/main/java/io/legado/app/help/coroutine/CoroutineContainer.kt ================================================ package io.legado.app.help.coroutine internal interface CoroutineContainer { fun add(coroutine: Coroutine<*>): Boolean fun addAll(vararg coroutines: Coroutine<*>): Boolean fun remove(coroutine: Coroutine<*>): Boolean fun delete(coroutine: Coroutine<*>): Boolean fun clear() } ================================================ FILE: app/src/main/java/io/legado/app/help/crypto/AsymmetricCrypto.kt ================================================ package io.legado.app.help.crypto import androidx.annotation.Keep import cn.hutool.crypto.KeyUtil import cn.hutool.crypto.asymmetric.KeyType import io.legado.app.utils.EncoderUtils import java.io.InputStream import cn.hutool.crypto.asymmetric.AsymmetricCrypto as HutoolAsymmetricCrypto @Keep @Suppress("unused") class AsymmetricCrypto(algorithm: String) : HutoolAsymmetricCrypto(algorithm) { @Suppress("MemberVisibilityCanBePrivate") fun setPrivateKey(key: ByteArray): AsymmetricCrypto { setPrivateKey( KeyUtil.generatePrivateKey(this.algorithm, key) ) return this } fun setPrivateKey(key: String): AsymmetricCrypto = setPrivateKey(key.encodeToByteArray()) @Suppress("MemberVisibilityCanBePrivate") fun setPublicKey(key: ByteArray): AsymmetricCrypto { setPublicKey( KeyUtil.generatePublicKey(this.algorithm, key) ) return this } fun setPublicKey(key: String): AsymmetricCrypto = setPublicKey(key.encodeToByteArray()) private fun getKeyType(usePublicKey: Boolean? = true): KeyType { return when (usePublicKey) { true -> KeyType.PublicKey else -> KeyType.PrivateKey } } @JvmOverloads fun decrypt(data: Any, usePublicKey: Boolean? = true): ByteArray { return when (data) { is ByteArray -> decrypt(data, getKeyType(usePublicKey)) is String -> decrypt(data, getKeyType(usePublicKey)) is InputStream -> decrypt(data, getKeyType(usePublicKey)) else -> throw IllegalArgumentException("Unexpected input type") } } @JvmOverloads fun decryptStr(data: Any, usePublicKey: Boolean? = true): String { return when (data) { is ByteArray -> String(decrypt(data, getKeyType(usePublicKey))) is String -> decryptStr(data, getKeyType(usePublicKey)) is InputStream -> String(decrypt(data, getKeyType(usePublicKey))) else -> throw IllegalArgumentException("Unexpected input type") } } @JvmOverloads fun encrypt(data: Any, usePublicKey: Boolean? = true): ByteArray { return when (data) { is ByteArray -> encrypt(data, getKeyType(usePublicKey)) is String -> encrypt(data, getKeyType(usePublicKey)) is InputStream -> encrypt(data, getKeyType(usePublicKey)) else -> throw IllegalArgumentException("Unexpected input type") } } @JvmOverloads fun encryptHex(data: Any, usePublicKey: Boolean? = true): String { return when (data) { is ByteArray -> encryptHex(data, getKeyType(usePublicKey)) is String -> encryptHex(data, getKeyType(usePublicKey)) is InputStream -> encryptHex(data, getKeyType(usePublicKey)) else -> throw IllegalArgumentException("Unexpected input type") } } @JvmOverloads fun encryptBase64(data: Any, usePublicKey: Boolean? = true): String { return EncoderUtils.base64Encode(encrypt(data, usePublicKey)) } } ================================================ FILE: app/src/main/java/io/legado/app/help/crypto/README.md ================================================ https://github.com/gedoor/legado/pull/2880 非对称加密一般只能知道其中一个密钥,而RhinoJs调用java方法不能传入null和KeyType, 因此提供以下重载函数 ```kotlin fun setPublicKey(key: ByteArray): T fun setPublicKey(key: String): T fun setPrivateKey(key: ByteArray): T fun setPrivateKey(key: String): T fun decrypt(data: Any, usePublicKey: Boolean? = true): ByteArray? fun decryptStr(data: Any, usePublicKey: Boolean? = true): String? fun encrypt(data: Any, usePublicKey: Boolean? = true): ByteArray? fun encryptHex(data: Any, usePublicKey: Boolean? = true): String? fun encryptBase64(data: Any, usePublicKey: Boolean? = true): String? ``` ================================================ FILE: app/src/main/java/io/legado/app/help/crypto/Sign.kt ================================================ package io.legado.app.help.crypto import androidx.annotation.Keep import cn.hutool.crypto.KeyUtil import cn.hutool.crypto.asymmetric.Sign as HutoolSign @Keep @Suppress("unused") class Sign(algorithm: String): HutoolSign(algorithm) { fun setPrivateKey(key: ByteArray): Sign { setPrivateKey(KeyUtil.generatePrivateKey(algorithm, key)) return this } fun setPrivateKey(key: String): Sign = setPrivateKey(key.encodeToByteArray()) fun setPublicKey(key: ByteArray): Sign { setPublicKey(KeyUtil.generatePublicKey(algorithm, key)) return this } fun setPublicKey(key: String): Sign = setPublicKey(key.encodeToByteArray()) } ================================================ FILE: app/src/main/java/io/legado/app/help/crypto/SymmetricCryptoAndroid.kt ================================================ package io.legado.app.help.crypto import androidx.annotation.Keep import cn.hutool.core.codec.Base64 import cn.hutool.core.util.HexUtil import cn.hutool.crypto.symmetric.SymmetricCrypto import io.legado.app.utils.EncoderUtils import io.legado.app.utils.isHex import java.io.InputStream import java.nio.charset.Charset @Keep class SymmetricCryptoAndroid( algorithm: String, key: ByteArray?, ) : SymmetricCrypto(algorithm, key) { override fun encryptBase64(data: ByteArray): String { return EncoderUtils.base64Encode(encrypt(data)) } override fun encryptBase64(data: String, charset: String?): String { return EncoderUtils.base64Encode(encrypt(data, charset)) } override fun encryptBase64(data: String, charset: Charset?): String { return EncoderUtils.base64Encode(encrypt(data, charset)) } override fun encryptBase64(data: String): String { return EncoderUtils.base64Encode(encrypt(data)) } override fun encryptBase64(data: InputStream): String { return EncoderUtils.base64Encode(encrypt(data)) } override fun decrypt(data: String): ByteArray { val bytes = if (data.isHex()) { HexUtil.decodeHex(data) } else { Base64.decode(data) } return decrypt(bytes) } } ================================================ FILE: app/src/main/java/io/legado/app/help/exoplayer/ExoPlayerHelper.kt ================================================ package io.legado.app.help.exoplayer import android.annotation.SuppressLint import android.content.Context import android.net.Uri import androidx.media3.common.MediaItem import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.datasource.FileDataSource import androidx.media3.datasource.ResolvingDataSource import androidx.media3.datasource.cache.Cache import androidx.media3.datasource.cache.CacheDataSink import androidx.media3.datasource.cache.CacheDataSource import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor import androidx.media3.datasource.cache.SimpleCache import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.extractor.DefaultExtractorsFactory import com.google.gson.reflect.TypeToken import io.legado.app.help.http.okHttpClient import io.legado.app.utils.GSON import io.legado.app.utils.externalCache import okhttp3.CacheControl import splitties.init.appCtx import java.io.File import java.util.concurrent.TimeUnit @Suppress("unused") @SuppressLint("UnsafeOptInUsageError") object ExoPlayerHelper { private const val SPLIT_TAG = "\uD83D\uDEA7" private val mapType by lazy { object : TypeToken>() {}.type } fun createMediaItem(url: String, headers: Map): MediaItem { val formatUrl = url + SPLIT_TAG + GSON.toJson(headers, mapType) return MediaItem.Builder().setUri(formatUrl).build() } fun createHttpExoPlayer(context: Context): ExoPlayer { return ExoPlayer.Builder(context).setLoadControl( DefaultLoadControl.Builder().setBufferDurationsMs( DefaultLoadControl.DEFAULT_MIN_BUFFER_MS, DefaultLoadControl.DEFAULT_MAX_BUFFER_MS, DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS / 10, DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS / 10 ).build() ).setMediaSourceFactory( DefaultMediaSourceFactory( context, DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true) ).setDataSourceFactory(resolvingDataSource) .setLiveTargetOffsetMs(5000) ).build() } private val resolvingDataSource: ResolvingDataSource.Factory by lazy { ResolvingDataSource.Factory(cacheDataSourceFactory) { var res = it if (it.uri.toString().contains(SPLIT_TAG)) { val urls = it.uri.toString().split(SPLIT_TAG) val url = urls[0] res = res.withUri(Uri.parse(url)) try { val headers: Map = GSON.fromJson(urls[1], mapType) okhttpDataFactory.setDefaultRequestProperties(headers) } catch (_: Exception) { } } res } } /** * 支持缓存的DataSource.Factory */ private val cacheDataSourceFactory by lazy { //使用自定义的CacheDataSource以支持设置UA CacheDataSource.Factory() .setCache(cache) .setUpstreamDataSourceFactory(okhttpDataFactory) .setCacheReadDataSourceFactory(FileDataSource.Factory()) .setCacheWriteDataSinkFactory( CacheDataSink.Factory() .setCache(cache) .setFragmentSize(CacheDataSink.DEFAULT_FRAGMENT_SIZE) ) } /** * Okhttp DataSource.Factory */ private val okhttpDataFactory by lazy { val client = okHttpClient.newBuilder() .callTimeout(0, TimeUnit.SECONDS) .build() OkHttpDataSource.Factory(client) .setCacheControl(CacheControl.Builder().maxAge(1, TimeUnit.DAYS).build()) } /** * Exoplayer 内置的缓存 */ private val cache: Cache by lazy { val databaseProvider = StandaloneDatabaseProvider(appCtx) return@lazy SimpleCache( //Exoplayer的缓存路径 File(appCtx.externalCache, "exoplayer"), //100M的缓存 LeastRecentlyUsedCacheEvictor((100 * 1024 * 1024).toLong()), //记录缓存的数据库 databaseProvider ) } /** * 通过kotlin扩展函数+反射实现CacheDataSource.Factory设置默认请求头 * 需要添加混淆规则 -keepclassmembers class com.google.android.exoplayer2.upstream.cache.CacheDataSource$Factory{upstreamDataSourceFactory;} * @param headers * @return */ // private fun CacheDataSource.Factory.setDefaultRequestProperties(headers: Map = mapOf()): CacheDataSource.Factory { // val declaredField = this.javaClass.getDeclaredField("upstreamDataSourceFactory") // declaredField.isAccessible = true // val df = declaredField[this] as DataSource.Factory // if (df is OkHttpDataSource.Factory) { // df.setDefaultRequestProperties(headers) // } // return this // } } ================================================ FILE: app/src/main/java/io/legado/app/help/exoplayer/InputStreamDataSource.kt ================================================ package io.legado.app.help.exoplayer import android.annotation.SuppressLint import android.net.Uri import androidx.media3.common.C import androidx.media3.datasource.BaseDataSource import androidx.media3.datasource.DataSpec import java.io.EOFException import java.io.IOException import java.io.InputStream import kotlin.math.min @SuppressLint("UnsafeOptInUsageError") class InputStreamDataSource(private val supplier: () -> InputStream) : BaseDataSource(false) { private var dataSpec: DataSpec? = null private var bytesRemaining: Long = 0 private var opened = false private val inputStream by lazy { supplier.invoke() } @Throws(IOException::class) override fun open(dataSpec: DataSpec): Long { this.dataSpec = dataSpec transferInitializing(dataSpec) inputStream.skip(dataSpec.position) bytesRemaining = dataSpec.length opened = true transferStarted(dataSpec) return bytesRemaining } override fun getUri(): Uri? = dataSpec?.uri @Throws(IOException::class) override fun read(buffer: ByteArray, offset: Int, readLength: Int): Int { if (readLength == 0) { return 0 } else if (bytesRemaining == 0L) { return C.RESULT_END_OF_INPUT } val bytesToRead = if (bytesRemaining == C.LENGTH_UNSET.toLong()) readLength else min(bytesRemaining, readLength.toLong()).toInt() val bytesRead = inputStream.read(buffer, offset, bytesToRead) if (bytesRead == -1) { if (bytesRemaining != C.LENGTH_UNSET.toLong()) { // End of stream reached having not read sufficient data. throw EOFException() } return C.RESULT_END_OF_INPUT } if (bytesRemaining != C.LENGTH_UNSET.toLong()) { bytesRemaining -= bytesRead.toLong() bytesTransferred(bytesRead) } return bytesRead } @Throws(IOException::class) override fun close() { if (!opened) { return } try { inputStream.close() } finally { opened = false transferEnded() } } } ================================================ FILE: app/src/main/java/io/legado/app/help/glide/AsyncRecycleBitmapPool.kt ================================================ package io.legado.app.help.glide import android.graphics.Bitmap import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPoolAdapter import com.bumptech.glide.load.engine.bitmap_recycle.LruBitmapPool import io.legado.app.help.globalExecutor class AsyncRecycleBitmapPool(private val delegate: BitmapPool) : BitmapPool by delegate { constructor(maxSize: Int) : this( if (maxSize > 0) { LruBitmapPool(maxSize.toLong()) } else { BitmapPoolAdapter() } ) override fun put(bitmap: Bitmap) { globalExecutor.execute { delegate.put(bitmap) } } } ================================================ FILE: app/src/main/java/io/legado/app/help/glide/BlurTransformation.kt ================================================ package io.legado.app.help.glide import android.graphics.Bitmap import androidx.annotation.IntRange import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool import com.bumptech.glide.load.resource.bitmap.BitmapTransformation import io.legado.app.utils.stackBlur import java.security.MessageDigest /** * 模糊 * @radius: 0..25 */ class BlurTransformation( @param:IntRange(from = 0, to = 25) private val radius: Int ) : BitmapTransformation() { override fun transform( pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int ): Bitmap { return toTransform.stackBlur(radius) } override fun updateDiskCacheKey(messageDigest: MessageDigest) { messageDigest.update("blur transformation".toByteArray()) } } ================================================ FILE: app/src/main/java/io/legado/app/help/glide/FilePathLoader.kt ================================================ package io.legado.app.help.glide import com.bumptech.glide.Priority import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.data.DataFetcher import com.bumptech.glide.load.model.ModelLoader import com.bumptech.glide.load.model.ModelLoaderFactory import com.bumptech.glide.load.model.MultiModelLoaderFactory import com.bumptech.glide.signature.ObjectKey import java.io.File class FilePathLoader : ModelLoader { override fun buildLoadData( model: String, width: Int, height: Int, options: com.bumptech.glide.load.Options ): ModelLoader.LoadData? { return ModelLoader.LoadData(ObjectKey(model), FilePathFetcher(model)) } override fun handles(model: String): Boolean { return true } class FilePathFetcher(private val filePath: String) : DataFetcher { override fun loadData( priority: Priority, callback: DataFetcher.DataCallback ) { val file = File(filePath) if (file.exists() && file.isFile) { callback.onDataReady(file) } else { callback.onLoadFailed(Exception("File not found: $filePath")) } } override fun cleanup() {} override fun cancel() {} override fun getDataClass(): Class = File::class.java override fun getDataSource(): DataSource = DataSource.LOCAL } class Factory : ModelLoaderFactory { override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader { return FilePathLoader() } override fun teardown() {} } } ================================================ FILE: app/src/main/java/io/legado/app/help/glide/GlideHeaders.kt ================================================ package io.legado.app.help.glide import com.bumptech.glide.load.model.Headers class GlideHeaders(private val headers: MutableMap) : Headers { override fun getHeaders(): MutableMap { return headers } } ================================================ FILE: app/src/main/java/io/legado/app/help/glide/ImageLoader.kt ================================================ package io.legado.app.help.glide import android.content.Context import android.graphics.Bitmap import android.graphics.drawable.Drawable import android.net.Uri import androidx.annotation.DrawableRes import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import com.bumptech.glide.Glide import com.bumptech.glide.RequestBuilder import io.legado.app.utils.isAbsUrl import io.legado.app.utils.isContentScheme import io.legado.app.utils.isDataUrl import io.legado.app.utils.lifecycle import java.io.File //https://bumptech.github.io/glide/doc/generatedapi.html //Instead of GlideApp, use com.bumptech.Glide @Suppress("unused") object ImageLoader { /** * 自动判断path类型 */ fun load(context: Context, path: String?): RequestBuilder { return when { path.isNullOrEmpty() -> Glide.with(context).load(path) path.isDataUrl() -> Glide.with(context).load(path) path.isAbsUrl() -> Glide.with(context).load(path) path.isContentScheme() -> Glide.with(context).load(Uri.parse(path)) else -> kotlin.runCatching { Glide.with(context).load(File(path)) }.getOrElse { Glide.with(context).load(path) } } } fun load(fragment: Fragment, lifecycle: Lifecycle, path: String?): RequestBuilder { val requestManager = Glide.with(fragment).lifecycle(lifecycle) return when { path.isNullOrEmpty() -> requestManager.load(path) path.isDataUrl() -> requestManager.load(path) path.isAbsUrl() -> requestManager.load(path) path.isContentScheme() -> requestManager.load(Uri.parse(path)) else -> kotlin.runCatching { requestManager.load(File(path)) }.getOrElse { requestManager.load(path) } } } fun loadBitmap(context: Context, path: String?): RequestBuilder { val requestManager = Glide.with(context).`as`(Bitmap::class.java) return when { path.isNullOrEmpty() -> requestManager.load(path) path.isDataUrl() -> requestManager.load(path) path.isAbsUrl() -> requestManager.load(path) path.isContentScheme() -> requestManager.load(Uri.parse(path)) else -> kotlin.runCatching { requestManager.load(File(path)) }.getOrElse { requestManager.load(path) } } } fun loadFile(context: Context, path: String?): RequestBuilder { return when { path.isNullOrEmpty() -> Glide.with(context).asFile().load(path) path.isAbsUrl() -> Glide.with(context).asFile().load(path) path.isContentScheme() -> Glide.with(context).asFile().load(Uri.parse(path)) else -> kotlin.runCatching { Glide.with(context).asFile().load(File(path)) }.getOrElse { Glide.with(context).asFile().load(path) } } } fun load(context: Context, @DrawableRes resId: Int?): RequestBuilder { return Glide.with(context).load(resId) } fun load(context: Context, file: File?): RequestBuilder { return Glide.with(context).load(file) } fun load(context: Context, uri: Uri?): RequestBuilder { return Glide.with(context).load(uri) } fun load(context: Context, drawable: Drawable?): RequestBuilder { return Glide.with(context).load(drawable) } fun load(context: Context, bitmap: Bitmap?): RequestBuilder { return Glide.with(context).load(bitmap) } fun load(context: Context, bytes: ByteArray?): RequestBuilder { return Glide.with(context).load(bytes) } } ================================================ FILE: app/src/main/java/io/legado/app/help/glide/LegadoDataUrlLoader.kt ================================================ package io.legado.app.help.glide import com.bumptech.glide.Priority import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.Options import com.bumptech.glide.load.data.DataFetcher import com.bumptech.glide.load.model.ModelLoader import com.bumptech.glide.load.model.ModelLoaderFactory import com.bumptech.glide.load.model.MultiModelLoaderFactory import com.bumptech.glide.signature.ObjectKey import io.legado.app.exception.NoStackTraceException import io.legado.app.model.ReadManga import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.utils.ImageUtils import com.script.rhino.runScriptWithContext import kotlinx.coroutines.Job import java.io.InputStream class LegadoDataUrlLoader : ModelLoader { override fun buildLoadData( model: String, width: Int, height: Int, options: Options ): ModelLoader.LoadData? { if (options.get(OkHttpModelLoader.mangaOption) == false) { return null } return ModelLoader.LoadData(ObjectKey(model), LegadoDataUrlFetcher(model)) } override fun handles(model: String): Boolean { return model.startsWith("data:") } class LegadoDataUrlFetcher(private val model: String) : DataFetcher { private val coroutineContext = Job() override fun loadData( priority: Priority, callback: DataFetcher.DataCallback ) { try { val bytes = AnalyzeUrl( model, source = ReadManga.bookSource, coroutineContext = coroutineContext ).getByteArray() val decoded = runScriptWithContext(coroutineContext) { ImageUtils.decode( model, bytes, isCover = false, ReadManga.bookSource, ReadManga.book )?.inputStream() } if (decoded == null) { throw NoStackTraceException("漫画图片解密失败") } callback.onDataReady(decoded) } catch (e: Exception) { callback.onLoadFailed(e) } } override fun cleanup() { // do nothing } override fun cancel() { coroutineContext.cancel() } override fun getDataClass(): Class { return InputStream::class.java } override fun getDataSource(): DataSource { return DataSource.LOCAL } } class Factory : ModelLoaderFactory { override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader { return LegadoDataUrlLoader() } override fun teardown() { // do nothing } } } ================================================ FILE: app/src/main/java/io/legado/app/help/glide/LegadoGlideModule.kt ================================================ package io.legado.app.help.glide import android.content.Context import android.util.Log import com.bumptech.glide.Glide import com.bumptech.glide.GlideBuilder import com.bumptech.glide.Registry import com.bumptech.glide.annotation.GlideModule import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory import com.bumptech.glide.load.engine.cache.MemorySizeCalculator import com.bumptech.glide.load.model.GlideUrl import com.bumptech.glide.module.AppGlideModule import io.legado.app.BuildConfig import io.legado.app.help.config.AppConfig import java.io.File import java.io.InputStream @Suppress("unused") @GlideModule class LegadoGlideModule : AppGlideModule() { override fun registerComponents(context: Context, glide: Glide, registry: Registry) { registry.replace( GlideUrl::class.java, InputStream::class.java, OkHttpModeLoaderFactory ) registry.prepend( String::class.java, InputStream::class.java, LegadoDataUrlLoader.Factory() ) registry.prepend( String::class.java, File::class.java, FilePathLoader.Factory() ) } override fun applyOptions(context: Context, builder: GlideBuilder) { super.applyOptions(context, builder) val calculator = MemorySizeCalculator.Builder(context).build() val bitmapPool = AsyncRecycleBitmapPool(calculator.bitmapPoolSize) builder.setMemorySizeCalculator(calculator) builder.setBitmapPool(bitmapPool) builder.setDiskCache(InternalCacheDiskCacheFactory(context, 1024 * 1024 * 1000)) if (!BuildConfig.DEBUG && !AppConfig.recordLog) { builder.setLogLevel(Log.ERROR) } } } ================================================ FILE: app/src/main/java/io/legado/app/help/glide/OkHttpModeLoaderFactory.kt ================================================ package io.legado.app.help.glide import com.bumptech.glide.load.model.GlideUrl import com.bumptech.glide.load.model.ModelLoader import com.bumptech.glide.load.model.ModelLoaderFactory import com.bumptech.glide.load.model.MultiModelLoaderFactory import okhttp3.Call import java.io.InputStream object OkHttpModeLoaderFactory: ModelLoaderFactory { override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader { return OkHttpModelLoader } override fun teardown() { // Do nothing, this instance doesn't own the client. } } ================================================ FILE: app/src/main/java/io/legado/app/help/glide/OkHttpModelLoader.kt ================================================ package io.legado.app.help.glide import com.bumptech.glide.load.Option import com.bumptech.glide.load.Options import com.bumptech.glide.load.model.GlideUrl import com.bumptech.glide.load.model.ModelLoader import java.io.InputStream object OkHttpModelLoader : ModelLoader { val loadOnlyWifiOption = Option.memory("loadOnlyWifi", false) val sourceOriginOption = Option.memory("sourceOrigin") val mangaOption = Option.memory("manga",false) override fun buildLoadData( model: GlideUrl, width: Int, height: Int, options: Options, ): ModelLoader.LoadData { return ModelLoader.LoadData(model, OkHttpStreamFetcher(model, options)) } override fun handles(model: GlideUrl): Boolean { return true } } ================================================ FILE: app/src/main/java/io/legado/app/help/glide/OkHttpStreamFetcher.kt ================================================ package io.legado.app.help.glide import com.bumptech.glide.Priority import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.HttpException import com.bumptech.glide.load.Options import com.bumptech.glide.load.data.DataFetcher import com.bumptech.glide.load.model.GlideUrl import com.bumptech.glide.util.ContentLengthInputStream import com.script.rhino.runScriptWithContext import io.legado.app.data.entities.BaseSource import io.legado.app.exception.NoStackTraceException import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.http.addHeaders import io.legado.app.help.http.okHttpClient import io.legado.app.help.http.okHttpClientManga import io.legado.app.help.source.SourceHelp import io.legado.app.model.ReadManga import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.utils.ImageUtils import io.legado.app.utils.isWifiConnect import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.SupervisorJob import okhttp3.Call import okhttp3.Request import okhttp3.Response import okhttp3.ResponseBody import splitties.init.appCtx import java.io.ByteArrayInputStream import java.io.IOException import java.io.InputStream class OkHttpStreamFetcher( private val url: GlideUrl, private val options: Options, ) : DataFetcher, okhttp3.Callback { private var stream: InputStream? = null private var responseBody: ResponseBody? = null private var callback: DataFetcher.DataCallback? = null private var source: BaseSource? = null private val manga = options.get(OkHttpModelLoader.mangaOption) == true private val coroutineContext = SupervisorJob() private val coroutineScope = CoroutineScope(coroutineContext) private lateinit var analyzedUrl: GlideUrl @Volatile private var call: Call? = null companion object { private val failUrl = hashSetOf() } override fun loadData(priority: Priority, callback: DataFetcher.DataCallback) { if (failUrl.contains(url.toStringUrl())) { callback.onLoadFailed(NoStackTraceException("跳过加载失败的图片")) return } val loadOnlyWifi = options.get(OkHttpModelLoader.loadOnlyWifiOption) ?: false if (loadOnlyWifi && !appCtx.isWifiConnect) { callback.onLoadFailed(NoStackTraceException("只在wifi加载图片")) return } options.get(OkHttpModelLoader.sourceOriginOption)?.let { sourceUrl -> source = SourceHelp.getSource(sourceUrl) } analyzedUrl = AnalyzeUrl( url.toString(), source = source, coroutineContext = coroutineContext ).getGlideUrl() val requestBuilder = Request.Builder().url(analyzedUrl.toStringUrl()) requestBuilder.addHeaders(analyzedUrl.headers) val request: Request = requestBuilder.build() this.callback = callback call = if (manga) { okHttpClientManga.newCall(request) } else { okHttpClient.newCall(request) } call?.enqueue(this) } override fun cleanup() { kotlin.runCatching { stream?.close() } responseBody?.close() coroutineContext.cancel() callback = null } override fun cancel() { call?.cancel() coroutineContext.cancel() } override fun getDataClass(): Class { return InputStream::class.java } override fun getDataSource(): DataSource { return DataSource.REMOTE } override fun onFailure(call: Call, e: IOException) { callback?.onLoadFailed(e) } override fun onResponse(call: Call, response: Response) { responseBody = response.body if (!response.isSuccessful) { if (!manga) { failUrl.add(url.toStringUrl()) } callback?.onLoadFailed(HttpException(response.message, response.code)) return } if (ImageUtils.skipDecode(source, !manga)) { onStreamReady(responseBody!!.byteStream()) return } Coroutine.async(coroutineScope, executeContext = IO) { val decodeResult = runScriptWithContext(coroutineContext) { if (manga) { ImageUtils.decode( url.toString(), responseBody!!.bytes(), isCover = false, source, ReadManga.book )?.inputStream() } else { ImageUtils.decode( analyzedUrl.toStringUrl(), responseBody!!.byteStream(), isCover = true, source ) } } onStreamReady(decodeResult) } } private fun onStreamReady(inputStream: InputStream?) { if (inputStream == null) { if (!manga) { failUrl.add(url.toStringUrl()) } callback?.onLoadFailed(NoStackTraceException("封面二次解密失败")) } else { val contentLength: Long = if (inputStream is ByteArrayInputStream) inputStream.available().toLong() else responseBody!!.contentLength() stream = ContentLengthInputStream.obtain(inputStream, contentLength) callback?.onDataReady(stream) } } } ================================================ FILE: app/src/main/java/io/legado/app/help/glide/progress/OnProgressListener.kt ================================================ package io.legado.app.help.glide.progress typealias OnProgressListener = (isComplete: Boolean, percentage: Int, bytesRead: Long, totalBytes: Long) -> Unit ================================================ FILE: app/src/main/java/io/legado/app/help/glide/progress/ProgressManager.kt ================================================ package io.legado.app.help.glide.progress import io.legado.app.model.analyzeRule.AnalyzeUrl import java.util.concurrent.ConcurrentHashMap /** * 进度监听器管理类 * 加入图片加载进度监听,加入Https支持 */ object ProgressManager { private val listenersMap = ConcurrentHashMap() val LISTENER = object : ProgressResponseBody.InternalProgressListener { override fun onProgress(url: String, bytesRead: Long, totalBytes: Long) { getProgressListener(url)?.let { var percentage = (bytesRead * 1f / totalBytes * 100f).toInt() var isComplete = percentage >= 100 if (percentage <= -100) { percentage = 0 isComplete = true } it.invoke(isComplete, percentage, bytesRead, totalBytes) if (isComplete) { removeListener(url) } } } } fun addListener(url: String, listener: OnProgressListener) { if (url.isNotEmpty()) { val url = getUrlNoOption(url) listenersMap[url] = listener listener.invoke(false, 1, 0, 0) } } fun removeListener(url: String) { if (url.isNotEmpty()) { val url = getUrlNoOption(url) listenersMap.remove(url) } } fun getProgressListener(url: String): OnProgressListener? { return if (url.isEmpty() || listenersMap.isEmpty()) { null } else { listenersMap[url] } } private fun getUrlNoOption(url: String): String { val urlMatcher = AnalyzeUrl.paramPattern.matcher(url) return if (urlMatcher.find()) { url.take(urlMatcher.start()) } else { url } } } ================================================ FILE: app/src/main/java/io/legado/app/help/glide/progress/ProgressResponseBody.kt ================================================ package io.legado.app.help.glide.progress import android.os.Handler import android.os.Looper import okhttp3.MediaType import okhttp3.ResponseBody import okio.* import java.io.IOException import kotlin.jvm.Throws class ProgressResponseBody internal constructor(private val url: String, private val internalProgressListener: InternalProgressListener?, private val responseBody: ResponseBody) : ResponseBody() { private var bufferedSource: BufferedSource? = null override fun contentType(): MediaType? { return responseBody.contentType() } override fun contentLength(): Long { return responseBody.contentLength() } override fun source(): BufferedSource { if (bufferedSource == null) { bufferedSource = source(responseBody.source()).buffer() } return bufferedSource!! } private fun source(source: Source): Source { return object : ForwardingSource(source) { var totalBytesRead: Long = 0 var lastTotalBytesRead: Long = 0 @Throws(IOException::class) override fun read(sink: Buffer, byteCount: Long): Long { val bytesRead = super.read(sink, byteCount) totalBytesRead += if (bytesRead == -1L) 0 else bytesRead if (internalProgressListener != null && lastTotalBytesRead != totalBytesRead) { lastTotalBytesRead = totalBytesRead mainThreadHandler.post { internalProgressListener.onProgress(url, totalBytesRead, contentLength()) } } return bytesRead } } } interface InternalProgressListener { fun onProgress(url: String, bytesRead: Long, totalBytes: Long) } companion object { private val mainThreadHandler = Handler(Looper.getMainLooper()) } } ================================================ FILE: app/src/main/java/io/legado/app/help/http/BackstageWebView.kt ================================================ package io.legado.app.help.http import android.annotation.SuppressLint import android.net.http.SslError import android.os.Build import android.os.Handler import android.os.Looper import android.util.AndroidRuntimeException import android.webkit.CookieManager import android.webkit.SslErrorHandler import android.webkit.WebResourceRequest import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient import io.legado.app.constant.AppConst import io.legado.app.exception.NoStackTraceException import io.legado.app.help.config.AppConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.utils.runOnUI import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Runnable import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeout import okhttp3.Protocol import okhttp3.Request import okhttp3.Response import org.apache.commons.text.StringEscapeUtils import splitties.init.appCtx import java.lang.ref.WeakReference import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException /** * 后台webView */ class BackstageWebView( private val url: String? = null, private val html: String? = null, private val encode: String? = null, private val tag: String? = null, private val headerMap: Map? = null, private val sourceRegex: String? = null, private val overrideUrlRegex: String? = null, private val javaScript: String? = null, private val delayTime: Long = 0, ) { private val mHandler = Handler(Looper.getMainLooper()) private var callback: Callback? = null private var mWebView: WebView? = null suspend fun getStrResponse(): StrResponse = withTimeout(60000L) { suspendCancellableCoroutine { block -> block.invokeOnCancellation { runOnUI { destroy() } } callback = object : Callback() { override fun onResult(response: StrResponse) { if (!block.isCompleted) { block.resume(response) } } override fun onError(error: Throwable) { if (!block.isCompleted) block.resumeWithException(error) } } runOnUI { try { load() } catch (error: Throwable) { block.resumeWithException(error) } } } } private fun getEncoding(): String { return encode ?: "utf-8" } @Throws(AndroidRuntimeException::class) private fun load() { val webView = createWebView() mWebView = webView try { when { !html.isNullOrEmpty() -> if (url.isNullOrEmpty()) { webView.loadData(html, "text/html", getEncoding()) } else { webView.loadDataWithBaseURL(url, html, "text/html", getEncoding(), url) } else -> if (headerMap == null) { webView.loadUrl(url!!) } else { webView.loadUrl(url!!, headerMap) } } } catch (e: Exception) { callback?.onError(e) } } @SuppressLint("SetJavaScriptEnabled", "JavascriptInterface") private fun createWebView(): WebView { val webView = WebView(appCtx) val settings = webView.settings settings.javaScriptEnabled = true settings.domStorageEnabled = true settings.blockNetworkImage = true settings.userAgentString = headerMap?.get(AppConst.UA_NAME) ?: AppConfig.userAgent settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW if (sourceRegex.isNullOrBlank() && overrideUrlRegex.isNullOrBlank()) { webView.webViewClient = HtmlWebViewClient() } else { webView.webViewClient = SnifferWebClient() } return webView } private fun destroy() { mWebView?.destroy() mWebView = null } private fun getJs(): String { javaScript?.let { if (it.isNotEmpty()) { return it } } return JS } private fun setCookie(url: String) { tag?.let { Coroutine.async(executeContext = IO) { val cookie = CookieManager.getInstance().getCookie(url) CookieStore.setCookie(it, cookie) } } } private inner class HtmlWebViewClient : WebViewClient() { private var runnable: EvalJsRunnable? = null private var isRedirect = false override fun shouldOverrideUrlLoading( view: WebView, request: WebResourceRequest ): Boolean { isRedirect = isRedirect || if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { request.isRedirect } else { request.url.toString() != view.url } return super.shouldOverrideUrlLoading(view, request) } override fun onPageFinished(view: WebView, url: String) { setCookie(url) if (runnable == null) { runnable = EvalJsRunnable(view, url, getJs()) } mHandler.removeCallbacks(runnable!!) mHandler.postDelayed(runnable!!, 1000 + delayTime) } @SuppressLint("WebViewClientOnReceivedSslError") override fun onReceivedSslError( view: WebView?, handler: SslErrorHandler?, error: SslError? ) { handler?.proceed() } private inner class EvalJsRunnable( webView: WebView, private val url: String, private val mJavaScript: String ) : Runnable { var retry = 0 private val mWebView: WeakReference = WeakReference(webView) override fun run() { mWebView.get()?.evaluateJavascript(mJavaScript) { handleResult(it) } } private fun handleResult(result: String) = Coroutine.async { if (result.isNotEmpty() && result != "null") { val content = StringEscapeUtils.unescapeJson(result) .replace(quoteRegex, "") try { val response = buildStrResponse(content) callback?.onResult(response) } catch (e: Exception) { callback?.onError(e) } mHandler.post { destroy() } return@async } if (retry > 30) { callback?.onError(NoStackTraceException("js执行超时")) mHandler.post { destroy() } return@async } retry++ mHandler.postDelayed(this@EvalJsRunnable, 1000) } private fun buildStrResponse(content: String): StrResponse { if (!isRedirect) { return StrResponse(url, content) } val originUrl = this@BackstageWebView.url ?: url val originResponse = Response.Builder() .code(302) .request(Request.Builder().url(originUrl).build()) .protocol(Protocol.HTTP_1_1) .message("Found") .build() val response = Response.Builder() .code(200) .request(Request.Builder().url(url).build()) .protocol(Protocol.HTTP_1_1) .message("OK") .priorResponse(originResponse) .build() return StrResponse(response, content) } } } private inner class SnifferWebClient : WebViewClient() { override fun shouldOverrideUrlLoading( view: WebView, request: WebResourceRequest ): Boolean { if (shouldOverrideUrlLoading(request.url.toString())) { return true } return super.shouldOverrideUrlLoading(view, request) } @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION", "KotlinRedundantDiagnosticSuppress") override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { if (shouldOverrideUrlLoading(url)) { return true } return super.shouldOverrideUrlLoading(view, url) } private fun shouldOverrideUrlLoading(requestUrl: String): Boolean { overrideUrlRegex?.let { if (requestUrl.matches(it.toRegex())) { try { val response = StrResponse(url!!, requestUrl) callback?.onResult(response) } catch (e: Exception) { callback?.onError(e) } destroy() return true } } return false } override fun onLoadResource(view: WebView, resUrl: String) { sourceRegex?.let { if (resUrl.matches(it.toRegex())) { try { val response = StrResponse(url!!, resUrl) callback?.onResult(response) } catch (e: Exception) { callback?.onError(e) } destroy() } } } override fun onPageFinished(webView: WebView, url: String) { setCookie(url) if (!javaScript.isNullOrEmpty()) { val runnable = LoadJsRunnable(webView, javaScript) mHandler.postDelayed(runnable, 1000L + delayTime) } } @SuppressLint("WebViewClientOnReceivedSslError") override fun onReceivedSslError( view: WebView?, handler: SslErrorHandler?, error: SslError? ) { handler?.proceed() } private inner class LoadJsRunnable( webView: WebView, private val mJavaScript: String? ) : Runnable { private val mWebView: WeakReference = WeakReference(webView) override fun run() { mWebView.get()?.loadUrl("javascript:${mJavaScript}") } } } companion object { const val JS = "document.documentElement.outerHTML" private val quoteRegex = "^\"|\"$".toRegex() } abstract class Callback { abstract fun onResult(response: StrResponse) abstract fun onError(error: Throwable) } } ================================================ FILE: app/src/main/java/io/legado/app/help/http/CookieManager.kt ================================================ package io.legado.app.help.http import android.webkit.CookieManager import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.help.CacheManager import io.legado.app.utils.NetworkUtils import io.legado.app.utils.splitNotBlank import okhttp3.Cookie import okhttp3.Headers import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.Request import okhttp3.Response import org.jsoup.Connection @Suppress("ConstPropertyName") object CookieManager { /** * _session_cookie 会话期 cookie,应用重启后失效 * _cookie cookies 缓存 */ const val cookieJarHeader = "CookieJar" /** * 从响应中保存Cookies */ fun saveResponse(response: Response) { val url = response.request.url val headers = response.headers saveCookiesFromHeaders(url, headers) } fun saveResponse(response: Connection.Response) { val url = response.url().toHttpUrlOrNull() ?: return val headers = response.multiHeaders().toHeaders() saveCookiesFromHeaders(url, headers) } private fun saveCookiesFromHeaders(url: HttpUrl, headers: Headers) { val domain = NetworkUtils.getSubDomain(url.toString()) val cookies = Cookie.parseAll(url, headers) val sessionCookie = cookies.filter { !it.persistent }.getString() updateSessionCookie(domain, sessionCookie) val cookieString = cookies.filter { it.persistent }.getString() CookieStore.replaceCookie(domain, cookieString) } /** * 加载Cookies到请求中 */ fun loadRequest(request: Request): Request { val url = request.url.toString() val domain = NetworkUtils.getSubDomain(url) val cookie = CookieStore.getCookie(domain) val requestCookie = request.header("Cookie") val newCookie = mergeCookies(requestCookie, cookie) ?: return request kotlin.runCatching { return request.newBuilder() .header("Cookie", newCookie) .build() }.onFailure { CookieStore.removeCookie(url) val msg = "设置cookie出错,已清除cookie $domain cookie:$newCookie\n$it" AppLog.put(msg, it) } return request } private fun getSessionCookieMap(domain: String): MutableMap? { return getSessionCookie(domain)?.let { CookieStore.cookieToMap(it) } } fun getSessionCookie(domain: String): String? { return CacheManager.getFromMemory("${domain}_session_cookie") as? String } private fun updateSessionCookie(domain: String, cookies: String) { val sessionCookie = getSessionCookie(domain) if (sessionCookie.isNullOrEmpty()) { CacheManager.putMemory("${domain}_session_cookie", cookies) return } val ck = mergeCookies(sessionCookie, cookies) ?: return CacheManager.putMemory("${domain}_session_cookie", ck) } fun mergeCookies(vararg cookies: String?): String? { val cookieMap = mergeCookiesToMap(*cookies) return CookieStore.mapToCookie(cookieMap) } fun mergeCookiesToMap(vararg cookies: String?): MutableMap { return cookies.filterNotNull().map { CookieStore.cookieToMap(it) }.reduce { acc, cookieMap -> acc.apply { putAll(cookieMap) } } } /** * 删除单个Cookie */ fun removeCookie(url: String, key: String) { val domain = NetworkUtils.getSubDomain(url) getSessionCookieMap(domain)?.let { it.remove(key) CookieStore.mapToCookie(it)?.let { cookie -> CacheManager.putMemory("${domain}_session_cookie", cookie) } } val cookie = getCookieNoSession(url) if (cookie.isNotEmpty()) { val cookieMap = CookieStore.cookieToMap(cookie).apply { remove(key) } CookieStore.mapToCookie(cookieMap)?.let { CookieStore.setCookie(url, it) } } } fun getCookieNoSession(url: String): String { val domain = NetworkUtils.getSubDomain(url) val cacheCookie = CacheManager.getFromMemory("${domain}_cookie") as? String return if (cacheCookie != null) { cacheCookie } else { val cookieBean = appDb.cookieDao.get(domain) cookieBean?.cookie ?: "" } } fun applyToWebView(url: String) { val baseUrl = NetworkUtils.getBaseUrl(url) ?: return val cookies = CookieStore.getCookie(url).splitNotBlank(";") val cookieManager = CookieManager.getInstance() cookieManager.removeSessionCookies(null) cookies.forEach { cookieManager.setCookie(baseUrl, it) } } fun List.getString() = buildString { this@getString.forEachIndexed { index, cookie -> if (index > 0) append("; ") append(cookie.name).append('=').append(cookie.value) } } private fun Map>.toHeaders(): Headers { return Headers.Builder().apply { this@toHeaders.forEach { (k, v) -> v.forEach { add(k, it) } } }.build() } } ================================================ FILE: app/src/main/java/io/legado/app/help/http/CookieStore.kt ================================================ @file:Suppress("unused") package io.legado.app.help.http import android.text.TextUtils import androidx.annotation.Keep import io.legado.app.constant.AppLog import io.legado.app.constant.AppPattern.equalsRegex import io.legado.app.constant.AppPattern.semicolonRegex import io.legado.app.data.appDb import io.legado.app.data.entities.Cookie import io.legado.app.help.CacheManager import io.legado.app.help.http.CookieManager.getCookieNoSession import io.legado.app.help.http.CookieManager.mergeCookiesToMap import io.legado.app.help.http.api.CookieManagerInterface import io.legado.app.utils.NetworkUtils import io.legado.app.utils.removeCookie @Keep object CookieStore : CookieManagerInterface { /** *保存cookie到数据库,会自动识别url的二级域名 */ override fun setCookie(url: String, cookie: String?) { try { val domain = NetworkUtils.getSubDomain(url) CacheManager.putMemory("${domain}_cookie", cookie ?: "") val cookieBean = Cookie(domain, cookie ?: "") appDb.cookieDao.insert(cookieBean) } catch (e: Exception) { AppLog.put("保存Cookie失败\n$e", e) } } override fun replaceCookie(url: String, cookie: String) { if (TextUtils.isEmpty(url) || TextUtils.isEmpty(cookie)) { return } val oldCookie = getCookieNoSession(url) if (TextUtils.isEmpty(oldCookie)) { setCookie(url, cookie) } else { val cookieMap = cookieToMap(oldCookie) cookieMap.putAll(cookieToMap(cookie)) val newCookie = mapToCookie(cookieMap) setCookie(url, newCookie) } } /** *获取url所属的二级域名的cookie */ override fun getCookie(url: String): String { val domain = NetworkUtils.getSubDomain(url) val cookie = getCookieNoSession(url) val sessionCookie = CookieManager.getSessionCookie(domain) val cookieMap = mergeCookiesToMap(cookie, sessionCookie) var ck = mapToCookie(cookieMap) ?: "" while (ck.length > 4096) { val removeKey = cookieMap.keys.random() CookieManager.removeCookie(url, removeKey) cookieMap.remove(removeKey) ck = mapToCookie(cookieMap) ?: "" } return ck } fun getKey(url: String, key: String): String { val cookie = getCookie(url) val sessionCookie = CookieManager.getSessionCookie(url) val cookieMap = mergeCookiesToMap(cookie, sessionCookie) return cookieMap[key] ?: "" } override fun removeCookie(url: String) { val domain = NetworkUtils.getSubDomain(url) appDb.cookieDao.delete(domain) CacheManager.deleteMemory("${domain}_cookie") CacheManager.deleteMemory("${domain}_session_cookie") android.webkit.CookieManager.getInstance().removeCookie(url) } override fun cookieToMap(cookie: String): MutableMap { val cookieMap = mutableMapOf() if (cookie.isBlank()) { return cookieMap } val pairArray = cookie.split(semicolonRegex).dropLastWhile { it.isEmpty() }.toTypedArray() for (pair in pairArray) { val pairs = pair.split(equalsRegex, 2).dropLastWhile { it.isEmpty() }.toTypedArray() if (pairs.size <= 1) { continue } val key = pairs[0].trim { it <= ' ' } val value = pairs[1] if (value.isNotBlank() || value.trim { it <= ' ' } == "null") { cookieMap[key] = value.trim { it <= ' ' } } } return cookieMap } override fun mapToCookie(cookieMap: Map?): String? { if (cookieMap.isNullOrEmpty()) { return null } val builder = StringBuilder() cookieMap.keys.forEachIndexed { index, key -> if (index > 0) builder.append("; ") builder.append(key).append("=").append(cookieMap[key]) } return builder.toString() } fun clear() { appDb.cookieDao.deleteOkHttp() } } ================================================ FILE: app/src/main/java/io/legado/app/help/http/Cronet.kt ================================================ package io.legado.app.help.http import io.legado.app.lib.cronet.CronetInterceptor import io.legado.app.lib.cronet.CronetLoader import okhttp3.Interceptor object Cronet { val loader: LoaderInterface? by lazy { CronetLoader } fun preDownload() { loader?.preDownload() } val interceptor: Interceptor? by lazy { CronetInterceptor(cookieJar) } interface LoaderInterface { fun install(): Boolean fun preDownload() } } ================================================ FILE: app/src/main/java/io/legado/app/help/http/DecompressInterceptor.kt ================================================ package io.legado.app.help.http import okhttp3.Interceptor import okhttp3.Response import okhttp3.ResponseBody import okhttp3.ResponseBody.Companion.asResponseBody import okhttp3.internal.http.promisesBody import okio.buffer import okio.source import java.util.zip.GZIPInputStream import java.util.zip.Inflater import java.util.zip.InflaterInputStream object DecompressInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() val requestBuilder = request.newBuilder() var transparentDecompress = false if (request.header("Accept-Encoding") == null && request.header("Range") == null) { transparentDecompress = true requestBuilder.header("Accept-Encoding", "gzip, deflate") } val response = chain.proceed(requestBuilder.build()) val body = response.body if (!transparentDecompress || !response.promisesBody() || body == ResponseBody.EMPTY) { return response } val encoding = response.header("Content-Encoding")?.lowercase() val source = when (encoding) { "gzip" -> GZIPInputStream(body.byteStream()).source().buffer() "deflate" -> InflaterInputStream(body.byteStream(), Inflater(true)).source().buffer() else -> return response } return response.newBuilder() .removeHeader("Content-Encoding") .removeHeader("Content-Length") .body(source.asResponseBody(body.contentType(), -1)) .build() } } ================================================ FILE: app/src/main/java/io/legado/app/help/http/HttpHelper.kt ================================================ package io.legado.app.help.http import io.legado.app.constant.AppConst import io.legado.app.help.CacheManager import io.legado.app.help.config.AppConfig import io.legado.app.help.glide.progress.ProgressManager.LISTENER import io.legado.app.help.glide.progress.ProgressResponseBody import io.legado.app.help.http.CookieManager.cookieJarHeader import io.legado.app.model.ReadManga import io.legado.app.utils.NetworkUtils import okhttp3.ConnectionSpec import okhttp3.Cookie import okhttp3.CookieJar import okhttp3.Credentials import okhttp3.HttpUrl import okhttp3.OkHttpClient import java.net.InetSocketAddress import java.net.Proxy import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ThreadFactory import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.TimeUnit private val proxyClientCache: ConcurrentHashMap by lazy { ConcurrentHashMap() } val cookieJar by lazy { object : CookieJar { override fun loadForRequest(url: HttpUrl): List { return emptyList() } override fun saveFromResponse(url: HttpUrl, cookies: List) { if (cookies.isEmpty()) return //临时保存 书源启用cookie选项再添加到数据库 val cookieBuilder = StringBuilder() cookies.forEachIndexed { index, cookie -> if (index > 0) cookieBuilder.append(";") cookieBuilder.append(cookie.name).append('=').append(cookie.value) } val domain = NetworkUtils.getSubDomain(url.toString()) CacheManager.putMemory("${domain}_cookieJar", cookieBuilder.toString()) } } } val okHttpClient: OkHttpClient by lazy { val specs = arrayListOf( ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS, ConnectionSpec.CLEARTEXT ) val builder = OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) .writeTimeout(15, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) .callTimeout(60, TimeUnit.SECONDS) //.cookieJar(cookieJar = cookieJar) .sslSocketFactory(SSLHelper.unsafeSSLSocketFactory, SSLHelper.unsafeTrustManager) .retryOnConnectionFailure(true) .hostnameVerifier(SSLHelper.unsafeHostnameVerifier) .connectionSpecs(specs) .followRedirects(true) .followSslRedirects(true) .addInterceptor(OkHttpExceptionInterceptor) .addInterceptor { chain -> val request = chain.request() val builder = request.newBuilder() if (request.header(AppConst.UA_NAME) == null) { builder.addHeader(AppConst.UA_NAME, AppConfig.userAgent) } else if (request.header(AppConst.UA_NAME) == "null") { builder.removeHeader(AppConst.UA_NAME) } builder.addHeader("Keep-Alive", "300") builder.addHeader("Connection", "Keep-Alive") builder.addHeader("Cache-Control", "no-cache") chain.proceed(builder.build()) } .addNetworkInterceptor { chain -> var request = chain.request() val enableCookieJar = request.header(cookieJarHeader) != null if (enableCookieJar) { val requestBuilder = request.newBuilder() requestBuilder.removeHeader(cookieJarHeader) request = CookieManager.loadRequest(requestBuilder.build()) } val networkResponse = chain.proceed(request) if (enableCookieJar) { CookieManager.saveResponse(networkResponse) } networkResponse } if (AppConfig.isCronet) { if (Cronet.loader?.install() == true) { Cronet.interceptor?.let { builder.addInterceptor(it) } } } builder.addInterceptor(DecompressInterceptor) builder.build().apply { val okHttpName = OkHttpClient::class.java.name.removePrefix("okhttp3.").removeSuffix("Client") val executor = dispatcher.executorService as ThreadPoolExecutor val threadName = "$okHttpName Dispatcher" executor.threadFactory = ThreadFactory { runnable -> Thread(runnable, threadName).apply { isDaemon = false uncaughtExceptionHandler = OkhttpUncaughtExceptionHandler } } } } val okHttpClientManga by lazy { okHttpClient.newBuilder().run { val interceptors = interceptors() interceptors.add(1) { chain -> val request = chain.request() val response = chain.proceed(request) val url = request.url.toString() response.newBuilder() .body(ProgressResponseBody(url, LISTENER, response.body)) .build() } interceptors.add(1) { chain -> ReadManga.rateLimiter.withLimitBlocking { chain.proceed(chain.request()) } } build() } } /** * 缓存代理okHttp */ fun getProxyClient(proxy: String? = null): OkHttpClient { if (proxy.isNullOrBlank()) { return okHttpClient } proxyClientCache[proxy]?.let { return it } val r = Regex("(http|socks4|socks5)://(.*):(\\d{2,5})(@.*@.*)?") val ms = r.findAll(proxy) val group = ms.first() var username = "" //代理服务器验证用户名 var password = "" //代理服务器验证密码 val type = if (group.groupValues[1] == "http") "http" else "socks" val host = group.groupValues[2] val port = group.groupValues[3].toInt() if (group.groupValues[4] != "") { username = group.groupValues[4].split("@")[1] password = group.groupValues[4].split("@")[2] } if (host != "") { val builder = okHttpClient.newBuilder() if (type == "http") { builder.proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(host, port))) } else { builder.proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress(host, port))) } if (username != "" && password != "") { builder.proxyAuthenticator { _, response -> //设置代理服务器账号密码 val credential: String = Credentials.basic(username, password) response.request.newBuilder() .header("Proxy-Authorization", credential) .build() } } val proxyClient = builder.build() proxyClientCache[proxy] = proxyClient return proxyClient } return okHttpClient } ================================================ FILE: app/src/main/java/io/legado/app/help/http/ObsoleteUrlFactory.kt ================================================ package io.legado.app.help.http import android.os.Build import androidx.annotation.RequiresApi import io.legado.app.help.http.CookieManager.cookieJarHeader import io.legado.app.help.http.SSLHelper.unsafeTrustManager import okhttp3.Call import okhttp3.Callback import okhttp3.Dispatcher import okhttp3.Handshake import okhttp3.Headers import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.Interceptor import okhttp3.MediaType import okhttp3.OkHttpClient import okhttp3.Protocol import okhttp3.Request import okhttp3.RequestBody import okhttp3.Response import okio.Buffer import okio.BufferedSink import okio.Pipe import okio.Timeout import okio.buffer import java.io.BufferedReader import java.io.FileNotFoundException import java.io.IOException import java.io.InputStream import java.io.InputStreamReader import java.io.InterruptedIOException import java.io.OutputStream import java.net.HttpURLConnection import java.net.InetSocketAddress import java.net.MalformedURLException import java.net.ProtocolException import java.net.Proxy import java.net.SocketPermission import java.net.SocketTimeoutException import java.net.URL import java.net.URLConnection import java.net.URLStreamHandler import java.net.URLStreamHandlerFactory import java.security.AccessControlException import java.security.Permission import java.security.Principal import java.security.cert.Certificate import java.text.DateFormat import java.text.SimpleDateFormat import java.util.Collections import java.util.Date import java.util.Locale import java.util.TimeZone import java.util.TreeMap import java.util.concurrent.TimeUnit import javax.net.ssl.HostnameVerifier import javax.net.ssl.HttpsURLConnection import javax.net.ssl.SSLSocketFactory /** * OkHttp 3.14 dropped support for the long-deprecated OkUrlFactory class, which allows you to use * the HttpURLConnection API with OkHttp's implementation. This class does the same thing using only * public APIs in OkHttp. It requires OkHttp 3.14 or newer. * * * Rather than pasting this 1100 line gist into your source code, please upgrade to OkHttp's * request/response API. Your code will be shorter, easier to read, and you'll be able to use * interceptors. */ @Suppress("unused", "MemberVisibilityCanBePrivate") class ObsoleteUrlFactory(private var client: OkHttpClient) : URLStreamHandlerFactory, Cloneable { fun client(): OkHttpClient { return client } fun setClient(client: OkHttpClient): ObsoleteUrlFactory { this.client = client return this } /** * Returns a copy of this stream handler factory that includes a shallow copy of the internal * [HTTP client][OkHttpClient]. */ public override fun clone(): ObsoleteUrlFactory { return ObsoleteUrlFactory(client) } fun open(url: URL): HttpURLConnection { return open(url, client.proxy) } fun open(url: URL, proxy: Proxy?): HttpURLConnection { val protocol = url.protocol val copy = client.newBuilder() .proxy(proxy) .build() if (protocol == "http") return OkHttpURLConnection(url, copy) if (protocol == "https") return OkHttpsURLConnection(url, copy) throw IllegalArgumentException("Unexpected protocol: $protocol") } /** * Creates a URLStreamHandler as a [java.net.URL.setURLStreamHandlerFactory]. * * * This code configures OkHttp to handle all HTTP and HTTPS connections * created with [java.net.URL.openConnection]:
   `OkHttpClient okHttpClient = new OkHttpClient();
     * URL.setURLStreamHandlerFactory(new ObsoleteUrlFactory(okHttpClient));
    `
* */ override fun createURLStreamHandler(protocol: String): URLStreamHandler? { return if (protocol != "http" && protocol != "https") null else object : URLStreamHandler() { override fun openConnection(url: URL): URLConnection { return open(url) } override fun openConnection(url: URL, proxy: Proxy): URLConnection { return open(url, proxy) } override fun getDefaultPort(): Int { if ((protocol == "http")) return 80 if ((protocol == "https")) return 443 throw AssertionError() } } } internal class OkHttpURLConnection( url: URL?, // These fields are confined to the application thread that uses HttpURLConnection. var client: OkHttpClient ) : HttpURLConnection(url), Callback { private val networkInterceptor: NetworkInterceptor = NetworkInterceptor() var requestHeaders: Headers.Builder = Headers.Builder() var responseHeaders: Headers? = null var executed = false var call: Call? = null /** Like the superclass field of the same name, but a long and available on all platforms. */ //var fixedContentLength = -1L // These fields are guarded by lock. private val lock = Any() private var response: Response? = null private var callFailure: Throwable? = null var networkResponse: Response? = null var connectPending = true var proxy: Proxy? = null var handshake: Handshake? = null @Throws(IOException::class) override fun connect() { if (executed) return val call = buildCall() executed = true call.enqueue(this) synchronized(lock) { try { while (connectPending && (response == null) && (callFailure == null)) { lock.wait() // Wait 'til the network interceptor is reached or the call fails. } if (callFailure != null) { throw propagate(callFailure) } } catch (e: InterruptedException) { Thread.currentThread().interrupt() // Retain interrupted status. throw InterruptedIOException() } } } override fun disconnect() { // Calling disconnect() before a connection exists should have no effect. if (call == null) return networkInterceptor.proceed() // Unblock any waiting async thread. call!!.cancel() } override fun getErrorStream(): InputStream? { return try { val response = getResponse(true) if (hasBody(response) && response.code >= HTTP_BAD_REQUEST) { response.body.byteStream() } else null } catch (e: IOException) { null } } @get:Throws(IOException::class) val headers: Headers get() { if (responseHeaders == null) { val response = getResponse(true) val headers = response.headers responseHeaders = headers.newBuilder() .add(SELECTED_PROTOCOL, response.protocol.toString()) .add(RESPONSE_SOURCE, responseSourceHeader(response)) .build() } return responseHeaders as Headers } override fun getHeaderField(position: Int): String? { return try { val headers = headers if (position < 0 || position >= headers.size) null else headers.value(position) } catch (e: IOException) { null } } override fun getHeaderField(fieldName: String?): String? { return try { if (fieldName == null) statusLineToString(getResponse(true)) else headers[fieldName] } catch (e: IOException) { null } } override fun getHeaderFieldKey(position: Int): String? { return try { val headers = headers if (position < 0 || position >= headers.size) null else headers.name(position) } catch (e: IOException) { null } } override fun getHeaderFields(): Map> { return try { toMultimap(headers, statusLineToString(getResponse(true))) } catch (e: IOException) { emptyMap() } } override fun getRequestProperties(): Map> { if (connected) { throw IllegalStateException( "Cannot access request header fields after connection is set" ) } return toMultimap(requestHeaders.build(), null) } @Throws(IOException::class) override fun getInputStream(): InputStream { if (!doInput) { throw ProtocolException("This protocol does not support input") } val response = getResponse(false) if (response.code >= HTTP_BAD_REQUEST) throw FileNotFoundException(url.toString()) return response.body.byteStream() } @Throws(IOException::class) override fun getOutputStream(): OutputStream { val requestBody = buildCall().request().body as OutputStreamRequestBody? ?: throw ProtocolException("method does not support a request body: $method") if (requestBody is StreamedRequestBody) { connect() networkInterceptor.proceed() } if (requestBody.closed) { throw ProtocolException("cannot write request body after response has been read") } return requestBody.outputStream!! } override fun getPermission(): Permission { val url = getURL() var hostname = url.host var hostPort = if (url.port != -1) url.port else HttpUrl.defaultPort(url.protocol) if (usingProxy()) { val proxyAddress = client.proxy!!.address() as InetSocketAddress hostname = proxyAddress.hostName hostPort = proxyAddress.port } return SocketPermission("$hostname:$hostPort", "connect, resolve") } override fun getRequestProperty(field: String?): String? { return if (field == null) null else requestHeaders[field] } override fun setConnectTimeout(timeoutMillis: Int) { client = client.newBuilder() .connectTimeout(timeoutMillis.toLong(), TimeUnit.MILLISECONDS) .build() } override fun setInstanceFollowRedirects(followRedirects: Boolean) { client = client.newBuilder() .followRedirects(followRedirects) .build() } override fun getInstanceFollowRedirects(): Boolean { return client.followRedirects } override fun getConnectTimeout(): Int { return client.connectTimeoutMillis } override fun setReadTimeout(timeoutMillis: Int) { client = client.newBuilder() .readTimeout(timeoutMillis.toLong(), TimeUnit.MILLISECONDS) .build() } override fun getReadTimeout(): Int { return client.readTimeoutMillis } @Throws(IOException::class) private fun buildCall(): Call { if (call != null) { return call as Call } connected = true if (doOutput) { if (method == "GET") { method = "POST" } else if (!permitsRequestBody(method)) { throw ProtocolException("$method does not support writing") } } if (requestHeaders["User-Agent"] == null) { requestHeaders.add("User-Agent", defaultUserAgent()) } var requestBody: OutputStreamRequestBody? = null if (permitsRequestBody(method)) { var contentType: String? = requestHeaders["Content-Type"] if (contentType == null) { contentType = "application/x-www-form-urlencoded" requestHeaders.add("Content-Type", contentType) } val stream = fixedContentLength != -1 || chunkLength > 0 var contentLength = -1L val contentLengthString: String? = requestHeaders["Content-Length"] if (fixedContentLength != -1) { contentLength = fixedContentLength.toLong() } else if (contentLengthString != null) { contentLength = contentLengthString.toLong() } requestBody = if (stream) StreamedRequestBody(contentLength) else BufferedRequestBody( contentLength ) requestBody.timeout!!.timeout( client.writeTimeoutMillis.toLong(), TimeUnit.MILLISECONDS ) } val url: HttpUrl try { url = getURL().toString().toHttpUrl() } catch (e: IllegalArgumentException) { val malformedUrl = MalformedURLException() malformedUrl.initCause(e) throw malformedUrl } val request: Request = Request.Builder() .url(url) .headers(requestHeaders.build()) .method(method, requestBody) .build() val clientBuilder: OkHttpClient.Builder = client.newBuilder() clientBuilder.interceptors().clear() clientBuilder.interceptors().add(UnexpectedException.INTERCEPTOR) clientBuilder.networkInterceptors().clear() clientBuilder.networkInterceptors().add(networkInterceptor) clientBuilder.addNetworkInterceptor { chain -> var request1 = chain.request() val enableCookieJar = request1.header(cookieJarHeader) != null if (enableCookieJar) { val requestBuilder = request1.newBuilder() requestBuilder.removeHeader(cookieJarHeader) request1 = CookieManager.loadRequest(requestBuilder.build()) } val networkResponse = chain.proceed(request1) if (enableCookieJar) { CookieManager.saveResponse(networkResponse) } networkResponse } // Use a separate dispatcher so that limits aren't impacted. But use the same executor service! clientBuilder.dispatcher(Dispatcher(client.dispatcher.executorService)) // If we're currently not using caches, make sure the engine's client doesn't have one. if (!getUseCaches()) { clientBuilder.cache(null) } return clientBuilder.build().newCall(request).also { call = it } } @Throws(IOException::class) private fun getResponse(networkResponseOnError: Boolean): Response { synchronized(lock) { if (response != null) return response as Response if (callFailure != null) { if (networkResponseOnError && networkResponse != null) return networkResponse as Response throw propagate(callFailure) } } val call = buildCall() networkInterceptor.proceed() val requestBody = call.request().body as OutputStreamRequestBody? if (requestBody != null) requestBody.outputStream!!.close() if (executed) { synchronized(lock) { try { while (response == null && callFailure == null) { lock.wait() // Wait until the response is returned or the call fails. } } catch (e: InterruptedException) { Thread.currentThread().interrupt() // Retain interrupted status. throw InterruptedIOException() } } } else { executed = true try { onResponse(call, call.execute()) } catch (e: IOException) { onFailure(call, e) } } synchronized(lock) { if (callFailure != null) throw propagate(callFailure) if (response != null) return response as Response } throw AssertionError() } override fun usingProxy(): Boolean { if (proxy != null) return true val clientProxy = client.proxy return clientProxy != null && clientProxy.type() != Proxy.Type.DIRECT } @Throws(IOException::class) override fun getResponseMessage(): String { return getResponse(true).message } @Throws(IOException::class) override fun getResponseCode(): Int { return getResponse(true).code } override fun setRequestProperty(field: String?, newValue: String?) { if (connected) { throw IllegalStateException("Cannot set request property after connection is made") } if (field == null) { throw NullPointerException("field == null") } if (newValue == null) { return } requestHeaders[field] = newValue } override fun setIfModifiedSince(newValue: Long) { super.setIfModifiedSince(newValue) if (ifModifiedSince != 0L) { requestHeaders["If-Modified-Since"] = format(Date(ifModifiedSince)) } else { requestHeaders.removeAll("If-Modified-Since") } } override fun addRequestProperty(field: String?, value: String?) { if (connected) { throw IllegalStateException("Cannot add request property after connection is made") } if (field == null) { throw NullPointerException("field == null") } if (value == null) { return } requestHeaders.add(field, value) } @Throws(ProtocolException::class) override fun setRequestMethod(method: String) { if (!METHODS.contains(method)) { throw ProtocolException("Expected one of $METHODS but was $method") } this.method = method } override fun setFixedLengthStreamingMode(contentLength: Int) { setFixedLengthStreamingMode(contentLength.toLong()) } override fun setFixedLengthStreamingMode(contentLength: Long) { if (super.connected) throw IllegalStateException("Already connected") if (chunkLength > 0) throw IllegalStateException("Already in chunked mode") if (contentLength < 0) throw IllegalArgumentException("contentLength < 0") this.fixedContentLength = contentLength.toInt() super.fixedContentLength = contentLength.toInt().coerceAtMost(Int.MAX_VALUE) } override fun onFailure(call: Call, e: IOException) { synchronized(lock) { callFailure = if ((e is UnexpectedException)) e.cause else e lock.notifyAll() } } override fun onResponse(call: Call, response: Response) { synchronized(lock) { this.response = response handshake = response.handshake url = response.request.url.toUrl() lock.notifyAll() } } internal inner class NetworkInterceptor : Interceptor { // Guarded by HttpUrlConnection.this. private var proceed = false fun proceed() { synchronized(lock) { proceed = true lock.notifyAll() } } @Throws(IOException::class) override fun intercept(chain: Interceptor.Chain): Response { var request: Request = chain.request() synchronized(lock) { connectPending = false proxy = chain.connection()!!.route().proxy handshake = chain.connection()!!.handshake() lock.notifyAll() try { while (!proceed) { lock.wait() // Wait until proceed() is called. } } catch (e: InterruptedException) { Thread.currentThread().interrupt() // Retain interrupted status. throw InterruptedIOException() } } // Try to lock in the Content-Length before transmitting the request body. if (request.body is OutputStreamRequestBody) { val requestBody = request.body as OutputStreamRequestBody? request = requestBody!!.prepareToSendRequest(request) } val response: Response = chain.proceed(request) synchronized(lock) { networkResponse = response url = response.request.url.toUrl() } return response } } } internal abstract class OutputStreamRequestBody : RequestBody() { var timeout: Timeout? = null var expectedContentLength: Long = 0 var outputStream: OutputStream? = null var closed = false fun initOutputStream(sink: BufferedSink, expectedContentLength: Long) { timeout = sink.timeout() this.expectedContentLength = expectedContentLength // An output stream that writes to sink. If expectedContentLength is not -1, then this expects // exactly that many bytes to be written. outputStream = object : OutputStream() { private var bytesReceived: Long = 0 @Throws(IOException::class) override fun write(b: Int) { write(byteArrayOf(b.toByte()), 0, 1) } @Throws(IOException::class) override fun write(source: ByteArray, offset: Int, byteCount: Int) { if (closed) throw IOException("closed") // Not IllegalStateException! if (expectedContentLength != -1L && bytesReceived + byteCount > expectedContentLength) { throw ProtocolException( "expected " + expectedContentLength + " bytes but received " + bytesReceived + byteCount ) } bytesReceived += byteCount.toLong() try { sink.write(source, offset, byteCount) } catch (e: InterruptedIOException) { throw SocketTimeoutException(e.message) } } @Throws(IOException::class) override fun flush() { if (closed) return // Weird, but consistent with historical behavior. sink.flush() } @Throws(IOException::class) override fun close() { closed = true if (expectedContentLength != -1L && bytesReceived < expectedContentLength) { throw ProtocolException( ("expected " + expectedContentLength + " bytes but received " + bytesReceived) ) } sink.close() } } } override fun contentLength(): Long { return expectedContentLength } override fun contentType(): MediaType? { return null // Let the caller provide this in a regular header. } @Throws(IOException::class) open fun prepareToSendRequest(request: Request): Request { return request } } @Suppress("MemberVisibilityCanBePrivate") internal class BufferedRequestBody(expectedContentLength: Long) : OutputStreamRequestBody() { val buffer = Buffer() var contentLength = -1L init { initOutputStream(buffer, expectedContentLength) } override fun contentLength(): Long { return contentLength } @Throws(IOException::class) override fun prepareToSendRequest(request: Request): Request { if (request.header("Content-Length") != null) return request outputStream!!.close() contentLength = buffer.size return request.newBuilder() .removeHeader("Transfer-Encoding") .header("Content-Length", buffer.size.toString()) .build() } override fun writeTo(sink: BufferedSink) { buffer.copyTo(sink.buffer, 0, buffer.size) } } internal class StreamedRequestBody(expectedContentLength: Long) : OutputStreamRequestBody() { private val pipe = Pipe(8192) init { initOutputStream(pipe.sink.buffer(), expectedContentLength) } override fun isOneShot(): Boolean { return true } @Throws(IOException::class) override fun writeTo(sink: BufferedSink) { val buffer = Buffer() while (pipe.source.read(buffer, 8192) != -1L) { sink.write(buffer, buffer.size) } } } internal abstract class DelegatingHttpsURLConnection(private val delegate: HttpURLConnection) : HttpsURLConnection(delegate.url) { protected abstract fun handshake(): Handshake? abstract override fun setHostnameVerifier(hostnameVerifier: HostnameVerifier) abstract override fun getHostnameVerifier(): HostnameVerifier abstract override fun setSSLSocketFactory(sslSocketFactory: SSLSocketFactory?) abstract override fun getSSLSocketFactory(): SSLSocketFactory override fun getCipherSuite(): String? { val handshake = handshake() return handshake?.cipherSuite?.javaName } override fun getLocalCertificates(): Array? { val handshake = handshake() ?: return null val result = handshake.localCertificates return if (result.isNotEmpty()) result.toTypedArray() else null } override fun getServerCertificates(): Array? { val handshake = handshake() ?: return null val result = handshake.peerCertificates return if (result.isNotEmpty()) result.toTypedArray() else null } override fun getPeerPrincipal(): Principal? { return handshake()?.peerPrincipal } override fun getLocalPrincipal(): Principal? { return handshake()?.localPrincipal } @Throws(IOException::class) override fun connect() { connected = true delegate.connect() } override fun disconnect() { delegate.disconnect() } override fun getErrorStream(): InputStream? { return delegate.errorStream } override fun getRequestMethod(): String { return delegate.requestMethod } @Throws(IOException::class) override fun getResponseCode(): Int { return delegate.responseCode } @Throws(IOException::class) override fun getResponseMessage(): String? { return delegate.responseMessage } @Throws(ProtocolException::class) override fun setRequestMethod(method: String) { delegate.requestMethod = method } override fun usingProxy(): Boolean { return delegate.usingProxy() } override fun getInstanceFollowRedirects(): Boolean { return delegate.instanceFollowRedirects } override fun setInstanceFollowRedirects(followRedirects: Boolean) { delegate.instanceFollowRedirects = followRedirects } override fun getAllowUserInteraction(): Boolean { return delegate.allowUserInteraction } @Throws(IOException::class) override fun getContent(): Any { return delegate.content } @Throws(IOException::class) override fun getContent(types: Array?>?): Any? { return delegate.getContent(types) } override fun getContentEncoding(): String? { return delegate.contentEncoding } override fun getContentLength(): Int { return delegate.contentLength } // Should only be invoked on Java 8+ or Android API 24+. @RequiresApi(Build.VERSION_CODES.N) override fun getContentLengthLong(): Long { return delegate.contentLengthLong } override fun getContentType(): String? { return delegate.contentType } override fun getDate(): Long { return delegate.date } override fun getDefaultUseCaches(): Boolean { return delegate.defaultUseCaches } override fun getDoInput(): Boolean { return delegate.doInput } override fun getDoOutput(): Boolean { return delegate.doOutput } override fun getExpiration(): Long { return delegate.expiration } override fun getHeaderField(pos: Int): String? { return delegate.getHeaderField(pos) } override fun getHeaderFields(): Map> { return delegate.headerFields } override fun getRequestProperties(): Map> { return delegate.requestProperties } override fun addRequestProperty(field: String, newValue: String) { delegate.addRequestProperty(field, newValue) } override fun getHeaderField(key: String): String? { return delegate.getHeaderField(key) } // Should only be invoked on Java 8+ or Android API 24+. @RequiresApi(Build.VERSION_CODES.N) override fun getHeaderFieldLong(field: String, defaultValue: Long): Long { return delegate.getHeaderFieldLong(field, defaultValue) } override fun getHeaderFieldDate(field: String, defaultValue: Long): Long { return delegate.getHeaderFieldDate(field, defaultValue) } override fun getHeaderFieldInt(field: String, defaultValue: Int): Int { return delegate.getHeaderFieldInt(field, defaultValue) } override fun getHeaderFieldKey(position: Int): String? { return delegate.getHeaderFieldKey(position) } override fun getIfModifiedSince(): Long { return delegate.ifModifiedSince } @Throws(IOException::class) override fun getInputStream(): InputStream { return delegate.inputStream } override fun getLastModified(): Long { return delegate.lastModified } @Throws(IOException::class) override fun getOutputStream(): OutputStream { return delegate.outputStream } @Throws(IOException::class) override fun getPermission(): Permission { return delegate.permission } override fun getRequestProperty(field: String): String? { return delegate.getRequestProperty(field) } override fun getURL(): URL { return delegate.url } override fun getUseCaches(): Boolean { return delegate.useCaches } override fun setAllowUserInteraction(newValue: Boolean) { delegate.allowUserInteraction = newValue } override fun setDefaultUseCaches(newValue: Boolean) { delegate.defaultUseCaches = newValue } override fun setDoInput(newValue: Boolean) { delegate.doInput = newValue } override fun setDoOutput(newValue: Boolean) { delegate.doOutput = newValue } // Should only be invoked on Java 8+ or Android API 24+. override fun setFixedLengthStreamingMode(contentLength: Long) { delegate.setFixedLengthStreamingMode(contentLength) } override fun setIfModifiedSince(newValue: Long) { delegate.ifModifiedSince = newValue } override fun setRequestProperty(field: String, newValue: String) { delegate.setRequestProperty(field, newValue) } override fun setUseCaches(newValue: Boolean) { delegate.useCaches = newValue } override fun setConnectTimeout(timeoutMillis: Int) { delegate.connectTimeout = timeoutMillis } override fun getConnectTimeout(): Int { return delegate.connectTimeout } override fun setReadTimeout(timeoutMillis: Int) { delegate.readTimeout = timeoutMillis } override fun getReadTimeout(): Int { return delegate.readTimeout } override fun toString(): String { return delegate.toString() } override fun setFixedLengthStreamingMode(contentLength: Int) { delegate.setFixedLengthStreamingMode(contentLength) } override fun setChunkedStreamingMode(chunkLength: Int) { delegate.setChunkedStreamingMode(chunkLength) } } internal class OkHttpsURLConnection(private val delegate: OkHttpURLConnection) : DelegatingHttpsURLConnection(delegate) { constructor(url: URL?, client: OkHttpClient) : this(OkHttpURLConnection(url, client)) override fun handshake(): Handshake? { if (delegate.call == null) { throw IllegalStateException("Connection has not yet been established") } return delegate.handshake } override fun setHostnameVerifier(hostnameVerifier: HostnameVerifier) { delegate.client = delegate.client.newBuilder() .hostnameVerifier(hostnameVerifier) .build() } override fun getHostnameVerifier(): HostnameVerifier { return delegate.client.hostnameVerifier } override fun setSSLSocketFactory(sslSocketFactory: SSLSocketFactory?) { if (sslSocketFactory == null) { throw IllegalArgumentException("sslSocketFactory == null") } // This fails in JDK 9 because OkHttp is unable to extract the trust manager. delegate.client = delegate.client.newBuilder() .sslSocketFactory(sslSocketFactory, unsafeTrustManager) .build() } override fun getSSLSocketFactory(): SSLSocketFactory { return delegate.client.sslSocketFactory } } internal class UnexpectedException(cause: Throwable?) : IOException(cause) { companion object { val INTERCEPTOR = Interceptor { chain: Interceptor.Chain -> try { return@Interceptor chain.proceed(chain.request()) } catch (e: Error) { throw UnexpectedException(e) } catch (e: RuntimeException) { throw UnexpectedException(e) } } } } companion object { const val SELECTED_PROTOCOL = "ObsoleteUrlFactory-Selected-Protocol" const val RESPONSE_SOURCE = "ObsoleteUrlFactory-Response-Source" val METHODS: Set = LinkedHashSet( listOf("OPTIONS", "GET", "HEAD", "POST", "PUT", "DELETE", "TRACE", "PATCH") ) val UTC: TimeZone = TimeZone.getTimeZone("GMT") const val HTTP_CONTINUE = 100 val STANDARD_DATE_FORMAT: ThreadLocal = ThreadLocal.withInitial { // Date format specified by RFC 7231 section 7.1.1.1. val rfc1123: DateFormat = SimpleDateFormat( "EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US ) rfc1123.isLenient = false rfc1123.timeZone = UTC rfc1123 } private val FIELD_NAME_COMPARATOR = java.util.Comparator { a: String?, b: String? -> // @FindBugsSuppressWarnings("ES_COMPARING_PARAMETER_STRING_WITH_EQ") if (a === b) { return@Comparator 0 } else if (a == null) { return@Comparator -1 } else if (b == null) { return@Comparator 1 } else { return@Comparator java.lang.String.CASE_INSENSITIVE_ORDER.compare(a, b) } } @Suppress( "RECEIVER_NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS", "NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS" ) fun format(value: Date?): String { return STANDARD_DATE_FORMAT.get().format(value) } fun permitsRequestBody(method: String): Boolean { return !((method == "GET") || (method == "HEAD")) } /** Returns true if the response must have a (possibly 0-length) body. See RFC 7231. */ fun hasBody(response: Response): Boolean { // HEAD requests never yield a body regardless of the response headers. if ((response.request.method == "HEAD")) { return false } val responseCode = response.code if (((responseCode < HTTP_CONTINUE || responseCode >= 200) && (responseCode != HttpURLConnection.HTTP_NO_CONTENT ) && (responseCode != HttpURLConnection.HTTP_NOT_MODIFIED)) ) { return true } // If the Content-Length or Transfer-Encoding headers disagree with the response code, the // response is malformed. For best compatibility, we honor the headers. return (contentLength(response.headers) != -1L || "chunked".equals( response.header("Transfer-Encoding"), ignoreCase = true )) } fun contentLength(headers: Headers): Long { val s = headers["Content-Length"] ?: return -1 return try { s.toLong() } catch (e: NumberFormatException) { -1 } } fun responseSourceHeader(response: Response): String { if (response.networkResponse == null) { return if (response.cacheResponse == null) "NONE" else "CACHE " + response.code } return if (response.cacheResponse == null) "NETWORK " + response.code else "CONDITIONAL_CACHE " + response.networkResponse!!.code } fun statusLineToString(response: Response): String { return ((if (response.protocol == Protocol.HTTP_1_0) "HTTP/1.0" else "HTTP/1.1") + ' ' + response.code + ' ' + response.message) } fun toHumanReadableAscii(s: String): String { var i = 0 val length = s.length var c: Int while (i < length) { c = s.codePointAt(i) if (c > '\u001f'.code && c < '\u007f'.code) { i += Character.charCount(c) continue } val buffer = Buffer() buffer.writeUtf8(s, 0, i) buffer.writeUtf8CodePoint('?'.code) var j = i + Character.charCount(c) while (j < length) { c = s.codePointAt(j) buffer.writeUtf8CodePoint( (if (c > '\u001f'.code && c < '\u007f'.code) c else '?') as Int ) j += Character.charCount(c) } return buffer.readUtf8() } return s } fun toMultimap(headers: Headers, valueForNullKey: String?): Map> { val result: MutableMap> = TreeMap(FIELD_NAME_COMPARATOR) var i = 0 val size = headers.size while (i < size) { val fieldName = headers.name(i) val value = headers.value(i) val allValues: MutableList = ArrayList() val otherValues = result[fieldName] if (otherValues != null) { allValues.addAll(otherValues) } allValues.add(value) result[fieldName] = Collections.unmodifiableList(allValues) i++ } if (valueForNullKey != null) { result[null] = Collections.unmodifiableList(listOf(valueForNullKey)) } return Collections.unmodifiableMap(result) } @Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS") fun getSystemProperty(key: String?, defaultValue: String?): String? { val value: String? try { value = System.getProperty(key) } catch (ex: AccessControlException) { return defaultValue } return value ?: defaultValue } fun defaultUserAgent(): String { val agent = getSystemProperty("http.agent", null) return if (agent != null) toHumanReadableAscii(agent) else "ObsoleteUrlFactory" } @Throws(IOException::class) fun propagate(throwable: Throwable?): IOException { if (throwable is IOException) throw throwable if (throwable is Error) throw throwable if (throwable is RuntimeException) throw throwable throw AssertionError() } @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "NOTHING_TO_INLINE") private inline fun Any.wait() = (this as Object).wait() @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "NOTHING_TO_INLINE") private inline fun Any.notify() = (this as Object).notify() @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "NOTHING_TO_INLINE") private inline fun Any.notifyAll() = (this as Object).notifyAll() @Throws(Exception::class) @JvmStatic fun main(args: Array) { val okHttpClient = OkHttpClient() URL.setURLStreamHandlerFactory(ObsoleteUrlFactory(okHttpClient)) val url = URL("https://publicobject.com/helloworld.txt") val urlConnection = url.openConnection() as HttpURLConnection BufferedReader( InputStreamReader(urlConnection.inputStream) ).use { reader -> var line: String? while ((reader.readLine().also { line = it }) != null) { println(line) } } } } } ================================================ FILE: app/src/main/java/io/legado/app/help/http/OkHttpExceptionInterceptor.kt ================================================ package io.legado.app.help.http import okhttp3.Interceptor import okhttp3.Response import java.io.IOException object OkHttpExceptionInterceptor : Interceptor { @Throws(IOException::class) override fun intercept(chain: Interceptor.Chain): Response { try { return chain.proceed(chain.request()) } catch (e: IOException) { throw e } catch (e: Throwable) { throw IOException(e) } } } ================================================ FILE: app/src/main/java/io/legado/app/help/http/OkHttpUtils.kt ================================================ package io.legado.app.help.http import io.legado.app.utils.EncodingDetect import io.legado.app.utils.GSON import io.legado.app.utils.Utf8BomUtils import kotlinx.coroutines.suspendCancellableCoroutine import okhttp3.Call import okhttp3.Callback import okhttp3.FormBody import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import okhttp3.ResponseBody import okhttp3.internal.http.RealResponseBody import okio.buffer import okio.source import java.io.File import java.io.IOException import java.nio.charset.Charset import java.util.zip.ZipInputStream import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException suspend fun OkHttpClient.newCallResponse( retry: Int = 0, builder: Request.Builder.() -> Unit ): Response { val requestBuilder = Request.Builder() requestBuilder.apply(builder) var response: Response? = null for (i in 0..retry) { response = newCall(requestBuilder.build()).await() if (response.isSuccessful) { return response } } return response!! } suspend fun OkHttpClient.newCallResponseBody( retry: Int = 0, builder: Request.Builder.() -> Unit ): ResponseBody { return newCallResponse(retry, builder).body } suspend fun OkHttpClient.newCallStrResponse( retry: Int = 0, builder: Request.Builder.() -> Unit ): StrResponse { return newCallResponse(retry, builder).let { StrResponse(it, it.body.text()) } } suspend fun Call.await(): Response = suspendCancellableCoroutine { block -> block.invokeOnCancellation { cancel() } enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { block.resumeWithException(e) } override fun onResponse(call: Call, response: Response) { block.resume(response) } }) } fun ResponseBody.text(encode: String? = null): String { val responseBytes = Utf8BomUtils.removeUTF8BOM(bytes()) var charsetName: String? = encode charsetName?.let { return String(responseBytes, Charset.forName(charsetName)) } //根据http头判断 contentType()?.charset()?.let { charset -> return String(responseBytes, charset) } //根据内容判断 charsetName = EncodingDetect.getHtmlEncode(responseBytes) return String(responseBytes, Charset.forName(charsetName)) } fun ResponseBody.decompressed(): ResponseBody { val contentType = contentType()?.toString() if (contentType != "application/zip") { return this } val source = ZipInputStream(byteStream()).apply { try { nextEntry } catch (e: Exception) { close() throw e } }.source().buffer() return RealResponseBody(null, -1, source) } fun Request.Builder.addHeaders(headers: Map) { headers.forEach { addHeader(it.key, it.value) } } fun Request.Builder.get(url: String, queryMap: Map, encoded: Boolean = false) { val httpBuilder = url.toHttpUrl().newBuilder() queryMap.forEach { if (encoded) { httpBuilder.addEncodedQueryParameter(it.key, it.value) } else { httpBuilder.addQueryParameter(it.key, it.value) } } url(httpBuilder.build()) } fun Request.Builder.get(url: String, encodedQuery: String?) { val httpBuilder = url.toHttpUrl().newBuilder() httpBuilder.encodedQuery(encodedQuery) url(httpBuilder.build()) } private val formContentType = "application/x-www-form-urlencoded".toMediaType() fun Request.Builder.postForm(encodedForm: String) { post(encodedForm.toRequestBody(formContentType)) } @Suppress("unused") fun Request.Builder.postForm(form: Map, encoded: Boolean = false) { val formBody = FormBody.Builder() form.forEach { if (encoded) { formBody.addEncoded(it.key, it.value) } else { formBody.add(it.key, it.value) } } post(formBody.build()) } fun Request.Builder.postMultipart(type: String?, form: Map) { val multipartBody = MultipartBody.Builder() type?.let { multipartBody.setType(type.toMediaType()) } form.forEach { when (val value = it.value) { is Map<*, *> -> { val fileName = value["fileName"] as String val file = value["file"] val mediaType = (value["contentType"] as? String)?.toMediaType() val requestBody = when (file) { is File -> { file.asRequestBody(mediaType) } is ByteArray -> { file.toRequestBody(mediaType) } is String -> { file.toRequestBody(mediaType) } else -> { GSON.toJson(file).toRequestBody(mediaType) } } multipartBody.addFormDataPart(it.key, fileName, requestBody) } else -> multipartBody.addFormDataPart(it.key, it.value.toString()) } } post(multipartBody.build()) } fun Request.Builder.postJson(json: String?) { json?.let { val requestBody = json.toRequestBody("application/json; charset=UTF-8".toMediaType()) post(requestBody) } } ================================================ FILE: app/src/main/java/io/legado/app/help/http/OkhttpUncaughtExceptionHandler.kt ================================================ package io.legado.app.help.http import io.legado.app.constant.AppLog object OkhttpUncaughtExceptionHandler : Thread.UncaughtExceptionHandler { override fun uncaughtException(t: Thread, e: Throwable) { AppLog.put("Okhttp Dispatcher中的线程执行出错\n${e.localizedMessage}", e) } } ================================================ FILE: app/src/main/java/io/legado/app/help/http/RequestMethod.kt ================================================ package io.legado.app.help.http enum class RequestMethod { GET, POST } ================================================ FILE: app/src/main/java/io/legado/app/help/http/SSLHelper.kt ================================================ package io.legado.app.help.http import android.annotation.SuppressLint import android.net.http.X509TrustManagerExtensions import io.legado.app.utils.printOnDebug import java.io.IOException import java.io.InputStream import java.security.KeyManagementException import java.security.KeyStore import java.security.NoSuchAlgorithmException import java.security.SecureRandom import java.security.cert.CertificateException import java.security.cert.CertificateFactory import java.security.cert.X509Certificate import javax.net.ssl.* @Suppress("unused") object SSLHelper { /** * 为了解决客户端不信任服务器数字证书的问题, * 网络上大部分的解决方案都是让客户端不对证书做任何检查, * 这是一种有很大安全漏洞的办法 */ val unsafeTrustManager: X509TrustManager = @SuppressLint("CustomX509TrustManager") object : X509TrustManager { @SuppressLint("TrustAllX509TrustManager") @Throws(CertificateException::class) override fun checkClientTrusted(chain: Array, authType: String) { //do nothing,接受任意客户端证书 } @SuppressLint("TrustAllX509TrustManager") @Throws(CertificateException::class) override fun checkServerTrusted(chain: Array, authType: String) { //do nothing,接受任意客户端证书 } fun checkServerTrusted(chain: Array, authType: String, host: String): List { return chain.toList() } override fun getAcceptedIssuers(): Array { return arrayOf() } } val unsafeTrustManagerExtensions by lazy { X509TrustManagerExtensions(unsafeTrustManager) } val unsafeSSLSocketFactory: SSLSocketFactory by lazy { try { val sslContext = SSLContext.getInstance("SSL") sslContext.init(null, arrayOf(unsafeTrustManager), SecureRandom()) sslContext.socketFactory } catch (e: Exception) { throw RuntimeException(e) } } /** * 此类是用于主机名验证的基接口。 在握手期间,如果 URL 的主机名和服务器的标识主机名不匹配, * 则验证机制可以回调此接口的实现程序来确定是否应该允许此连接。策略可以是基于证书的或依赖于其他验证方案。 * 当验证 URL 主机名使用的默认规则失败时使用这些回调。如果主机名是可接受的,则返回 true */ val unsafeHostnameVerifier: HostnameVerifier = HostnameVerifier { _, _ -> true } class SSLParams { lateinit var sSLSocketFactory: SSLSocketFactory lateinit var trustManager: X509TrustManager } /** * https单向认证 * 可以额外配置信任服务端的证书策略,否则默认是按CA证书去验证的,若不是CA可信任的证书,则无法通过验证 */ fun getSslSocketFactory(trustManager: X509TrustManager): SSLParams? { return getSslSocketFactoryBase(trustManager, null, null) } /** * https单向认证 * 用含有服务端公钥的证书校验服务端证书 */ fun getSslSocketFactory(vararg certificates: InputStream): SSLParams? { return getSslSocketFactoryBase(null, null, null, *certificates) } /** * https双向认证 * bksFile 和 password -> 客户端使用bks证书校验服务端证书 * certificates -> 用含有服务端公钥的证书校验服务端证书 */ fun getSslSocketFactory( bksFile: InputStream, password: String, vararg certificates: InputStream ): SSLParams? { return getSslSocketFactoryBase(null, bksFile, password, *certificates) } /** * https双向认证 * bksFile 和 password -> 客户端使用bks证书校验服务端证书 * X509TrustManager -> 如果需要自己校验,那么可以自己实现相关校验,如果不需要自己校验,那么传null即可 */ fun getSslSocketFactory( bksFile: InputStream, password: String, trustManager: X509TrustManager ): SSLParams? { return getSslSocketFactoryBase(trustManager, bksFile, password) } private fun getSslSocketFactoryBase( trustManager: X509TrustManager?, bksFile: InputStream?, password: String?, vararg certificates: InputStream ): SSLParams? { val sslParams = SSLParams() try { val keyManagers = prepareKeyManager(bksFile, password) val trustManagers = prepareTrustManager(*certificates) val manager: X509TrustManager = trustManager ?: chooseTrustManager(trustManagers) // 创建TLS类型的SSLContext对象, that uses our TrustManager val sslContext = SSLContext.getInstance("TLS") // 用上面得到的trustManagers初始化SSLContext,这样sslContext就会信任keyStore中的证书 // 第一个参数是授权的密钥管理器,用来授权验证,比如授权自签名的证书验证。第二个是被授权的证书管理器,用来验证服务器端的证书 sslContext.init(keyManagers, arrayOf(manager), null) // 通过sslContext获取SSLSocketFactory对象 sslParams.sSLSocketFactory = sslContext.socketFactory sslParams.trustManager = manager return sslParams } catch (e: NoSuchAlgorithmException) { e.printOnDebug() } catch (e: KeyManagementException) { e.printOnDebug() } return null } private fun prepareKeyManager(bksFile: InputStream?, password: String?): Array? { try { if (bksFile == null || password == null) return null val clientKeyStore = KeyStore.getInstance("BKS") clientKeyStore.load(bksFile, password.toCharArray()) val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) kmf.init(clientKeyStore, password.toCharArray()) return kmf.keyManagers } catch (e: Exception) { e.printOnDebug() } return null } private fun prepareTrustManager(vararg certificates: InputStream): Array { val certificateFactory = CertificateFactory.getInstance("X.509") // 创建一个默认类型的KeyStore,存储我们信任的证书 val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) keyStore.load(null) for ((index, certStream) in certificates.withIndex()) { val certificateAlias = index.toString() // 证书工厂根据证书文件的流生成证书 cert val cert = certificateFactory.generateCertificate(certStream) // 将 cert 作为可信证书放入到keyStore中 keyStore.setCertificateEntry(certificateAlias, cert) try { certStream.close() } catch (e: IOException) { e.printOnDebug() } } //我们创建一个默认类型的TrustManagerFactory val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) //用我们之前的keyStore实例初始化TrustManagerFactory,这样tmf就会信任keyStore中的证书 tmf.init(keyStore) //通过tmf获取TrustManager数组,TrustManager也会信任keyStore中的证书 return tmf.trustManagers } private fun chooseTrustManager(trustManagers: Array): X509TrustManager { for (trustManager in trustManagers) { if (trustManager is X509TrustManager) { return trustManager } } throw NullPointerException() } } ================================================ FILE: app/src/main/java/io/legado/app/help/http/StrResponse.kt ================================================ package io.legado.app.help.http import androidx.annotation.Keep import okhttp3.Headers import okhttp3.Protocol import okhttp3.Request import okhttp3.Response import okhttp3.Response.Builder import okhttp3.ResponseBody /** * An HTTP response. */ @Keep @Suppress("unused", "MemberVisibilityCanBePrivate") class StrResponse { var raw: Response private set var body: String? = null private set var errorBody: ResponseBody? = null private set constructor(rawResponse: Response, body: String?) { this.raw = rawResponse this.body = body } constructor(url: String, body: String?) { val request = try { Request.Builder().url(url).build() } catch (e: Exception) { Request.Builder().url("http://localhost/").build() } raw = Builder() .code(200) .message("OK") .protocol(Protocol.HTTP_1_1) .request(request) .build() this.body = body } constructor(rawResponse: Response, errorBody: ResponseBody?) { this.raw = rawResponse this.errorBody = errorBody } fun raw() = raw fun url(): String { raw.networkResponse?.let { return it.request.url.toString() } return raw.request.url.toString() } val url: String get() = url() fun body() = body fun code(): Int { return raw.code } fun message(): String { return raw.message } fun headers(): Headers { return raw.headers } fun isSuccessful(): Boolean = raw.isSuccessful fun errorBody(): ResponseBody? { return errorBody } override fun toString(): String { return raw.toString() } } ================================================ FILE: app/src/main/java/io/legado/app/help/http/api/CookieManagerInterface.kt ================================================ package io.legado.app.help.http.api interface CookieManagerInterface { /** * 保存cookie */ fun setCookie(url: String, cookie: String?) /** * 替换cookie */ fun replaceCookie(url: String, cookie: String) /** * 获取cookie */ fun getCookie(url: String): String /** * 移除cookie */ fun removeCookie(url: String) fun cookieToMap(cookie: String): MutableMap fun mapToCookie(cookieMap: Map?): String? } ================================================ FILE: app/src/main/java/io/legado/app/help/rhino/NativeBaseSource.kt ================================================ package io.legado.app.help.rhino import com.script.rhino.JavaObjectWrapFactory import org.mozilla.javascript.NativeJavaObject import org.mozilla.javascript.Scriptable class NativeBaseSource(scope: Scriptable?, javaObject: Any, staticType: Class<*>?) : NativeJavaObject(scope, javaObject, staticType) { override fun has(name: String, start: Scriptable): Boolean { if (name != "setVariable" && name.length > 3 && name.startsWith("set")) { val name = name.substring(3).replaceFirstChar { it.lowercase() } if (super.has(name, start)) { return false } } return super.has(name, start) } override fun get(name: String, start: Scriptable): Any? { if (name != "setVariable" && name.length > 3 && name.startsWith("set")) { val name = name.substring(3).replaceFirstChar { it.lowercase() } if (super.has(name, start)) { return NOT_FOUND } } return super.get(name, start) } override fun put( name: String, start: Scriptable, value: Any? ) { if (name == "variable") { super.put(name, start, value) } } companion object { val factory = JavaObjectWrapFactory { scope, javaObject, staticType -> NativeBaseSource(scope, javaObject, staticType) } } } ================================================ FILE: app/src/main/java/io/legado/app/help/source/BaseSourceExtensions.kt ================================================ package io.legado.app.help.source import io.legado.app.constant.SourceType import io.legado.app.data.entities.BaseSource import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.RssSource import io.legado.app.model.SharedJsScope import org.mozilla.javascript.Scriptable import kotlin.coroutines.CoroutineContext fun BaseSource.getShareScope(coroutineContext: CoroutineContext? = null): Scriptable? { return SharedJsScope.getScope(jsLib, coroutineContext) } fun BaseSource.getSourceType(): Int { return when (this) { is BookSource -> SourceType.book is RssSource -> SourceType.rss else -> error("unknown source type: ${this::class.simpleName}.") } } ================================================ FILE: app/src/main/java/io/legado/app/help/source/BookSourceExtensions.kt ================================================ package io.legado.app.help.source import com.script.rhino.runScriptWithContext import io.legado.app.constant.BookSourceType import io.legado.app.constant.BookType import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.rule.ExploreKind import io.legado.app.utils.ACache import io.legado.app.utils.GSON import io.legado.app.utils.MD5Utils import io.legado.app.utils.fromJsonArray import io.legado.app.utils.isJsonArray import io.legado.app.utils.printOnDebug import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.util.concurrent.ConcurrentHashMap /** * 采用md5作为key可以在分类修改后自动重新计算,不需要手动刷新 */ private val mutexMap by lazy { hashMapOf() } private val exploreKindsMap by lazy { ConcurrentHashMap>() } private val aCache by lazy { ACache.get("explore") } private fun BookSource.getExploreKindsKey(): String { return MD5Utils.md5Encode(bookSourceUrl + exploreUrl) } private fun BookSourcePart.getExploreKindsKey(): String { return getBookSource()!!.getExploreKindsKey() } suspend fun BookSourcePart.exploreKinds(): List { return getBookSource()!!.exploreKinds() } suspend fun BookSource.exploreKinds(): List { val exploreKindsKey = getExploreKindsKey() exploreKindsMap[exploreKindsKey]?.let { return it } val exploreUrl = exploreUrl if (exploreUrl.isNullOrBlank()) { return emptyList() } val mutex = mutexMap[bookSourceUrl] ?: Mutex().apply { mutexMap[bookSourceUrl] = this } mutex.withLock { exploreKindsMap[exploreKindsKey]?.let { return it } val kinds = arrayListOf() withContext(Dispatchers.IO) { kotlin.runCatching { var ruleStr = exploreUrl if (exploreUrl.startsWith("", true) || exploreUrl.startsWith("@js:", true) ) { ruleStr = aCache.getAsString(exploreKindsKey) if (ruleStr.isNullOrBlank()) { val jsStr = if (exploreUrl.startsWith("@")) { exploreUrl.substring(4) } else { exploreUrl.substring(4, exploreUrl.lastIndexOf("<")) } ruleStr = runScriptWithContext { evalJS(jsStr).toString().trim() } aCache.put(exploreKindsKey, ruleStr) } } if (ruleStr.isJsonArray()) { GSON.fromJsonArray(ruleStr).getOrThrow().let { kinds.addAll(it) } } else { ruleStr.split("(&&|\n)+".toRegex()).forEach { kindStr -> val kindCfg = kindStr.split("::") kinds.add(ExploreKind(kindCfg.first(), kindCfg.getOrNull(1))) } } }.onFailure { kinds.add(ExploreKind("ERROR:${it.localizedMessage}", it.stackTraceToString())) it.printOnDebug() } } exploreKindsMap[exploreKindsKey] = kinds return kinds } } suspend fun BookSourcePart.clearExploreKindsCache() { withContext(Dispatchers.IO) { val exploreKindsKey = getExploreKindsKey() aCache.remove(exploreKindsKey) exploreKindsMap.remove(exploreKindsKey) } } suspend fun BookSource.clearExploreKindsCache() { withContext(Dispatchers.IO) { val exploreKindsKey = getExploreKindsKey() aCache.remove(exploreKindsKey) exploreKindsMap.remove(exploreKindsKey) } } fun BookSource.exploreKindsJson(): String { val exploreKindsKey = getExploreKindsKey() return aCache.getAsString(exploreKindsKey)?.takeIf { it.isJsonArray() } ?: exploreUrl.takeIf { it.isJsonArray() } ?: "" } fun BookSource.getBookType(): Int { return when (bookSourceType) { BookSourceType.file -> BookType.text or BookType.webFile BookSourceType.image -> BookType.image BookSourceType.audio -> BookType.audio else -> BookType.text } } ================================================ FILE: app/src/main/java/io/legado/app/help/source/RssSourceExtensions.kt ================================================ package io.legado.app.help.source import io.legado.app.data.entities.RssSource import io.legado.app.utils.ACache import io.legado.app.utils.MD5Utils import io.legado.app.utils.NetworkUtils import com.script.rhino.runScriptWithContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext private val aCache by lazy { ACache.get("rssSortUrl") } private fun RssSource.getSortUrlsKey(): String { return MD5Utils.md5Encode(sourceUrl + sortUrl) } suspend fun RssSource.sortUrls(): List> { return arrayListOf>().apply { val sortUrlsKey = getSortUrlsKey() withContext(Dispatchers.IO) { kotlin.runCatching { var str = sortUrl if (sortUrl?.startsWith("", false) == true || sortUrl?.startsWith("@js:", false) == true ) { str = aCache.getAsString(sortUrlsKey) if (str.isNullOrBlank()) { val jsStr = if (sortUrl!!.startsWith("@")) { sortUrl!!.substring(4) } else { sortUrl!!.substring(4, sortUrl!!.lastIndexOf("<")) } str = runScriptWithContext { evalJS(jsStr).toString() } aCache.put(sortUrlsKey, str) } } str?.split("(&&|\n)+".toRegex())?.forEach { sort -> val name = sort.substringBefore("::") val url = sort.substringAfter("::", "") if (url.isNotEmpty()) { if (url.startsWith("{{")) { add(Pair(name, url)) } else { add(Pair(name, NetworkUtils.getAbsoluteURL(sourceUrl, url))) } } } if (isEmpty()) { add(Pair("", sourceUrl)) } } } } } suspend fun RssSource.removeSortCache() { withContext(Dispatchers.IO) { aCache.remove(getSortUrlsKey()) } } ================================================ FILE: app/src/main/java/io/legado/app/help/source/SourceHelp.kt ================================================ package io.legado.app.help.source import io.legado.app.constant.SourceType 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.BookSourcePart import io.legado.app.data.entities.RssSource import io.legado.app.help.AppCacheManager import io.legado.app.help.config.SourceConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.model.AudioPlay import io.legado.app.model.ReadBook import io.legado.app.model.ReadManga import io.legado.app.utils.EncoderUtils import io.legado.app.utils.NetworkUtils import io.legado.app.utils.splitNotBlank import io.legado.app.utils.toastOnUi import splitties.init.appCtx object SourceHelp { private val list18Plus by lazy { try { return@lazy String(appCtx.assets.open("18PlusList.txt").readBytes()) .splitNotBlank("\n").map { EncoderUtils.base64Decode(it) }.toHashSet() } catch (_: Exception) { return@lazy emptySet() } } fun getSource(key: String?): BaseSource? { key ?: return null if (ReadBook.bookSource?.bookSourceUrl == key) { return ReadBook.bookSource } else if (AudioPlay.bookSource?.bookSourceUrl == key) { return AudioPlay.bookSource } else if (ReadManga.bookSource?.bookSourceUrl == key) { return ReadManga.bookSource } return appDb.bookSourceDao.getBookSource(key) ?: appDb.rssSourceDao.getByKey(key) } fun getSource(key: String?, @SourceType.Type type: Int): BaseSource? { key ?: return null return when (type) { SourceType.book -> appDb.bookSourceDao.getBookSource(key) SourceType.rss -> appDb.rssSourceDao.getByKey(key) else -> null } } fun deleteSource(key: String, @SourceType.Type type: Int) { when (type) { SourceType.book -> deleteBookSource(key) SourceType.rss -> deleteRssSource(key) } } fun deleteBookSourceParts(sources: List) { appDb.runInTransaction { sources.forEach { deleteBookSourceInternal(it.bookSourceUrl) } } AppCacheManager.clearSourceVariables() } fun deleteBookSources(sources: List) { appDb.runInTransaction { sources.forEach { deleteBookSourceInternal(it.bookSourceUrl) } } AppCacheManager.clearSourceVariables() } private fun deleteBookSourceInternal(key: String) { appDb.bookSourceDao.delete(key) appDb.cacheDao.deleteSourceVariables(key) SourceConfig.removeSource(key) } fun deleteBookSource(key: String) { deleteBookSourceInternal(key) AppCacheManager.clearSourceVariables() } fun deleteRssSources(sources: List) { appDb.runInTransaction { sources.forEach { deleteRssSourceInternal(it.sourceUrl) } } AppCacheManager.clearSourceVariables() } private fun deleteRssSourceInternal(key: String) { appDb.rssSourceDao.delete(key) appDb.rssArticleDao.delete(key) appDb.cacheDao.deleteSourceVariables(key) } fun deleteRssSource(key: String) { deleteRssSourceInternal(key) AppCacheManager.clearSourceVariables() } fun enableSource(key: String, @SourceType.Type type: Int, enable: Boolean) { when (type) { SourceType.book -> appDb.bookSourceDao.enable(key, enable) SourceType.rss -> appDb.rssSourceDao.enable(key, enable) } } fun insertRssSource(vararg rssSources: RssSource) { val rssSourcesGroup = rssSources.groupBy { is18Plus(it.sourceUrl) } rssSourcesGroup[true]?.forEach { appCtx.toastOnUi("${it.sourceName}是18+网址,禁止导入.") } rssSourcesGroup[false]?.let { appDb.rssSourceDao.insert(*it.toTypedArray()) } } fun insertBookSource(vararg bookSources: BookSource) { val bookSourcesGroup = bookSources.groupBy { is18Plus(it.bookSourceUrl) } bookSourcesGroup[true]?.forEach { appCtx.toastOnUi("${it.bookSourceName}是18+网址,禁止导入.") } bookSourcesGroup[false]?.let { appDb.bookSourceDao.insert(*it.toTypedArray()) } Coroutine.async { adjustSortNumber() } } private fun is18Plus(url: String?): Boolean { if (list18Plus.isEmpty()) { return false } url ?: return false val baseUrl = NetworkUtils.getBaseUrl(url) ?: return false kotlin.runCatching { val host = baseUrl.split("//", ".").let { if (it.size > 2) "${it[it.lastIndex - 1]}.${it.last()}" else return false } return list18Plus.contains(host) } return false } /** * 调整排序序号 */ fun adjustSortNumber() { if ( appDb.bookSourceDao.maxOrder > 99999 || appDb.bookSourceDao.minOrder < -99999 || appDb.bookSourceDao.hasDuplicateOrder ) { val sources = appDb.bookSourceDao.allPart sources.forEachIndexed { index, bookSource -> bookSource.customOrder = index } appDb.bookSourceDao.upOrder(sources) } } } ================================================ FILE: app/src/main/java/io/legado/app/help/source/SourceVerificationHelp.kt ================================================ package io.legado.app.help.source import io.legado.app.constant.AppLog import io.legado.app.data.entities.BaseSource import io.legado.app.exception.NoStackTraceException import io.legado.app.help.CacheManager import io.legado.app.help.IntentData import io.legado.app.ui.association.VerificationCodeActivity import io.legado.app.ui.browser.WebViewActivity import io.legado.app.utils.isMainThread import io.legado.app.utils.startActivity import splitties.init.appCtx import java.util.concurrent.locks.LockSupport import kotlin.time.Duration.Companion.minutes /** * 源验证 */ object SourceVerificationHelp { private val waitTime = 1.minutes.inWholeNanoseconds private fun getVerificationResultKey(source: BaseSource) = getVerificationResultKey(source.getKey()) private fun getVerificationResultKey(sourceKey: String) = "${sourceKey}_verificationResult" /** * 获取书源验证结果 * 图片验证码 防爬 滑动验证码 点击字符 等等 */ @Synchronized fun getVerificationResult( source: BaseSource?, url: String, title: String, useBrowser: Boolean, refetchAfterSuccess: Boolean = true ): String { source ?: throw NoStackTraceException("getVerificationResult parameter source cannot be null") require(url.length < 64 * 1024) { "getVerificationResult parameter url too long" } check(!isMainThread) { "getVerificationResult must be called on a background thread" } clearResult(source.getKey()) if (!useBrowser) { appCtx.startActivity { putExtra("imageUrl", url) putExtra("sourceOrigin", source.getKey()) putExtra("sourceName", source.getTag()) putExtra("sourceType", source.getSourceType()) IntentData.put(getVerificationResultKey(source), Thread.currentThread()) } } else { startBrowser(source, url, title, true, refetchAfterSuccess) } var waitUserInput = false while (getResult(source.getKey()) == null) { if (!waitUserInput) { AppLog.putDebug("等待返回验证结果...") waitUserInput = true } LockSupport.parkNanos(this, waitTime) } val result = getResult(source.getKey())!! clearResult(source.getKey()) result.ifBlank { throw NoStackTraceException("验证结果为空") } return result } /** * 启动内置浏览器 * @param saveResult 保存网页源代码到数据库 */ fun startBrowser( source: BaseSource?, url: String, title: String, saveResult: Boolean? = false, refetchAfterSuccess: Boolean? = true ) { source ?: throw NoStackTraceException("startBrowser parameter source cannot be null") require(url.length < 64 * 1024) { "startBrowser parameter url too long" } appCtx.startActivity { putExtra("title", title) putExtra("url", url) putExtra("sourceOrigin", source.getKey()) putExtra("sourceName", source.getTag()) putExtra("sourceType", source.getSourceType()) putExtra("sourceVerificationEnable", saveResult) putExtra("refetchAfterSuccess", refetchAfterSuccess) IntentData.put(getVerificationResultKey(source), Thread.currentThread()) } } fun checkResult(sourceKey: String) { getResult(sourceKey) ?: setResult(sourceKey, "") val thread = IntentData.get(getVerificationResultKey(sourceKey)) LockSupport.unpark(thread) } fun setResult(sourceKey: String, result: String?) { CacheManager.putMemory(getVerificationResultKey(sourceKey), result ?: "") } fun getResult(sourceKey: String): String? { return CacheManager.getFromMemory(getVerificationResultKey(sourceKey)) as? String } fun clearResult(sourceKey: String) { CacheManager.delete(getVerificationResultKey(sourceKey)) } } ================================================ FILE: app/src/main/java/io/legado/app/help/storage/Backup.kt ================================================ package io.legado.app.help.storage import android.content.Context import android.net.Uri import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import io.legado.app.constant.AppLog import io.legado.app.constant.PreferKey import io.legado.app.data.appDb import io.legado.app.exception.NoStackTraceException import io.legado.app.help.AppWebDav import io.legado.app.help.DirectLinkUpload import io.legado.app.help.config.AppConfig import io.legado.app.help.config.LocalConfig import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.config.ThemeConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.model.BookCover import io.legado.app.utils.FileUtils import io.legado.app.utils.GSON import io.legado.app.utils.LogUtils import io.legado.app.utils.compress.ZipUtils import io.legado.app.utils.createFolderIfNotExist import io.legado.app.utils.defaultSharedPreferences import io.legado.app.utils.externalFiles import io.legado.app.utils.getFile import io.legado.app.utils.getSharedPreferences import io.legado.app.utils.isContentScheme import io.legado.app.utils.normalizeFileName import io.legado.app.utils.openOutputStream import io.legado.app.utils.outputStream import io.legado.app.utils.writeToOutputStream import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import splitties.init.appCtx import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.concurrent.TimeUnit /** * 备份 */ object Backup { val backupPath: String by lazy { appCtx.filesDir.getFile("backup").createFolderIfNotExist().absolutePath } val zipFilePath = "${appCtx.externalFiles.absolutePath}${File.separator}tmp_backup.zip" private const val TAG = "Backup" private val mutex = Mutex() private val backupFileNames by lazy { arrayOf( "bookshelf.json", "bookmark.json", "bookGroup.json", "bookSource.json", "rssSources.json", "rssStar.json", "replaceRule.json", "readRecord.json", "searchHistory.json", "sourceSub.json", "txtTocRule.json", "httpTTS.json", "keyboardAssists.json", "dictRule.json", "servers.json", DirectLinkUpload.ruleFileName, ReadBookConfig.configFileName, ReadBookConfig.shareConfigFileName, ThemeConfig.configFileName, BookCover.configFileName, "config.xml" ) } private fun getNowZipFileName(): String { val backupDate = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) .format(Date(System.currentTimeMillis())) val deviceName = AppConfig.webDavDeviceName return if (deviceName?.isNotBlank() == true) { "backup${backupDate}-${deviceName}.zip" } else { "backup${backupDate}.zip" }.normalizeFileName() } private fun shouldBackup(): Boolean { val lastBackup = LocalConfig.lastBackup return lastBackup + TimeUnit.DAYS.toMillis(1) < System.currentTimeMillis() } fun autoBack(context: Context) { if (shouldBackup()) { Coroutine.async { mutex.withLock { if (shouldBackup()) { val backupZipFileName = getNowZipFileName() if (!AppWebDav.hasBackUp(backupZipFileName)) { backup(context, AppConfig.backupPath) } else { LocalConfig.lastBackup = System.currentTimeMillis() } } } }.onError { AppLog.put("自动备份失败\n${it.localizedMessage}") } } } suspend fun backupLocked(context: Context, path: String?) { mutex.withLock { withContext(IO) { backup(context, path) } } } private suspend fun backup(context: Context, path: String?) { LogUtils.d(TAG, "开始备份 path:$path") LocalConfig.lastBackup = System.currentTimeMillis() val aes = BackupAES() FileUtils.delete(backupPath) writeListToJson(appDb.bookDao.all, "bookshelf.json", backupPath) writeListToJson(appDb.bookmarkDao.all, "bookmark.json", backupPath) writeListToJson(appDb.bookGroupDao.all, "bookGroup.json", backupPath) writeListToJson(appDb.bookSourceDao.all, "bookSource.json", backupPath) writeListToJson(appDb.rssSourceDao.all, "rssSources.json", backupPath) writeListToJson(appDb.rssStarDao.all, "rssStar.json", backupPath) writeListToJson(appDb.replaceRuleDao.all, "replaceRule.json", backupPath) writeListToJson(appDb.readRecordDao.all, "readRecord.json", backupPath) writeListToJson(appDb.searchKeywordDao.all, "searchHistory.json", backupPath) writeListToJson(appDb.ruleSubDao.all, "sourceSub.json", backupPath) writeListToJson(appDb.txtTocRuleDao.all, "txtTocRule.json", backupPath) writeListToJson(appDb.httpTTSDao.all, "httpTTS.json", backupPath) writeListToJson(appDb.keyboardAssistsDao.all, "keyboardAssists.json", backupPath) writeListToJson(appDb.dictRuleDao.all, "dictRule.json", backupPath) GSON.toJson(appDb.serverDao.all).let { json -> aes.runCatching { encryptBase64(json) }.getOrDefault(json).let { FileUtils.createFileIfNotExist(backupPath + File.separator + "servers.json") .writeText(it) } } currentCoroutineContext().ensureActive() GSON.toJson(ReadBookConfig.configList).let { FileUtils.createFileIfNotExist(backupPath + File.separator + ReadBookConfig.configFileName) .writeText(it) } GSON.toJson(ReadBookConfig.shareConfig).let { FileUtils.createFileIfNotExist(backupPath + File.separator + ReadBookConfig.shareConfigFileName) .writeText(it) } GSON.toJson(ThemeConfig.configList).let { FileUtils.createFileIfNotExist(backupPath + File.separator + ThemeConfig.configFileName) .writeText(it) } DirectLinkUpload.getConfig()?.let { FileUtils.createFileIfNotExist(backupPath + File.separator + DirectLinkUpload.ruleFileName) .writeText(GSON.toJson(it)) } BookCover.getConfig()?.let { FileUtils.createFileIfNotExist(backupPath + File.separator + BookCover.configFileName) .writeText(GSON.toJson(it)) } currentCoroutineContext().ensureActive() appCtx.getSharedPreferences(backupPath, "config")?.let { sp -> val edit = sp.edit() appCtx.defaultSharedPreferences.all.forEach { (key, value) -> if (BackupConfig.keyIsNotIgnore(key)) { when (key) { PreferKey.webDavPassword -> { edit.putString(key, aes.runCatching { encryptBase64(value.toString()) }.getOrDefault(value.toString())) } else -> when (value) { is Int -> edit.putInt(key, value) is Boolean -> edit.putBoolean(key, value) is Long -> edit.putLong(key, value) is Float -> edit.putFloat(key, value) is String -> edit.putString(key, value) } } } } edit.commit() } currentCoroutineContext().ensureActive() val zipFileName = getNowZipFileName() val paths = arrayListOf(*backupFileNames) for (i in 0 until paths.size) { paths[i] = backupPath + File.separator + paths[i] } FileUtils.delete(zipFilePath) FileUtils.delete(zipFilePath.replace("tmp_", "")) val backupFileName = if (AppConfig.onlyLatestBackup) { "backup.zip" } else { zipFileName } if (ZipUtils.zipFiles(paths, zipFilePath)) { when { path.isNullOrBlank() -> { copyBackup(context.getExternalFilesDir(null)!!, backupFileName) } path.isContentScheme() -> { copyBackup(context, path.toUri(), backupFileName) } else -> { copyBackup(File(path), backupFileName) } } try { AppWebDav.backUpWebDav(zipFileName) } catch (e: Exception) { AppLog.put("上传备份至webdav失败\n$e", e) } } FileUtils.delete(backupPath) FileUtils.delete(zipFilePath) currentCoroutineContext().ensureActive() ReadBookConfig.getAllPicBgStr().map { if (it.contains(File.separator)) { File(it) } else { appCtx.externalFiles.getFile("bg", it) } }.let { AppWebDav.upBgs(it.toTypedArray()) } } private suspend fun writeListToJson(list: List, fileName: String, path: String) { currentCoroutineContext().ensureActive() withContext(IO) { if (list.isNotEmpty()) { LogUtils.d(TAG, "阅读备份 $fileName 列表大小 ${list.size}") val file = FileUtils.createFileIfNotExist(path + File.separator + fileName) file.outputStream().buffered().use { GSON.writeToOutputStream(it, list) } LogUtils.d(TAG, "阅读备份 $fileName 写入大小 ${file.length()}") } else { LogUtils.d(TAG, "阅读备份 $fileName 列表为空") } } } @Throws(Exception::class) @Suppress("SameParameterValue") private fun copyBackup(context: Context, uri: Uri, fileName: String) { val treeDoc = DocumentFile.fromTreeUri(context, uri)!! treeDoc.findFile(fileName)?.delete() val fileDoc = treeDoc.createFile("", fileName) ?: throw NoStackTraceException("创建文件失败") val outputS = fileDoc.openOutputStream() ?: throw NoStackTraceException("打开OutputStream失败") outputS.use { FileInputStream(zipFilePath).use { inputS -> inputS.copyTo(outputS) } } } @Throws(Exception::class) @Suppress("SameParameterValue") private fun copyBackup(rootFile: File, fileName: String) { FileInputStream(File(zipFilePath)).use { inputS -> val file = FileUtils.createFileIfNotExist(rootFile, fileName) FileOutputStream(file).use { outputS -> inputS.copyTo(outputS) } } } fun clearCache() { FileUtils.delete(backupPath) FileUtils.delete(zipFilePath) } } ================================================ FILE: app/src/main/java/io/legado/app/help/storage/BackupAES.kt ================================================ package io.legado.app.help.storage import cn.hutool.crypto.symmetric.AES import io.legado.app.help.config.LocalConfig import io.legado.app.utils.MD5Utils class BackupAES : AES( MD5Utils.md5Encode(LocalConfig.password ?: "").encodeToByteArray(0, 16) ) ================================================ FILE: app/src/main/java/io/legado/app/help/storage/BackupConfig.kt ================================================ package io.legado.app.help.storage import io.legado.app.R import io.legado.app.constant.PreferKey import io.legado.app.utils.FileUtils import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonObject import splitties.init.appCtx /** * 备份配置 */ @Suppress("ConstPropertyName") object BackupConfig { private val ignoreConfigPath = FileUtils.getPath(appCtx.filesDir, "restoreIgnore.json") val ignoreConfig: HashMap by lazy { val file = FileUtils.createFileIfNotExist(ignoreConfigPath) val json = file.readText() GSON.fromJsonObject>(json).getOrNull() ?: hashMapOf() } private const val readConfigKey = "readConfig" private const val themeConfigKey = "themeConfig" private const val coverConfigKey = "coverConfig" private const val localBookKey = "localBook" //配置忽略key val ignoreKeys = arrayOf( readConfigKey, PreferKey.themeMode, themeConfigKey, coverConfigKey, PreferKey.bookshelfLayout, PreferKey.showRss, PreferKey.threadCount, localBookKey ) //配置忽略标题 val ignoreTitle = arrayOf( appCtx.getString(R.string.read_config), appCtx.getString(R.string.theme_mode), appCtx.getString(R.string.theme_config), appCtx.getString(R.string.cover_config), appCtx.getString(R.string.bookshelf_layout), appCtx.getString(R.string.show_rss), appCtx.getString(R.string.thread_count), appCtx.getString(R.string.local_book) ) //自动忽略keys private val ignorePrefKeys = arrayOf( PreferKey.defaultCover, PreferKey.defaultCoverDark, PreferKey.backupPath, PreferKey.defaultBookTreeUri, PreferKey.webDavDeviceName, PreferKey.launcherIcon, PreferKey.bitmapCacheSize, PreferKey.webServiceWakeLock, PreferKey.readAloudWakeLock, PreferKey.audioPlayWakeLock ) //阅读配置 private val readPrefKeys = arrayOf( PreferKey.readStyleSelect, PreferKey.comicStyleSelect, PreferKey.shareLayout, PreferKey.hideStatusBar, PreferKey.hideNavigationBar, PreferKey.autoReadSpeed, PreferKey.clickActionTL, PreferKey.clickActionTC, PreferKey.clickActionTR, PreferKey.clickActionML, PreferKey.clickActionMC, PreferKey.clickActionMR, PreferKey.clickActionBL, PreferKey.clickActionBC, PreferKey.clickActionBR ) private val themePrefKeys = arrayOf( PreferKey.cPrimary, PreferKey.cAccent, PreferKey.cBackground, PreferKey.cBBackground, PreferKey.bgImage, PreferKey.bgImageBlurring, PreferKey.cNPrimary, PreferKey.cNAccent, PreferKey.cNBackground, PreferKey.cNBBackground, PreferKey.bgImageN, PreferKey.bgImageNBlurring ) private val coverPrefKeys = arrayOf( PreferKey.useDefaultCover, PreferKey.loadCoverOnlyWifi, PreferKey.coverShowName, PreferKey.coverShowAuthor, PreferKey.coverShowNameN, PreferKey.coverShowAuthorN ) fun keyIsNotIgnore(key: String): Boolean { return when { ignorePrefKeys.contains(key) -> false ignoreReadConfig && readPrefKeys.contains(key) -> false ignoreThemeConfig && themePrefKeys.contains(key) -> false ignoreCoverConfig && coverPrefKeys.contains(key) -> false PreferKey.themeMode == key && ignoreThemeMode -> false PreferKey.bookshelfLayout == key && ignoreBookshelfLayout -> false PreferKey.showRss == key && ignoreShowRss -> false PreferKey.threadCount == key && ignoreThreadCount -> false else -> true } } val ignoreReadConfig: Boolean get() = ignoreConfig[readConfigKey] == true private val ignoreThemeMode: Boolean get() = ignoreConfig[PreferKey.themeMode] == true private val ignoreThemeConfig: Boolean get() = ignoreConfig[themeConfigKey] == true private val ignoreCoverConfig: Boolean get() = ignoreConfig[coverConfigKey] == true private val ignoreBookshelfLayout: Boolean get() = ignoreConfig[PreferKey.bookshelfLayout] == true private val ignoreShowRss: Boolean get() = ignoreConfig[PreferKey.showRss] == true private val ignoreThreadCount: Boolean get() = ignoreConfig[PreferKey.threadCount] == true val ignoreLocalBook: Boolean get() = ignoreConfig[localBookKey] == true fun saveIgnoreConfig() { val json = GSON.toJson(ignoreConfig) FileUtils.createFileIfNotExist(ignoreConfigPath).writeText(json) } } ================================================ FILE: app/src/main/java/io/legado/app/help/storage/ImportOldData.kt ================================================ package io.legado.app.help.storage import android.content.Context import android.net.Uri import androidx.documentfile.provider.DocumentFile import com.jayway.jsonpath.DocumentContext import io.legado.app.R import io.legado.app.constant.AppConst import io.legado.app.constant.BookSourceType import io.legado.app.constant.BookType import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.rule.* import io.legado.app.exception.NoStackTraceException import io.legado.app.help.ReplaceAnalyzer import io.legado.app.utils.* import splitties.init.appCtx import java.io.File import java.util.regex.Pattern object ImportOldData { @Suppress("RegExpRedundantEscape") private val headerPattern = Pattern.compile("@Header:\\{.+?\\}", Pattern.CASE_INSENSITIVE) @Suppress("RegExpRedundantEscape") private val jsPattern = Pattern.compile("\\{\\{.+?\\}\\}", Pattern.CASE_INSENSITIVE) fun importUri(context: Context, uri: Uri) { if (uri.isContentScheme()) { DocumentFile.fromTreeUri(context, uri)?.listFiles()?.forEach { doc -> when (doc.name) { "myBookShelf.json" -> kotlin.runCatching { doc.uri.readText(context).let { json -> val importCount = importOldBookshelf(json) context.toastOnUi("成功导入书架${importCount}") } }.onFailure { context.toastOnUi("导入书架失败\n${it.localizedMessage}") } "myBookSource.json" -> kotlin.runCatching { doc.uri.readText(context).let { json -> val importCount = importOldSource(json) context.toastOnUi("成功导入书源${importCount}") } }.onFailure { context.toastOnUi("导入源失败\n${it.localizedMessage}") } "myBookReplaceRule.json" -> kotlin.runCatching { doc.uri.readText(context).let { json -> val importCount = importOldReplaceRule(json) context.toastOnUi("成功导入替换规则${importCount}") } }.onFailure { context.toastOnUi("导入替换规则失败\n${it.localizedMessage}") } } } } else { uri.path?.let { path -> val file = File(path) kotlin.runCatching {// 导入书架 val shelfFile = FileUtils.createFileIfNotExist(file, "myBookShelf.json") val json = shelfFile.readText() val importCount = importOldBookshelf(json) context.toastOnUi("成功导入书架${importCount}") }.onFailure { context.toastOnUi("导入书架失败\n${it.localizedMessage}") } kotlin.runCatching {// Book source val sourceFile = file.getFile("myBookSource.json") val json = sourceFile.readText() val importCount = importOldSource(json) context.toastOnUi("成功导入书源${importCount}") }.onFailure { context.toastOnUi("导入源失败\n${it.localizedMessage}") } kotlin.runCatching {// Replace rules val ruleFile = file.getFile("myBookReplaceRule.json") if (ruleFile.exists()) { val json = ruleFile.readText() val importCount = importOldReplaceRule(json) context.toastOnUi("成功导入替换规则${importCount}") } else { context.toastOnUi("未找到替换规则") } }.onFailure { context.toastOnUi("导入替换规则失败\n${it.localizedMessage}") } } } } private fun importOldBookshelf(json: String): Int { val books = fromOldBooks(json) appDb.bookDao.insert(*books.toTypedArray()) return books.size } fun importOldSource(json: String): Int { val sources = fromOldBookSources(json) appDb.bookSourceDao.insert(*sources.toTypedArray()) return sources.size } private fun importOldReplaceRule(json: String): Int { val rules = ReplaceAnalyzer.jsonToReplaceRules(json).getOrNull() rules?.let { appDb.replaceRuleDao.insert(*rules.toTypedArray()) return rules.size } return 0 } private fun fromOldBooks(json: String): List { val books = mutableListOf() val items: List> = jsonPath.parse(json).read("$") val existingBooks = appDb.bookDao.allBookUrls.toSet() for (item in items) { val jsonItem = jsonPath.parse(item) val book = Book() book.bookUrl = jsonItem.readString("$.noteUrl") ?: "" if (book.bookUrl.isBlank()) continue book.name = jsonItem.readString("$.bookInfoBean.name") ?: "" if (book.bookUrl in existingBooks) { DebugLog.d(javaClass.name, "Found existing book: " + book.name) continue } book.origin = jsonItem.readString("$.tag") ?: "" book.originName = jsonItem.readString("$.bookInfoBean.origin") ?: "" book.author = jsonItem.readString("$.bookInfoBean.author") ?: "" val local = if (book.origin == "loc_book") BookType.local else 0 val isAudio = jsonItem.readString("$.bookInfoBean.bookSourceType") == "AUDIO" book.type = local or if (isAudio) BookType.audio else BookType.text book.tocUrl = jsonItem.readString("$.bookInfoBean.chapterUrl") ?: book.bookUrl book.coverUrl = jsonItem.readString("$.bookInfoBean.coverUrl") book.customCoverUrl = jsonItem.readString("$.customCoverPath") book.lastCheckTime = jsonItem.readLong("$.bookInfoBean.finalRefreshData") ?: 0 book.canUpdate = jsonItem.readBool("$.allowUpdate") == true book.totalChapterNum = jsonItem.readInt("$.chapterListSize") ?: 0 book.durChapterIndex = jsonItem.readInt("$.durChapter") ?: 0 book.durChapterTitle = jsonItem.readString("$.durChapterName") book.durChapterPos = jsonItem.readInt("$.durChapterPage") ?: 0 book.durChapterTime = jsonItem.readLong("$.finalDate") ?: 0 book.intro = jsonItem.readString("$.bookInfoBean.introduce") book.latestChapterTitle = jsonItem.readString("$.lastChapterName") book.lastCheckCount = jsonItem.readInt("$.newChapters") ?: 0 book.order = jsonItem.readInt("$.serialNumber") ?: 0 book.variable = jsonItem.readString("$.variable") book.setUseReplaceRule(jsonItem.readBool("$.useReplaceRule") == true) books.add(book) } return books } private fun fromOldBookSources(json: String): MutableList { val sources = mutableListOf() val items: List> = jsonPath.parse(json).read("$") for (item in items) { val jsonItem = jsonPath.parse(item) val source = fromOldBookSource(jsonItem) sources.add(source) } return sources } fun fromOldBookSource(jsonItem: DocumentContext): BookSource { val source = BookSource() return source.apply { bookSourceUrl = jsonItem.readString("bookSourceUrl") ?: throw NoStackTraceException(appCtx.getString(R.string.wrong_format)) bookSourceName = jsonItem.readString("bookSourceName") ?: "" bookSourceGroup = jsonItem.readString("bookSourceGroup") loginUrl = jsonItem.readString("loginUrl") loginUi = jsonItem.readString("loginUi") loginCheckJs = jsonItem.readString("loginCheckJs") coverDecodeJs = jsonItem.readString("coverDecodeJs") bookSourceComment = jsonItem.readString("bookSourceComment") ?: "" bookUrlPattern = jsonItem.readString("ruleBookUrlPattern") customOrder = jsonItem.readInt("serialNumber") ?: 0 header = uaToHeader(jsonItem.readString("httpUserAgent")) searchUrl = toNewUrl(jsonItem.readString("ruleSearchUrl")) exploreUrl = toNewUrls(jsonItem.readString("ruleFindUrl")) bookSourceType = if (jsonItem.readString("bookSourceType") == "AUDIO") BookSourceType.audio else BookSourceType.default enabled = jsonItem.readBool("enable") ?: true if (exploreUrl.isNullOrBlank()) { enabledExplore = false } ruleSearch = SearchRule( bookList = toNewRule(jsonItem.readString("ruleSearchList")), name = toNewRule(jsonItem.readString("ruleSearchName")), author = toNewRule(jsonItem.readString("ruleSearchAuthor")), intro = toNewRule(jsonItem.readString("ruleSearchIntroduce")), kind = toNewRule(jsonItem.readString("ruleSearchKind")), bookUrl = toNewRule(jsonItem.readString("ruleSearchNoteUrl")), coverUrl = toNewRule(jsonItem.readString("ruleSearchCoverUrl")), lastChapter = toNewRule(jsonItem.readString("ruleSearchLastChapter")) ) ruleExplore = ExploreRule( bookList = toNewRule(jsonItem.readString("ruleFindList")), name = toNewRule(jsonItem.readString("ruleFindName")), author = toNewRule(jsonItem.readString("ruleFindAuthor")), intro = toNewRule(jsonItem.readString("ruleFindIntroduce")), kind = toNewRule(jsonItem.readString("ruleFindKind")), bookUrl = toNewRule(jsonItem.readString("ruleFindNoteUrl")), coverUrl = toNewRule(jsonItem.readString("ruleFindCoverUrl")), lastChapter = toNewRule(jsonItem.readString("ruleFindLastChapter")) ) ruleBookInfo = BookInfoRule( init = toNewRule(jsonItem.readString("ruleBookInfoInit")), name = toNewRule(jsonItem.readString("ruleBookName")), author = toNewRule(jsonItem.readString("ruleBookAuthor")), intro = toNewRule(jsonItem.readString("ruleIntroduce")), kind = toNewRule(jsonItem.readString("ruleBookKind")), coverUrl = toNewRule(jsonItem.readString("ruleCoverUrl")), lastChapter = toNewRule(jsonItem.readString("ruleBookLastChapter")), tocUrl = toNewRule(jsonItem.readString("ruleChapterUrl")) ) ruleToc = TocRule( chapterList = toNewRule(jsonItem.readString("ruleChapterList")), chapterName = toNewRule(jsonItem.readString("ruleChapterName")), chapterUrl = toNewRule(jsonItem.readString("ruleContentUrl")), nextTocUrl = toNewRule(jsonItem.readString("ruleChapterUrlNext")) ) var content = toNewRule(jsonItem.readString("ruleBookContent")) ?: "" if (content.startsWith("$") && !content.startsWith("$.")) { content = content.substring(1) } ruleContent = ContentRule( content = content, replaceRegex = toNewRule(jsonItem.readString("ruleBookContentReplace")), nextContentUrl = toNewRule(jsonItem.readString("ruleContentUrlNext")) ) } } // default规则适配 // #正则#替换内容 替换成 ##正则##替换内容 // | 替换成 || // & 替换成 && private fun toNewRule(oldRule: String?): String? { if (oldRule.isNullOrBlank()) return null var newRule = oldRule var reverse = false var allinone = false if (oldRule.startsWith("-")) { reverse = true newRule = oldRule.substring(1) } if (newRule.startsWith("+")) { allinone = true newRule = newRule.substring(1) } if (!newRule.startsWith("@CSS:", true) && !newRule.startsWith("@XPath:", true) && !newRule.startsWith("//") && !newRule.startsWith("##") && !newRule.startsWith(":") && !newRule.contains("@js:", true) && !newRule.contains("", true) ) { if (newRule.contains("#") && !newRule.contains("##")) { newRule = oldRule.replace("#", "##") } if (newRule.contains("|") && !newRule.contains("||")) { if (newRule.contains("##")) { val list = newRule.split("##") if (list[0].contains("|")) { newRule = list[0].replace("|", "||") for (i in 1 until list.size) { newRule += "##" + list[i] } } } else { newRule = newRule.replace("|", "||") } } if (newRule.contains("&") && !newRule.contains("&&") && !newRule.contains("http") && !newRule.startsWith("/") ) { newRule = newRule.replace("&", "&&") } } if (allinone) { newRule = "+$newRule" } if (reverse) { newRule = "-$newRule" } return newRule } private fun toNewUrls(oldUrls: String?): String? { if (oldUrls.isNullOrBlank()) return null if (oldUrls.startsWith("@js:") || oldUrls.startsWith("")) { return oldUrls } if (!oldUrls.contains("\n") && !oldUrls.contains("&&")) { return toNewUrl(oldUrls) } val urls = oldUrls.split("(&&|\r?\n)+".toRegex()) return urls.map { toNewUrl(it)?.replace("\n\\s*".toRegex(), "") }.joinToString("\n") } private fun toNewUrl(oldUrl: String?): String? { if (oldUrl.isNullOrBlank()) return null var url: String = oldUrl if (oldUrl.startsWith("", true)) { url = url.replace("=searchKey", "={{key}}") .replace("=searchPage", "={{page}}") return url } val map = HashMap() var mather = headerPattern.matcher(url) if (mather.find()) { val header = mather.group() url = url.replace(header, "") map["headers"] = header.substring(8) } var urlList = url.split("|") url = urlList[0] if (urlList.size > 1) { map["charset"] = urlList[1].split("=")[1] } mather = jsPattern.matcher(url) val jsList = arrayListOf() while (mather.find()) { jsList.add(mather.group()) url = url.replace(jsList.last(), "$${jsList.size - 1}") } url = url.replace("{", "<").replace("}", ">") url = url.replace("searchKey", "{{key}}") url = url.replace("".toRegex(), "{{page$1}}") .replace("searchPage([-+]1)".toRegex(), "{{page$1}}") .replace("searchPage", "{{page}}") for ((index, item) in jsList.withIndex()) { url = url.replace( "$$index", item.replace("searchKey", "key").replace("searchPage", "page") ) } urlList = url.split("@") url = urlList[0] if (urlList.size > 1) { map["method"] = "POST" map["body"] = urlList[1] } if (map.size > 0) { url += "," + GSON.toJson(map) } return url } private fun uaToHeader(ua: String?): String? { if (ua.isNullOrEmpty()) return null val map = mapOf(Pair(AppConst.UA_NAME, ua)) return GSON.toJson(map) } } ================================================ FILE: app/src/main/java/io/legado/app/help/storage/Restore.kt ================================================ package io.legado.app.help.storage import android.content.Context import android.database.sqlite.SQLiteConstraintException import android.net.Uri import androidx.documentfile.provider.DocumentFile import io.legado.app.BuildConfig import io.legado.app.R import io.legado.app.constant.AppConst.androidId import io.legado.app.constant.AppLog import io.legado.app.constant.PreferKey import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookGroup import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.Bookmark 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 import io.legado.app.data.entities.ReplaceRule import io.legado.app.data.entities.RssSource import io.legado.app.data.entities.RssStar import io.legado.app.data.entities.RuleSub 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.help.DirectLinkUpload import io.legado.app.help.LauncherIconHelp import io.legado.app.help.book.isLocal import io.legado.app.help.book.upType import io.legado.app.help.config.LocalConfig import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.config.ThemeConfig import io.legado.app.model.BookCover import io.legado.app.model.localBook.LocalBook import io.legado.app.utils.ACache import io.legado.app.utils.FileUtils import io.legado.app.utils.GSON import io.legado.app.utils.LogUtils import io.legado.app.utils.compress.ZipUtils import io.legado.app.utils.defaultSharedPreferences import io.legado.app.utils.fromJsonArray import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.getPrefInt import io.legado.app.utils.getPrefString import io.legado.app.utils.getSharedPreferences import io.legado.app.utils.isContentScheme import io.legado.app.utils.isJsonArray import io.legado.app.utils.openInputStream import io.legado.app.utils.toastOnUi import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import splitties.init.appCtx import java.io.File import java.io.FileInputStream /** * 恢复 */ object Restore { private val mutex = Mutex() private const val TAG = "Restore" suspend fun restore(context: Context, uri: Uri) { LogUtils.d(TAG, "开始恢复备份 uri:$uri") kotlin.runCatching { FileUtils.delete(Backup.backupPath) if (uri.isContentScheme()) { DocumentFile.fromSingleUri(context, uri)!!.openInputStream()!!.use { ZipUtils.unZipToPath(it, Backup.backupPath) } } else { ZipUtils.unZipToPath(File(uri.path!!), Backup.backupPath) } }.onFailure { AppLog.put("复制解压文件出错\n${it.localizedMessage}", it) return } kotlin.runCatching { restoreLocked(Backup.backupPath) LocalConfig.lastBackup = System.currentTimeMillis() }.onFailure { appCtx.toastOnUi("恢复备份出错\n${it.localizedMessage}") AppLog.put("恢复备份出错\n${it.localizedMessage}", it) } } suspend fun restoreLocked(path: String) { mutex.withLock { restore(path) } } private suspend fun restore(path: String) { val aes = BackupAES() fileToListT(path, "bookshelf.json")?.let { it.forEach { book -> book.upType() } it.filter { book -> book.isLocal } .forEach { book -> book.coverUrl = LocalBook.getCoverPath(book) } val newBooks = arrayListOf() val ignoreLocalBook = BackupConfig.ignoreLocalBook it.forEach { book -> if (ignoreLocalBook && book.isLocal) { return@forEach } if (appDb.bookDao.has(book.bookUrl)) { try { appDb.bookDao.update(book) } catch (_: SQLiteConstraintException) { appDb.bookDao.insert(book) } } else { newBooks.add(book) } } appDb.bookDao.insert(*newBooks.toTypedArray()) } fileToListT(path, "bookmark.json")?.let { appDb.bookmarkDao.insert(*it.toTypedArray()) } fileToListT(path, "bookGroup.json")?.let { appDb.bookGroupDao.insert(*it.toTypedArray()) } fileToListT(path, "bookSource.json")?.let { appDb.bookSourceDao.insert(*it.toTypedArray()) } ?: run { val bookSourceFile = File(path, "bookSource.json") if (bookSourceFile.exists()) { val json = bookSourceFile.readText() ImportOldData.importOldSource(json) } } fileToListT(path, "rssSources.json")?.let { appDb.rssSourceDao.insert(*it.toTypedArray()) } fileToListT(path, "rssStar.json")?.let { appDb.rssStarDao.insert(*it.toTypedArray()) } fileToListT(path, "replaceRule.json")?.let { appDb.replaceRuleDao.insert(*it.toTypedArray()) } fileToListT(path, "searchHistory.json")?.let { appDb.searchKeywordDao.insert(*it.toTypedArray()) } fileToListT(path, "sourceSub.json")?.let { appDb.ruleSubDao.insert(*it.toTypedArray()) } fileToListT(path, "txtTocRule.json")?.let { appDb.txtTocRuleDao.insert(*it.toTypedArray()) } fileToListT(path, "httpTTS.json")?.let { appDb.httpTTSDao.insert(*it.toTypedArray()) } fileToListT(path, "dictRule.json")?.let { appDb.dictRuleDao.insert(*it.toTypedArray()) } fileToListT(path, "keyboardAssists.json")?.let { appDb.keyboardAssistsDao.insert(*it.toTypedArray()) } fileToListT(path, "readRecord.json")?.let { it.forEach { readRecord -> //判断是不是本机记录 if (readRecord.deviceId != androidId) { appDb.readRecordDao.insert(readRecord) } else { val time = appDb.readRecordDao .getReadTime(readRecord.deviceId, readRecord.bookName) if (time == null || time < readRecord.readTime) { appDb.readRecordDao.insert(readRecord) } } } } File(path, "servers.json").takeIf { it.exists() }?.runCatching { var json = readText() if (!json.isJsonArray()) { json = aes.decryptStr(json) } GSON.fromJsonArray(json).getOrNull()?.let { appDb.serverDao.insert(*it.toTypedArray()) } }?.onFailure { AppLog.put("恢复服务器配置出错\n${it.localizedMessage}", it) } File(path, DirectLinkUpload.ruleFileName).takeIf { it.exists() }?.runCatching { val json = readText() ACache.get(cacheDir = false).put(DirectLinkUpload.ruleFileName, json) }?.onFailure { AppLog.put("恢复直链上传出错\n${it.localizedMessage}", it) } //恢复主题配置 File(path, ThemeConfig.configFileName).takeIf { it.exists() }?.runCatching { FileUtils.delete(ThemeConfig.configFilePath) copyTo(File(ThemeConfig.configFilePath)) ThemeConfig.upConfig() }?.onFailure { AppLog.put("恢复主题出错\n${it.localizedMessage}", it) } File(path, BookCover.configFileName).takeIf { it.exists() }?.runCatching { val json = readText() BookCover.saveCoverRule(json) }?.onFailure { AppLog.put("恢复封面规则出错\n${it.localizedMessage}", it) } if (!BackupConfig.ignoreReadConfig) { //恢复阅读界面配置 File(path, ReadBookConfig.configFileName).takeIf { it.exists() }?.runCatching { FileUtils.delete(ReadBookConfig.configFilePath) copyTo(File(ReadBookConfig.configFilePath)) ReadBookConfig.initConfigs() }?.onFailure { AppLog.put("恢复阅读界面出错\n${it.localizedMessage}", it) } File(path, ReadBookConfig.shareConfigFileName).takeIf { it.exists() }?.runCatching { FileUtils.delete(ReadBookConfig.shareConfigFilePath) copyTo(File(ReadBookConfig.shareConfigFilePath)) ReadBookConfig.initShareConfig() }?.onFailure { AppLog.put("恢复阅读界面出错\n${it.localizedMessage}", it) } } //AppWebDav.downBgs() appCtx.getSharedPreferences(path, "config")?.all?.let { map -> val edit = appCtx.defaultSharedPreferences.edit() map.forEach { (key, value) -> if (BackupConfig.keyIsNotIgnore(key)) { when (key) { PreferKey.webDavPassword -> { kotlin.runCatching { aes.decryptStr(value.toString()) }.getOrNull()?.let { edit.putString(key, it) } ?: let { if (appCtx.getPrefString(PreferKey.webDavPassword) .isNullOrBlank() ) { edit.putString(key, value.toString()) } } } else -> when (value) { is Int -> edit.putInt(key, value) is Boolean -> edit.putBoolean(key, value) is Long -> edit.putLong(key, value) is Float -> edit.putFloat(key, value) is String -> edit.putString(key, value) } } } } edit.apply() } ReadBookConfig.apply { comicStyleSelect = appCtx.getPrefInt(PreferKey.comicStyleSelect) readStyleSelect = appCtx.getPrefInt(PreferKey.readStyleSelect) shareLayout = appCtx.getPrefBoolean(PreferKey.shareLayout) hideStatusBar = appCtx.getPrefBoolean(PreferKey.hideStatusBar) hideNavigationBar = appCtx.getPrefBoolean(PreferKey.hideNavigationBar) autoReadSpeed = appCtx.getPrefInt(PreferKey.autoReadSpeed, 46) } appCtx.toastOnUi(R.string.restore_success) withContext(Main) { delay(100) if (!BuildConfig.DEBUG) { LauncherIconHelp.changeIcon(appCtx.getPrefString(PreferKey.launcherIcon)) } ThemeConfig.applyDayNight(appCtx) } } private inline fun fileToListT(path: String, fileName: String): List? { try { val file = File(path, fileName) if (file.exists()) { LogUtils.d(TAG, "阅读恢复备份 $fileName 文件大小 ${file.length()}") FileInputStream(file).use { return GSON.fromJsonArray(it).getOrThrow().also { list -> LogUtils.d(TAG, "阅读恢复备份 $fileName 列表大小 ${list.size}") } } } else { LogUtils.d(TAG, "阅读恢复备份 $fileName 文件不存在") } } catch (e: Exception) { AppLog.put("$fileName\n读取解析出错\n${e.localizedMessage}", e) appCtx.toastOnUi("$fileName\n读取文件出错\n${e.localizedMessage}") } return null } } ================================================ FILE: app/src/main/java/io/legado/app/help/update/AppReleaseInfo.kt ================================================ package io.legado.app.help.update import androidx.annotation.Keep import com.google.gson.annotations.SerializedName import io.legado.app.exception.NoStackTraceException import java.time.Instant data class AppReleaseInfo( val appVariant: AppVariant, val createdAt: Long, val note: String, val name: String, val downloadUrl: String, val assetUrl: String ) { val versionName: String = name.split("_").getOrNull(2)?.dropLast(2) ?: "" } enum class AppVariant { OFFICIAL, BETA_RELEASEA, BETA_RELEASE, UNKNOWN; fun isBeta(): Boolean { return this == BETA_RELEASE || this == BETA_RELEASEA } } @Keep data class GithubRelease( val assets: List?, val body: String, @SerializedName("prerelease") val isPreRelease: Boolean, ) { fun gitReleaseToAppReleaseInfo(): List { assets ?: throw NoStackTraceException("获取新版本出错") return assets .filter { it.isValid } .map { it.assetToAppReleaseInfo(isPreRelease, body) } } } @Keep data class Asset( @SerializedName("browser_download_url") val apkUrl: String, @SerializedName("content_type") val contentType: String, @SerializedName("created_at") val createdAt: String, @SerializedName("download_count") val downloadCount: Int, val id: Int, val name: String, val state: String, val url: String ) { val isValid: Boolean get() = (contentType == "application/vnd.android.package-archive") && (state == "uploaded") fun assetToAppReleaseInfo(preRelease: Boolean, note: String): AppReleaseInfo { val instant = Instant.parse(createdAt) val timestamp: Long = instant.toEpochMilli() val appVariant = when { preRelease && name.contains("releaseA") -> AppVariant.BETA_RELEASEA preRelease && name.contains("release") -> AppVariant.BETA_RELEASE else -> AppVariant.OFFICIAL } return AppReleaseInfo(appVariant, timestamp, note, name, apkUrl, url) } } ================================================ FILE: app/src/main/java/io/legado/app/help/update/AppUpdate.kt ================================================ package io.legado.app.help.update import io.legado.app.help.coroutine.Coroutine import kotlinx.coroutines.CoroutineScope object AppUpdate { val gitHubUpdate: AppUpdateInterface? by lazy { AppUpdateGitHub } data class UpdateInfo( val tagName: String, val updateLog: String, val downloadUrl: String, val fileName: String ) interface AppUpdateInterface { fun check(scope: CoroutineScope): Coroutine } } ================================================ FILE: app/src/main/java/io/legado/app/help/update/AppUpdateGitHub.kt ================================================ package io.legado.app.help.update import androidx.annotation.Keep import io.legado.app.constant.AppConst import io.legado.app.exception.NoStackTraceException import io.legado.app.help.config.AppConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.http.newCallResponse import io.legado.app.help.http.okHttpClient import io.legado.app.help.http.text import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonObject import kotlinx.coroutines.CoroutineScope @Keep @Suppress("unused") object AppUpdateGitHub : AppUpdate.AppUpdateInterface { private val checkVariant: AppVariant get() = when (AppConfig.updateToVariant) { "official_version" -> AppVariant.OFFICIAL "beta_release_version" -> AppVariant.BETA_RELEASE "beta_releaseA_version" -> AppVariant.BETA_RELEASEA else -> AppConst.appInfo.appVariant } private suspend fun getLatestRelease(): List { val lastReleaseUrl = if (checkVariant.isBeta()) { "https://api.github.com/repos/gedoor/legado/releases/tags/beta" } else { "https://api.github.com/repos/gedoor/legado/releases/latest" } val res = okHttpClient.newCallResponse { url(lastReleaseUrl) } if (!res.isSuccessful) { throw NoStackTraceException("获取新版本出错(${res.code})") } val body = res.body.text() if (body.isBlank()) { throw NoStackTraceException("获取新版本出错") } return GSON.fromJsonObject(body) .getOrElse { throw NoStackTraceException("获取新版本出错 " + it.localizedMessage) } .gitReleaseToAppReleaseInfo() .sortedByDescending { it.createdAt } } override fun check( scope: CoroutineScope, ): Coroutine { return Coroutine.async(scope) { getLatestRelease() .filter { it.appVariant == checkVariant } .firstOrNull { it.versionName > AppConst.appInfo.versionName } ?.let { return@async AppUpdate.UpdateInfo( it.versionName, it.note, it.downloadUrl, it.name ) } ?: throw NoStackTraceException("已是最新版本") }.timeout(10000) } } ================================================ FILE: app/src/main/java/io/legado/app/lib/README.md ================================================ # 放置一些copy过来的库 * dialogs 弹出框 * icu4j 编码识别库 * permission 权限申请库 * theme 主题 * webDav 网络存储 ================================================ FILE: app/src/main/java/io/legado/app/lib/aliyun/ALiYun.kt ================================================ package io.legado.app.lib.aliyun object ALiYun { fun getToken() { } } ================================================ FILE: app/src/main/java/io/legado/app/lib/cronet/AbsCallBack.kt ================================================ package io.legado.app.lib.cronet import androidx.annotation.Keep import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.http.CookieManager import io.legado.app.help.http.CookieManager.cookieJarHeader import io.legado.app.help.http.okHttpClient import io.legado.app.utils.DebugLog import io.legado.app.utils.asIOException import io.legado.app.utils.splitNotBlank import kotlinx.coroutines.delay import okhttp3.Call import okhttp3.Callback import okhttp3.EventListener import okhttp3.Headers import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.Protocol import okhttp3.Request import okhttp3.Response import okhttp3.ResponseBody import okhttp3.ResponseBody.Companion.asResponseBody import okhttp3.internal.http.HTTP_PERM_REDIRECT import okhttp3.internal.http.HTTP_TEMP_REDIRECT import okhttp3.internal.http.HttpMethod import okio.Buffer import okio.Source import okio.Timeout import okio.buffer import org.chromium.net.CronetException import org.chromium.net.UrlRequest import org.chromium.net.UrlResponseInfo import java.io.IOException import java.net.ProtocolException import java.nio.ByteBuffer import java.util.Locale import java.util.concurrent.ArrayBlockingQueue import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean @Keep abstract class AbsCallBack( var originalRequest: Request, val mCall: Call, var readTimeoutMillis: Int, private val eventListener: EventListener? = null, private val responseCallback: Callback? = null ) : UrlRequest.Callback() { var mResponse: Response private var followCount = 0 private var request: UrlRequest? = null private var finished = AtomicBoolean(false) private val canceled = AtomicBoolean(false) private val callbackResults = ArrayBlockingQueue(2) private val urlResponseInfoChain = arrayListOf() private var cancelJob: Coroutine<*>? = null private var followRedirect = false private var enableCookieJar = false private var redirectRequest: Request? = null init { if (readTimeoutMillis == 0) { readTimeoutMillis = Int.MAX_VALUE } if (originalRequest.header(cookieJarHeader) != null) { enableCookieJar = true originalRequest = originalRequest.newBuilder() .removeHeader(cookieJarHeader).build() } } @Throws(IOException::class) abstract fun waitForDone(urlRequest: UrlRequest): Response /** * 当发生错误时,通知子类终止阻塞抛出错误 * @param error */ abstract fun onError(error: IOException) /** * 请求成功后,通知子类结束阻塞,返回response * @param response */ abstract fun onSuccess(response: Response) override fun onRedirectReceived( request: UrlRequest, info: UrlResponseInfo, newLocationUrl: String ) { if (followCount > MAX_FOLLOW_COUNT) { request.cancel() onError(IOException("Too many redirect")) return } if (mCall.isCanceled()) { onError(IOException("Cronet Request Canceled")) request.cancel() return } followCount += 1 urlResponseInfoChain.add(info) val client = okHttpClient if (originalRequest.url.isHttps && newLocationUrl.startsWith("http://") && client.followSslRedirects ) { followRedirect = true } else if (!originalRequest.url.isHttps && newLocationUrl.startsWith("https://") && client.followSslRedirects ) { followRedirect = true } else if (okHttpClient.followRedirects) { followRedirect = true } if (!followRedirect) { onError(IOException("Too many redirect")) } else { val response = toResponse(originalRequest, info, urlResponseInfoChain) if (enableCookieJar) { CookieManager.saveResponse(response) } redirectRequest = buildRedirectRequest(response, originalRequest.method, newLocationUrl) } request.cancel() } override fun onResponseStarted(request: UrlRequest, info: UrlResponseInfo) { this.request = request val response: Response try { response = toResponse(originalRequest, info, urlResponseInfoChain, CronetBodySource()) } catch (e: IOException) { request.cancel() cancelJob?.cancel() onError(e) return } if (enableCookieJar) { CookieManager.saveResponse(response) } mResponse = response onSuccess(response) //打印协议,用于调试 val msg = "onResponseStarted[${info.negotiatedProtocol}][${info.httpStatusCode}]${info.url}" DebugLog.i(javaClass.simpleName, msg) if (eventListener != null) { eventListener.responseHeadersEnd(mCall, response) eventListener.responseBodyStart(mCall) } try { responseCallback?.onResponse(mCall, response) } catch (e: IOException) { // Pass? } } @Throws(IOException::class) override fun onReadCompleted( request: UrlRequest, info: UrlResponseInfo, byteBuffer: ByteBuffer ) { callbackResults.add(CallbackResult(CallbackStep.ON_READ_COMPLETED, byteBuffer)) } override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) { callbackResults.add(CallbackResult(CallbackStep.ON_SUCCESS)) cancelJob?.cancel() eventListener?.responseBodyEnd(mCall, info.receivedByteCount) //DebugLog.i(javaClass.simpleName, "end[${info.negotiatedProtocol}]${info.url}") eventListener?.callEnd(mCall) } //UrlResponseInfo可能为null override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: CronetException) { callbackResults.add(CallbackResult(CallbackStep.ON_FAILED, null, error)) cancelJob?.cancel() DebugLog.e(javaClass.name, error.message.toString()) onError(error.asIOException()) eventListener?.callFailed(mCall, error) responseCallback?.onFailure(mCall, error) } override fun onCanceled(request: UrlRequest?, info: UrlResponseInfo?) { if (followRedirect) { followRedirect = false if (enableCookieJar) { val newRequest = CookieManager.loadRequest(redirectRequest!!) buildRequest(newRequest, this)?.start() } else { buildRequest(redirectRequest!!, this)?.start() } return } canceled.set(true) callbackResults.add(CallbackResult(CallbackStep.ON_CANCELED)) cancelJob?.cancel() //DebugLog.i(javaClass.simpleName, "cancel[${info?.negotiatedProtocol}]${info?.url}") eventListener?.callEnd(mCall) onError(IOException("Cronet Request Canceled")) } fun startCheckCancelJob(request: UrlRequest) { cancelJob = Coroutine.async { while (!mCall.isCanceled()) { delay(1000) } request.cancel() } } init { mResponse = Response.Builder() .sentRequestAtMillis(System.currentTimeMillis()) .request(originalRequest) .protocol(Protocol.HTTP_1_0) .code(0) .message("") .build() } companion object { const val MAX_FOLLOW_COUNT = 20 private val encodingsHandledByCronet = setOf("br", "deflate", "gzip", "x-gzip") private fun protocolFromNegotiatedProtocol(responseInfo: UrlResponseInfo): Protocol { val negotiatedProtocol = responseInfo.negotiatedProtocol.lowercase(Locale.getDefault()) return when { negotiatedProtocol.contains("h3") -> { Protocol.QUIC } negotiatedProtocol.contains("quic") -> { Protocol.QUIC } negotiatedProtocol.contains("spdy") -> { @Suppress("DEPRECATION") Protocol.SPDY_3 } negotiatedProtocol.contains("h2") -> { Protocol.HTTP_2 } negotiatedProtocol.contains("1.1") -> { Protocol.HTTP_1_1 } else -> { Protocol.HTTP_1_0 } } } private fun headersFromResponse( responseInfo: UrlResponseInfo, keepEncodingAffectedHeaders: Boolean ): Headers { val headers = responseInfo.allHeadersAsList return Headers.Builder().apply { for ((key, value) in headers) { try { if (!keepEncodingAffectedHeaders && (key.equals("content-encoding", ignoreCase = true) || key.equals("Content-Length", ignoreCase = true)) ) { // Strip all content encoding headers as decoding is done handled by cronet continue } add(key, value) } catch (e: Exception) { DebugLog.w(javaClass.name, "Invalid HTTP header/value: $key$value") // Ignore that header } } }.build() } @Throws(IOException::class) private fun createResponse( request: Request, responseInfo: UrlResponseInfo, bodySource: Source? = null ): Response.Builder { val protocol = protocolFromNegotiatedProtocol(responseInfo) val contentEncodingHeaders = responseInfo.allHeaders.getOrDefault("content-encoding", emptyList()) val contentEncodingItems = contentEncodingHeaders.flatMap { it.splitNotBlank(",").toList() } val keepEncodingAffectedHeaders = contentEncodingItems.isEmpty() || !encodingsHandledByCronet.containsAll(contentEncodingItems) val headers = headersFromResponse(responseInfo, keepEncodingAffectedHeaders) val contentLength = if (keepEncodingAffectedHeaders) { responseInfo.allHeaders["Content-Length"]?.lastOrNull() } else null val contentType = responseInfo.allHeaders["content-type"]?.lastOrNull() ?: "text/plain; charset=\"utf-8\"" val responseBody = bodySource?.let { createResponseBody( request, responseInfo.httpStatusCode, contentType, contentLength, bodySource ) } ?: ResponseBody.EMPTY return Response.Builder() .request(request) .receivedResponseAtMillis(System.currentTimeMillis()) .protocol(protocol) .code(responseInfo.httpStatusCode) .message(responseInfo.httpStatusText) .headers(headers) .body(responseBody) } private fun buildPriorResponse( request: Request, redirectResponseInfos: List, ): Response? { var priorResponse: Response? = null if (redirectResponseInfos.isNotEmpty()) { for (i in redirectResponseInfos.indices) { val url = redirectResponseInfos[i].url val redirectedRequest = request.newBuilder().url(url).build() priorResponse = createResponse(redirectedRequest, redirectResponseInfos[i]) .priorResponse(priorResponse) .build() } } return priorResponse } @Throws(IOException::class) private fun createResponseBody( request: Request, httpStatusCode: Int, contentType: String?, contentLengthString: String?, bodySource: Source ): ResponseBody { // Ignore content-length header for HEAD requests (consistency with OkHttp) val contentLength: Long = if (request.method == "HEAD") { 0 } else { contentLengthString?.toLongOrNull() ?: -1 } // Check for absence of body in No Content / Reset Content responses (OkHttp consistency) if ((httpStatusCode == 204 || httpStatusCode == 205) && contentLength > 0) { throw ProtocolException( "HTTP $httpStatusCode had non-zero Content-Length: $contentLengthString" ) } return bodySource.buffer() .asResponseBody(contentType?.toMediaTypeOrNull(), contentLength) } private fun buildRedirectRequest( userResponse: Response, method: String, newLocationUrl: String ): Request { // Most redirects don't include a request body. val requestBuilder = userResponse.request.newBuilder() if (HttpMethod.permitsRequestBody(method)) { val responseCode = userResponse.code val maintainBody = HttpMethod.redirectsWithBody(method) || responseCode == HTTP_PERM_REDIRECT || responseCode == HTTP_TEMP_REDIRECT if (HttpMethod.redirectsToGet(method) && responseCode != HTTP_PERM_REDIRECT && responseCode != HTTP_TEMP_REDIRECT ) { requestBuilder.method("GET", null) } else { val requestBody = if (maintainBody) userResponse.request.body else null requestBuilder.method(method, requestBody) } if (!maintainBody) { requestBuilder.removeHeader("Transfer-Encoding") requestBuilder.removeHeader("Content-Length") requestBuilder.removeHeader("Content-Type") } } return requestBuilder.url(newLocationUrl).build() } private fun toResponse( request: Request, responseInfo: UrlResponseInfo, redirectResponseInfos: List, bodySource: Source? = null ): Response { val responseBuilder = createResponse(request, responseInfo, bodySource) val newRequest = request.newBuilder().url(responseInfo.url).build() return responseBuilder .request(newRequest) .priorResponse(buildPriorResponse(request, redirectResponseInfos)) .build() } } inner class CronetBodySource : Source { private var buffer = ByteBuffer.allocateDirect(32 * 1024) private var closed = false private val timeout = readTimeoutMillis.toLong() override fun close() { cancelJob?.cancel() if (closed) { return } closed = true if (!finished.get()) { request?.cancel() } } @Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS") override fun read(sink: Buffer, byteCount: Long): Long { if (canceled.get()) { throw IOException("Cronet Request Canceled") } require(byteCount >= 0L) { "byteCount < 0: $byteCount" } check(!closed) { "closed" } if (finished.get()) { return -1 } if (byteCount < buffer.limit()) { buffer.limit(byteCount.toInt()) } request?.read(buffer) val result = callbackResults.poll(timeout, TimeUnit.MILLISECONDS) if (result == null) { request?.cancel() throw IOException("Cronet request body read timeout after wait $timeout ms") } return when (result.callbackStep) { CallbackStep.ON_FAILED -> { finished.set(true) buffer = null throw IOException(result.exception) } CallbackStep.ON_SUCCESS -> { finished.set(true) buffer = null -1 } CallbackStep.ON_CANCELED -> { buffer = null throw IOException("Request Canceled") } CallbackStep.ON_READ_COMPLETED -> { result.buffer!!.flip() val bytesWritten = sink.write(result.buffer) result.buffer.clear() bytesWritten.toLong() } } } override fun timeout(): Timeout { return mCall.timeout() } } } ================================================ FILE: app/src/main/java/io/legado/app/lib/cronet/BodyUploadProvider.kt ================================================ package io.legado.app.lib.cronet import androidx.annotation.Keep import okhttp3.RequestBody import okio.Buffer import org.chromium.net.UploadDataProvider import org.chromium.net.UploadDataSink import java.io.IOException import java.nio.ByteBuffer @Keep class BodyUploadProvider(private val body: RequestBody) : UploadDataProvider(), AutoCloseable { private val buffer = Buffer() @Volatile private var filled: Boolean = false init { fillBuffer() } private fun fillBuffer() { try { buffer.clear() filled = true body.writeTo(buffer) buffer.flush() } catch (e: IOException) { e.printStackTrace() } } @Throws(IOException::class) override fun getLength(): Long { return body.contentLength() } @Throws(IOException::class) override fun read(uploadDataSink: UploadDataSink, byteBuffer: ByteBuffer) { if (!filled) { fillBuffer() } check(byteBuffer.hasRemaining()) { "Cronet passed a buffer with no bytes remaining" } var read: Int var bytesRead = 0 while (bytesRead == 0) { read = buffer.read(byteBuffer) bytesRead += read } uploadDataSink.onReadSucceeded(false) } @Throws(IOException::class) override fun rewind(uploadDataSink: UploadDataSink) { check(body.isOneShot()) { "Okhttp RequestBody is oneShot" } filled = false fillBuffer() uploadDataSink.onRewindSucceeded() } @Throws(IOException::class) override fun close() { buffer.close() super.close() } } ================================================ FILE: app/src/main/java/io/legado/app/lib/cronet/CallbackResult.kt ================================================ package io.legado.app.lib.cronet import org.chromium.net.CronetException import java.nio.ByteBuffer data class CallbackResult( val callbackStep: CallbackStep, val buffer: ByteBuffer? = null, val exception: CronetException? = null ) ================================================ FILE: app/src/main/java/io/legado/app/lib/cronet/CallbackStep.kt ================================================ package io.legado.app.lib.cronet enum class CallbackStep { ON_READ_COMPLETED, ON_SUCCESS, ON_FAILED, ON_CANCELED } ================================================ FILE: app/src/main/java/io/legado/app/lib/cronet/CronetCoroutineInterceptor.kt ================================================ package io.legado.app.lib.cronet import androidx.annotation.Keep import io.legado.app.utils.printOnDebug import kotlinx.coroutines.runBlocking import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeout import okhttp3.Call import okhttp3.CookieJar import okhttp3.HttpUrl import okhttp3.Interceptor import okhttp3.Request import okhttp3.Response import okhttp3.internal.http.receiveHeaders import org.chromium.net.UrlRequest import org.chromium.net.UrlResponseInfo import java.io.IOException import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @Keep @Suppress("unused") class CronetCoroutineInterceptor(private val cookieJar: CookieJar) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { if (chain.call().isCanceled()) { throw IOException("Canceled") } val original: Request = chain.request() //Cronet未初始化 return if (!CronetLoader.install() || cronetEngine == null) { chain.proceed(original) } else try { val builder: Request.Builder = original.newBuilder() //移除Keep-Alive,手动设置会导致400 BadRequest builder.removeHeader("Keep-Alive") builder.removeHeader("Accept-Encoding") if (cookieJar != CookieJar.NO_COOKIES) { val cookieStr = getCookie(original.url) //设置Cookie if (cookieStr.length > 3) { builder.addHeader("Cookie", cookieStr) } } val newReq = builder.build() val timeout = chain.call().timeout().timeoutNanos() / 1000000 runBlocking() { if (timeout > 0) { withTimeout(timeout) { proceedWithCronet(newReq, chain.call(), chain.readTimeoutMillis()).also { response -> cookieJar.receiveHeaders(newReq.url, response.headers) } } } else { proceedWithCronet(newReq, chain.call(), chain.readTimeoutMillis()).also { response -> cookieJar.receiveHeaders(newReq.url, response.headers) } } } } catch (e: Exception) { //不能抛出错误,抛出错误会导致应用崩溃 //遇到Cronet处理有问题时的情况,如证书过期等等,回退到okhttp处理 if (!e.message.toString().contains("ERR_CERT_", true) && !e.message.toString().contains("ERR_SSL_", true) ) { e.printOnDebug() } chain.proceed(original) } } private suspend fun proceedWithCronet( request: Request, call: Call, readTimeoutMillis: Int ): Response = suspendCancellableCoroutine { coroutine -> val callBack = object : AbsCallBack(request, call, readTimeoutMillis) { override fun waitForDone(urlRequest: UrlRequest): Response { TODO("Not yet implemented") } override fun onError(error: IOException) { coroutine.resumeWithException(error) } override fun onSuccess(response: Response) { coroutine.resume(response) } override fun onCanceled(request: UrlRequest?, info: UrlResponseInfo?) { super.onCanceled(request, info) coroutine.cancel() } } val req = buildRequest(request, callBack)?.also { it.start() } coroutine.invokeOnCancellation { req?.cancel() } } /** Returns a 'Cookie' HTTP request header with all cookies, like `a=b; c=d`. */ private fun getCookie(url: HttpUrl): String = buildString { val cookies = cookieJar.loadForRequest(url) cookies.forEachIndexed { index, cookie -> if (index > 0) append("; ") append(cookie.name).append('=').append(cookie.value) } } } ================================================ FILE: app/src/main/java/io/legado/app/lib/cronet/CronetHelper.kt ================================================ @file:Keep @file:Suppress("DEPRECATION") package io.legado.app.lib.cronet import androidx.annotation.Keep import io.legado.app.constant.AppLog import io.legado.app.help.http.CookieManager.cookieJarHeader import io.legado.app.help.http.SSLHelper import io.legado.app.help.http.okHttpClient import io.legado.app.utils.DebugLog import io.legado.app.utils.externalCache import okhttp3.Headers import okhttp3.MediaType import okhttp3.Request import org.chromium.net.CronetEngine.Builder.HTTP_CACHE_DISK import org.chromium.net.ExperimentalCronetEngine import org.chromium.net.UploadDataProvider import org.chromium.net.UrlRequest import org.chromium.net.X509Util import org.json.JSONObject import splitties.init.appCtx internal const val BUFFER_SIZE = 32 * 1024 val cronetEngine: ExperimentalCronetEngine? by lazy { CronetLoader.preDownload() disableCertificateVerify() val builder = ExperimentalCronetEngine.Builder(appCtx).apply { if (CronetLoader.install()) { setLibraryLoader(CronetLoader)//设置自定义so库加载 } setStoragePath(appCtx.externalCache.absolutePath)//设置缓存路径 enableHttpCache(HTTP_CACHE_DISK, (1024 * 1024 * 50).toLong())//设置50M的磁盘缓存 enableQuic(true)//设置支持http/3 enableHttp2(true) //设置支持http/2 enablePublicKeyPinningBypassForLocalTrustAnchors(true) enableBrotli(true)//Brotli压缩 setExperimentalOptions(options) } try { val engine = builder.build() DebugLog.d("Cronet Version:", engine.versionString) return@lazy engine } catch (e: Throwable) { AppLog.put("初始化cronetEngine出错", e) return@lazy null } } val options by lazy { val options = JSONObject() //设置域名映射规则 //MAP hostname ip,MAP hostname ip // val host = JSONObject() // host.put("host_resolver_rules","") // options.put("HostResolverRules", host) //启用DnsHttpsSvcb更容易迁移到http3 val dnsSvcb = JSONObject() dnsSvcb.put("enable", true) dnsSvcb.put("enable_insecure", true) dnsSvcb.put("use_alpn", true) options.put("UseDnsHttpsSvcb", dnsSvcb) options.put("AsyncDNS", JSONObject("{'enable':true}")) options.toString() } fun buildRequest(request: Request, callback: UrlRequest.Callback): UrlRequest? { val url = request.url.toString() val headers: Headers = request.headers val requestBody = request.body return cronetEngine?.newUrlRequestBuilder( url, callback, okHttpClient.dispatcher.executorService )?.apply { setHttpMethod(request.method)//设置 allowDirectExecutor() headers.forEachIndexed { index, _ -> if (headers.name(index) == cookieJarHeader) return@forEachIndexed addHeader(headers.name(index), headers.value(index)) } if (requestBody != null) { val contentType: MediaType? = requestBody.contentType() if (contentType != null) { addHeader("Content-Type", contentType.toString()) } else { addHeader("Content-Type", "text/plain") } val provider: UploadDataProvider = if (requestBody.contentLength() > BUFFER_SIZE) { LargeBodyUploadProvider(requestBody, okHttpClient.dispatcher.executorService) } else { BodyUploadProvider(requestBody) } provider.use { this.setUploadDataProvider(it, okHttpClient.dispatcher.executorService) } } }?.build() } private fun disableCertificateVerify() { runCatching { val sDefaultTrustManager = X509Util::class.java.getDeclaredField("sDefaultTrustManager") sDefaultTrustManager.isAccessible = true sDefaultTrustManager.set(null, SSLHelper.unsafeTrustManagerExtensions) } runCatching { val sTestTrustManager = X509Util::class.java.getDeclaredField("sTestTrustManager") sTestTrustManager.isAccessible = true sTestTrustManager.set(null, SSLHelper.unsafeTrustManagerExtensions) } } ================================================ FILE: app/src/main/java/io/legado/app/lib/cronet/CronetInterceptor.kt ================================================ package io.legado.app.lib.cronet import android.annotation.SuppressLint import android.os.Build import androidx.annotation.Keep import io.legado.app.help.http.CookieManager import io.legado.app.help.http.CookieManager.cookieJarHeader import io.legado.app.utils.printOnDebug import okhttp3.Call import okhttp3.CookieJar import okhttp3.HttpUrl import okhttp3.Interceptor import okhttp3.Request import okhttp3.Response import java.io.IOException @Keep @Suppress("unused") class CronetInterceptor(private val cookieJar: CookieJar) : Interceptor { @Throws(IOException::class) override fun intercept(chain: Interceptor.Chain): Response { if (chain.call().isCanceled()) { throw IOException("Canceled") } val original: Request = chain.request() //Cronet未初始化 if (!CronetLoader.install() || cronetEngine == null) { return chain.proceed(original) } val cronetException: Exception try { val builder: Request.Builder = original.newBuilder() //移除Keep-Alive,手动设置会导致400 BadRequest builder.removeHeader("Keep-Alive") builder.removeHeader("Accept-Encoding") // https://github.com/gedoor/legado/issues/5025#issuecomment-2851156500 if (!original.isHttps && original.header("User-Agent")?.startsWith("Mozilla", true) == true ) { val referer = original.header("Referer") if (referer != null && referer.startsWith("https:", true)) { builder.header("Referer", "http" + referer.substring(5)) } } var newReq = builder.build() if (newReq.header(cookieJarHeader) != null) { newReq = CookieManager.loadRequest(newReq) } return proceedWithCronet(newReq, chain.call(), chain.readTimeoutMillis())!! } catch (e: Exception) { cronetException = e //不能抛出错误,抛出错误会导致应用崩溃 //遇到Cronet处理有问题时的情况,如证书过期等等,回退到okhttp处理 if (!e.message.toString().contains("ERR_CERT_", true) && !e.message.toString().contains("ERR_SSL_", true) ) { e.printOnDebug() } } try { return chain.proceed(original) } catch (e: Exception) { e.addSuppressed(cronetException) throw e } } @SuppressLint("ObsoleteSdkInt") @Throws(IOException::class) private fun proceedWithCronet(request: Request, call: Call, readTimeoutMillis: Int): Response? { val callBack = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { NewCallBack(request, call, readTimeoutMillis) } else { OldCallback(request, call, readTimeoutMillis) } buildRequest(request, callBack)?.let { return callBack.waitForDone(it) } return null } /** Returns a 'Cookie' HTTP request header with all cookies, like `a=b; c=d`. */ private fun getCookie(url: HttpUrl): String = buildString { val cookies = cookieJar.loadForRequest(url) cookies.forEachIndexed { index, cookie -> if (index > 0) append("; ") append(cookie.name).append('=').append(cookie.value) } } } ================================================ FILE: app/src/main/java/io/legado/app/lib/cronet/CronetLoader.kt ================================================ package io.legado.app.lib.cronet import android.annotation.SuppressLint import android.content.Context import android.content.pm.ApplicationInfo import android.os.Build import android.text.TextUtils import androidx.annotation.Keep import io.legado.app.BuildConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.http.Cronet import io.legado.app.utils.DebugLog import io.legado.app.utils.printOnDebug import org.chromium.net.CronetEngine import org.json.JSONObject import splitties.init.appCtx import java.io.BufferedReader import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException import java.io.InputStream import java.io.InputStreamReader import java.io.OutputStream import java.math.BigInteger import java.net.HttpURLConnection import java.net.URL import java.security.MessageDigest import java.util.Objects @Suppress("ConstPropertyName") @Keep object CronetLoader : CronetEngine.Builder.LibraryLoader(), Cronet.LoaderInterface { //https://storage.googleapis.com/chromium-cronet/android/92.0.4515.159/Release/cronet/libs/arm64-v8a/libcronet.92.0.4515.159.so private const val soVersion = BuildConfig.Cronet_Version private const val soName = "libcronet.$soVersion.so" private val soUrl: String private val soFile: File private val downloadFile: File private var cpuAbi: String? = null private var md5: String var download = false @Volatile private var cacheInstall = false init { soUrl = ("https://storage.googleapis.com/chromium-cronet/android/" + soVersion + "/Release/cronet/libs/" + getCpuAbi(appCtx) + "/" + soName) md5 = getMd5(appCtx) val dir = appCtx.getDir("cronet", Context.MODE_PRIVATE) soFile = File(dir.toString() + "/" + getCpuAbi(appCtx), soName) downloadFile = File(appCtx.cacheDir.toString() + "/so_download", soName) DebugLog.d(javaClass.simpleName, "soName+:$soName") DebugLog.d(javaClass.simpleName, "destSuccessFile:$soFile") DebugLog.d(javaClass.simpleName, "tempFile:$downloadFile") DebugLog.d(javaClass.simpleName, "soUrl:$soUrl") } /** * 判断Cronet是否安装完成 */ override fun install(): Boolean { synchronized(this) { if (cacheInstall) { return true } } if (md5.length != 32 || !soFile.exists() || md5 != getFileMD5(soFile)) { cacheInstall = false return cacheInstall } cacheInstall = soFile.exists() return cacheInstall } /** * 预加载Cronet */ override fun preDownload() { Coroutine.async { //md5 = getUrlMd5(md5Url) if (soFile.exists() && md5 == getFileMD5(soFile)) { DebugLog.d(javaClass.simpleName, "So 库已存在") } else { download(soUrl, md5, downloadFile, soFile) } DebugLog.d(javaClass.simpleName, soName) } } private fun getMd5(context: Context): String { val stringBuilder = StringBuilder() return try { //获取assets资源管理器 val assetManager = context.assets //通过管理器打开文件并读取 val bf = BufferedReader( InputStreamReader( assetManager.open("cronet.json") ) ) var line: String? while (bf.readLine().also { line = it } != null) { stringBuilder.append(line) } JSONObject(stringBuilder.toString()).optString(getCpuAbi(context), "") } catch (e: java.lang.Exception) { return "" } } @SuppressLint("UnsafeDynamicallyLoadedCode") override fun loadLibrary(libName: String) { DebugLog.d(javaClass.simpleName, "libName:$libName") val start = System.currentTimeMillis() @Suppress("SameParameterValue") try { //非cronet的so调用系统方法加载 if (!libName.contains("cronet")) { System.loadLibrary(libName) return } //以下逻辑为cronet加载,优先加载本地,否则从远程加载 //首先调用系统行为进行加载 System.loadLibrary(libName) DebugLog.d(javaClass.simpleName, "load from system") } catch (e: Throwable) { //如果找不到,则从远程下载 //删除历史文件 deleteHistoryFile(Objects.requireNonNull(soFile.parentFile), soFile) //md5 = getUrlMd5(md5Url) DebugLog.d(javaClass.simpleName, "soMD5:$md5") if (md5.length != 32 || soUrl.isEmpty()) { //如果md5或下载的url为空,则调用系统行为进行加载 System.loadLibrary(libName) return } if (!soFile.exists() || !soFile.isFile) { soFile.delete() download(soUrl, md5, downloadFile, soFile) //如果文件不存在或不是文件,则调用系统行为进行加载 System.loadLibrary(libName) return } if (soFile.exists()) { //如果文件存在,则校验md5值 val fileMD5 = getFileMD5(soFile) if (fileMD5 != null && fileMD5.equals(md5, ignoreCase = true)) { //md5值一样,则加载 System.load(soFile.absolutePath) DebugLog.d(javaClass.simpleName, "load from:$soFile") return } //md5不一样则删除 soFile.delete() } //不存在则下载 download(soUrl, md5, downloadFile, soFile) //使用系统加载方法 System.loadLibrary(libName) } finally { DebugLog.d(javaClass.simpleName, "time:" + (System.currentTimeMillis() - start)) } } @SuppressLint("DiscouragedPrivateApi") private fun getCpuAbi(context: Context): String? { if (cpuAbi != null) { return cpuAbi } // 5.0以上Application才有primaryCpuAbi字段 try { val appInfo = context.applicationInfo val abiField = ApplicationInfo::class.java.getDeclaredField("primaryCpuAbi") abiField.isAccessible = true cpuAbi = abiField.get(appInfo) as String? } catch (e: Exception) { e.printOnDebug() } if (TextUtils.isEmpty(cpuAbi)) { cpuAbi = Build.SUPPORTED_ABIS[0] } return cpuAbi } /** * 删除历史文件 */ private fun deleteHistoryFile(dir: File, currentFile: File?) { val files = dir.listFiles() @Suppress("SameParameterValue") if (files != null && files.isNotEmpty()) { for (f in files) { if (f.exists() && (currentFile == null || f.absolutePath != currentFile.absolutePath)) { val delete = f.delete() DebugLog.d(javaClass.simpleName, "delete file: $f result: $delete") if (!delete) { f.deleteOnExit() } } } } } /** * 下载文件 */ private fun downloadFileIfNotExist(url: String, destFile: File): Boolean { var inputStream: InputStream? = null var outputStream: OutputStream? = null try { val connection = URL(url).openConnection() as HttpURLConnection inputStream = connection.inputStream if (destFile.exists()) { return true } destFile.parentFile!!.mkdirs() destFile.createNewFile() outputStream = FileOutputStream(destFile) val buffer = ByteArray(32768) var read: Int while (inputStream.read(buffer).also { read = it } != -1) { outputStream.write(buffer, 0, read) outputStream.flush() } return true } catch (e: Throwable) { e.printOnDebug() if (destFile.exists() && !destFile.delete()) { destFile.deleteOnExit() } } finally { if (inputStream != null) { try { inputStream.close() } catch (e: IOException) { e.printOnDebug() } } if (outputStream != null) { try { outputStream.close() } catch (e: IOException) { e.printOnDebug() } } } return false } /** * 下载并拷贝文件 */ @Suppress("SameParameterValue") @Synchronized private fun download( url: String, md5: String?, downloadTempFile: File, destSuccessFile: File ) { if (download) { return } download = true Coroutine.async { val result = downloadFileIfNotExist(url, downloadTempFile) DebugLog.d(javaClass.simpleName, "download result:$result") //文件md5再次校验 val fileMD5 = getFileMD5(downloadTempFile) if (md5 != null && !md5.equals(fileMD5, ignoreCase = true)) { val delete = downloadTempFile.delete() if (!delete) { downloadTempFile.deleteOnExit() } download = false return@async } DebugLog.d(javaClass.simpleName, "download success, copy to $destSuccessFile") //下载成功拷贝文件 copyFile(downloadTempFile, destSuccessFile) cacheInstall = false val parentFile = downloadTempFile.parentFile @Suppress("SameParameterValue") (deleteHistoryFile(parentFile!!, null)) } } /** * 拷贝文件 */ private fun copyFile(source: File?, dest: File?): Boolean { if (source == null || !source.exists() || !source.isFile || dest == null) { return false } if (source.absolutePath == dest.absolutePath) { return true } var fileInputStream: FileInputStream? = null var os: FileOutputStream? = null val parent = dest.parentFile if (parent != null && !parent.exists()) { val mkdirs = parent.mkdirs() if (!mkdirs) { parent.mkdirs() } } try { fileInputStream = FileInputStream(source) os = FileOutputStream(dest, false) val buffer = ByteArray(1024 * 512) var length: Int while (fileInputStream.read(buffer).also { length = it } > 0) { os.write(buffer, 0, length) } return true } catch (e: Exception) { e.printOnDebug() } finally { if (fileInputStream != null) { try { fileInputStream.close() } catch (e: Exception) { e.printOnDebug() } } if (os != null) { try { os.close() } catch (e: Exception) { e.printOnDebug() } } } return false } /** * 获得文件md5 */ private fun getFileMD5(file: File): String? { var fileInputStream: FileInputStream? = null try { fileInputStream = FileInputStream(file) val md5 = MessageDigest.getInstance("MD5") val buffer = ByteArray(1024) var numRead: Int while (fileInputStream.read(buffer).also { numRead = it } > 0) { md5.update(buffer, 0, numRead) } return String.format("%032x", BigInteger(1, md5.digest())).lowercase() } catch (e: Exception) { e.printOnDebug() } catch (e: OutOfMemoryError) { e.printOnDebug() } finally { if (fileInputStream != null) { try { fileInputStream.close() } catch (e: Exception) { e.printOnDebug() } } } return null } } ================================================ FILE: app/src/main/java/io/legado/app/lib/cronet/LargeBodyUploadProvider.kt ================================================ package io.legado.app.lib.cronet import androidx.annotation.Keep import okhttp3.RequestBody import okio.BufferedSource import okio.Pipe import okio.buffer import org.chromium.net.UploadDataProvider import org.chromium.net.UploadDataSink import java.io.IOException import java.nio.ByteBuffer import java.util.concurrent.ExecutorService /** * 用于上传大型文件 * * @property body * @property executorService */ @Keep class LargeBodyUploadProvider( private val body: RequestBody, private val executorService: ExecutorService ) : UploadDataProvider(), AutoCloseable { private val pipe = Pipe(BUFFER_SIZE.toLong()) private var source: BufferedSource = pipe.source.buffer() @Volatile private var filled: Boolean = false override fun getLength(): Long { return body.contentLength() } override fun read(uploadDataSink: UploadDataSink, byteBuffer: ByteBuffer) { if (!filled) { fillBuffer() } check(byteBuffer.hasRemaining()) { "Cronet passed a buffer with no bytes remaining" } var read: Int var bytesRead = 0 while (bytesRead <= 0) { read = source.read(byteBuffer) bytesRead += read } uploadDataSink.onReadSucceeded(false) } @Synchronized private fun fillBuffer() { executorService.submit { try { val writeSink = pipe.sink.buffer() filled = true body.writeTo(writeSink) writeSink.flush() } catch (e: IOException) { e.printStackTrace() } } } override fun rewind(p0: UploadDataSink?) { check(body.isOneShot()) { "Okhttp RequestBody is OneShot" } filled = false fillBuffer() } override fun close() { // pipe.cancel() // source.close() super.close() } } ================================================ FILE: app/src/main/java/io/legado/app/lib/cronet/NewCallBack.kt ================================================ package io.legado.app.lib.cronet import android.annotation.SuppressLint import android.os.Build import androidx.annotation.Keep import androidx.annotation.RequiresApi import okhttp3.Call import okhttp3.Request import okhttp3.Response import org.chromium.net.UrlRequest import java.io.IOException import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit @SuppressLint("ObsoleteSdkInt") @Keep @RequiresApi(api = Build.VERSION_CODES.N) class NewCallBack(originalRequest: Request, mCall: Call, readTimeoutMillis: Int) : AbsCallBack(originalRequest, mCall, readTimeoutMillis) { private val responseFuture = CompletableFuture() @Throws(IOException::class) override fun waitForDone(urlRequest: UrlRequest): Response { urlRequest.start() startCheckCancelJob(urlRequest) //DebugLog.i(javaClass.simpleName, "start ${originalRequest.method} ${originalRequest.url}") return if (mCall.timeout().timeoutNanos() > 0) { responseFuture.get(mCall.timeout().timeoutNanos(), TimeUnit.NANOSECONDS) } else { return responseFuture.get() } } /** * 当发生错误时,通知子类终止阻塞抛出错误 * @param error */ override fun onError(error: IOException) { responseFuture.completeExceptionally(error) } /** * 请求成功后,通知子类结束阻塞,返回response * @param response */ override fun onSuccess(response: Response) { responseFuture.complete(response) } } ================================================ FILE: app/src/main/java/io/legado/app/lib/cronet/OldCallback.kt ================================================ package io.legado.app.lib.cronet import android.os.ConditionVariable import androidx.annotation.Keep import okhttp3.Call import okhttp3.Request import okhttp3.Response import org.chromium.net.UrlRequest import java.io.IOException @Keep class OldCallback(originalRequest: Request, mCall: Call, readTimeoutMillis: Int) : AbsCallBack(originalRequest, mCall, readTimeoutMillis) { private val mResponseCondition = ConditionVariable() private var mException: IOException? = null @Throws(IOException::class) override fun waitForDone(urlRequest: UrlRequest): Response { //获取okhttp call的完整请求的超时时间 val timeOutMs: Long = mCall.timeout().timeoutNanos() / 1000000 urlRequest.start() startCheckCancelJob(urlRequest) if (timeOutMs > 0) { mResponseCondition.block(timeOutMs) } else { mResponseCondition.block() } //ConditionVariable 正常open或者超时open后,检查urlRequest是否完成 if (!urlRequest.isDone) { urlRequest.cancel() mException = IOException("Cronet timeout after wait " + timeOutMs + "ms") } if (mException != null) { throw mException as IOException } return mResponse } /** * 当发生错误时,通知子类终止阻塞抛出错误 * @param error */ override fun onError(error: IOException) { mException = error mResponseCondition.open() } /** * 请求成功后,通知子类结束阻塞,返回response * @param response */ override fun onSuccess(response: Response) { mResponseCondition.open() } } ================================================ FILE: app/src/main/java/io/legado/app/lib/dialogs/AlertBuilder.kt ================================================ @file:Suppress("unused") package io.legado.app.lib.dialogs import android.annotation.SuppressLint import android.content.Context import android.content.DialogInterface import android.graphics.drawable.Drawable import android.view.KeyEvent import android.view.View import androidx.annotation.DrawableRes import androidx.annotation.StringRes import io.legado.app.R @SuppressLint("SupportAnnotationUsage") interface AlertBuilder { val ctx: Context fun setTitle(title: CharSequence) fun setTitle(titleResource: Int) fun setMessage(message: CharSequence) fun setMessage(messageResource: Int) fun setIcon(icon: Drawable) fun setIcon(@DrawableRes iconResource: Int) fun setCustomTitle(customTitle: View) fun setCustomView(customView: View) fun setCancelable(isCancelable: Boolean) fun positiveButton(buttonText: String, onClicked: ((dialog: DialogInterface) -> Unit)? = null) fun positiveButton( @StringRes buttonTextResource: Int, onClicked: ((dialog: DialogInterface) -> Unit)? = null ) fun negativeButton(buttonText: String, onClicked: ((dialog: DialogInterface) -> Unit)? = null) fun negativeButton( @StringRes buttonTextResource: Int, onClicked: ((dialog: DialogInterface) -> Unit)? = null ) fun neutralButton(buttonText: String, onClicked: ((dialog: DialogInterface) -> Unit)? = null) fun neutralButton( @StringRes buttonTextResource: Int, onClicked: ((dialog: DialogInterface) -> Unit)? = null ) fun onCancelled(handler: (dialog: DialogInterface) -> Unit) fun onKeyPressed(handler: (dialog: DialogInterface, keyCode: Int, e: KeyEvent) -> Boolean) fun onDismiss(handler: (dialog: DialogInterface) -> Unit) fun items( items: List, onItemSelected: (dialog: DialogInterface, index: Int) -> Unit ) fun items( items: List, onItemSelected: (dialog: DialogInterface, item: T, index: Int) -> Unit ) fun multiChoiceItems( items: Array, checkedItems: BooleanArray, onClick: (dialog: DialogInterface, which: Int, isChecked: Boolean) -> Unit ) fun singleChoiceItems( items: Array, checkedItem: Int = 0, onClick: ((dialog: DialogInterface, which: Int) -> Unit)? = null ) fun build(): D fun show(): D fun customTitle(view: () -> View) { setCustomTitle(view()) } fun customView(view: () -> View) { setCustomView(view()) } fun okButton(handler: ((dialog: DialogInterface) -> Unit)? = null) = positiveButton(android.R.string.ok, handler) fun cancelButton(handler: ((dialog: DialogInterface) -> Unit)? = null) = negativeButton(android.R.string.cancel, handler) fun yesButton(handler: ((dialog: DialogInterface) -> Unit)? = null) = positiveButton(R.string.yes, handler) fun noButton(handler: ((dialog: DialogInterface) -> Unit)? = null) = negativeButton(R.string.no, handler) } ================================================ FILE: app/src/main/java/io/legado/app/lib/dialogs/AndroidAlertBuilder.kt ================================================ package io.legado.app.lib.dialogs import android.content.Context import android.content.DialogInterface import android.graphics.drawable.Drawable import android.view.KeyEvent import android.view.View import androidx.appcompat.app.AlertDialog import io.legado.app.R import io.legado.app.help.config.AppConfig import io.legado.app.utils.applyTint internal class AndroidAlertBuilder(override val ctx: Context) : AlertBuilder { private val builder = AlertDialog.Builder(ctx) override fun setTitle(title: CharSequence) { builder.setTitle(title) } override fun setTitle(titleResource: Int) { builder.setTitle(titleResource) } override fun setMessage(message: CharSequence) { builder.setMessage(message) } override fun setMessage(messageResource: Int) { builder.setMessage(messageResource) } override fun setIcon(icon: Drawable) { builder.setIcon(icon) } override fun setIcon(iconResource: Int) { builder.setIcon(iconResource) } override fun setCustomTitle(customTitle: View) { builder.setCustomTitle(customTitle) } override fun setCustomView(customView: View) { builder.setView(customView) } override fun setCancelable(isCancelable: Boolean) { builder.setCancelable(isCancelable) } override fun onCancelled(handler: (DialogInterface) -> Unit) { builder.setOnCancelListener(handler) } override fun onKeyPressed(handler: (dialog: DialogInterface, keyCode: Int, e: KeyEvent) -> Boolean) { builder.setOnKeyListener(handler) } override fun positiveButton( buttonText: String, onClicked: ((dialog: DialogInterface) -> Unit)? ) { builder.setPositiveButton(buttonText) { dialog, _ -> onClicked?.invoke(dialog) } } override fun positiveButton( buttonTextResource: Int, onClicked: ((dialog: DialogInterface) -> Unit)? ) { builder.setPositiveButton(buttonTextResource) { dialog, _ -> onClicked?.invoke(dialog) } } override fun negativeButton( buttonText: String, onClicked: ((dialog: DialogInterface) -> Unit)? ) { builder.setNegativeButton(buttonText) { dialog, _ -> onClicked?.invoke(dialog) } } override fun negativeButton( buttonTextResource: Int, onClicked: ((dialog: DialogInterface) -> Unit)? ) { builder.setNegativeButton(buttonTextResource) { dialog, _ -> onClicked?.invoke(dialog) } } override fun neutralButton( buttonText: String, onClicked: ((dialog: DialogInterface) -> Unit)? ) { builder.setNeutralButton(buttonText) { dialog, _ -> onClicked?.invoke(dialog) } } override fun neutralButton( buttonTextResource: Int, onClicked: ((dialog: DialogInterface) -> Unit)? ) { builder.setNeutralButton(buttonTextResource) { dialog, _ -> onClicked?.invoke(dialog) } } override fun onDismiss(handler: (dialog: DialogInterface) -> Unit) { builder.setOnDismissListener(handler) } override fun items( items: List, onItemSelected: (dialog: DialogInterface, index: Int) -> Unit ) { builder.setItems(Array(items.size) { i -> items[i].toString() }) { dialog, which -> onItemSelected(dialog, which) } } override fun items( items: List, onItemSelected: (dialog: DialogInterface, item: T, index: Int) -> Unit ) { builder.setItems(Array(items.size) { i -> items[i].toString() }) { dialog, which -> onItemSelected(dialog, items[which], which) } } override fun multiChoiceItems( items: Array, checkedItems: BooleanArray, onClick: (dialog: DialogInterface, which: Int, isChecked: Boolean) -> Unit ) { builder.setMultiChoiceItems(items, checkedItems) { dialog, which, isChecked -> onClick(dialog, which, isChecked) } } override fun singleChoiceItems( items: Array, checkedItem: Int, onClick: ((dialog: DialogInterface, which: Int) -> Unit)? ) { builder.setSingleChoiceItems(items, checkedItem) { dialog, which -> onClick?.invoke(dialog, which) } } override fun build(): AlertDialog { val dialog = builder.create() if (AppConfig.isEInkMode) { dialog.window?.run { val attr = attributes attr.dimAmount = 0f attr.windowAnimations = 0 attributes = attr setBackgroundDrawableResource(R.drawable.bg_eink_border_dialog) } } return dialog } override fun show(): AlertDialog { val dialog = builder.show().applyTint() if (AppConfig.isEInkMode) { dialog.window?.run { val attr = attributes attr.dimAmount = 0f attr.windowAnimations = 0 attributes = attr setBackgroundDrawableResource(R.drawable.bg_eink_border_dialog) } } return dialog } } ================================================ FILE: app/src/main/java/io/legado/app/lib/dialogs/AndroidDialogs.kt ================================================ @file:Suppress("NOTHING_TO_INLINE", "unused", "DEPRECATION") package io.legado.app.lib.dialogs import android.app.ProgressDialog import android.content.Context import android.content.DialogInterface import androidx.appcompat.app.AlertDialog import androidx.fragment.app.Fragment fun Context.alert( title: CharSequence? = null, message: CharSequence? = null, init: (AlertBuilder.() -> Unit)? = null ): AlertDialog { return AndroidAlertBuilder(this).apply { if (title != null) { this.setTitle(title) } if (message != null) { this.setMessage(message) } if (init != null) init() }.show() } inline fun Fragment.alert( title: CharSequence? = null, message: CharSequence? = null, noinline init: (AlertBuilder.() -> Unit)? = null ) = requireActivity().alert(title, message, init) fun Context.alert( titleResource: Int? = null, messageResource: Int? = null, init: (AlertBuilder.() -> Unit)? = null ): AlertDialog { return AndroidAlertBuilder(this).apply { if (titleResource != null) { this.setTitle(titleResource) } if (messageResource != null) { this.setMessage(messageResource) } if (init != null) init() }.show() } inline fun Fragment.alert( titleResource: Int? = null, messageResource: Int? = null, noinline init: (AlertBuilder.() -> Unit)? = null ) = requireActivity().alert(titleResource, messageResource, init) fun Context.alert(init: AlertBuilder.() -> Unit): AlertDialog = AndroidAlertBuilder(this).apply { init() }.show() inline fun Fragment.alert(noinline init: AlertBuilder.() -> Unit) = requireContext().alert(init) inline fun Fragment.progressDialog( title: Int? = null, message: Int? = null, noinline init: (ProgressDialog.() -> Unit)? = null ) = requireActivity().progressDialog(title, message, init) fun Context.progressDialog( title: Int? = null, message: Int? = null, init: (ProgressDialog.() -> Unit)? = null ) = progressDialog(title?.let { getString(it) }, message?.let { getString(it) }, false, init) inline fun Fragment.indeterminateProgressDialog( title: Int? = null, message: Int? = null, noinline init: (ProgressDialog.() -> Unit)? = null ) = requireActivity().indeterminateProgressDialog(title, message, init) fun Context.indeterminateProgressDialog( title: Int? = null, message: Int? = null, init: (ProgressDialog.() -> Unit)? = null ) = progressDialog(title?.let { getString(it) }, message?.let { getString(it) }, true, init) inline fun Fragment.progressDialog( title: CharSequence? = null, message: CharSequence? = null, noinline init: (ProgressDialog.() -> Unit)? = null ) = requireActivity().progressDialog(title, message, init) fun Context.progressDialog( title: CharSequence? = null, message: CharSequence? = null, init: (ProgressDialog.() -> Unit)? = null ) = progressDialog(title, message, false, init) inline fun Fragment.indeterminateProgressDialog( title: CharSequence? = null, message: CharSequence? = null, noinline init: (ProgressDialog.() -> Unit)? = null ) = requireActivity().indeterminateProgressDialog(title, message, init) fun Context.indeterminateProgressDialog( title: CharSequence? = null, message: CharSequence? = null, init: (ProgressDialog.() -> Unit)? = null ) = progressDialog(title, message, true, init) private fun Context.progressDialog( title: CharSequence? = null, message: CharSequence? = null, indeterminate: Boolean, init: (ProgressDialog.() -> Unit)? = null ) = ProgressDialog(this).apply { isIndeterminate = indeterminate if (!indeterminate) setProgressStyle(ProgressDialog.STYLE_HORIZONTAL) if (message != null) setMessage(message) if (title != null) setTitle(title) if (init != null) init() show() } typealias AlertBuilderFactory = (Context) -> AlertBuilder inline fun Fragment.alert( noinline factory: AlertBuilderFactory, title: String? = null, message: String? = null, noinline init: (AlertBuilder.() -> Unit)? = null ) = activity?.alert(factory, title, message, init) fun Context.alert( factory: AlertBuilderFactory, title: String? = null, message: String? = null, init: (AlertBuilder.() -> Unit)? = null ): AlertBuilder { return factory(this).apply { if (title != null) { this.setTitle(title) } if (message != null) { this.setMessage(message) } if (init != null) init() } } inline fun Fragment.alert( noinline factory: AlertBuilderFactory, titleResource: Int? = null, messageResource: Int? = null, noinline init: (AlertBuilder.() -> Unit)? = null ) = requireActivity().alert(factory, titleResource, messageResource, init) fun Context.alert( factory: AlertBuilderFactory, titleResource: Int? = null, messageResource: Int? = null, init: (AlertBuilder.() -> Unit)? = null ): AlertBuilder { return factory(this).apply { if (titleResource != null) { this.setTitle(titleResource) } if (messageResource != null) { this.setMessage(messageResource) } if (init != null) init() } } inline fun Fragment.alert( noinline factory: AlertBuilderFactory, noinline init: AlertBuilder.() -> Unit ) = requireActivity().alert(factory, init) fun Context.alert( factory: AlertBuilderFactory, init: AlertBuilder.() -> Unit ): AlertBuilder = factory(this).apply { init() } ================================================ FILE: app/src/main/java/io/legado/app/lib/dialogs/AndroidSelectors.kt ================================================ /* * Copyright 2016 JetBrains s.r.o. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ @file:Suppress("unused") package io.legado.app.lib.dialogs import android.content.Context import android.content.DialogInterface fun Context.selector( items: List, onClick: (DialogInterface, Int) -> Unit ) { with(AndroidAlertBuilder(this)) { items(items, onClick) show() } } fun Context.selector( items: List, onClick: (DialogInterface, T, Int) -> Unit ) { with(AndroidAlertBuilder(this)) { items(items, onClick) show() } } fun Context.selector( title: CharSequence, items: List, onClick: (DialogInterface, Int) -> Unit ) { with(AndroidAlertBuilder(this)) { this.setTitle(title) items(items, onClick) show() } } fun Context.selector( title: CharSequence, items: List, onClick: (DialogInterface, T, Int) -> Unit ) { with(AndroidAlertBuilder(this)) { this.setTitle(title) items(items, onClick) show() } } fun Context.selector( titleSource: Int, items: List, onClick: (DialogInterface, Int) -> Unit ) { with(AndroidAlertBuilder(this)) { this.setTitle(titleSource) items(items, onClick) show() } } fun Context.selector( titleSource: Int, items: List, onClick: (DialogInterface, T, Int) -> Unit ) { with(AndroidAlertBuilder(this)) { this.setTitle(titleSource) items(items, onClick) show() } } ================================================ FILE: app/src/main/java/io/legado/app/lib/dialogs/SelectItem.kt ================================================ package io.legado.app.lib.dialogs @Suppress("unused") data class SelectItem( val title: String, val value: T ) { override fun toString(): String { return title } } ================================================ FILE: app/src/main/java/io/legado/app/lib/icu4j/CharsetDetector.java ================================================ // © 2016 and later: Unicode, Inc. and others. // License & terms of use: http://www.unicode.org/copyright.html /* ****************************************************************************** Copyright (C) 2005-2016, International Business Machines Corporation and * others. All Rights Reserved. * ****************************************************************************** */ package io.legado.app.lib.icu4j; import android.os.ParcelFileDescriptor; import android.system.OsConstants; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.io.IOException; import java.io.InputStream; import java.io.Reader; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; /** * CharsetDetector provides a facility for detecting the * charset or encoding of character data in an unknown format. * The input data can either be from an input stream or an array of bytes. * The result of the detection operation is a list of possibly matching * charsets, or, for simple use, you can just ask for a Java Reader that * will will work over the input data. *

* Character set detection is at best an imprecise operation. The detection * process will attempt to identify the charset that best matches the characteristics * of the byte data, but the process is partly statistical in nature, and * the results can not be guaranteed to always be correct. *

* For best accuracy in charset detection, the input data should be primarily * in a single language, and a minimum of a few hundred bytes worth of plain text * in the language are needed. The detection process will attempt to * ignore html or xml style markup that could otherwise obscure the content. *

* * @stable ICU 3.4 */ @SuppressWarnings({"JavaDoc", "unused", "RedundantSuppression"}) public class CharsetDetector { // Question: Should we have getters corresponding to the setters for input text // and declared encoding? // A thought: If we were to create our own type of Java Reader, we could defer // figuring out an actual charset for data that starts out with too much English // only ASCII until the user actually read through to something that didn't look // like 7 bit English. If nothing else ever appeared, we would never need to // actually choose the "real" charset. All assuming that the application just // wants the data, and doesn't care about a char set name. /** * Constructor * * @stable ICU 3.4 */ public CharsetDetector() { } /** * Set the declared encoding for charset detection. * The declared encoding of an input text is an encoding obtained * from an http header or xml declaration or similar source that * can be provided as additional information to the charset detector. * A match between a declared encoding and a possible detected encoding * will raise the quality of that detected encoding by a small delta, * and will also appear as a "reason" for the match. *

* A declared encoding that is incompatible with the input data being * analyzed will not be added to the list of possible encodings. * * @param encoding The declared encoding * @stable ICU 3.4 */ public CharsetDetector setDeclaredEncoding(String encoding) { fDeclaredEncoding = encoding; return this; } /** * Set the input text (byte) data whose charset is to be detected. * * @param in the input text of unknown encoding * @return This CharsetDetector * @stable ICU 3.4 */ public CharsetDetector setText(@NonNull byte[] in) { fRawInput = in; fRawLength = in.length; return this; } public CharsetDetector setText(@NonNull ParcelFileDescriptor pfd) { fRawInput = new byte[kBufSize]; try { android.system.Os.lseek(pfd.getFileDescriptor(), 0, OsConstants.SEEK_SET); fRawLength = android.system.Os.read(pfd.getFileDescriptor(), fRawInput, 0, fRawInput.length); android.system.Os.lseek(pfd.getFileDescriptor(), 0, OsConstants.SEEK_SET); } catch (Exception e) { throw new RuntimeException(e); } return this; } private static final int kBufSize = 8000; /** * Set the input text (byte) data whose charset is to be detected. *

* The input stream that supplies the character data must have markSupported() * == true; the charset detection process will read a small amount of data, * then return the stream to its original position via * the InputStream.reset() operation. The exact amount that will * be read depends on the characteristics of the data itself. * * @param in the input text of unknown encoding * @return This CharsetDetector * @stable ICU 3.4 */ public CharsetDetector setText(@NonNull InputStream in) throws IOException { fInputStream = in; fInputStream.mark(kBufSize); fRawInput = new byte[kBufSize]; // Always make a new buffer because the // previous one may have come from the caller, // in which case we can't touch it. fRawLength = 0; int remainingLength = kBufSize; while (remainingLength > 0) { // read() may give data in smallish chunks, esp. for remote sources. Hence, this loop. int bytesRead = fInputStream.read(fRawInput, fRawLength, remainingLength); if (bytesRead <= 0) { break; } fRawLength += bytesRead; remainingLength -= bytesRead; } fInputStream.reset(); return this; } /** * Return the charset that best matches the supplied input data. *

* Note though, that because the detection * only looks at the start of the input data, * there is a possibility that the returned charset will fail to handle * the full set of input data. * p> * aise an exception if *

    *
  • no charset appears to match the data.
  • *
  • no input text has been provided
  • *
* * @return a CharsetMatch object representing the best matching charset, or * null if there are no matches. * @stable ICU 3.4 */ @Nullable public CharsetMatch detect() { // TODO: A better implementation would be to copy the detect loop from // detectAll(), and cut it short as soon as a match with a high confidence // is found. This is something to be done later, after things are otherwise // working. CharsetMatch[] matches = detectAll(); if (matches == null || matches.length == 0) { return null; } return matches[0]; } /** * Return an array of all charsets that appear to be plausible * matches with the input data. The array is ordered with the * best quality match first. *

* aise an exception if *

    *
  • no charsets appear to match the input data.
  • *
  • no input text has been provided
  • *
* * @return An array of CharsetMatch objects representing possibly matching charsets. * @stable ICU 3.4 */ public CharsetMatch[] detectAll() { ArrayList matches = new ArrayList<>(); MungeInput(); // Strip html markup, collect byte stats. // Iterate over all possible charsets, remember all that // give a match quality > 0. for (int i = 0; i < ALL_CS_RECOGNIZERS.size(); i++) { CSRecognizerInfo rcinfo = ALL_CS_RECOGNIZERS.get(i); boolean active = (fEnabledRecognizers != null) ? fEnabledRecognizers[i] : rcinfo.isDefaultEnabled; if (active) { CharsetMatch m = rcinfo.recognizer.match(this); if (m != null) { matches.add(m); } } } Collections.sort(matches); // CharsetMatch compares on confidence Collections.reverse(matches); // Put best match first. CharsetMatch[] resultArray = new CharsetMatch[matches.size()]; resultArray = matches.toArray(resultArray); return resultArray; } /** * Autodetect the charset of an inputStream, and return a Java Reader * to access the converted input data. *

* This is a convenience method that is equivalent to * this.setDeclaredEncoding(declaredEncoding).setText(in).detect().getReader(); *

* For the input stream that supplies the character data, markSupported() * must be true; the charset detection will read a small amount of data, * then return the stream to its original position via * the InputStream.reset() operation. The exact amount that will * be read depends on the characteristics of the data itself. *

* Raise an exception if no charsets appear to match the input data. * * @param in The source of the byte data in the unknown charset. * @param declaredEncoding A declared encoding for the data, if available, * or null or an empty string if none is available. * @stable ICU 3.4 */ public Reader getReader(InputStream in, String declaredEncoding) { fDeclaredEncoding = declaredEncoding; try { setText(in); CharsetMatch match = detect(); if (match == null) { return null; } return match.getReader(); } catch (IOException e) { return null; } } /** * Autodetect the charset of an inputStream, and return a String * containing the converted input data. *

* This is a convenience method that is equivalent to * this.setDeclaredEncoding(declaredEncoding).setText(in).detect().getString(); *

* Raise an exception if no charsets appear to match the input data. * * @param in The source of the byte data in the unknown charset. * @param declaredEncoding A declared encoding for the data, if available, * or null or an empty string if none is available. * @stable ICU 3.4 */ public String getString(byte[] in, String declaredEncoding) { fDeclaredEncoding = declaredEncoding; try { setText(in); CharsetMatch match = detect(); if (match == null) { return null; } return match.getString(-1); } catch (IOException e) { return null; } } /** * Get the names of all charsets supported by CharsetDetector class. *

* Note: Multiple different charset encodings in a same family may use * a single shared name in this implementation. For example, this method returns * an array including "ISO-8859-1" (ISO Latin 1), but not including "windows-1252" * (Windows Latin 1). However, actual detection result could be "windows-1252" * when the input data matches Latin 1 code points with any points only available * in "windows-1252". * * @return an array of the names of all charsets supported by * CharsetDetector class. * @stable ICU 3.4 */ public static String[] getAllDetectableCharsets() { String[] allCharsetNames = new String[ALL_CS_RECOGNIZERS.size()]; for (int i = 0; i < allCharsetNames.length; i++) { allCharsetNames[i] = ALL_CS_RECOGNIZERS.get(i).recognizer.getName(); } return allCharsetNames; } /** * Test whether or not input filtering is enabled. * * @return true if input text will be filtered. * @stable ICU 3.4 * @see #enableInputFilter */ public boolean inputFilterEnabled() { return fStripTags; } /** * Enable filtering of input text. If filtering is enabled, * text within angle brackets ("<" and ">") will be removed * before detection. * * @param filter true to enable input text filtering. * @return The previous setting. * @stable ICU 3.4 */ public boolean enableInputFilter(boolean filter) { boolean previous = fStripTags; fStripTags = filter; return previous; } /* * MungeInput - after getting a set of raw input data to be analyzed, preprocess * it by removing what appears to be html markup. */ private void MungeInput() { int srci; int dsti = 0; byte b; boolean inMarkup = false; int openTags = 0; int badTags = 0; // // html / xml markup stripping. // quick and dirty, not 100% accurate, but hopefully good enough, statistically. // discard everything within < brackets > // Count how many total '<' and illegal (nested) '<' occur, so we can make some // guess as to whether the input was actually marked up at all. if (fStripTags) { for (srci = 0; srci < fRawLength && dsti < fInputBytes.length; srci++) { b = fRawInput[srci]; if (b == (byte) '<') { if (inMarkup) { badTags++; } inMarkup = true; openTags++; } if (!inMarkup) { fInputBytes[dsti++] = b; } if (b == (byte) '>') { inMarkup = false; } } fInputLen = dsti; } // // If it looks like this input wasn't marked up, or if it looks like it's // essentially nothing but markup abandon the markup stripping. // Detection will have to work on the unstripped input. // if (openTags < 5 || openTags / 5 < badTags || (fInputLen < 100 && fRawLength > 600)) { int limit = fRawLength; if (limit > kBufSize) { limit = kBufSize; } for (srci = 0; srci < limit; srci++) { fInputBytes[srci] = fRawInput[srci]; } fInputLen = srci; } // // Tally up the byte occurence statistics. // These are available for use by the various detectors. // Arrays.fill(fByteStats, (short) 0); for (srci = 0; srci < fInputLen; srci++) { int val = fInputBytes[srci] & 0x00ff; fByteStats[val]++; } fC1Bytes = false; for (int i = 0x80; i <= 0x9F; i += 1) { if (fByteStats[i] != 0) { fC1Bytes = true; break; } } } /* * The following items are accessed by individual CharsetRecongizers during * the recognition process * */ byte[] fInputBytes = // The text to be checked. Markup will have been new byte[kBufSize]; // removed if appropriate. int fInputLen; // Length of the byte data in fInputBytes. short[] fByteStats = // byte frequency statistics for the input text. new short[256]; // Value is percent, not absolute. // Value is rounded up, so zero really means zero occurences. boolean fC1Bytes = // True if any bytes in the range 0x80 - 0x9F are in the input; false; String fDeclaredEncoding; byte[] fRawInput; // Original, untouched input bytes. // If user gave us a byte array, this is it. // If user gave us a stream, it's read to a // buffer here. int fRawLength; // Length of data in fRawInput array. InputStream fInputStream; // User's input stream, or null if the user // gave us a byte array. // // Stuff private to CharsetDetector // private boolean fStripTags = // If true, setText() will strip tags from input text. false; private boolean[] fEnabledRecognizers; // If not null, active set of charset recognizers had // been changed from the default. The array index is // corresponding to ALL_RECOGNIZER. See setDetectableCharset(). private static class CSRecognizerInfo { CharsetRecognizer recognizer; boolean isDefaultEnabled; CSRecognizerInfo(CharsetRecognizer recognizer, boolean isDefaultEnabled) { this.recognizer = recognizer; this.isDefaultEnabled = isDefaultEnabled; } } /* * List of recognizers for all charsets known to the implementation. */ private static final List ALL_CS_RECOGNIZERS; static { List list = new ArrayList<>(); list.add(new CSRecognizerInfo(new CharsetRecog_UTF8(), true)); list.add(new CSRecognizerInfo(new CharsetRecog_Unicode.CharsetRecog_UTF_16_BE(), true)); list.add(new CSRecognizerInfo(new CharsetRecog_Unicode.CharsetRecog_UTF_16_LE(), true)); list.add(new CSRecognizerInfo(new CharsetRecog_Unicode.CharsetRecog_UTF_32_BE(), true)); list.add(new CSRecognizerInfo(new CharsetRecog_Unicode.CharsetRecog_UTF_32_LE(), true)); list.add(new CSRecognizerInfo(new CharsetRecog_mbcs.CharsetRecog_sjis(), true)); list.add(new CSRecognizerInfo(new CharsetRecog_2022.CharsetRecog_2022JP(), true)); list.add(new CSRecognizerInfo(new CharsetRecog_2022.CharsetRecog_2022CN(), true)); list.add(new CSRecognizerInfo(new CharsetRecog_2022.CharsetRecog_2022KR(), true)); list.add(new CSRecognizerInfo(new CharsetRecog_mbcs.CharsetRecog_euc.CharsetRecog_gb_18030(), true)); list.add(new CSRecognizerInfo(new CharsetRecog_mbcs.CharsetRecog_euc.CharsetRecog_euc_jp(), true)); list.add(new CSRecognizerInfo(new CharsetRecog_mbcs.CharsetRecog_euc.CharsetRecog_euc_kr(), true)); list.add(new CSRecognizerInfo(new CharsetRecog_mbcs.CharsetRecog_big5(), true)); list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_8859_1(), true)); list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_8859_2(), true)); list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_8859_5_ru(), true)); list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_8859_6_ar(), true)); list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_8859_7_el(), true)); list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_8859_8_I_he(), true)); list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_8859_8_he(), true)); list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_windows_1251(), true)); list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_windows_1256(), true)); list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_KOI8_R(), true)); list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_8859_9_tr(), true)); // IBM 420/424 recognizers are disabled by default list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_IBM424_he_rtl(), false)); list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_IBM424_he_ltr(), false)); list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_IBM420_ar_rtl(), false)); list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_IBM420_ar_ltr(), false)); //noinspection Java9CollectionFactory ALL_CS_RECOGNIZERS = Collections.unmodifiableList(list); } /** * Get the names of charsets that can be recognized by this CharsetDetector instance. * * @return an array of the names of charsets that can be recognized by this CharsetDetector * instance. * @internal * @deprecated This API is ICU internal only. */ @Deprecated public String[] getDetectableCharsets() { List csnames = new ArrayList<>(ALL_CS_RECOGNIZERS.size()); for (int i = 0; i < ALL_CS_RECOGNIZERS.size(); i++) { CSRecognizerInfo rcinfo = ALL_CS_RECOGNIZERS.get(i); boolean active = (fEnabledRecognizers == null) ? rcinfo.isDefaultEnabled : fEnabledRecognizers[i]; if (active) { csnames.add(rcinfo.recognizer.getName()); } } return csnames.toArray(new String[0]); } /** * Enable or disable individual charset encoding. * A name of charset encoding must be included in the names returned by * {@link #getAllDetectableCharsets()}. * * @param encoding the name of charset encoding. * @param enabled true to enable, or false to disable the * charset encoding. * @return A reference to this CharsetDetector. * @throws IllegalArgumentException when the name of charset encoding is * not supported. * @internal * @deprecated This API is ICU internal only. */ @Deprecated public CharsetDetector setDetectableCharset(String encoding, boolean enabled) { int modIdx = -1; boolean isDefaultVal = false; for (int i = 0; i < ALL_CS_RECOGNIZERS.size(); i++) { CSRecognizerInfo csrinfo = ALL_CS_RECOGNIZERS.get(i); if (csrinfo.recognizer.getName().equals(encoding)) { modIdx = i; isDefaultVal = (csrinfo.isDefaultEnabled == enabled); break; } } if (modIdx < 0) { // No matching encoding found throw new IllegalArgumentException("Invalid encoding: " + "\"" + encoding + "\""); } if (fEnabledRecognizers == null && !isDefaultVal) { // Create an array storing the non default setting fEnabledRecognizers = new boolean[ALL_CS_RECOGNIZERS.size()]; // Initialize the array with default info for (int i = 0; i < ALL_CS_RECOGNIZERS.size(); i++) { fEnabledRecognizers[i] = ALL_CS_RECOGNIZERS.get(i).isDefaultEnabled; } } if (fEnabledRecognizers != null) { fEnabledRecognizers[modIdx] = enabled; } return this; } } ================================================ FILE: app/src/main/java/io/legado/app/lib/icu4j/CharsetMatch.java ================================================ // © 2016 and later: Unicode, Inc. and others. // License & terms of use: http://www.unicode.org/copyright.html /* * ****************************************************************************** * Copyright (C) 2005-2016, International Business Machines Corporation and * * others. All Rights Reserved. * * ****************************************************************************** */ package io.legado.app.lib.icu4j; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.Reader; /** * This class represents a charset that has been identified by a CharsetDetector * as a possible encoding for a set of input data. From an instance of this * class, you can ask for a confidence level in the charset identification, * or for Java Reader or String to access the original byte data in Unicode form. *

* Instances of this class are created only by CharsetDetectors. *

* Note: this class has a natural ordering that is inconsistent with equals. * The natural ordering is based on the match confidence value. * * @stable ICU 3.4 */ @SuppressWarnings({"JavaDoc", "unused"}) public class CharsetMatch implements Comparable { /** * Create a java.io.Reader for reading the Unicode character data corresponding * to the original byte data supplied to the Charset detect operation. *

* CAUTION: if the source of the byte data was an InputStream, a Reader * can be created for only one matching char set using this method. If more * than one charset needs to be tried, the caller will need to reset * the InputStream and create InputStreamReaders itself, based on the charset name. * * @return the Reader for the Unicode character data. * @stable ICU 3.4 */ public Reader getReader() { InputStream inputStream = fInputStream; if (inputStream == null) { inputStream = new ByteArrayInputStream(fRawInput, 0, fRawLength); } try { inputStream.reset(); return new InputStreamReader(inputStream, getName()); } catch (IOException e) { return null; } } /** * Create a Java String from Unicode character data corresponding * to the original byte data supplied to the Charset detect operation. * * @return a String created from the converted input data. * @stable ICU 3.4 */ public String getString() throws java.io.IOException { return getString(-1); } /** * Create a Java String from Unicode character data corresponding * to the original byte data supplied to the Charset detect operation. * The length of the returned string is limited to the specified size; * the string will be trunctated to this length if necessary. A limit value of * zero or less is ignored, and treated as no limit. * * @param maxLength The maximium length of the String to be created when the * source of the data is an input stream, or -1 for * unlimited length. * @return a String created from the converted input data. * @stable ICU 3.4 */ public String getString(int maxLength) throws java.io.IOException { String result; if (fInputStream != null) { StringBuilder sb = new StringBuilder(); char[] buffer = new char[1024]; Reader reader = getReader(); int max = maxLength < 0 ? Integer.MAX_VALUE : maxLength; int bytesRead; while ((bytesRead = reader.read(buffer, 0, Math.min(max, 1024))) >= 0) { sb.append(buffer, 0, bytesRead); max -= bytesRead; } reader.close(); return sb.toString(); } else { String name = getName(); /* * getName() may return a name with a suffix 'rtl' or 'ltr'. This cannot * be used to open a charset (e.g. IBM424_rtl). The ending '_rtl' or 'ltr' * should be stripped off before creating the string. */ int startSuffix = !name.contains("_rtl") ? name.indexOf("_ltr") : name.indexOf("_rtl"); if (startSuffix > 0) { name = name.substring(0, startSuffix); } result = new String(fRawInput, name); } return result; } /** * Get an indication of the confidence in the charset detected. * Confidence values range from 0-100, with larger numbers indicating * a better match of the input data to the characteristics of the * charset. * * @return the confidence in the charset match * @stable ICU 3.4 */ public int getConfidence() { return fConfidence; } /** * Get the name of the detected charset. * The name will be one that can be used with other APIs on the * platform that accept charset names. It is the "Canonical name" * as defined by the class java.nio.charset.Charset; for * charsets that are registered with the IANA charset registry, * this is the MIME-preferred registerd name. * * @return The name of the charset. * @stable ICU 3.4 * @see java.nio.charset.Charset * @see java.io.InputStreamReader */ public String getName() { return fCharsetName; } /** * Get the ISO code for the language of the detected charset. * * @return The ISO code for the language or null if the language cannot be determined. * @stable ICU 3.4 */ public String getLanguage() { return fLang; } /** * Compare to other CharsetMatch objects. * Comparison is based on the match confidence value, which * allows CharsetDetector.detectAll() to order its results. * * @param other the CharsetMatch object to compare against. * @return a negative integer, zero, or a positive integer as the * confidence level of this CharsetMatch * is less than, equal to, or greater than that of * the argument. * @throws ClassCastException if the argument is not a CharsetMatch. * @stable ICU 4.4 */ @Override public int compareTo(CharsetMatch other) { int compareResult = 0; if (this.fConfidence > other.fConfidence) { compareResult = 1; } else if (this.fConfidence < other.fConfidence) { compareResult = -1; } return compareResult; } /* * Constructor. Implementation internal */ CharsetMatch(CharsetDetector det, CharsetRecognizer rec, int conf) { fConfidence = conf; // The references to the original application input data must be copied out // of the charset recognizer to here, in case the application resets the // recognizer before using this CharsetMatch. if (det.fInputStream == null) { // We only want the existing input byte data if it came straight from the user, // not if is just the head of a stream. fRawInput = det.fRawInput; fRawLength = det.fRawLength; } fInputStream = det.fInputStream; fCharsetName = rec.getName(); fLang = rec.getLanguage(); } /* * Constructor. Implementation internal */ CharsetMatch(CharsetDetector det, CharsetRecognizer rec, int conf, String csName, String lang) { fConfidence = conf; // The references to the original application input data must be copied out // of the charset recognizer to here, in case the application resets the // recognizer before using this CharsetMatch. if (det.fInputStream == null) { // We only want the existing input byte data if it came straight from the user, // not if is just the head of a stream. fRawInput = det.fRawInput; fRawLength = det.fRawLength; } fInputStream = det.fInputStream; fCharsetName = csName; fLang = lang; } // // Private Data // private final int fConfidence; private byte[] fRawInput = null; // Original, untouched input bytes. // If user gave us a byte array, this is it. private int fRawLength; // Length of data in fRawInput array. private final InputStream fInputStream; // User's input stream, or null if the user // gave us a byte array. private final String fCharsetName; // The name of the charset this CharsetMatch // represents. Filled in by the recognizer. private final String fLang; // The language, if one was determined by // the recognizer during the detect operation. } ================================================ FILE: app/src/main/java/io/legado/app/lib/icu4j/CharsetRecog_2022.java ================================================ // © 2016 and later: Unicode, Inc. and others. // License & terms of use: http://www.unicode.org/copyright.html /* ******************************************************************************* * Copyright (C) 2005 - 2012, International Business Machines Corporation and * * others. All Rights Reserved. * ******************************************************************************* */ package io.legado.app.lib.icu4j; /** * class CharsetRecog_2022 part of the ICU charset detection imlementation. * This is a superclass for the individual detectors for * each of the detectable members of the ISO 2022 family * of encodings. *

* The separate classes are nested within this class. */ abstract class CharsetRecog_2022 extends CharsetRecognizer { /** * Matching function shared among the 2022 detectors JP, CN and KR * Counts up the number of legal an unrecognized escape sequences in * the sample of text, and computes a score based on the total number & * the proportion that fit the encoding. * * @param text the byte buffer containing text to analyse * @param textLen the size of the text in the byte. * @param escapeSequences the byte escape sequences to test for. * @return match quality, in the range of 0-100. */ int match(byte[] text, int textLen, byte[][] escapeSequences) { int i, j; int escN; int hits = 0; int misses = 0; int shifts = 0; int quality; scanInput: for (i = 0; i < textLen; i++) { if (text[i] == 0x1b) { checkEscapes: for (escN = 0; escN < escapeSequences.length; escN++) { byte[] seq = escapeSequences[escN]; if ((textLen - i) < seq.length) { continue; } for (j = 1; j < seq.length; j++) { if (seq[j] != text[i + j]) { continue checkEscapes; } } hits++; i += seq.length - 1; continue scanInput; } misses++; } if (text[i] == 0x0e || text[i] == 0x0f) { // Shift in/out shifts++; } } if (hits == 0) { return 0; } // // Initial quality is based on relative proportion of recongized vs. // unrecognized escape sequences. // All good: quality = 100; // half or less good: quality = 0; // linear inbetween. quality = (100 * hits - 100 * misses) / (hits + misses); // Back off quality if there were too few escape sequences seen. // Include shifts in this computation, so that KR does not get penalized // for having only a single Escape sequence, but many shifts. if (hits + shifts < 5) { quality -= (5 - (hits + shifts)) * 10; } if (quality < 0) { quality = 0; } return quality; } static class CharsetRecog_2022JP extends CharsetRecog_2022 { private final byte[][] escapeSequences = { {0x1b, 0x24, 0x28, 0x43}, // KS X 1001:1992 {0x1b, 0x24, 0x28, 0x44}, // JIS X 212-1990 {0x1b, 0x24, 0x40}, // JIS C 6226-1978 {0x1b, 0x24, 0x41}, // GB 2312-80 {0x1b, 0x24, 0x42}, // JIS X 208-1983 {0x1b, 0x26, 0x40}, // JIS X 208 1990, 1997 {0x1b, 0x28, 0x42}, // ASCII {0x1b, 0x28, 0x48}, // JIS-Roman {0x1b, 0x28, 0x49}, // Half-width katakana {0x1b, 0x28, 0x4a}, // JIS-Roman {0x1b, 0x2e, 0x41}, // ISO 8859-1 {0x1b, 0x2e, 0x46} // ISO 8859-7 }; @Override String getName() { return "ISO-2022-JP"; } @Override CharsetMatch match(CharsetDetector det) { int confidence = match(det.fInputBytes, det.fInputLen, escapeSequences); return confidence == 0 ? null : new CharsetMatch(det, this, confidence); } } static class CharsetRecog_2022KR extends CharsetRecog_2022 { private final byte[][] escapeSequences = { {0x1b, 0x24, 0x29, 0x43} }; @Override String getName() { return "ISO-2022-KR"; } @Override CharsetMatch match(CharsetDetector det) { int confidence = match(det.fInputBytes, det.fInputLen, escapeSequences); return confidence == 0 ? null : new CharsetMatch(det, this, confidence); } } static class CharsetRecog_2022CN extends CharsetRecog_2022 { private final byte[][] escapeSequences = { {0x1b, 0x24, 0x29, 0x41}, // GB 2312-80 {0x1b, 0x24, 0x29, 0x47}, // CNS 11643-1992 Plane 1 {0x1b, 0x24, 0x2A, 0x48}, // CNS 11643-1992 Plane 2 {0x1b, 0x24, 0x29, 0x45}, // ISO-IR-165 {0x1b, 0x24, 0x2B, 0x49}, // CNS 11643-1992 Plane 3 {0x1b, 0x24, 0x2B, 0x4A}, // CNS 11643-1992 Plane 4 {0x1b, 0x24, 0x2B, 0x4B}, // CNS 11643-1992 Plane 5 {0x1b, 0x24, 0x2B, 0x4C}, // CNS 11643-1992 Plane 6 {0x1b, 0x24, 0x2B, 0x4D}, // CNS 11643-1992 Plane 7 {0x1b, 0x4e}, // SS2 {0x1b, 0x4f}, // SS3 }; @Override String getName() { return "ISO-2022-CN"; } @Override CharsetMatch match(CharsetDetector det) { int confidence = match(det.fInputBytes, det.fInputLen, escapeSequences); return confidence == 0 ? null : new CharsetMatch(det, this, confidence); } } } ================================================ FILE: app/src/main/java/io/legado/app/lib/icu4j/CharsetRecog_UTF8.java ================================================ // © 2016 and later: Unicode, Inc. and others. // License & terms of use: http://www.unicode.org/copyright.html /** * ****************************************************************************** * Copyright (C) 2005 - 2014, International Business Machines Corporation and * * others. All Rights Reserved. * * ****************************************************************************** */ package io.legado.app.lib.icu4j; /** * Charset recognizer for UTF-8 */ class CharsetRecog_UTF8 extends CharsetRecognizer { @Override String getName() { return "UTF-8"; } /* (non-Javadoc) * @see com.ibm.icu.text.CharsetRecognizer#match(com.ibm.icu.text.CharsetDetector) */ @Override CharsetMatch match(CharsetDetector det) { boolean hasBOM = false; int numValid = 0; int numInvalid = 0; byte[] input = det.fRawInput; int i; int trailBytes = 0; int confidence; if (det.fRawLength >= 3 && (input[0] & 0xFF) == 0xef && (input[1] & 0xFF) == 0xbb && (input[2] & 0xFF) == 0xbf) { hasBOM = true; } // Scan for multi-byte sequences for (i = 0; i < det.fRawLength; i++) { int b = input[i]; if ((b & 0x80) == 0) { continue; // ASCII } // Hi bit on char found. Figure out how long the sequence should be if ((b & 0x0e0) == 0x0c0) { trailBytes = 1; } else if ((b & 0x0f0) == 0x0e0) { trailBytes = 2; } else if ((b & 0x0f8) == 0xf0) { trailBytes = 3; } else { numInvalid++; continue; } // Verify that we've got the right number of trail bytes in the sequence for (; ; ) { i++; if (i >= det.fRawLength) { break; } b = input[i]; if ((b & 0xc0) != 0x080) { numInvalid++; break; } if (--trailBytes == 0) { numValid++; break; } } } // Cook up some sort of confidence score, based on presense of a BOM // and the existence of valid and/or invalid multi-byte sequences. confidence = 0; if (hasBOM && numInvalid == 0) { confidence = 100; } else if (hasBOM && numValid > numInvalid * 10) { confidence = 80; } else if (numValid > 3 && numInvalid == 0) { confidence = 100; } else if (numValid > 0 && numInvalid == 0) { confidence = 80; } else if (numValid == 0 && numInvalid == 0) { // Plain ASCII. Confidence must be > 10, it's more likely than UTF-16, which // accepts ASCII with confidence = 10. // TODO: add plain ASCII as an explicitly detected type. confidence = 15; } else if (numValid > numInvalid * 10) { // Probably corruput utf-8 data. Valid sequences aren't likely by chance. confidence = 25; } return confidence == 0 ? null : new CharsetMatch(det, this, confidence); } } ================================================ FILE: app/src/main/java/io/legado/app/lib/icu4j/CharsetRecog_Unicode.java ================================================ // © 2016 and later: Unicode, Inc. and others. // License & terms of use: http://www.unicode.org/copyright.html /* ******************************************************************************* * Copyright (C) 1996-2013, International Business Machines Corporation and * * others. All Rights Reserved. * ******************************************************************************* * */ package io.legado.app.lib.icu4j; /** * This class matches UTF-16 and UTF-32, both big- and little-endian. The * BOM will be used if it is present. */ abstract class CharsetRecog_Unicode extends CharsetRecognizer { /* (non-Javadoc) * @see com.ibm.icu.text.CharsetRecognizer#getName() */ @Override abstract String getName(); /* (non-Javadoc) * @see com.ibm.icu.text.CharsetRecognizer#match(com.ibm.icu.text.CharsetDetector) */ @Override abstract CharsetMatch match(CharsetDetector det); static int codeUnit16FromBytes(byte hi, byte lo) { return ((hi & 0xff) << 8) | (lo & 0xff); } // UTF-16 confidence calculation. Very simple minded, but better than nothing. // Any 8 bit non-control characters bump the confidence up. These have a zero high byte, // and are very likely to be UTF-16, although they could also be part of a UTF-32 code. // NULs are a contra-indication, they will appear commonly if the actual encoding is UTF-32. // NULs should be rare in actual text. static int adjustConfidence(int codeUnit, int confidence) { if (codeUnit == 0) { confidence -= 10; } else if ((codeUnit >= 0x20 && codeUnit <= 0xff) || codeUnit == 0x0a) { confidence += 10; } if (confidence < 0) { confidence = 0; } else if (confidence > 100) { confidence = 100; } return confidence; } static class CharsetRecog_UTF_16_BE extends CharsetRecog_Unicode { @Override String getName() { return "UTF-16BE"; } @Override CharsetMatch match(CharsetDetector det) { byte[] input = det.fRawInput; int confidence = 10; int bytesToCheck = Math.min(input.length, 30); for (int charIndex = 0; charIndex < bytesToCheck - 1; charIndex += 2) { int codeUnit = codeUnit16FromBytes(input[charIndex], input[charIndex + 1]); if (charIndex == 0 && codeUnit == 0xFEFF) { confidence = 100; break; } confidence = adjustConfidence(codeUnit, confidence); if (confidence == 0 || confidence == 100) { break; } } if (bytesToCheck < 4 && confidence < 100) { confidence = 0; } if (confidence > 0) { return new CharsetMatch(det, this, confidence); } return null; } } static class CharsetRecog_UTF_16_LE extends CharsetRecog_Unicode { @Override String getName() { return "UTF-16LE"; } @Override CharsetMatch match(CharsetDetector det) { byte[] input = det.fRawInput; int confidence = 10; int bytesToCheck = Math.min(input.length, 30); for (int charIndex = 0; charIndex < bytesToCheck - 1; charIndex += 2) { int codeUnit = codeUnit16FromBytes(input[charIndex + 1], input[charIndex]); if (charIndex == 0 && codeUnit == 0xFEFF) { confidence = 100; break; } confidence = adjustConfidence(codeUnit, confidence); if (confidence == 0 || confidence == 100) { break; } } if (bytesToCheck < 4 && confidence < 100) { confidence = 0; } if (confidence > 0) { return new CharsetMatch(det, this, confidence); } return null; } } static abstract class CharsetRecog_UTF_32 extends CharsetRecog_Unicode { abstract int getChar(byte[] input, int index); @Override abstract String getName(); @Override CharsetMatch match(CharsetDetector det) { byte[] input = det.fRawInput; int limit = (det.fRawLength / 4) * 4; int numValid = 0; int numInvalid = 0; boolean hasBOM = false; int confidence = 0; if (limit == 0) { return null; } if (getChar(input, 0) == 0x0000FEFF) { hasBOM = true; } for (int i = 0; i < limit; i += 4) { int ch = getChar(input, i); if (ch < 0 || ch >= 0x10FFFF || (ch >= 0xD800 && ch <= 0xDFFF)) { numInvalid += 1; } else { numValid += 1; } } // Cook up some sort of confidence score, based on presence of a BOM // and the existence of valid and/or invalid multi-byte sequences. if (hasBOM && numInvalid == 0) { confidence = 100; } else if (hasBOM && numValid > numInvalid * 10) { confidence = 80; } else if (numValid > 3 && numInvalid == 0) { confidence = 100; } else if (numValid > 0 && numInvalid == 0) { confidence = 80; } else if (numValid > numInvalid * 10) { // Probably corrupt UTF-32BE data. Valid sequences aren't likely by chance. confidence = 25; } return confidence == 0 ? null : new CharsetMatch(det, this, confidence); } } static class CharsetRecog_UTF_32_BE extends CharsetRecog_UTF_32 { @Override int getChar(byte[] input, int index) { return (input[index + 0] & 0xFF) << 24 | (input[index + 1] & 0xFF) << 16 | (input[index + 2] & 0xFF) << 8 | (input[index + 3] & 0xFF); } @Override String getName() { return "UTF-32BE"; } } static class CharsetRecog_UTF_32_LE extends CharsetRecog_UTF_32 { @Override int getChar(byte[] input, int index) { return (input[index + 3] & 0xFF) << 24 | (input[index + 2] & 0xFF) << 16 | (input[index + 1] & 0xFF) << 8 | (input[index + 0] & 0xFF); } @Override String getName() { return "UTF-32LE"; } } } ================================================ FILE: app/src/main/java/io/legado/app/lib/icu4j/CharsetRecog_mbcs.java ================================================ // © 2016 and later: Unicode, Inc. and others. // License & terms of use: http://www.unicode.org/copyright.html /* **************************************************************************** * Copyright (C) 2005-2012, International Business Machines Corporation and * * others. All Rights Reserved. * **************************************************************************** * */ package io.legado.app.lib.icu4j; import java.util.Arrays; /** * CharsetRecognizer implemenation for Asian - double or multi-byte - charsets. * Match is determined mostly by the input data adhering to the * encoding scheme for the charset, and, optionally, * frequency-of-occurence of characters. *

* Instances of this class are singletons, one per encoding * being recognized. They are created in the main * CharsetDetector class and kept in the global list of available * encodings to be checked. The specific encoding being recognized * is determined by subclass. */ abstract class CharsetRecog_mbcs extends CharsetRecognizer { /** * Get the IANA name of this charset. * * @return the charset name. */ @Override abstract String getName(); /** * Test the match of this charset with the input text data * which is obtained via the CharsetDetector object. * * @param det The CharsetDetector, which contains the input text * to be checked for being in this charset. * @return Two values packed into one int (Damn java, anyhow) *
* bits 0-7: the match confidence, ranging from 0-100 *
* bits 8-15: The match reason, an enum-like value. */ int match(CharsetDetector det, int[] commonChars) { @SuppressWarnings("unused") int singleByteCharCount = 0; //TODO Do we really need this? int doubleByteCharCount = 0; int commonCharCount = 0; int badCharCount = 0; int totalCharCount = 0; int confidence = 0; iteratedChar iter = new iteratedChar(); detectBlock: { for (iter.reset(); nextChar(iter, det); ) { totalCharCount++; if (iter.error) { badCharCount++; } else { long cv = iter.charValue & 0xFFFFFFFFL; if (cv <= 0xff) { singleByteCharCount++; } else { doubleByteCharCount++; if (commonChars != null) { // NOTE: This assumes that there are no 4-byte common chars. if (Arrays.binarySearch(commonChars, (int) cv) >= 0) { commonCharCount++; } } } } if (badCharCount >= 2 && badCharCount * 5 >= doubleByteCharCount) { // Bail out early if the byte data is not matching the encoding scheme. break detectBlock; } } if (doubleByteCharCount <= 10 && badCharCount == 0) { // Not many multi-byte chars. if (doubleByteCharCount == 0 && totalCharCount < 10) { // There weren't any multibyte sequences, and there was a low density of non-ASCII single bytes. // We don't have enough data to have any confidence. // Statistical analysis of single byte non-ASCII charcters would probably help here. confidence = 0; } else { // ASCII or ISO file? It's probably not our encoding, // but is not incompatible with our encoding, so don't give it a zero. confidence = 10; } break detectBlock; } // // No match if there are too many characters that don't fit the encoding scheme. // (should we have zero tolerance for these?) // if (doubleByteCharCount < 20 * badCharCount) { confidence = 0; break detectBlock; } if (commonChars == null) { // We have no statistics on frequently occuring characters. // Assess confidence purely on having a reasonable number of // multi-byte characters (the more the better confidence = 30 + doubleByteCharCount - 20 * badCharCount; if (confidence > 100) { confidence = 100; } } else { // // Frequency of occurence statistics exist. // double maxVal = Math.log((float) doubleByteCharCount / 4); double scaleFactor = 90.0 / maxVal; confidence = (int) (Math.log(commonCharCount + 1) * scaleFactor + 10); confidence = Math.min(confidence, 100); } } // end of detectBlock: return confidence; } // "Character" iterated character class. // Recognizers for specific mbcs encodings make their "characters" available // by providing a nextChar() function that fills in an instance of iteratedChar // with the next char from the input. // The returned characters are not converted to Unicode, but remain as the raw // bytes (concatenated into an int) from the codepage data. // // For Asian charsets, use the raw input rather than the input that has been // stripped of markup. Detection only considers multi-byte chars, effectively // stripping markup anyway, and double byte chars do occur in markup too. // static class iteratedChar { int charValue = 0; // 1-4 bytes from the raw input data int nextIndex = 0; boolean error = false; boolean done = false; void reset() { charValue = 0; nextIndex = 0; error = false; done = false; } int nextByte(CharsetDetector det) { if (nextIndex >= det.fRawLength) { done = true; return -1; } return det.fRawInput[nextIndex++] & 0x00ff; } } /** * Get the next character (however many bytes it is) from the input data * Subclasses for specific charset encodings must implement this function * to get characters according to the rules of their encoding scheme. *

* This function is not a method of class iteratedChar only because * that would require a lot of extra derived classes, which is awkward. * * @param it The iteratedChar "struct" into which the returned char is placed. * @param det The charset detector, which is needed to get at the input byte data * being iterated over. * @return True if a character was returned, false at end of input. */ abstract boolean nextChar(iteratedChar it, CharsetDetector det); /** * Shift-JIS charset recognizer. */ static class CharsetRecog_sjis extends CharsetRecog_mbcs { static int[] commonChars = // TODO: This set of data comes from the character frequency- // of-occurence analysis tool. The data needs to be moved // into a resource and loaded from there. {0x8140, 0x8141, 0x8142, 0x8145, 0x815b, 0x8169, 0x816a, 0x8175, 0x8176, 0x82a0, 0x82a2, 0x82a4, 0x82a9, 0x82aa, 0x82ab, 0x82ad, 0x82af, 0x82b1, 0x82b3, 0x82b5, 0x82b7, 0x82bd, 0x82be, 0x82c1, 0x82c4, 0x82c5, 0x82c6, 0x82c8, 0x82c9, 0x82cc, 0x82cd, 0x82dc, 0x82e0, 0x82e7, 0x82e8, 0x82e9, 0x82ea, 0x82f0, 0x82f1, 0x8341, 0x8343, 0x834e, 0x834f, 0x8358, 0x835e, 0x8362, 0x8367, 0x8375, 0x8376, 0x8389, 0x838a, 0x838b, 0x838d, 0x8393, 0x8e96, 0x93fa, 0x95aa}; @Override boolean nextChar(iteratedChar it, CharsetDetector det) { it.error = false; int firstByte; firstByte = it.charValue = it.nextByte(det); if (firstByte < 0) { return false; } if (firstByte <= 0x7f || (firstByte > 0xa0 && firstByte <= 0xdf)) { return true; } int secondByte = it.nextByte(det); if (secondByte < 0) { return false; } it.charValue = (firstByte << 8) | secondByte; if (!((secondByte >= 0x40 && secondByte <= 0x7f) || (secondByte >= 0x80 && secondByte <= 0xff))) { // Illegal second byte value. it.error = true; } return true; } @Override CharsetMatch match(CharsetDetector det) { int confidence = match(det, commonChars); return confidence == 0 ? null : new CharsetMatch(det, this, confidence); } @Override String getName() { return "Shift_JIS"; } @Override public String getLanguage() { return "ja"; } } /** * Big5 charset recognizer. */ static class CharsetRecog_big5 extends CharsetRecog_mbcs { static int[] commonChars = // TODO: This set of data comes from the character frequency- // of-occurence analysis tool. The data needs to be moved // into a resource and loaded from there. {0xa140, 0xa141, 0xa142, 0xa143, 0xa147, 0xa149, 0xa175, 0xa176, 0xa440, 0xa446, 0xa447, 0xa448, 0xa451, 0xa454, 0xa457, 0xa464, 0xa46a, 0xa46c, 0xa477, 0xa4a3, 0xa4a4, 0xa4a7, 0xa4c1, 0xa4ce, 0xa4d1, 0xa4df, 0xa4e8, 0xa4fd, 0xa540, 0xa548, 0xa558, 0xa569, 0xa5cd, 0xa5e7, 0xa657, 0xa661, 0xa662, 0xa668, 0xa670, 0xa6a8, 0xa6b3, 0xa6b9, 0xa6d3, 0xa6db, 0xa6e6, 0xa6f2, 0xa740, 0xa751, 0xa759, 0xa7da, 0xa8a3, 0xa8a5, 0xa8ad, 0xa8d1, 0xa8d3, 0xa8e4, 0xa8fc, 0xa9c0, 0xa9d2, 0xa9f3, 0xaa6b, 0xaaba, 0xaabe, 0xaacc, 0xaafc, 0xac47, 0xac4f, 0xacb0, 0xacd2, 0xad59, 0xaec9, 0xafe0, 0xb0ea, 0xb16f, 0xb2b3, 0xb2c4, 0xb36f, 0xb44c, 0xb44e, 0xb54c, 0xb5a5, 0xb5bd, 0xb5d0, 0xb5d8, 0xb671, 0xb7ed, 0xb867, 0xb944, 0xbad8, 0xbb44, 0xbba1, 0xbdd1, 0xc2c4, 0xc3b9, 0xc440, 0xc45f}; @Override boolean nextChar(iteratedChar it, CharsetDetector det) { it.error = false; int firstByte; firstByte = it.charValue = it.nextByte(det); if (firstByte < 0) { return false; } if (firstByte <= 0x7f || firstByte == 0xff) { // single byte character. return true; } int secondByte = it.nextByte(det); if (secondByte < 0) { return false; } it.charValue = (it.charValue << 8) | secondByte; if (secondByte < 0x40 || secondByte == 0x7f || secondByte == 0xff) { it.error = true; } return true; } @Override CharsetMatch match(CharsetDetector det) { int confidence = match(det, commonChars); return confidence == 0 ? null : new CharsetMatch(det, this, confidence); } @Override String getName() { return "Big5"; } @Override public String getLanguage() { return "zh"; } } /** * EUC charset recognizers. One abstract class that provides the common function * for getting the next character according to the EUC encoding scheme, * and nested derived classes for EUC_KR, EUC_JP, EUC_CN. */ abstract static class CharsetRecog_euc extends CharsetRecog_mbcs { /* * (non-Javadoc) * Get the next character value for EUC based encodings. * Character "value" is simply the raw bytes that make up the character * packed into an int. */ @Override boolean nextChar(iteratedChar it, CharsetDetector det) { it.error = false; int firstByte; int secondByte; int thirdByte; //int fourthByte = 0; buildChar: { firstByte = it.charValue = it.nextByte(det); if (firstByte < 0) { // Ran off the end of the input data it.done = true; break buildChar; } if (firstByte <= 0x8d) { // single byte char break buildChar; } secondByte = it.nextByte(det); it.charValue = (it.charValue << 8) | secondByte; if (firstByte >= 0xA1 && firstByte <= 0xfe) { // Two byte Char if (secondByte < 0xa1) { it.error = true; } break buildChar; } if (firstByte == 0x8e) { // Code Set 2. // In EUC-JP, total char size is 2 bytes, only one byte of actual char value. // In EUC-TW, total char size is 4 bytes, three bytes contribute to char value. // We don't know which we've got. // Treat it like EUC-JP. If the data really was EUC-TW, the following two // bytes will look like a well formed 2 byte char. if (secondByte < 0xa1) { it.error = true; } break buildChar; } if (firstByte == 0x8f) { // Code set 3. // Three byte total char size, two bytes of actual char value. thirdByte = it.nextByte(det); it.charValue = (it.charValue << 8) | thirdByte; if (thirdByte < 0xa1) { it.error = true; } } } return (!it.done); } /** * The charset recognize for EUC-JP. A singleton instance of this class * is created and kept by the public CharsetDetector class */ static class CharsetRecog_euc_jp extends CharsetRecog_euc { static int[] commonChars = // TODO: This set of data comes from the character frequency- // of-occurence analysis tool. The data needs to be moved // into a resource and loaded from there. {0xa1a1, 0xa1a2, 0xa1a3, 0xa1a6, 0xa1bc, 0xa1ca, 0xa1cb, 0xa1d6, 0xa1d7, 0xa4a2, 0xa4a4, 0xa4a6, 0xa4a8, 0xa4aa, 0xa4ab, 0xa4ac, 0xa4ad, 0xa4af, 0xa4b1, 0xa4b3, 0xa4b5, 0xa4b7, 0xa4b9, 0xa4bb, 0xa4bd, 0xa4bf, 0xa4c0, 0xa4c1, 0xa4c3, 0xa4c4, 0xa4c6, 0xa4c7, 0xa4c8, 0xa4c9, 0xa4ca, 0xa4cb, 0xa4ce, 0xa4cf, 0xa4d0, 0xa4de, 0xa4df, 0xa4e1, 0xa4e2, 0xa4e4, 0xa4e8, 0xa4e9, 0xa4ea, 0xa4eb, 0xa4ec, 0xa4ef, 0xa4f2, 0xa4f3, 0xa5a2, 0xa5a3, 0xa5a4, 0xa5a6, 0xa5a7, 0xa5aa, 0xa5ad, 0xa5af, 0xa5b0, 0xa5b3, 0xa5b5, 0xa5b7, 0xa5b8, 0xa5b9, 0xa5bf, 0xa5c3, 0xa5c6, 0xa5c7, 0xa5c8, 0xa5c9, 0xa5cb, 0xa5d0, 0xa5d5, 0xa5d6, 0xa5d7, 0xa5de, 0xa5e0, 0xa5e1, 0xa5e5, 0xa5e9, 0xa5ea, 0xa5eb, 0xa5ec, 0xa5ed, 0xa5f3, 0xb8a9, 0xb9d4, 0xbaee, 0xbbc8, 0xbef0, 0xbfb7, 0xc4ea, 0xc6fc, 0xc7bd, 0xcab8, 0xcaf3, 0xcbdc, 0xcdd1}; @Override String getName() { return "EUC-JP"; } @Override CharsetMatch match(CharsetDetector det) { int confidence = match(det, commonChars); return confidence == 0 ? null : new CharsetMatch(det, this, confidence); } @Override public String getLanguage() { return "ja"; } } /** * The charset recognize for EUC-KR. A singleton instance of this class * is created and kept by the public CharsetDetector class */ static class CharsetRecog_euc_kr extends CharsetRecog_euc { static int[] commonChars = // TODO: This set of data comes from the character frequency- // of-occurence analysis tool. The data needs to be moved // into a resource and loaded from there. {0xb0a1, 0xb0b3, 0xb0c5, 0xb0cd, 0xb0d4, 0xb0e6, 0xb0ed, 0xb0f8, 0xb0fa, 0xb0fc, 0xb1b8, 0xb1b9, 0xb1c7, 0xb1d7, 0xb1e2, 0xb3aa, 0xb3bb, 0xb4c2, 0xb4cf, 0xb4d9, 0xb4eb, 0xb5a5, 0xb5b5, 0xb5bf, 0xb5c7, 0xb5e9, 0xb6f3, 0xb7af, 0xb7c2, 0xb7ce, 0xb8a6, 0xb8ae, 0xb8b6, 0xb8b8, 0xb8bb, 0xb8e9, 0xb9ab, 0xb9ae, 0xb9cc, 0xb9ce, 0xb9fd, 0xbab8, 0xbace, 0xbad0, 0xbaf1, 0xbbe7, 0xbbf3, 0xbbfd, 0xbcad, 0xbcba, 0xbcd2, 0xbcf6, 0xbdba, 0xbdc0, 0xbdc3, 0xbdc5, 0xbec6, 0xbec8, 0xbedf, 0xbeee, 0xbef8, 0xbefa, 0xbfa1, 0xbfa9, 0xbfc0, 0xbfe4, 0xbfeb, 0xbfec, 0xbff8, 0xc0a7, 0xc0af, 0xc0b8, 0xc0ba, 0xc0bb, 0xc0bd, 0xc0c7, 0xc0cc, 0xc0ce, 0xc0cf, 0xc0d6, 0xc0da, 0xc0e5, 0xc0fb, 0xc0fc, 0xc1a4, 0xc1a6, 0xc1b6, 0xc1d6, 0xc1df, 0xc1f6, 0xc1f8, 0xc4a1, 0xc5cd, 0xc6ae, 0xc7cf, 0xc7d1, 0xc7d2, 0xc7d8, 0xc7e5, 0xc8ad}; @Override String getName() { return "EUC-KR"; } @Override CharsetMatch match(CharsetDetector det) { int confidence = match(det, commonChars); return confidence == 0 ? null : new CharsetMatch(det, this, confidence); } @Override public String getLanguage() { return "ko"; } } } /** * GB-18030 recognizer. Uses simplified Chinese statistics. */ static class CharsetRecog_gb_18030 extends CharsetRecog_mbcs { /* * (non-Javadoc) * Get the next character value for EUC based encodings. * Character "value" is simply the raw bytes that make up the character * packed into an int. */ @Override boolean nextChar(iteratedChar it, CharsetDetector det) { it.error = false; int firstByte; int secondByte; int thirdByte; int fourthByte; buildChar: { firstByte = it.charValue = it.nextByte(det); if (firstByte < 0) { // Ran off the end of the input data it.done = true; break buildChar; } if (firstByte <= 0x80) { // single byte char break buildChar; } secondByte = it.nextByte(det); it.charValue = (it.charValue << 8) | secondByte; if (firstByte >= 0x81 && firstByte <= 0xFE) { // Two byte Char if ((secondByte >= 0x40 && secondByte <= 0x7E) || (secondByte >= 80 && secondByte <= 0xFE)) { break buildChar; } // Four byte char if (secondByte >= 0x30 && secondByte <= 0x39) { thirdByte = it.nextByte(det); if (thirdByte >= 0x81 && thirdByte <= 0xFE) { fourthByte = it.nextByte(det); if (fourthByte >= 0x30 && fourthByte <= 0x39) { it.charValue = (it.charValue << 16) | (thirdByte << 8) | fourthByte; break buildChar; } } } it.error = true; } } return (!it.done); } static int[] commonChars = // TODO: This set of data comes from the character frequency- // of-occurence analysis tool. The data needs to be moved // into a resource and loaded from there. {0xa1a1, 0xa1a2, 0xa1a3, 0xa1a4, 0xa1b0, 0xa1b1, 0xa1f1, 0xa1f3, 0xa3a1, 0xa3ac, 0xa3ba, 0xb1a8, 0xb1b8, 0xb1be, 0xb2bb, 0xb3c9, 0xb3f6, 0xb4f3, 0xb5bd, 0xb5c4, 0xb5e3, 0xb6af, 0xb6d4, 0xb6e0, 0xb7a2, 0xb7a8, 0xb7bd, 0xb7d6, 0xb7dd, 0xb8b4, 0xb8df, 0xb8f6, 0xb9ab, 0xb9c9, 0xb9d8, 0xb9fa, 0xb9fd, 0xbacd, 0xbba7, 0xbbd6, 0xbbe1, 0xbbfa, 0xbcbc, 0xbcdb, 0xbcfe, 0xbdcc, 0xbecd, 0xbedd, 0xbfb4, 0xbfc6, 0xbfc9, 0xc0b4, 0xc0ed, 0xc1cb, 0xc2db, 0xc3c7, 0xc4dc, 0xc4ea, 0xc5cc, 0xc6f7, 0xc7f8, 0xc8ab, 0xc8cb, 0xc8d5, 0xc8e7, 0xc9cf, 0xc9fa, 0xcab1, 0xcab5, 0xcac7, 0xcad0, 0xcad6, 0xcaf5, 0xcafd, 0xccec, 0xcdf8, 0xceaa, 0xcec4, 0xced2, 0xcee5, 0xcfb5, 0xcfc2, 0xcfd6, 0xd0c2, 0xd0c5, 0xd0d0, 0xd0d4, 0xd1a7, 0xd2aa, 0xd2b2, 0xd2b5, 0xd2bb, 0xd2d4, 0xd3c3, 0xd3d0, 0xd3fd, 0xd4c2, 0xd4da, 0xd5e2, 0xd6d0}; @Override String getName() { return "GB18030"; } @Override CharsetMatch match(CharsetDetector det) { int confidence = match(det, commonChars); return confidence == 0 ? null : new CharsetMatch(det, this, confidence); } @Override public String getLanguage() { return "zh"; } } } ================================================ FILE: app/src/main/java/io/legado/app/lib/icu4j/CharsetRecog_sbcs.java ================================================ // © 2016 and later: Unicode, Inc. and others. // License & terms of use: http://www.unicode.org/copyright.html /* **************************************************************************** * Copyright (C) 2005-2013, International Business Machines Corporation and * * others. All Rights Reserved. * ************************************************************************** * * */ package io.legado.app.lib.icu4j; /** * This class recognizes single-byte encodings. Because the encoding scheme is so * simple, language statistics are used to do the matching. */ abstract class CharsetRecog_sbcs extends CharsetRecognizer { /* (non-Javadoc) * @see com.ibm.icu.text.CharsetRecognizer#getName() */ @Override abstract String getName(); static class NGramParser { // private static final int N_GRAM_SIZE = 3; private static final int N_GRAM_MASK = 0xFFFFFF; protected int byteIndex = 0; private int ngram = 0; private final int[] ngramList; protected byte[] byteMap; private int ngramCount; private int hitCount; protected byte spaceChar; public NGramParser(int[] theNgramList, byte[] theByteMap) { ngramList = theNgramList; byteMap = theByteMap; ngram = 0; ngramCount = hitCount = 0; } /* * Binary search for value in table, which must have exactly 64 entries. */ private static int search(int[] table, int value) { int index = 0; if (table[index + 32] <= value) { index += 32; } if (table[index + 16] <= value) { index += 16; } if (table[index + 8] <= value) { index += 8; } if (table[index + 4] <= value) { index += 4; } if (table[index + 2] <= value) { index += 2; } if (table[index + 1] <= value) { index += 1; } if (table[index] > value) { index -= 1; } if (index < 0 || table[index] != value) { return -1; } return index; } private void lookup(int thisNgram) { ngramCount += 1; if (search(ngramList, thisNgram) >= 0) { hitCount += 1; } } protected void addByte(int b) { ngram = ((ngram << 8) + (b & 0xFF)) & N_GRAM_MASK; lookup(ngram); } private int nextByte(CharsetDetector det) { if (byteIndex >= det.fInputLen) { return -1; } return det.fInputBytes[byteIndex++] & 0xFF; } protected void parseCharacters(CharsetDetector det) { int b; boolean ignoreSpace = false; while ((b = nextByte(det)) >= 0) { byte mb = byteMap[b]; // TODO: 0x20 might not be a space in all character sets... if (mb != 0) { if (!(mb == spaceChar && ignoreSpace)) { addByte(mb); } ignoreSpace = (mb == spaceChar); } } } public int parse(CharsetDetector det) { return parse(det, (byte) 0x20); } public int parse(CharsetDetector det, byte spaceCh) { this.spaceChar = spaceCh; parseCharacters(det); // TODO: Is this OK? The buffer could have ended in the middle of a word... addByte(spaceChar); double rawPercent = (double) hitCount / (double) ngramCount; // if (rawPercent <= 2.0) { // return 0; // } // TODO - This is a bit of a hack to take care of a case // were we were getting a confidence of 135... if (rawPercent > 0.33) { return 98; } return (int) (rawPercent * 300.0); } } static class NGramParser_IBM420 extends NGramParser { private byte alef = 0x00; protected static byte[] unshapeMap = { /* -0 -1 -2 -3 -4 -5 -6 -7 -8 -9 -A -B -C -D -E -F */ /* 0- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, /* 1- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, /* 2- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, /* 3- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, /* 4- */ (byte) 0x40, (byte) 0x40, (byte) 0x42, (byte) 0x42, (byte) 0x44, (byte) 0x45, (byte) 0x46, (byte) 0x47, (byte) 0x47, (byte) 0x49, (byte) 0x4A, (byte) 0x4B, (byte) 0x4C, (byte) 0x4D, (byte) 0x4E, (byte) 0x4F, /* 5- */ (byte) 0x50, (byte) 0x49, (byte) 0x52, (byte) 0x53, (byte) 0x54, (byte) 0x55, (byte) 0x56, (byte) 0x56, (byte) 0x58, (byte) 0x58, (byte) 0x5A, (byte) 0x5B, (byte) 0x5C, (byte) 0x5D, (byte) 0x5E, (byte) 0x5F, /* 6- */ (byte) 0x60, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x63, (byte) 0x65, (byte) 0x65, (byte) 0x67, (byte) 0x67, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, /* 7- */ (byte) 0x69, (byte) 0x71, (byte) 0x71, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, (byte) 0x77, (byte) 0x79, (byte) 0x7A, (byte) 0x7B, (byte) 0x7C, (byte) 0x7D, (byte) 0x7E, (byte) 0x7F, /* 8- */ (byte) 0x80, (byte) 0x81, (byte) 0x82, (byte) 0x83, (byte) 0x84, (byte) 0x85, (byte) 0x86, (byte) 0x87, (byte) 0x88, (byte) 0x89, (byte) 0x80, (byte) 0x8B, (byte) 0x8B, (byte) 0x8D, (byte) 0x8D, (byte) 0x8F, /* 9- */ (byte) 0x90, (byte) 0x91, (byte) 0x92, (byte) 0x93, (byte) 0x94, (byte) 0x95, (byte) 0x96, (byte) 0x97, (byte) 0x98, (byte) 0x99, (byte) 0x9A, (byte) 0x9A, (byte) 0x9A, (byte) 0x9A, (byte) 0x9E, (byte) 0x9E, /* A- */ (byte) 0x9E, (byte) 0xA1, (byte) 0xA2, (byte) 0xA3, (byte) 0xA4, (byte) 0xA5, (byte) 0xA6, (byte) 0xA7, (byte) 0xA8, (byte) 0xA9, (byte) 0x9E, (byte) 0xAB, (byte) 0xAB, (byte) 0xAD, (byte) 0xAD, (byte) 0xAF, /* B- */ (byte) 0xAF, (byte) 0xB1, (byte) 0xB2, (byte) 0xB3, (byte) 0xB4, (byte) 0xB5, (byte) 0xB6, (byte) 0xB7, (byte) 0xB8, (byte) 0xB9, (byte) 0xB1, (byte) 0xBB, (byte) 0xBB, (byte) 0xBD, (byte) 0xBD, (byte) 0xBF, /* C- */ (byte) 0xC0, (byte) 0xC1, (byte) 0xC2, (byte) 0xC3, (byte) 0xC4, (byte) 0xC5, (byte) 0xC6, (byte) 0xC7, (byte) 0xC8, (byte) 0xC9, (byte) 0xCA, (byte) 0xBF, (byte) 0xCC, (byte) 0xBF, (byte) 0xCE, (byte) 0xCF, /* D- */ (byte) 0xD0, (byte) 0xD1, (byte) 0xD2, (byte) 0xD3, (byte) 0xD4, (byte) 0xD5, (byte) 0xD6, (byte) 0xD7, (byte) 0xD8, (byte) 0xD9, (byte) 0xDA, (byte) 0xDA, (byte) 0xDC, (byte) 0xDC, (byte) 0xDC, (byte) 0xDF, /* E- */ (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, /* F- */ (byte) 0xF0, (byte) 0xF1, (byte) 0xF2, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0xF7, (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0xFB, (byte) 0xFC, (byte) 0xFD, (byte) 0xFE, (byte) 0xFF, }; public NGramParser_IBM420(int[] theNgramList, byte[] theByteMap) { super(theNgramList, theByteMap); } private byte isLamAlef(byte b) { if (b == (byte) 0xb2 || b == (byte) 0xb3) { return (byte) 0x47; } else if (b == (byte) 0xb4 || b == (byte) 0xb5) { return (byte) 0x49; } else if (b == (byte) 0xb8 || b == (byte) 0xb9) { return (byte) 0x56; } else return (byte) 0x00; } /* * Arabic shaping needs to be done manually. Cannot call ArabicShaping class * because CharsetDetector is dealing with bytes not Unicode code points. We could * convert the bytes to Unicode code points but that would leave us dependent * on CharsetICU which we try to avoid. IBM420 converter amongst different versions * of JDK can produce different results and therefore is also avoided. */ private int nextByte(CharsetDetector det) { if (byteIndex >= det.fInputLen || det.fInputBytes[byteIndex] == 0) { return -1; } int next; alef = isLamAlef(det.fInputBytes[byteIndex]); if (alef != (byte) 0x00) next = 0xB1 & 0xFF; else next = unshapeMap[det.fInputBytes[byteIndex] & 0xFF] & 0xFF; byteIndex++; return next; } @Override protected void parseCharacters(CharsetDetector det) { int b; boolean ignoreSpace = false; while ((b = nextByte(det)) >= 0) { byte mb = byteMap[b]; // TODO: 0x20 might not be a space in all character sets... if (mb != 0) { if (!(mb == spaceChar && ignoreSpace)) { addByte(mb); } ignoreSpace = (mb == spaceChar); } if (alef != (byte) 0x00) { mb = byteMap[alef & 0xFF]; // TODO: 0x20 might not be a space in all character sets... if (mb != 0) { if (!(mb == spaceChar && ignoreSpace)) { addByte(mb); } ignoreSpace = (mb == spaceChar); } } } } } int match(CharsetDetector det, int[] ngrams, byte[] byteMap) { return match(det, ngrams, byteMap, (byte) 0x20); } int match(CharsetDetector det, int[] ngrams, byte[] byteMap, byte spaceChar) { NGramParser parser = new NGramParser(ngrams, byteMap); return parser.parse(det, spaceChar); } @SuppressWarnings("SameParameterValue") int matchIBM420(CharsetDetector det, int[] ngrams, byte[] byteMap, byte spaceChar) { NGramParser_IBM420 parser = new NGramParser_IBM420(ngrams, byteMap); return parser.parse(det, spaceChar); } static class NGramsPlusLang { int[] fNGrams; String fLang; NGramsPlusLang(String la, int[] ng) { fLang = la; fNGrams = ng; } } static class CharsetRecog_8859_1 extends CharsetRecog_sbcs { protected static byte[] byteMap = { (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x00, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xAA, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xB5, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xBA, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, (byte) 0xF0, (byte) 0xF1, (byte) 0xF2, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0x20, (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0xFB, (byte) 0xFC, (byte) 0xFD, (byte) 0xFE, (byte) 0xDF, (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, (byte) 0xF0, (byte) 0xF1, (byte) 0xF2, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0x20, (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0xFB, (byte) 0xFC, (byte) 0xFD, (byte) 0xFE, (byte) 0xFF, }; private static final NGramsPlusLang[] ngrams_8859_1 = new NGramsPlusLang[]{ new NGramsPlusLang( "da", new int[]{ 0x206166, 0x206174, 0x206465, 0x20656E, 0x206572, 0x20666F, 0x206861, 0x206920, 0x206D65, 0x206F67, 0x2070E5, 0x207369, 0x207374, 0x207469, 0x207669, 0x616620, 0x616E20, 0x616E64, 0x617220, 0x617420, 0x646520, 0x64656E, 0x646572, 0x646574, 0x652073, 0x656420, 0x656465, 0x656E20, 0x656E64, 0x657220, 0x657265, 0x657320, 0x657420, 0x666F72, 0x676520, 0x67656E, 0x676572, 0x696765, 0x696C20, 0x696E67, 0x6B6520, 0x6B6B65, 0x6C6572, 0x6C6967, 0x6C6C65, 0x6D6564, 0x6E6465, 0x6E6520, 0x6E6720, 0x6E6765, 0x6F6720, 0x6F6D20, 0x6F7220, 0x70E520, 0x722064, 0x722065, 0x722073, 0x726520, 0x737465, 0x742073, 0x746520, 0x746572, 0x74696C, 0x766572, }), new NGramsPlusLang( "de", new int[]{ 0x20616E, 0x206175, 0x206265, 0x206461, 0x206465, 0x206469, 0x206569, 0x206765, 0x206861, 0x20696E, 0x206D69, 0x207363, 0x207365, 0x20756E, 0x207665, 0x20766F, 0x207765, 0x207A75, 0x626572, 0x636820, 0x636865, 0x636874, 0x646173, 0x64656E, 0x646572, 0x646965, 0x652064, 0x652073, 0x65696E, 0x656974, 0x656E20, 0x657220, 0x657320, 0x67656E, 0x68656E, 0x687420, 0x696368, 0x696520, 0x696E20, 0x696E65, 0x697420, 0x6C6963, 0x6C6C65, 0x6E2061, 0x6E2064, 0x6E2073, 0x6E6420, 0x6E6465, 0x6E6520, 0x6E6720, 0x6E6765, 0x6E7465, 0x722064, 0x726465, 0x726569, 0x736368, 0x737465, 0x742064, 0x746520, 0x74656E, 0x746572, 0x756E64, 0x756E67, 0x766572, }), new NGramsPlusLang( "en", new int[]{ 0x206120, 0x20616E, 0x206265, 0x20636F, 0x20666F, 0x206861, 0x206865, 0x20696E, 0x206D61, 0x206F66, 0x207072, 0x207265, 0x207361, 0x207374, 0x207468, 0x20746F, 0x207768, 0x616964, 0x616C20, 0x616E20, 0x616E64, 0x617320, 0x617420, 0x617465, 0x617469, 0x642061, 0x642074, 0x652061, 0x652073, 0x652074, 0x656420, 0x656E74, 0x657220, 0x657320, 0x666F72, 0x686174, 0x686520, 0x686572, 0x696420, 0x696E20, 0x696E67, 0x696F6E, 0x697320, 0x6E2061, 0x6E2074, 0x6E6420, 0x6E6720, 0x6E7420, 0x6F6620, 0x6F6E20, 0x6F7220, 0x726520, 0x727320, 0x732061, 0x732074, 0x736169, 0x737420, 0x742074, 0x746572, 0x746861, 0x746865, 0x74696F, 0x746F20, 0x747320, }), new NGramsPlusLang( "es", new int[]{ 0x206120, 0x206361, 0x20636F, 0x206465, 0x20656C, 0x20656E, 0x206573, 0x20696E, 0x206C61, 0x206C6F, 0x207061, 0x20706F, 0x207072, 0x207175, 0x207265, 0x207365, 0x20756E, 0x207920, 0x612063, 0x612064, 0x612065, 0x61206C, 0x612070, 0x616369, 0x61646F, 0x616C20, 0x617220, 0x617320, 0x6369F3, 0x636F6E, 0x646520, 0x64656C, 0x646F20, 0x652064, 0x652065, 0x65206C, 0x656C20, 0x656E20, 0x656E74, 0x657320, 0x657374, 0x69656E, 0x69F36E, 0x6C6120, 0x6C6F73, 0x6E2065, 0x6E7465, 0x6F2064, 0x6F2065, 0x6F6E20, 0x6F7220, 0x6F7320, 0x706172, 0x717565, 0x726120, 0x726573, 0x732064, 0x732065, 0x732070, 0x736520, 0x746520, 0x746F20, 0x756520, 0xF36E20, }), new NGramsPlusLang( "fr", new int[]{ 0x206175, 0x20636F, 0x206461, 0x206465, 0x206475, 0x20656E, 0x206574, 0x206C61, 0x206C65, 0x207061, 0x20706F, 0x207072, 0x207175, 0x207365, 0x20736F, 0x20756E, 0x20E020, 0x616E74, 0x617469, 0x636520, 0x636F6E, 0x646520, 0x646573, 0x647520, 0x652061, 0x652063, 0x652064, 0x652065, 0x65206C, 0x652070, 0x652073, 0x656E20, 0x656E74, 0x657220, 0x657320, 0x657420, 0x657572, 0x696F6E, 0x697320, 0x697420, 0x6C6120, 0x6C6520, 0x6C6573, 0x6D656E, 0x6E2064, 0x6E6520, 0x6E7320, 0x6E7420, 0x6F6E20, 0x6F6E74, 0x6F7572, 0x717565, 0x72206C, 0x726520, 0x732061, 0x732064, 0x732065, 0x73206C, 0x732070, 0x742064, 0x746520, 0x74696F, 0x756520, 0x757220, }), new NGramsPlusLang( "it", new int[]{ 0x20616C, 0x206368, 0x20636F, 0x206465, 0x206469, 0x206520, 0x20696C, 0x20696E, 0x206C61, 0x207065, 0x207072, 0x20756E, 0x612063, 0x612064, 0x612070, 0x612073, 0x61746F, 0x636865, 0x636F6E, 0x64656C, 0x646920, 0x652061, 0x652063, 0x652064, 0x652069, 0x65206C, 0x652070, 0x652073, 0x656C20, 0x656C6C, 0x656E74, 0x657220, 0x686520, 0x692061, 0x692063, 0x692064, 0x692073, 0x696120, 0x696C20, 0x696E20, 0x696F6E, 0x6C6120, 0x6C6520, 0x6C6920, 0x6C6C61, 0x6E6520, 0x6E6920, 0x6E6F20, 0x6E7465, 0x6F2061, 0x6F2064, 0x6F2069, 0x6F2073, 0x6F6E20, 0x6F6E65, 0x706572, 0x726120, 0x726520, 0x736920, 0x746120, 0x746520, 0x746920, 0x746F20, 0x7A696F, }), new NGramsPlusLang( "nl", new int[]{ 0x20616C, 0x206265, 0x206461, 0x206465, 0x206469, 0x206565, 0x20656E, 0x206765, 0x206865, 0x20696E, 0x206D61, 0x206D65, 0x206F70, 0x207465, 0x207661, 0x207665, 0x20766F, 0x207765, 0x207A69, 0x61616E, 0x616172, 0x616E20, 0x616E64, 0x617220, 0x617420, 0x636874, 0x646520, 0x64656E, 0x646572, 0x652062, 0x652076, 0x65656E, 0x656572, 0x656E20, 0x657220, 0x657273, 0x657420, 0x67656E, 0x686574, 0x696520, 0x696E20, 0x696E67, 0x697320, 0x6E2062, 0x6E2064, 0x6E2065, 0x6E2068, 0x6E206F, 0x6E2076, 0x6E6465, 0x6E6720, 0x6F6E64, 0x6F6F72, 0x6F7020, 0x6F7220, 0x736368, 0x737465, 0x742064, 0x746520, 0x74656E, 0x746572, 0x76616E, 0x766572, 0x766F6F, }), new NGramsPlusLang( "no", new int[]{ 0x206174, 0x206176, 0x206465, 0x20656E, 0x206572, 0x20666F, 0x206861, 0x206920, 0x206D65, 0x206F67, 0x2070E5, 0x207365, 0x20736B, 0x20736F, 0x207374, 0x207469, 0x207669, 0x20E520, 0x616E64, 0x617220, 0x617420, 0x646520, 0x64656E, 0x646574, 0x652073, 0x656420, 0x656E20, 0x656E65, 0x657220, 0x657265, 0x657420, 0x657474, 0x666F72, 0x67656E, 0x696B6B, 0x696C20, 0x696E67, 0x6B6520, 0x6B6B65, 0x6C6520, 0x6C6C65, 0x6D6564, 0x6D656E, 0x6E2073, 0x6E6520, 0x6E6720, 0x6E6765, 0x6E6E65, 0x6F6720, 0x6F6D20, 0x6F7220, 0x70E520, 0x722073, 0x726520, 0x736F6D, 0x737465, 0x742073, 0x746520, 0x74656E, 0x746572, 0x74696C, 0x747420, 0x747465, 0x766572, }), new NGramsPlusLang( "pt", new int[]{ 0x206120, 0x20636F, 0x206461, 0x206465, 0x20646F, 0x206520, 0x206573, 0x206D61, 0x206E6F, 0x206F20, 0x207061, 0x20706F, 0x207072, 0x207175, 0x207265, 0x207365, 0x20756D, 0x612061, 0x612063, 0x612064, 0x612070, 0x616465, 0x61646F, 0x616C20, 0x617220, 0x617261, 0x617320, 0x636F6D, 0x636F6E, 0x646120, 0x646520, 0x646F20, 0x646F73, 0x652061, 0x652064, 0x656D20, 0x656E74, 0x657320, 0x657374, 0x696120, 0x696361, 0x6D656E, 0x6E7465, 0x6E746F, 0x6F2061, 0x6F2063, 0x6F2064, 0x6F2065, 0x6F2070, 0x6F7320, 0x706172, 0x717565, 0x726120, 0x726573, 0x732061, 0x732064, 0x732065, 0x732070, 0x737461, 0x746520, 0x746F20, 0x756520, 0xE36F20, 0xE7E36F, }), new NGramsPlusLang( "sv", new int[]{ 0x206174, 0x206176, 0x206465, 0x20656E, 0x2066F6, 0x206861, 0x206920, 0x20696E, 0x206B6F, 0x206D65, 0x206F63, 0x2070E5, 0x20736B, 0x20736F, 0x207374, 0x207469, 0x207661, 0x207669, 0x20E472, 0x616465, 0x616E20, 0x616E64, 0x617220, 0x617474, 0x636820, 0x646520, 0x64656E, 0x646572, 0x646574, 0x656420, 0x656E20, 0x657220, 0x657420, 0x66F672, 0x67656E, 0x696C6C, 0x696E67, 0x6B6120, 0x6C6C20, 0x6D6564, 0x6E2073, 0x6E6120, 0x6E6465, 0x6E6720, 0x6E6765, 0x6E696E, 0x6F6368, 0x6F6D20, 0x6F6E20, 0x70E520, 0x722061, 0x722073, 0x726120, 0x736B61, 0x736F6D, 0x742073, 0x746120, 0x746520, 0x746572, 0x74696C, 0x747420, 0x766172, 0xE47220, 0xF67220, }), }; @Override public CharsetMatch match(CharsetDetector det) { String name = det.fC1Bytes ? "windows-1252" : "ISO-8859-1"; int bestConfidenceSoFar = -1; String lang = null; for (NGramsPlusLang ngl : ngrams_8859_1) { int confidence = match(det, ngl.fNGrams, byteMap); if (confidence > bestConfidenceSoFar) { bestConfidenceSoFar = confidence; lang = ngl.fLang; } } return bestConfidenceSoFar <= 0 ? null : new CharsetMatch(det, this, bestConfidenceSoFar, name, lang); } @Override public String getName() { return "ISO-8859-1"; } } static class CharsetRecog_8859_2 extends CharsetRecog_sbcs { protected static byte[] byteMap = { (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x00, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xB1, (byte) 0x20, (byte) 0xB3, (byte) 0x20, (byte) 0xB5, (byte) 0xB6, (byte) 0x20, (byte) 0x20, (byte) 0xB9, (byte) 0xBA, (byte) 0xBB, (byte) 0xBC, (byte) 0x20, (byte) 0xBE, (byte) 0xBF, (byte) 0x20, (byte) 0xB1, (byte) 0x20, (byte) 0xB3, (byte) 0x20, (byte) 0xB5, (byte) 0xB6, (byte) 0xB7, (byte) 0x20, (byte) 0xB9, (byte) 0xBA, (byte) 0xBB, (byte) 0xBC, (byte) 0x20, (byte) 0xBE, (byte) 0xBF, (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, (byte) 0xF0, (byte) 0xF1, (byte) 0xF2, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0x20, (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0xFB, (byte) 0xFC, (byte) 0xFD, (byte) 0xFE, (byte) 0xDF, (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, (byte) 0xF0, (byte) 0xF1, (byte) 0xF2, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0x20, (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0xFB, (byte) 0xFC, (byte) 0xFD, (byte) 0xFE, (byte) 0x20, }; private static final NGramsPlusLang[] ngrams_8859_2 = new NGramsPlusLang[]{ new NGramsPlusLang( "cs", new int[]{ 0x206120, 0x206279, 0x20646F, 0x206A65, 0x206E61, 0x206E65, 0x206F20, 0x206F64, 0x20706F, 0x207072, 0x2070F8, 0x20726F, 0x207365, 0x20736F, 0x207374, 0x20746F, 0x207620, 0x207679, 0x207A61, 0x612070, 0x636520, 0x636820, 0x652070, 0x652073, 0x652076, 0x656D20, 0x656EED, 0x686F20, 0x686F64, 0x697374, 0x6A6520, 0x6B7465, 0x6C6520, 0x6C6920, 0x6E6120, 0x6EE920, 0x6EEC20, 0x6EED20, 0x6F2070, 0x6F646E, 0x6F6A69, 0x6F7374, 0x6F7520, 0x6F7661, 0x706F64, 0x706F6A, 0x70726F, 0x70F865, 0x736520, 0x736F75, 0x737461, 0x737469, 0x73746E, 0x746572, 0x746EED, 0x746F20, 0x752070, 0xBE6520, 0xE16EED, 0xE9686F, 0xED2070, 0xED2073, 0xED6D20, 0xF86564, }), new NGramsPlusLang( "hu", new int[]{ 0x206120, 0x20617A, 0x206265, 0x206567, 0x20656C, 0x206665, 0x206861, 0x20686F, 0x206973, 0x206B65, 0x206B69, 0x206BF6, 0x206C65, 0x206D61, 0x206D65, 0x206D69, 0x206E65, 0x20737A, 0x207465, 0x20E973, 0x612061, 0x61206B, 0x61206D, 0x612073, 0x616B20, 0x616E20, 0x617A20, 0x62616E, 0x62656E, 0x656779, 0x656B20, 0x656C20, 0x656C65, 0x656D20, 0x656E20, 0x657265, 0x657420, 0x657465, 0x657474, 0x677920, 0x686F67, 0x696E74, 0x697320, 0x6B2061, 0x6BF67A, 0x6D6567, 0x6D696E, 0x6E2061, 0x6E616B, 0x6E656B, 0x6E656D, 0x6E7420, 0x6F6779, 0x732061, 0x737A65, 0x737A74, 0x737AE1, 0x73E967, 0x742061, 0x747420, 0x74E173, 0x7A6572, 0xE16E20, 0xE97320, }), new NGramsPlusLang( "pl", new int[]{ 0x20637A, 0x20646F, 0x206920, 0x206A65, 0x206B6F, 0x206D61, 0x206D69, 0x206E61, 0x206E69, 0x206F64, 0x20706F, 0x207072, 0x207369, 0x207720, 0x207769, 0x207779, 0x207A20, 0x207A61, 0x612070, 0x612077, 0x616E69, 0x636820, 0x637A65, 0x637A79, 0x646F20, 0x647A69, 0x652070, 0x652073, 0x652077, 0x65207A, 0x65676F, 0x656A20, 0x656D20, 0x656E69, 0x676F20, 0x696120, 0x696520, 0x69656A, 0x6B6120, 0x6B6920, 0x6B6965, 0x6D6965, 0x6E6120, 0x6E6961, 0x6E6965, 0x6F2070, 0x6F7761, 0x6F7769, 0x706F6C, 0x707261, 0x70726F, 0x70727A, 0x727A65, 0x727A79, 0x7369EA, 0x736B69, 0x737461, 0x776965, 0x796368, 0x796D20, 0x7A6520, 0x7A6965, 0x7A7920, 0xF37720, }), new NGramsPlusLang( "ro", new int[]{ 0x206120, 0x206163, 0x206361, 0x206365, 0x20636F, 0x206375, 0x206465, 0x206469, 0x206C61, 0x206D61, 0x207065, 0x207072, 0x207365, 0x2073E3, 0x20756E, 0x20BA69, 0x20EE6E, 0x612063, 0x612064, 0x617265, 0x617420, 0x617465, 0x617520, 0x636172, 0x636F6E, 0x637520, 0x63E320, 0x646520, 0x652061, 0x652063, 0x652064, 0x652070, 0x652073, 0x656120, 0x656920, 0x656C65, 0x656E74, 0x657374, 0x692061, 0x692063, 0x692064, 0x692070, 0x696520, 0x696920, 0x696E20, 0x6C6120, 0x6C6520, 0x6C6F72, 0x6C7569, 0x6E6520, 0x6E7472, 0x6F7220, 0x70656E, 0x726520, 0x726561, 0x727520, 0x73E320, 0x746520, 0x747275, 0x74E320, 0x756920, 0x756C20, 0xBA6920, 0xEE6E20, }) }; @Override public CharsetMatch match(CharsetDetector det) { String name = det.fC1Bytes ? "windows-1250" : "ISO-8859-2"; int bestConfidenceSoFar = -1; String lang = null; for (NGramsPlusLang ngl : ngrams_8859_2) { int confidence = match(det, ngl.fNGrams, byteMap); if (confidence > bestConfidenceSoFar) { bestConfidenceSoFar = confidence; lang = ngl.fLang; } } return bestConfidenceSoFar <= 0 ? null : new CharsetMatch(det, this, bestConfidenceSoFar, name, lang); } @Override public String getName() { return "ISO-8859-2"; } } abstract static class CharsetRecog_8859_5 extends CharsetRecog_sbcs { protected static byte[] byteMap = { (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x00, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xF1, (byte) 0xF2, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0xF7, (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0xFB, (byte) 0xFC, (byte) 0x20, (byte) 0xFE, (byte) 0xFF, (byte) 0xD0, (byte) 0xD1, (byte) 0xD2, (byte) 0xD3, (byte) 0xD4, (byte) 0xD5, (byte) 0xD6, (byte) 0xD7, (byte) 0xD8, (byte) 0xD9, (byte) 0xDA, (byte) 0xDB, (byte) 0xDC, (byte) 0xDD, (byte) 0xDE, (byte) 0xDF, (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, (byte) 0xD0, (byte) 0xD1, (byte) 0xD2, (byte) 0xD3, (byte) 0xD4, (byte) 0xD5, (byte) 0xD6, (byte) 0xD7, (byte) 0xD8, (byte) 0xD9, (byte) 0xDA, (byte) 0xDB, (byte) 0xDC, (byte) 0xDD, (byte) 0xDE, (byte) 0xDF, (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, (byte) 0x20, (byte) 0xF1, (byte) 0xF2, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0xF7, (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0xFB, (byte) 0xFC, (byte) 0x20, (byte) 0xFE, (byte) 0xFF, }; @Override public String getName() { return "ISO-8859-5"; } } static class CharsetRecog_8859_5_ru extends CharsetRecog_8859_5 { private static final int[] ngrams = { 0x20D220, 0x20D2DE, 0x20D4DE, 0x20D7D0, 0x20D820, 0x20DAD0, 0x20DADE, 0x20DDD0, 0x20DDD5, 0x20DED1, 0x20DFDE, 0x20DFE0, 0x20E0D0, 0x20E1DE, 0x20E1E2, 0x20E2DE, 0x20E7E2, 0x20EDE2, 0xD0DDD8, 0xD0E2EC, 0xD3DE20, 0xD5DBEC, 0xD5DDD8, 0xD5E1E2, 0xD5E220, 0xD820DF, 0xD8D520, 0xD8D820, 0xD8EF20, 0xDBD5DD, 0xDBD820, 0xDBECDD, 0xDDD020, 0xDDD520, 0xDDD8D5, 0xDDD8EF, 0xDDDE20, 0xDDDED2, 0xDE20D2, 0xDE20DF, 0xDE20E1, 0xDED220, 0xDED2D0, 0xDED3DE, 0xDED920, 0xDEDBEC, 0xDEDC20, 0xDEE1E2, 0xDFDEDB, 0xDFE0D5, 0xDFE0D8, 0xDFE0DE, 0xE0D0D2, 0xE0D5D4, 0xE1E2D0, 0xE1E2D2, 0xE1E2D8, 0xE1EF20, 0xE2D5DB, 0xE2DE20, 0xE2DEE0, 0xE2EC20, 0xE7E2DE, 0xEBE520, }; @Override public String getLanguage() { return "ru"; } @Override public CharsetMatch match(CharsetDetector det) { int confidence = match(det, ngrams, byteMap); return confidence == 0 ? null : new CharsetMatch(det, this, confidence); } } abstract static class CharsetRecog_8859_6 extends CharsetRecog_sbcs { protected static byte[] byteMap = { (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x00, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xC1, (byte) 0xC2, (byte) 0xC3, (byte) 0xC4, (byte) 0xC5, (byte) 0xC6, (byte) 0xC7, (byte) 0xC8, (byte) 0xC9, (byte) 0xCA, (byte) 0xCB, (byte) 0xCC, (byte) 0xCD, (byte) 0xCE, (byte) 0xCF, (byte) 0xD0, (byte) 0xD1, (byte) 0xD2, (byte) 0xD3, (byte) 0xD4, (byte) 0xD5, (byte) 0xD6, (byte) 0xD7, (byte) 0xD8, (byte) 0xD9, (byte) 0xDA, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, }; @Override public String getName() { return "ISO-8859-6"; } } static class CharsetRecog_8859_6_ar extends CharsetRecog_8859_6 { private static final int[] ngrams = { 0x20C7E4, 0x20C7E6, 0x20C8C7, 0x20D9E4, 0x20E1EA, 0x20E4E4, 0x20E5E6, 0x20E8C7, 0xC720C7, 0xC7C120, 0xC7CA20, 0xC7D120, 0xC7E420, 0xC7E4C3, 0xC7E4C7, 0xC7E4C8, 0xC7E4CA, 0xC7E4CC, 0xC7E4CD, 0xC7E4CF, 0xC7E4D3, 0xC7E4D9, 0xC7E4E2, 0xC7E4E5, 0xC7E4E8, 0xC7E4EA, 0xC7E520, 0xC7E620, 0xC7E6CA, 0xC820C7, 0xC920C7, 0xC920E1, 0xC920E4, 0xC920E5, 0xC920E8, 0xCA20C7, 0xCF20C7, 0xCFC920, 0xD120C7, 0xD1C920, 0xD320C7, 0xD920C7, 0xD9E4E9, 0xE1EA20, 0xE420C7, 0xE4C920, 0xE4E920, 0xE4EA20, 0xE520C7, 0xE5C720, 0xE5C920, 0xE5E620, 0xE620C7, 0xE720C7, 0xE7C720, 0xE8C7E4, 0xE8E620, 0xE920C7, 0xEA20C7, 0xEA20E5, 0xEA20E8, 0xEAC920, 0xEAD120, 0xEAE620, }; @Override public String getLanguage() { return "ar"; } @Override public CharsetMatch match(CharsetDetector det) { int confidence = match(det, ngrams, byteMap); return confidence == 0 ? null : new CharsetMatch(det, this, confidence); } } abstract static class CharsetRecog_8859_7 extends CharsetRecog_sbcs { protected static byte[] byteMap = { (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x00, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xA1, (byte) 0xA2, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xDC, (byte) 0x20, (byte) 0xDD, (byte) 0xDE, (byte) 0xDF, (byte) 0x20, (byte) 0xFC, (byte) 0x20, (byte) 0xFD, (byte) 0xFE, (byte) 0xC0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, (byte) 0xF0, (byte) 0xF1, (byte) 0x20, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0xF7, (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0xFB, (byte) 0xDC, (byte) 0xDD, (byte) 0xDE, (byte) 0xDF, (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, (byte) 0xF0, (byte) 0xF1, (byte) 0xF2, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0xF7, (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0xFB, (byte) 0xFC, (byte) 0xFD, (byte) 0xFE, (byte) 0x20, }; @Override public String getName() { return "ISO-8859-7"; } } static class CharsetRecog_8859_7_el extends CharsetRecog_8859_7 { private static final int[] ngrams = { 0x20E1ED, 0x20E1F0, 0x20E3E9, 0x20E4E9, 0x20E5F0, 0x20E720, 0x20EAE1, 0x20ECE5, 0x20EDE1, 0x20EF20, 0x20F0E1, 0x20F0EF, 0x20F0F1, 0x20F3F4, 0x20F3F5, 0x20F4E7, 0x20F4EF, 0xDFE120, 0xE120E1, 0xE120F4, 0xE1E920, 0xE1ED20, 0xE1F0FC, 0xE1F220, 0xE3E9E1, 0xE5E920, 0xE5F220, 0xE720F4, 0xE7ED20, 0xE7F220, 0xE920F4, 0xE9E120, 0xE9EADE, 0xE9F220, 0xEAE1E9, 0xEAE1F4, 0xECE520, 0xED20E1, 0xED20E5, 0xED20F0, 0xEDE120, 0xEFF220, 0xEFF520, 0xF0EFF5, 0xF0F1EF, 0xF0FC20, 0xF220E1, 0xF220E5, 0xF220EA, 0xF220F0, 0xF220F4, 0xF3E520, 0xF3E720, 0xF3F4EF, 0xF4E120, 0xF4E1E9, 0xF4E7ED, 0xF4E7F2, 0xF4E9EA, 0xF4EF20, 0xF4EFF5, 0xF4F9ED, 0xF9ED20, 0xFEED20, }; @Override public String getLanguage() { return "el"; } @Override public CharsetMatch match(CharsetDetector det) { String name = det.fC1Bytes ? "windows-1253" : "ISO-8859-7"; int confidence = match(det, ngrams, byteMap); return confidence == 0 ? null : new CharsetMatch(det, this, confidence, name, "el"); } } abstract static class CharsetRecog_8859_8 extends CharsetRecog_sbcs { protected static byte[] byteMap = { (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x00, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xB5, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, (byte) 0xF0, (byte) 0xF1, (byte) 0xF2, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0xF7, (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, }; @Override public String getName() { return "ISO-8859-8"; } } static class CharsetRecog_8859_8_I_he extends CharsetRecog_8859_8 { private static final int[] ngrams = { 0x20E0E5, 0x20E0E7, 0x20E0E9, 0x20E0FA, 0x20E1E9, 0x20E1EE, 0x20E4E0, 0x20E4E5, 0x20E4E9, 0x20E4EE, 0x20E4F2, 0x20E4F9, 0x20E4FA, 0x20ECE0, 0x20ECE4, 0x20EEE0, 0x20F2EC, 0x20F9EC, 0xE0FA20, 0xE420E0, 0xE420E1, 0xE420E4, 0xE420EC, 0xE420EE, 0xE420F9, 0xE4E5E0, 0xE5E020, 0xE5ED20, 0xE5EF20, 0xE5F820, 0xE5FA20, 0xE920E4, 0xE9E420, 0xE9E5FA, 0xE9E9ED, 0xE9ED20, 0xE9EF20, 0xE9F820, 0xE9FA20, 0xEC20E0, 0xEC20E4, 0xECE020, 0xECE420, 0xED20E0, 0xED20E1, 0xED20E4, 0xED20EC, 0xED20EE, 0xED20F9, 0xEEE420, 0xEF20E4, 0xF0E420, 0xF0E920, 0xF0E9ED, 0xF2EC20, 0xF820E4, 0xF8E9ED, 0xF9EC20, 0xFA20E0, 0xFA20E1, 0xFA20E4, 0xFA20EC, 0xFA20EE, 0xFA20F9, }; @Override public String getName() { return "ISO-8859-8-I"; } @Override public String getLanguage() { return "he"; } @Override public CharsetMatch match(CharsetDetector det) { String name = det.fC1Bytes ? "windows-1255" : "ISO-8859-8-I"; int confidence = match(det, ngrams, byteMap); return confidence == 0 ? null : new CharsetMatch(det, this, confidence, name, "he"); } } static class CharsetRecog_8859_8_he extends CharsetRecog_8859_8 { private static final int[] ngrams = { 0x20E0E5, 0x20E0EC, 0x20E4E9, 0x20E4EC, 0x20E4EE, 0x20E4F0, 0x20E9F0, 0x20ECF2, 0x20ECF9, 0x20EDE5, 0x20EDE9, 0x20EFE5, 0x20EFE9, 0x20F8E5, 0x20F8E9, 0x20FAE0, 0x20FAE5, 0x20FAE9, 0xE020E4, 0xE020EC, 0xE020ED, 0xE020FA, 0xE0E420, 0xE0E5E4, 0xE0EC20, 0xE0EE20, 0xE120E4, 0xE120ED, 0xE120FA, 0xE420E4, 0xE420E9, 0xE420EC, 0xE420ED, 0xE420EF, 0xE420F8, 0xE420FA, 0xE4EC20, 0xE5E020, 0xE5E420, 0xE7E020, 0xE9E020, 0xE9E120, 0xE9E420, 0xEC20E4, 0xEC20ED, 0xEC20FA, 0xECF220, 0xECF920, 0xEDE9E9, 0xEDE9F0, 0xEDE9F8, 0xEE20E4, 0xEE20ED, 0xEE20FA, 0xEEE120, 0xEEE420, 0xF2E420, 0xF920E4, 0xF920ED, 0xF920FA, 0xF9E420, 0xFAE020, 0xFAE420, 0xFAE5E9, }; @Override public String getLanguage() { return "he"; } @Override public CharsetMatch match(CharsetDetector det) { String name = det.fC1Bytes ? "windows-1255" : "ISO-8859-8"; int confidence = match(det, ngrams, byteMap); return confidence == 0 ? null : new CharsetMatch(det, this, confidence, name, "he"); } } abstract static class CharsetRecog_8859_9 extends CharsetRecog_sbcs { protected static byte[] byteMap = { (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x00, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xAA, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xB5, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xBA, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, (byte) 0xF0, (byte) 0xF1, (byte) 0xF2, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0x20, (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0xFB, (byte) 0xFC, (byte) 0x69, (byte) 0xFE, (byte) 0xDF, (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, (byte) 0xF0, (byte) 0xF1, (byte) 0xF2, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0x20, (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0xFB, (byte) 0xFC, (byte) 0xFD, (byte) 0xFE, (byte) 0xFF, }; @Override public String getName() { return "ISO-8859-9"; } } static class CharsetRecog_8859_9_tr extends CharsetRecog_8859_9 { private static final int[] ngrams = { 0x206261, 0x206269, 0x206275, 0x206461, 0x206465, 0x206765, 0x206861, 0x20696C, 0x206B61, 0x206B6F, 0x206D61, 0x206F6C, 0x207361, 0x207461, 0x207665, 0x207961, 0x612062, 0x616B20, 0x616C61, 0x616D61, 0x616E20, 0x616EFD, 0x617220, 0x617261, 0x6172FD, 0x6173FD, 0x617961, 0x626972, 0x646120, 0x646520, 0x646920, 0x652062, 0x65206B, 0x656469, 0x656E20, 0x657220, 0x657269, 0x657369, 0x696C65, 0x696E20, 0x696E69, 0x697220, 0x6C616E, 0x6C6172, 0x6C6520, 0x6C6572, 0x6E2061, 0x6E2062, 0x6E206B, 0x6E6461, 0x6E6465, 0x6E6520, 0x6E6920, 0x6E696E, 0x6EFD20, 0x72696E, 0x72FD6E, 0x766520, 0x796120, 0x796F72, 0xFD6E20, 0xFD6E64, 0xFD6EFD, 0xFDF0FD, }; @Override public String getLanguage() { return "tr"; } @Override public CharsetMatch match(CharsetDetector det) { String name = det.fC1Bytes ? "windows-1254" : "ISO-8859-9"; int confidence = match(det, ngrams, byteMap); return confidence == 0 ? null : new CharsetMatch(det, this, confidence, name, "tr"); } } static class CharsetRecog_windows_1251 extends CharsetRecog_sbcs { private static final int[] ngrams = { 0x20E220, 0x20E2EE, 0x20E4EE, 0x20E7E0, 0x20E820, 0x20EAE0, 0x20EAEE, 0x20EDE0, 0x20EDE5, 0x20EEE1, 0x20EFEE, 0x20EFF0, 0x20F0E0, 0x20F1EE, 0x20F1F2, 0x20F2EE, 0x20F7F2, 0x20FDF2, 0xE0EDE8, 0xE0F2FC, 0xE3EE20, 0xE5EBFC, 0xE5EDE8, 0xE5F1F2, 0xE5F220, 0xE820EF, 0xE8E520, 0xE8E820, 0xE8FF20, 0xEBE5ED, 0xEBE820, 0xEBFCED, 0xEDE020, 0xEDE520, 0xEDE8E5, 0xEDE8FF, 0xEDEE20, 0xEDEEE2, 0xEE20E2, 0xEE20EF, 0xEE20F1, 0xEEE220, 0xEEE2E0, 0xEEE3EE, 0xEEE920, 0xEEEBFC, 0xEEEC20, 0xEEF1F2, 0xEFEEEB, 0xEFF0E5, 0xEFF0E8, 0xEFF0EE, 0xF0E0E2, 0xF0E5E4, 0xF1F2E0, 0xF1F2E2, 0xF1F2E8, 0xF1FF20, 0xF2E5EB, 0xF2EE20, 0xF2EEF0, 0xF2FC20, 0xF7F2EE, 0xFBF520, }; private static final byte[] byteMap = { (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x00, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x90, (byte) 0x83, (byte) 0x20, (byte) 0x83, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x9A, (byte) 0x20, (byte) 0x9C, (byte) 0x9D, (byte) 0x9E, (byte) 0x9F, (byte) 0x90, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x9A, (byte) 0x20, (byte) 0x9C, (byte) 0x9D, (byte) 0x9E, (byte) 0x9F, (byte) 0x20, (byte) 0xA2, (byte) 0xA2, (byte) 0xBC, (byte) 0x20, (byte) 0xB4, (byte) 0x20, (byte) 0x20, (byte) 0xB8, (byte) 0x20, (byte) 0xBA, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xBF, (byte) 0x20, (byte) 0x20, (byte) 0xB3, (byte) 0xB3, (byte) 0xB4, (byte) 0xB5, (byte) 0x20, (byte) 0x20, (byte) 0xB8, (byte) 0x20, (byte) 0xBA, (byte) 0x20, (byte) 0xBC, (byte) 0xBE, (byte) 0xBE, (byte) 0xBF, (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, (byte) 0xF0, (byte) 0xF1, (byte) 0xF2, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0xF7, (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0xFB, (byte) 0xFC, (byte) 0xFD, (byte) 0xFE, (byte) 0xFF, (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, (byte) 0xF0, (byte) 0xF1, (byte) 0xF2, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0xF7, (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0xFB, (byte) 0xFC, (byte) 0xFD, (byte) 0xFE, (byte) 0xFF, }; @Override public String getName() { return "windows-1251"; } @Override public String getLanguage() { return "ru"; } @Override public CharsetMatch match(CharsetDetector det) { int confidence = match(det, ngrams, byteMap); return confidence == 0 ? null : new CharsetMatch(det, this, confidence); } } static class CharsetRecog_windows_1256 extends CharsetRecog_sbcs { private static final int[] ngrams = { 0x20C7E1, 0x20C7E4, 0x20C8C7, 0x20DAE1, 0x20DDED, 0x20E1E1, 0x20E3E4, 0x20E6C7, 0xC720C7, 0xC7C120, 0xC7CA20, 0xC7D120, 0xC7E120, 0xC7E1C3, 0xC7E1C7, 0xC7E1C8, 0xC7E1CA, 0xC7E1CC, 0xC7E1CD, 0xC7E1CF, 0xC7E1D3, 0xC7E1DA, 0xC7E1DE, 0xC7E1E3, 0xC7E1E6, 0xC7E1ED, 0xC7E320, 0xC7E420, 0xC7E4CA, 0xC820C7, 0xC920C7, 0xC920DD, 0xC920E1, 0xC920E3, 0xC920E6, 0xCA20C7, 0xCF20C7, 0xCFC920, 0xD120C7, 0xD1C920, 0xD320C7, 0xDA20C7, 0xDAE1EC, 0xDDED20, 0xE120C7, 0xE1C920, 0xE1EC20, 0xE1ED20, 0xE320C7, 0xE3C720, 0xE3C920, 0xE3E420, 0xE420C7, 0xE520C7, 0xE5C720, 0xE6C7E1, 0xE6E420, 0xEC20C7, 0xED20C7, 0xED20E3, 0xED20E6, 0xEDC920, 0xEDD120, 0xEDE420, }; private static final byte[] byteMap = { (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x00, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x81, (byte) 0x20, (byte) 0x83, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x88, (byte) 0x20, (byte) 0x8A, (byte) 0x20, (byte) 0x9C, (byte) 0x8D, (byte) 0x8E, (byte) 0x8F, (byte) 0x90, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x98, (byte) 0x20, (byte) 0x9A, (byte) 0x20, (byte) 0x9C, (byte) 0x20, (byte) 0x20, (byte) 0x9F, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xAA, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xB5, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xC0, (byte) 0xC1, (byte) 0xC2, (byte) 0xC3, (byte) 0xC4, (byte) 0xC5, (byte) 0xC6, (byte) 0xC7, (byte) 0xC8, (byte) 0xC9, (byte) 0xCA, (byte) 0xCB, (byte) 0xCC, (byte) 0xCD, (byte) 0xCE, (byte) 0xCF, (byte) 0xD0, (byte) 0xD1, (byte) 0xD2, (byte) 0xD3, (byte) 0xD4, (byte) 0xD5, (byte) 0xD6, (byte) 0x20, (byte) 0xD8, (byte) 0xD9, (byte) 0xDA, (byte) 0xDB, (byte) 0xDC, (byte) 0xDD, (byte) 0xDE, (byte) 0xDF, (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xF4, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xF9, (byte) 0x20, (byte) 0xFB, (byte) 0xFC, (byte) 0x20, (byte) 0x20, (byte) 0xFF, }; @Override public String getName() { return "windows-1256"; } @Override public String getLanguage() { return "ar"; } @Override public CharsetMatch match(CharsetDetector det) { int confidence = match(det, ngrams, byteMap); return confidence == 0 ? null : new CharsetMatch(det, this, confidence); } } static class CharsetRecog_KOI8_R extends CharsetRecog_sbcs { private static final int[] ngrams = { 0x20C4CF, 0x20C920, 0x20CBC1, 0x20CBCF, 0x20CEC1, 0x20CEC5, 0x20CFC2, 0x20D0CF, 0x20D0D2, 0x20D2C1, 0x20D3CF, 0x20D3D4, 0x20D4CF, 0x20D720, 0x20D7CF, 0x20DAC1, 0x20DCD4, 0x20DED4, 0xC1CEC9, 0xC1D4D8, 0xC5CCD8, 0xC5CEC9, 0xC5D3D4, 0xC5D420, 0xC7CF20, 0xC920D0, 0xC9C520, 0xC9C920, 0xC9D120, 0xCCC5CE, 0xCCC920, 0xCCD8CE, 0xCEC120, 0xCEC520, 0xCEC9C5, 0xCEC9D1, 0xCECF20, 0xCECFD7, 0xCF20D0, 0xCF20D3, 0xCF20D7, 0xCFC7CF, 0xCFCA20, 0xCFCCD8, 0xCFCD20, 0xCFD3D4, 0xCFD720, 0xCFD7C1, 0xD0CFCC, 0xD0D2C5, 0xD0D2C9, 0xD0D2CF, 0xD2C1D7, 0xD2C5C4, 0xD3D120, 0xD3D4C1, 0xD3D4C9, 0xD3D4D7, 0xD4C5CC, 0xD4CF20, 0xD4CFD2, 0xD4D820, 0xD9C820, 0xDED4CF, }; private static final byte[] byteMap = { (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x00, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xA3, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xA3, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xC0, (byte) 0xC1, (byte) 0xC2, (byte) 0xC3, (byte) 0xC4, (byte) 0xC5, (byte) 0xC6, (byte) 0xC7, (byte) 0xC8, (byte) 0xC9, (byte) 0xCA, (byte) 0xCB, (byte) 0xCC, (byte) 0xCD, (byte) 0xCE, (byte) 0xCF, (byte) 0xD0, (byte) 0xD1, (byte) 0xD2, (byte) 0xD3, (byte) 0xD4, (byte) 0xD5, (byte) 0xD6, (byte) 0xD7, (byte) 0xD8, (byte) 0xD9, (byte) 0xDA, (byte) 0xDB, (byte) 0xDC, (byte) 0xDD, (byte) 0xDE, (byte) 0xDF, (byte) 0xC0, (byte) 0xC1, (byte) 0xC2, (byte) 0xC3, (byte) 0xC4, (byte) 0xC5, (byte) 0xC6, (byte) 0xC7, (byte) 0xC8, (byte) 0xC9, (byte) 0xCA, (byte) 0xCB, (byte) 0xCC, (byte) 0xCD, (byte) 0xCE, (byte) 0xCF, (byte) 0xD0, (byte) 0xD1, (byte) 0xD2, (byte) 0xD3, (byte) 0xD4, (byte) 0xD5, (byte) 0xD6, (byte) 0xD7, (byte) 0xD8, (byte) 0xD9, (byte) 0xDA, (byte) 0xDB, (byte) 0xDC, (byte) 0xDD, (byte) 0xDE, (byte) 0xDF, }; @Override public String getName() { return "KOI8-R"; } @Override public String getLanguage() { return "ru"; } @Override public CharsetMatch match(CharsetDetector det) { int confidence = match(det, ngrams, byteMap); return confidence == 0 ? null : new CharsetMatch(det, this, confidence); } } abstract static class CharsetRecog_IBM424_he extends CharsetRecog_sbcs { protected static byte[] byteMap = { /* -0 -1 -2 -3 -4 -5 -6 -7 -8 -9 -A -B -C -D -E -F */ /* 0- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, /* 1- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, /* 2- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, /* 3- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, /* 4- */ (byte) 0x40, (byte) 0x41, (byte) 0x42, (byte) 0x43, (byte) 0x44, (byte) 0x45, (byte) 0x46, (byte) 0x47, (byte) 0x48, (byte) 0x49, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, /* 5- */ (byte) 0x40, (byte) 0x51, (byte) 0x52, (byte) 0x53, (byte) 0x54, (byte) 0x55, (byte) 0x56, (byte) 0x57, (byte) 0x58, (byte) 0x59, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, /* 6- */ (byte) 0x40, (byte) 0x40, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, (byte) 0x68, (byte) 0x69, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, /* 7- */ (byte) 0x40, (byte) 0x71, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x00, (byte) 0x40, (byte) 0x40, /* 8- */ (byte) 0x40, (byte) 0x81, (byte) 0x82, (byte) 0x83, (byte) 0x84, (byte) 0x85, (byte) 0x86, (byte) 0x87, (byte) 0x88, (byte) 0x89, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, /* 9- */ (byte) 0x40, (byte) 0x91, (byte) 0x92, (byte) 0x93, (byte) 0x94, (byte) 0x95, (byte) 0x96, (byte) 0x97, (byte) 0x98, (byte) 0x99, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, /* A- */ (byte) 0xA0, (byte) 0x40, (byte) 0xA2, (byte) 0xA3, (byte) 0xA4, (byte) 0xA5, (byte) 0xA6, (byte) 0xA7, (byte) 0xA8, (byte) 0xA9, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, /* B- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, /* C- */ (byte) 0x40, (byte) 0x81, (byte) 0x82, (byte) 0x83, (byte) 0x84, (byte) 0x85, (byte) 0x86, (byte) 0x87, (byte) 0x88, (byte) 0x89, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, /* D- */ (byte) 0x40, (byte) 0x91, (byte) 0x92, (byte) 0x93, (byte) 0x94, (byte) 0x95, (byte) 0x96, (byte) 0x97, (byte) 0x98, (byte) 0x99, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, /* E- */ (byte) 0x40, (byte) 0x40, (byte) 0xA2, (byte) 0xA3, (byte) 0xA4, (byte) 0xA5, (byte) 0xA6, (byte) 0xA7, (byte) 0xA8, (byte) 0xA9, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, /* F- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, }; @Override public String getLanguage() { return "he"; } } static class CharsetRecog_IBM424_he_rtl extends CharsetRecog_IBM424_he { @Override public String getName() { return "IBM424_rtl"; } private static final int[] ngrams = { 0x404146, 0x404148, 0x404151, 0x404171, 0x404251, 0x404256, 0x404541, 0x404546, 0x404551, 0x404556, 0x404562, 0x404569, 0x404571, 0x405441, 0x405445, 0x405641, 0x406254, 0x406954, 0x417140, 0x454041, 0x454042, 0x454045, 0x454054, 0x454056, 0x454069, 0x454641, 0x464140, 0x465540, 0x465740, 0x466840, 0x467140, 0x514045, 0x514540, 0x514671, 0x515155, 0x515540, 0x515740, 0x516840, 0x517140, 0x544041, 0x544045, 0x544140, 0x544540, 0x554041, 0x554042, 0x554045, 0x554054, 0x554056, 0x554069, 0x564540, 0x574045, 0x584540, 0x585140, 0x585155, 0x625440, 0x684045, 0x685155, 0x695440, 0x714041, 0x714042, 0x714045, 0x714054, 0x714056, 0x714069, }; @Override public CharsetMatch match(CharsetDetector det) { int confidence = match(det, ngrams, byteMap, (byte) 0x40); return confidence == 0 ? null : new CharsetMatch(det, this, confidence); } } static class CharsetRecog_IBM424_he_ltr extends CharsetRecog_IBM424_he { @Override public String getName() { return "IBM424_ltr"; } private static final int[] ngrams = { 0x404146, 0x404154, 0x404551, 0x404554, 0x404556, 0x404558, 0x405158, 0x405462, 0x405469, 0x405546, 0x405551, 0x405746, 0x405751, 0x406846, 0x406851, 0x407141, 0x407146, 0x407151, 0x414045, 0x414054, 0x414055, 0x414071, 0x414540, 0x414645, 0x415440, 0x415640, 0x424045, 0x424055, 0x424071, 0x454045, 0x454051, 0x454054, 0x454055, 0x454057, 0x454068, 0x454071, 0x455440, 0x464140, 0x464540, 0x484140, 0x514140, 0x514240, 0x514540, 0x544045, 0x544055, 0x544071, 0x546240, 0x546940, 0x555151, 0x555158, 0x555168, 0x564045, 0x564055, 0x564071, 0x564240, 0x564540, 0x624540, 0x694045, 0x694055, 0x694071, 0x694540, 0x714140, 0x714540, 0x714651 }; @Override public CharsetMatch match(CharsetDetector det) { int confidence = match(det, ngrams, byteMap, (byte) 0x40); return confidence == 0 ? null : new CharsetMatch(det, this, confidence); } } abstract static class CharsetRecog_IBM420_ar extends CharsetRecog_sbcs { protected static byte[] byteMap = { /* -0 -1 -2 -3 -4 -5 -6 -7 -8 -9 -A -B -C -D -E -F */ /* 0- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, /* 1- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, /* 2- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, /* 3- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, /* 4- */ (byte) 0x40, (byte) 0x40, (byte) 0x42, (byte) 0x43, (byte) 0x44, (byte) 0x45, (byte) 0x46, (byte) 0x47, (byte) 0x48, (byte) 0x49, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, /* 5- */ (byte) 0x40, (byte) 0x51, (byte) 0x52, (byte) 0x40, (byte) 0x40, (byte) 0x55, (byte) 0x56, (byte) 0x57, (byte) 0x58, (byte) 0x59, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, /* 6- */ (byte) 0x40, (byte) 0x40, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, (byte) 0x68, (byte) 0x69, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, /* 7- */ (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, (byte) 0x78, (byte) 0x79, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, /* 8- */ (byte) 0x80, (byte) 0x81, (byte) 0x82, (byte) 0x83, (byte) 0x84, (byte) 0x85, (byte) 0x86, (byte) 0x87, (byte) 0x88, (byte) 0x89, (byte) 0x8A, (byte) 0x8B, (byte) 0x8C, (byte) 0x8D, (byte) 0x8E, (byte) 0x8F, /* 9- */ (byte) 0x90, (byte) 0x91, (byte) 0x92, (byte) 0x93, (byte) 0x94, (byte) 0x95, (byte) 0x96, (byte) 0x97, (byte) 0x98, (byte) 0x99, (byte) 0x9A, (byte) 0x9B, (byte) 0x9C, (byte) 0x9D, (byte) 0x9E, (byte) 0x9F, /* A- */ (byte) 0xA0, (byte) 0x40, (byte) 0xA2, (byte) 0xA3, (byte) 0xA4, (byte) 0xA5, (byte) 0xA6, (byte) 0xA7, (byte) 0xA8, (byte) 0xA9, (byte) 0xAA, (byte) 0xAB, (byte) 0xAC, (byte) 0xAD, (byte) 0xAE, (byte) 0xAF, /* B- */ (byte) 0xB0, (byte) 0xB1, (byte) 0xB2, (byte) 0xB3, (byte) 0xB4, (byte) 0xB5, (byte) 0x40, (byte) 0x40, (byte) 0xB8, (byte) 0xB9, (byte) 0xBA, (byte) 0xBB, (byte) 0xBC, (byte) 0xBD, (byte) 0xBE, (byte) 0xBF, /* C- */ (byte) 0x40, (byte) 0x81, (byte) 0x82, (byte) 0x83, (byte) 0x84, (byte) 0x85, (byte) 0x86, (byte) 0x87, (byte) 0x88, (byte) 0x89, (byte) 0x40, (byte) 0xCB, (byte) 0x40, (byte) 0xCD, (byte) 0x40, (byte) 0xCF, /* D- */ (byte) 0x40, (byte) 0x91, (byte) 0x92, (byte) 0x93, (byte) 0x94, (byte) 0x95, (byte) 0x96, (byte) 0x97, (byte) 0x98, (byte) 0x99, (byte) 0xDA, (byte) 0xDB, (byte) 0xDC, (byte) 0xDD, (byte) 0xDE, (byte) 0xDF, /* E- */ (byte) 0x40, (byte) 0x40, (byte) 0xA2, (byte) 0xA3, (byte) 0xA4, (byte) 0xA5, (byte) 0xA6, (byte) 0xA7, (byte) 0xA8, (byte) 0xA9, (byte) 0xEA, (byte) 0xEB, (byte) 0x40, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, /* F- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0xFB, (byte) 0xFC, (byte) 0xFD, (byte) 0xFE, (byte) 0x40, }; @Override public String getLanguage() { return "ar"; } } static class CharsetRecog_IBM420_ar_rtl extends CharsetRecog_IBM420_ar { private static final int[] ngrams = { 0x4056B1, 0x4056BD, 0x405856, 0x409AB1, 0x40ABDC, 0x40B1B1, 0x40BBBD, 0x40CF56, 0x564056, 0x564640, 0x566340, 0x567540, 0x56B140, 0x56B149, 0x56B156, 0x56B158, 0x56B163, 0x56B167, 0x56B169, 0x56B173, 0x56B178, 0x56B19A, 0x56B1AD, 0x56B1BB, 0x56B1CF, 0x56B1DC, 0x56BB40, 0x56BD40, 0x56BD63, 0x584056, 0x624056, 0x6240AB, 0x6240B1, 0x6240BB, 0x6240CF, 0x634056, 0x734056, 0x736240, 0x754056, 0x756240, 0x784056, 0x9A4056, 0x9AB1DA, 0xABDC40, 0xB14056, 0xB16240, 0xB1DA40, 0xB1DC40, 0xBB4056, 0xBB5640, 0xBB6240, 0xBBBD40, 0xBD4056, 0xBF4056, 0xBF5640, 0xCF56B1, 0xCFBD40, 0xDA4056, 0xDC4056, 0xDC40BB, 0xDC40CF, 0xDC6240, 0xDC7540, 0xDCBD40, }; @Override public String getName() { return "IBM420_rtl"; } @Override public CharsetMatch match(CharsetDetector det) { int confidence = matchIBM420(det, ngrams, byteMap, (byte) 0x40); return confidence == 0 ? null : new CharsetMatch(det, this, confidence); } } static class CharsetRecog_IBM420_ar_ltr extends CharsetRecog_IBM420_ar { private static final int[] ngrams = { 0x404656, 0x4056BB, 0x4056BF, 0x406273, 0x406275, 0x4062B1, 0x4062BB, 0x4062DC, 0x406356, 0x407556, 0x4075DC, 0x40B156, 0x40BB56, 0x40BD56, 0x40BDBB, 0x40BDCF, 0x40BDDC, 0x40DAB1, 0x40DCAB, 0x40DCB1, 0x49B156, 0x564056, 0x564058, 0x564062, 0x564063, 0x564073, 0x564075, 0x564078, 0x56409A, 0x5640B1, 0x5640BB, 0x5640BD, 0x5640BF, 0x5640DA, 0x5640DC, 0x565840, 0x56B156, 0x56CF40, 0x58B156, 0x63B156, 0x63BD56, 0x67B156, 0x69B156, 0x73B156, 0x78B156, 0x9AB156, 0xAB4062, 0xADB156, 0xB14062, 0xB15640, 0xB156CF, 0xB19A40, 0xB1B140, 0xBB4062, 0xBB40DC, 0xBBB156, 0xBD5640, 0xBDBB40, 0xCF4062, 0xCF40DC, 0xCFB156, 0xDAB19A, 0xDCAB40, 0xDCB156 }; @Override public String getName() { return "IBM420_ltr"; } @Override public CharsetMatch match(CharsetDetector det) { int confidence = matchIBM420(det, ngrams, byteMap, (byte) 0x40); return confidence == 0 ? null : new CharsetMatch(det, this, confidence); } } } ================================================ FILE: app/src/main/java/io/legado/app/lib/icu4j/CharsetRecognizer.java ================================================ // © 2016 and later: Unicode, Inc. and others. // License & terms of use: http://www.unicode.org/copyright.html /** * ****************************************************************************** * Copyright (C) 2005-2012, International Business Machines Corporation and * * others. All Rights Reserved. * * ****************************************************************************** */ package io.legado.app.lib.icu4j; /** * Abstract class for recognizing a single charset. * Part of the implementation of ICU's CharsetDetector. *

* Each specific charset that can be recognized will have an instance * of some subclass of this class. All interaction between the overall * CharsetDetector and the stuff specific to an individual charset happens * via the interface provided here. *

* Instances of CharsetDetector DO NOT have or maintain * state pertaining to a specific match or detect operation. * The WILL be shared by multiple instances of CharsetDetector. * They encapsulate const charset-specific information. */ abstract class CharsetRecognizer { /** * Get the IANA name of this charset. * * @return the charset name. */ abstract String getName(); /** * Get the ISO language code for this charset. * * @return the language code, or null if the language cannot be determined. */ public String getLanguage() { return null; } /** * Test the match of this charset with the input text data * which is obtained via the CharsetDetector object. * * @param det The CharsetDetector, which contains the input text * to be checked for being in this charset. * @return A CharsetMatch object containing details of match * with this charset, or null if there was no match. */ abstract CharsetMatch match(CharsetDetector det); } ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/KF6Book.kt ================================================ package io.legado.app.lib.mobi import io.legado.app.lib.mobi.entities.KF6Section import io.legado.app.lib.mobi.entities.MobiEntryHeaders import io.legado.app.lib.mobi.entities.NCX import io.legado.app.lib.mobi.entities.TOC import java.nio.CharBuffer /** * Kindle Format 6 Book */ class KF6Book( pdbFile: PDBFile, headers: MobiEntryHeaders, kf8BoundaryOffset: Int, resourceStart: Int ) : MobiBook(pdbFile, headers, kf8BoundaryOffset, resourceStart) { lateinit var sections: List lateinit var sectionIdMap: LinkedHashMap> init { processSections() processNCX() processSectionsMap() } fun getResourceByHref(href: String): ByteArray? { val recindex = href.substringAfter("recindex:").toIntOrNull() ?: return null return getResource(recindex - 1).array() } fun getSectionText(section: KF6Section): String { val inputStream = getTextRecordInputStream() val byteArray = ByteArray(section.length) inputStream.skip(section.start.toLong()) inputStream.read(byteArray) return String(byteArray, charset) } fun getSectionByHref(href: String): KF6Section? { val index = getIndexByHref(href) return sections.getOrNull(index) } private fun processSectionsMap() { sectionIdMap = linkedMapOf() if (toc == null) { return } fun fmap(item: TOC) { val index = getIndexByHref(item.href) if (index == -1) return val array = sectionIdMap.getOrPut(index) { arrayListOf() } array.add(item) item.subitems?.forEach(::fmap) } toc!!.forEach(::fmap) } private fun getIndexByHref(href: String): Int { val filepos = href.substringAfter("filepos:").toIntOrNull() ?: return -1 return sections.indexOfFirst { it.end > filepos } } private fun processNCX() { val ncx = getNCX() ?: return fun fmap(item: NCX): TOC { val filepos = item.offset!! val href = "filepos:${filepos.toString().padStart(10, '0')}" return TOC(item.label, href, item.children?.map(::fmap)) } toc = ncx.map(::fmap) } private fun processSections() { val sections = arrayListOf() val pattern = mbpPagebreakRegex.toPattern() val inputStream = getTextRecordInputStream() val available = inputStream.available() val reader = inputStream.reader(Charsets.ISO_8859_1) var buffer = CharBuffer.allocate(4096) reader.read(buffer) buffer.flip() val matcher = pattern.matcher(buffer) var droppedOffset = 0 var nextStart = 0 var position = 0 while (true) { if (!matcher.find()) { buffer.position(position) if (buffer.limit() == buffer.capacity()) { if (position > 0) { buffer.compact() } else { val newBuf = CharBuffer.allocate(buffer.capacity() * 2) newBuf.put(buffer) buffer = newBuf } droppedOffset += position position = 0 } if (reader.read(buffer) == -1) { break } buffer.flip() matcher.reset(buffer) } else { val last = sections.lastOrNull() val index = sections.size val start = nextStart val end = matcher.start() + droppedOffset nextStart = matcher.end() + droppedOffset position = matcher.end() val length = end - start val href = "filepos:${start.toString().padStart(10, '0')}" val section = KF6Section(index, start, end, length, href) last?.next = section sections.add(section) } } if (nextStart > 0) { val last = sections.lastOrNull() val index = sections.size val start = nextStart val length = available - start val href = "filepos:${start.toString().padStart(10, '0')}" val section = KF6Section(index, start, available, length, href) last?.next = section sections.add(section) } this.sections = sections } companion object { val mbpPagebreakRegex = "(?i)<\\s*(?:mbp:)?pagebreak[^>]*>".toRegex() } } ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/KF8Book.kt ================================================ package io.legado.app.lib.mobi import io.legado.app.lib.mobi.entities.FdstHeader import io.legado.app.lib.mobi.entities.Fragment import io.legado.app.lib.mobi.entities.KF8Pos import io.legado.app.lib.mobi.entities.KF8Resource import io.legado.app.lib.mobi.entities.KF8Section import io.legado.app.lib.mobi.entities.MobiEntryHeaders import io.legado.app.lib.mobi.entities.NCX import io.legado.app.lib.mobi.entities.Skeleton import io.legado.app.lib.mobi.entities.TOC import io.legado.app.lib.mobi.utils.readString import io.legado.app.lib.mobi.utils.readUInt32 import java.nio.ByteBuffer import java.util.Locale /** * Kindle Format 8 Book */ @Suppress("SpellCheckingInspection") class KF8Book( pdbFile: PDBFile, headers: MobiEntryHeaders, kf8BoundaryOffset: Int, resourceStart: Int ) : MobiBook(pdbFile, headers, kf8BoundaryOffset, resourceStart) { private var fdstTableStarts: IntArray? = null private var fdstTableEnds: IntArray? = null private lateinit var skelTable: List private lateinit var fragTable: List private var kf8 = headers.kf8!! lateinit var sections: List lateinit var sectionIdMap: LinkedHashMap> init { readFdstTable() readSkelTable() readFragTable() processSections() processNCX() processSectionsMap() } /** * 建立 section id -> toc 的 map */ private fun processSectionsMap() { sectionIdMap = linkedMapOf() if (toc == null) { return } fun fmap(item: TOC) { val index = getIndexByHref(item.href) if (index == -1) return val array = sectionIdMap.getOrPut(index) { arrayListOf() } array.add(item) item.subitems?.forEach(::fmap) } toc!!.forEach(::fmap) } fun parsePosURI(href: String): KF8Pos? { val match = kindlePosRegex.find(href) ?: return null val fid = match.groupValues[1].toInt(32) val off = match.groupValues[2].toInt(32) return KF8Pos(fid, off) } private fun parseResourceURI(href: String): KF8Resource? { val match = kindleResourceRegex.find(href) ?: return null val resourceType = match.groupValues[1] val id = match.groupValues[2].toInt(32) val type = match.groupValues[3] return KF8Resource(resourceType, id, type) } fun getResourceByHref(href: String): ByteArray? { val resource = parseResourceURI(href) ?: return null if (resource.resourceType == "flow") return null return getResource(resource.id - 1).array() } fun getSectionByHref(href: String): KF8Section? { val index = getIndexByHref(href) return sections.getOrNull(index) } private fun getIndexByHref(href: String): Int { val pos = parsePosURI(href) ?: return -1 return getIndexByFID(pos.fid) } private fun getIndexByFID(fid: Int): Int { return sections.indexOfFirst { it.frags.any { frag -> frag.index == fid } } } fun getTextByHref(href: String, nextHref: String): String { val pos = parsePosURI(href) ?: return "" val nextPos = parsePosURI(nextHref) ?: return "" val index = getIndexByFID(pos.fid) val nextIndex = getIndexByFID(nextPos.fid) val startFid = pos.fid val endFid = if (index == nextIndex) nextPos.fid else Int.MAX_VALUE val section = sections[index] val skel = section.skeleton val droppedFrags = section.frags.filter { it.index < startFid } var droppedFragsLength = droppedFrags.sumOf { it.length } val frags = section.frags.filter { it.index in startFid..endFid } val length = skel.length + frags.sumOf { it.length } val raw = getRaw(skel.offset, section.length) val lastFragDroppedLength = if (index == nextIndex) frags.last().length - nextPos.offset else 0 val skeleton = ByteArray(length - pos.offset - lastFragDroppedLength) var leftBytes = skeleton.size raw.copyInto(skeleton, 0, 0, skel.length) leftBytes -= skel.length for ((i, frag) in frags.withIndex()) { val isFirstFrag = i == 0 val isLastFrag = i == frags.lastIndex val insertOffset = frag.insertOffset - skel.offset - droppedFragsLength val offset = skel.length + frag.offset skeleton.copyInto( skeleton, insertOffset + frag.length - (if (isFirstFrag) pos.offset else 0) - (if (isLastFrag) lastFragDroppedLength else 0), insertOffset, skeleton.size - leftBytes ) raw.copyInto( skeleton, insertOffset, offset + if (isFirstFrag) pos.offset else 0, offset + frag.length - if (isLastFrag) lastFragDroppedLength else 0 ) leftBytes -= frag.length - (if (isFirstFrag) pos.offset else 0) - (if (isLastFrag && index == nextIndex) nextPos.offset else 0) if (isFirstFrag) { droppedFragsLength += pos.offset } } return String(skeleton, charset) } fun getSectionText(section: KF8Section): String { val skel = section.skeleton val frags = section.frags val length = section.length val raw = getRaw(skel.offset, length) val skeleton = ByteArray(raw.size) var leftBytes = raw.size raw.copyInto(skeleton, 0, 0, skel.length) leftBytes -= skel.length for (frag in frags) { val insertOffset = frag.insertOffset - skel.offset val offset = skel.length + frag.offset skeleton.copyInto( skeleton, insertOffset + frag.length, insertOffset, skeleton.size - leftBytes ) raw.copyInto(skeleton, insertOffset, offset, offset + frag.length) leftBytes -= frag.length } return String(skeleton, charset) } private fun getRaw(offset: Int, len: Int): ByteArray { val inputStream = getTextRecordInputStream() val byteArray = ByteArray(len) inputStream.skip(offset.toLong()) inputStream.read(byteArray) return byteArray } private fun processNCX() { val ncx = getNCX() ?: return fun fmap(item: NCX): TOC { val (fid, off) = item.pos!! val href = makePosURI(fid, off) return TOC(item.label, href, item.children?.map(::fmap)) } toc = ncx.map(::fmap) } private fun makePosURI(fid: Int, off: Int): String { val encodedFid = fid.toString(32).uppercase(Locale.ROOT).padStart(4, '0') val encodedOff = off.toString(32).uppercase(Locale.ROOT).padStart(10, '0') return "kindle:pos:fid:$encodedFid:off:$encodedOff" } private fun processSections() { sections = skelTable.fold(arrayListOf()) { arr, skel -> val last = arr.lastOrNull() val index = arr.size val fragStart = last?.fragEnd ?: 0 val fragEnd = fragStart + skel.numFrag val frags = fragTable.slice(fragStart.. val tagMap = indexEntry.tagMap Fragment( indexEntry.label.toInt(), fragData.cncx[tagMap[2].tagValues[0]], tagMap[4].tagValues[0], tagMap[6].tagValues[0], tagMap[6].tagValues[1] ) } } private fun readSkelTable() { skelTable = getIndexData(kf8.skel).table.mapIndexed { index, indexEntry -> val tagMap = indexEntry.tagMap Skeleton( index, indexEntry.label, tagMap[1].tagValues[0], tagMap[6].tagValues[0], tagMap[6].tagValues[1], ) } } private fun readFdstTable() { try { val fdstBuffer = getRecord(kf8.fdst) val fdstHeader = readFdstHeader(fdstBuffer) fdstTableStarts = IntArray(fdstHeader.numEntries) fdstTableEnds = IntArray(fdstHeader.numEntries) fdstBuffer.position(12) for (i in 0.. Charsets.UTF_8 1252 -> Charset.forName("windows-1252") else -> error("unknown charset $charset") } private val decompressor: Decompressor = when (val compression = palmdoc.compression) { 1 -> PlainDecompressor() 2 -> Lz77Decompressor(max(4096, palmdoc.recordSize)) 17480 -> HuffcdicDecompressor(this, mobi) else -> error("unknown compression $charset") } @Suppress("UNCHECKED_CAST") val metadata: MobiMetadata by lazy { MobiMetadata( mobi.uid.toString(), exth["title"] as? String ?: mobi.title, exth["creator"] as? List ?: emptyList(), exth["publisher"] as? String ?: "", exth["language"] as? String ?: mobi.languege, exth["date"] as? String ?: "", exth["description"] as? String ?: "", exth["subject"] as? List ?: emptyList(), exth["rights"] as? String ?: "" ) } var toc: List? = null private var textRecordOffsets = arrayListOf() init { buildTextRecordOffsets() } private fun buildTextRecordOffsets() { var offset = 0 for (i in 0..= palmdoc.numTextRecords) { throw IndexOutOfBoundsException("Text record index out of bounds") } var content = getRecord(index + 1).array() content = removeTrailingEntries(content) return decompressor.decompress(content) } fun getTextRecordInputStream(): InputStream { return object : InputStream() { private var index = -1 private var bis: ByteArrayInputStream = emptyByteArrayInputStream private var available = textRecordOffsets.last() private var pos = 0 override fun read(): Int { if (index >= palmdoc.numTextRecords) { return -1 } if (bis.available() == 0) { if (++index >= palmdoc.numTextRecords) { return -1 } bis = getTextRecord(index).inputStream() } val b = bis.read() available-- pos++ return b } override fun skip(n: Long): Long { if (n == 0L) return 0L val n1 = min(available, n.toInt()) if (n1 < bis.available()) { bis.skip(n1.toLong()) available -= n1 pos += n1 return n1.toLong() } val bIndex = textRecordOffsets.binarySearch(pos + n1) index = abs(bIndex + 1) bis = getTextRecord(index).inputStream() val offset = textRecordOffsets.getOrNull(index - 1) ?: 0 bis.skip((pos + n1 - offset).toLong()) available -= n1 pos += n1 return n1.toLong() } override fun available(): Int { return available } } } private fun removeTrailingEntries(byteArray: ByteArray): ByteArray { if (trailingFlags == 0) return byteArray val multibyte = trailingFlags and 1 != 0 val numTrailingEntries = (trailingFlags shr 1).countOneBits() val lastIndex = byteArray.lastIndex var extraSize = 0 for (i in 0..? { val indxIndex = mobi.indx if (indxIndex == -1) { return null } val indexData = getIndexData(indxIndex) val items = indexData.table.mapIndexed { index, indexEntry -> val tagMap = indexEntry.tagMap NCX( index, tagMap[1]?.tagValues?.getOrNull(0), tagMap[2]?.tagValues?.getOrNull(0), indexData.cncx[tagMap[3].tagValues[0]], tagMap[4]?.tagValues?.getOrNull(0), tagMap[6]?.tagValues, tagMap[21]?.tagValues?.getOrNull(0), tagMap[22]?.tagValues?.getOrNull(0), tagMap[23]?.tagValues?.getOrNull(0), ) } val parentItemMap = hashMapOf>() items.forEach { val parent = it.parent ?: return@forEach val array = parentItemMap.getOrPut(parent) { arrayListOf() } array.add(it) } fun getChildren(item: NCX): NCX { if (item.firstChild == null) return item item.children = parentItemMap[item.index]?.map(::getChildren) return item } return items.filter { it.headingLevel == 0 }.map(::getChildren) } fun getCover(): ByteArray? { val coverOffset = exth["coverOffset"] as? Int val thumbnailOffset = exth["thumbnailOffset"] as? Int if (coverOffset != null && coverOffset != -1) { return getResource(coverOffset).array() } if (thumbnailOffset != null && thumbnailOffset != -1) { return getResource(thumbnailOffset).array() } return null } fun getIndexData(indxIndex: Int): IndexData { val indxRecord = getRecord(indxIndex) val indx = readIndxHeader(indxRecord) indxRecord.position(indx.length) val tagxBuffer = indxRecord.slice() val tagx = readTagxHeader(tagxBuffer) val tagTable = readTagxTags(tagx, tagxBuffer) val cncx = readCncx(indxIndex, indx) val table = arrayListOf() for (i in 0.., idxtOffset: Int ): IndexEntry { val array = indxBuffer.array() val len = indxBuffer.readUInt8(idxtOffset) val label = indxBuffer.readString(idxtOffset + 1, len) val ptagxs = arrayListOf() val startPos = idxtOffset + 1 + len var controlByteIndex = 0 var pos = startPos + tagx.numControlBytes for (tag in tagTable) { if (tag.controlByte == 1) { controlByteIndex++ continue } val offset = startPos + controlByteIndex var value = indxBuffer.readUInt8(offset) and tag.bitmask if (value == tag.bitmask) { if (tag.bitmask.countOneBits() > 1) { var v = 0 for (a in pos..() val tagMap = SparseArray() for (ptagx in ptagxs) { val values = arrayListOf() if (ptagx.valueCount != null) { repeat(ptagx.valueCount * ptagx.tagValueCount) { var v = 0 for (a in pos.. { val numTags = (tagx.length - 12) / 4 val tags = arrayListOf() tagxBuffer.position(12) for (i in 0.. { val cncx = SparseArray() var cncxRecordOffset = 0 for (i in 0..= 8 var kf8BoundaryOffset = 0 if (!isKF8) { val boundary = exth["boundary"] as? Int if (boundary != null && boundary != -1) { try { val buffer = pdbFile.getRecordData(boundary) mobiEntryHeaders = readMobiEntryHeaders(buffer) kf8BoundaryOffset = boundary isKF8 = true } catch (e: Exception) { e.printStackTrace() } } } return if (isKF8) { KF8Book(pdbFile, mobiEntryHeaders, kf8BoundaryOffset, resourceStart) } else { KF6Book(pdbFile, mobiEntryHeaders, kf8BoundaryOffset, resourceStart) } } private fun readMobiEntryHeaders(buffer: ByteBuffer): MobiEntryHeaders { val palmDocHeader = readPalmDocHeader(buffer) val mobiHeader = readMobiHeader(buffer) val exth = if (mobiHeader.exthFlag and 0b100_0000 != 0) { buffer.position(mobiHeader.length + 16) readExth(buffer.slice()) } else { emptyMap() } val kF8Header = if (mobiHeader.version >= 8) { readKF8Header(buffer) } else { null } return MobiEntryHeaders(palmDocHeader, mobiHeader, exth, kF8Header) } private fun readExth(buffer: ByteBuffer): Map { val magic = buffer.readString(0, 4) check(magic == "EXTH") { "Invalid EXTH header" } val count = buffer.readUInt32(8) var offset = 12 val map = HashMap() for (i in 0..() } @Suppress("UNCHECKED_CAST") val array = map[name] as ArrayList array.add(data as String) } else { map[name] = data } } offset += length } return map } private fun readPalmDocHeader(content: ByteBuffer): PalmDocHeader { val compression = content.readUInt16(0) val numTextRecords = content.readUInt16(8) val recordSize = content.readUInt16(10) val encryption = content.readUInt16(12) return PalmDocHeader(compression, numTextRecords, recordSize, encryption) } private fun readMobiHeader(content: ByteBuffer): MobiHeader { val identifier = content.readString(16, 4) check(identifier == "MOBI") { "Missing MOBI header" } val length = content.readUInt32(20) val type = content.readUInt32(24) val encoding = content.readUInt32(28) val uid = content.readUInt32(32) val version = content.readUInt32(36) val titleOffset = content.readUInt32(84) val titleLength = content.readUInt32(88) val localeRegion = content.readUInt8(94) val localeLanguage = content.readUInt8(95) val resourceStar = content.readUInt32(108) val huffcdic = content.readUInt32(112) val numHuffcdic = content.readUInt32(116) val exthFlag = content.readUInt32(128) val trailingFlags = content.readUInt32(240) val indx = content.readUInt32(244) val charset: Charset = when (encoding) { 65001 -> Charsets.UTF_8 1252 -> Charset.forName("windows-1252") else -> error("unknown charset $encoding") } val title = content.readString(titleOffset, titleLength, charset) val lang = mobiLangMap[localeLanguage] val language = lang?.getOrNull(localeRegion shr 2) ?: lang?.first() ?: "" return MobiHeader( identifier, length, type, encoding, uid, version, titleOffset, titleLength, localeRegion, localeLanguage, resourceStar, huffcdic, numHuffcdic, exthFlag, trailingFlags, indx, title, language ) } private fun readKF8Header(content: ByteBuffer): KF8Header { val fdst = content.readUInt32(192) val numFdst = content.readUInt32(196) val frag = content.readUInt32(248) val skel = content.readUInt32(252) val guide = content.readUInt32(260) return KF8Header(fdst, numFdst, frag, skel, guide) } companion object { val exthRecordTypeMap = mapOf( 100 to ExthRecordType("creator", "string", true), 101 to ExthRecordType("publisher"), 103 to ExthRecordType("description"), 104 to ExthRecordType("isbn"), 105 to ExthRecordType("subject", "string", true), 106 to ExthRecordType("date"), 108 to ExthRecordType("contributor", "string", true), 109 to ExthRecordType("rights"), 110 to ExthRecordType("subjectCode", "string", true), 112 to ExthRecordType("source", "string", true), 113 to ExthRecordType("asin"), 121 to ExthRecordType("boundary", "uint"), 122 to ExthRecordType("fixedLayout"), 125 to ExthRecordType("numResources", "uint"), 126 to ExthRecordType("originalResolution"), 127 to ExthRecordType("zeroGutter"), 128 to ExthRecordType("zeroMargin"), 129 to ExthRecordType("coverURI"), 132 to ExthRecordType("regionMagnification"), 201 to ExthRecordType("coverOffset", "uint"), 202 to ExthRecordType("thumbnailOffset", "uint"), 204 to ExthRecordType("creatorSoftware", "uint"), 503 to ExthRecordType("title"), 524 to ExthRecordType("language", "string", true), 527 to ExthRecordType("pageProgressionDirection"), ) val mobiLangMap = mapOf( 1 to listOf( "ar", "ar-SA", "ar-IQ", "ar-EG", "ar-LY", "ar-DZ", "ar-MA", "ar-TN", "ar-OM", "ar-YE", "ar-SY", "ar-JO", "ar-LB", "ar-KW", "ar-AE", "ar-BH", "ar-QA" ), 2 to listOf("bg"), 3 to listOf("ca"), 4 to listOf("zh", "zh-TW", "zh-CN", "zh-HK", "zh-SG"), 5 to listOf("cs"), 6 to listOf("da"), 7 to listOf("de", "de-DE", "de-CH", "de-AT", "de-LU", "de-LI"), 8 to listOf("el"), 9 to listOf( "en", "en-US", "en-GB", "en-AU", "en-CA", "en-NZ", "en-IE", "en-ZA", "en-JM", null, "en-BZ", "en-TT", "en-ZW", "en-PH" ), 10 to listOf( "es", "es-ES", "es-MX", null, "es-GT", "es-CR", "es-PA", "es-DO", "es-VE", "es-CO", "es-PE", "es-AR", "es-EC", "es-CL", "es-UY", "es-PY", "es-BO", "es-SV", "es-HN", "es-NI", "es-PR" ), 11 to listOf("fi"), 12 to listOf("fr", "fr-FR", "fr-BE", "fr-CA", "fr-CH", "fr-LU", "fr-MC"), 13 to listOf("he"), 14 to listOf("hu"), 15 to listOf("is"), 16 to listOf("it", "it-IT", "it-CH"), 17 to listOf("ja"), 18 to listOf("ko"), 19 to listOf("nl", "nl-NL", "nl-BE"), 20 to listOf("no", "nb", "nn"), 21 to listOf("pl"), 22 to listOf("pt", "pt-BR", "pt-PT"), 23 to listOf("rm"), 24 to listOf("ro"), 25 to listOf("ru"), 26 to listOf("hr", null, "sr"), 27 to listOf("sk"), 28 to listOf("sq"), 29 to listOf("sv", "sv-SE", "sv-FI"), 30 to listOf("th"), 31 to listOf("tr"), 32 to listOf("ur"), 33 to listOf("id"), 34 to listOf("uk"), 35 to listOf("be"), 36 to listOf("sl"), 37 to listOf("et"), 38 to listOf("lv"), 39 to listOf("lt"), 41 to listOf("fa"), 42 to listOf("vi"), 43 to listOf("hy"), 44 to listOf("az"), 45 to listOf("eu"), 46 to listOf("hsb"), 47 to listOf("mk"), 48 to listOf("st"), 49 to listOf("ts"), 50 to listOf("tn"), 52 to listOf("xh"), 53 to listOf("zu"), 54 to listOf("af"), 55 to listOf("ka"), 56 to listOf("fo"), 57 to listOf("hi"), 58 to listOf("mt"), 59 to listOf("se"), 62 to listOf("ms"), 63 to listOf("kk"), 65 to listOf("sw"), 67 to listOf("uz", null, "uz-UZ"), 68 to listOf("tt"), 69 to listOf("bn"), 70 to listOf("pa"), 71 to listOf("gu"), 72 to listOf("or"), 73 to listOf("ta"), 74 to listOf("te"), 75 to listOf("kn"), 76 to listOf("ml"), 77 to listOf("as"), 78 to listOf("mr"), 79 to listOf("sa"), 82 to listOf("cy", "cy-GB"), 83 to listOf("gl", "gl-ES"), 87 to listOf("kok"), 97 to listOf("ne"), 98 to listOf("fy") ) } } ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/PDBFile.kt ================================================ package io.legado.app.lib.mobi import android.os.ParcelFileDescriptor import io.legado.app.lib.mobi.utils.readString import io.legado.app.lib.mobi.utils.readUInt16 import io.legado.app.lib.mobi.utils.readUInt32 import java.io.FileInputStream import java.nio.ByteBuffer import java.nio.channels.FileChannel class PDBFile(private val pfd: ParcelFileDescriptor) { private val fc: FileChannel = FileInputStream(pfd.fileDescriptor).channel private val offsets: IntArray val name: String val type: String val creator: String val recordCount: Int init { var buffer = ByteBuffer.allocate(79) fc.read(buffer) name = buffer.readString(0, 32) type = buffer.readString(60, 4) creator = buffer.readString(64, 4) recordCount = buffer.readUInt16(76) buffer = ByteBuffer.allocate(recordCount * 8) fc.read(buffer, 78) offsets = IntArray(recordCount) { buffer.readUInt32(it * 8) } } fun getRecordData(index: Int): ByteBuffer { if (index < 0 || index >= recordCount) { throw IndexOutOfBoundsException("Record index out of bounds") } val len = offsets.getOrElse(index + 1) { fc.size().toInt() } - offsets[index] val buffer = ByteBuffer.allocate(len) fc.read(buffer, offsets[index].toLong()) return buffer } fun close() { fc.close() pfd.close() } } ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/decompress/CDICData.kt ================================================ package io.legado.app.lib.mobi.decompress class CDICEntry( var data: ByteArray, var decompressed: Boolean ) ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/decompress/Decompressor.kt ================================================ package io.legado.app.lib.mobi.decompress interface Decompressor { fun decompress(data: ByteArray): ByteArray } ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/decompress/HuffcdicDecompressor.kt ================================================ package io.legado.app.lib.mobi.decompress import io.legado.app.lib.mobi.MobiBook import io.legado.app.lib.mobi.entities.MobiHeader import io.legado.app.lib.mobi.utils.readIntArray import io.legado.app.lib.mobi.utils.readString import io.legado.app.lib.mobi.utils.readUInt16 import io.legado.app.lib.mobi.utils.readUInt32 import java.io.ByteArrayOutputStream import java.nio.ByteBuffer import kotlin.math.min @Suppress("SpellCheckingInspection") class HuffcdicDecompressor( mobiBook: MobiBook, mobiHeader: MobiHeader, ) : Decompressor { private val magic: String private val offset1: Int private val offset2: Int private val table1: IntArray private val mincodeTable = LongArray(33) private val maxcodeTable = LongArray(33) private val dictionary = arrayListOf() init { val huff = mobiBook.getRecord(mobiHeader.huffcdic) magic = huff.readString(0, 4) if (magic != "HUFF") error("Invalid HUFF record") offset1 = huff.readUInt32(8) offset2 = huff.readUInt32(12) table1 = huff.readIntArray(offset1, 256) huff.position(offset2) for (i in 1..32) { val mincode = huff.readUInt32().toLong() val maxcode = huff.readUInt32().toLong() mincodeTable[i] = mincode shl (32 - i) maxcodeTable[i] = ((maxcode + 1) shl (32 - i)) - 1 } for (i in 1.. 0 && bytesLeft-- > 0) { value = value or ((get().toLong() and 0xFF) shl (i * 8)) } return value } } ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/decompress/Lz77Decompressor.kt ================================================ package io.legado.app.lib.mobi.decompress import androidx.core.util.Pools.SynchronizedPool class Lz77Decompressor(private val textRecordSize: Int) : Decompressor { val pool = SynchronizedPool(2) override fun decompress(data: ByteArray): ByteArray { val out = pool.acquire() ?: ByteArray(textRecordSize) var i = 0 var o = 0 while (i < data.size) { var c = data[i++].toInt() and 0x00FF if (c in 0x01..0x08) { var j = 0 while (j < c && i + j < data.size) { out[o++] = data[i + j] j++ } i += c } else if (c <= 0x7f) { out[o++] = c.toByte() } else if (c >= 0xC0) { out[o++] = ' '.code.toByte() out[o++] = (c xor 0x80).toByte() } else { if (i < data.size) { c = c shl 8 or (data[i++].toInt() and 0xFF) val length = (c and 0x0007) + 3 val location = (c shr 3) and 0x7FF if (location in 1..o) { for (j in 0 until length) { val idx = o - location out[o++] = out[idx] } } } } } val result = ByteArray(o) System.arraycopy(out, 0, result, 0, o) return result.also { pool.release(out) } } } ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/decompress/PlainDecompressor.kt ================================================ package io.legado.app.lib.mobi.decompress class PlainDecompressor : Decompressor { override fun decompress(data: ByteArray): ByteArray { return data } } ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/entities/ExthRecordType.kt ================================================ package io.legado.app.lib.mobi.entities data class ExthRecordType( val name: String, val type: String = "string", val many: Boolean = false ) ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/entities/FdstHeader.kt ================================================ package io.legado.app.lib.mobi.entities data class FdstHeader( val magic: String, val numEntries: Int ) ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/entities/Fragment.kt ================================================ package io.legado.app.lib.mobi.entities data class Fragment( val insertOffset: Int, val selector: String, val index: Int, val offset: Int, val length: Int ) ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/entities/IndexData.kt ================================================ package io.legado.app.lib.mobi.entities import android.util.SparseArray data class IndexData( val table: List, val cncx: SparseArray ) ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/entities/IndexEntry.kt ================================================ package io.legado.app.lib.mobi.entities import android.util.SparseArray data class IndexEntry( val label: String, val tags: List, val tagMap: SparseArray ) ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/entities/IndexTag.kt ================================================ package io.legado.app.lib.mobi.entities data class IndexTag( val tagId: Int, val tagValues: List ) ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/entities/IndxHeader.kt ================================================ package io.legado.app.lib.mobi.entities data class IndxHeader( val magic: String, val length: Int, val type: Int, val idxt: Int, val numRecords: Int, val encoding: Int, val language: Int, val total: Int, val ordt: Int, val ligt: Int, val numLigt: Int, val numCncx: Int, ) ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/entities/KF6Section.kt ================================================ package io.legado.app.lib.mobi.entities data class KF6Section( val index: Int, val start: Int, val end: Int, val length: Int, val href: String, var next: KF6Section? = null ) ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/entities/KF8Header.kt ================================================ package io.legado.app.lib.mobi.entities data class KF8Header( val fdst: Int, val numFdst: Int, val frag: Int, val skel: Int, val guide: Int, ) ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/entities/KF8Pos.kt ================================================ package io.legado.app.lib.mobi.entities data class KF8Pos( val fid: Int, val offset: Int ) ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/entities/KF8Resource.kt ================================================ package io.legado.app.lib.mobi.entities data class KF8Resource( val resourceType: String, val id: Int, val type: String ) ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/entities/KF8Section.kt ================================================ package io.legado.app.lib.mobi.entities data class KF8Section( val index: Int, val skeleton: Skeleton, val frags: List, val fragEnd: Int, val length: Int, val totalLength: Int, val href: String, var next: KF8Section? = null ) { val linear get() = frags.isNotEmpty() } ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/entities/MobiEntryHeaders.kt ================================================ package io.legado.app.lib.mobi.entities data class MobiEntryHeaders( val palmdoc: PalmDocHeader, val mobi: MobiHeader, val exth: Map, val kf8: KF8Header? ) ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/entities/MobiHeader.kt ================================================ package io.legado.app.lib.mobi.entities data class MobiHeader( val identifier: String, val length: Int, val type: Int, val encoding: Int, val uid: Int, val version: Int, val titleOffset: Int, val titleLength: Int, val localeRegion: Int, val localeLanguage: Int, val resourceStart: Int, val huffcdic: Int, val numHuffcdic: Int, val exthFlag: Int, val trailingFlags: Int, val indx: Int, val title: String, val languege: String ) ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/entities/MobiMetadata.kt ================================================ package io.legado.app.lib.mobi.entities data class MobiMetadata( val identifier: String, val title: String, val author: List, val publisher: String, val language: String, val published: String, val description: String, val subject: List, val rights: String ) ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/entities/NCX.kt ================================================ package io.legado.app.lib.mobi.entities data class NCX( val index: Int, val offset: Int?, val size: Int?, val label: String, val headingLevel: Int?, val pos: List?, val parent: Int?, val firstChild: Int?, val lastChild: Int?, var children: List? = null ) ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/entities/PalmDocHeader.kt ================================================ package io.legado.app.lib.mobi.entities data class PalmDocHeader( val compression: Int, val numTextRecords: Int, val recordSize: Int, val encryption: Int ) ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/entities/Ptagx.kt ================================================ package io.legado.app.lib.mobi.entities data class Ptagx( val tag: Int, val tagValueCount: Int, val valueCount: Int?, val valueBytes: Int? ) ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/entities/Skeleton.kt ================================================ package io.legado.app.lib.mobi.entities data class Skeleton( val index: Int, val name: String, val numFrag: Int, val offset: Int, val length: Int ) ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/entities/TOC.kt ================================================ package io.legado.app.lib.mobi.entities data class TOC( val label: String, val href: String, val subitems: List? = null ) ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/entities/TagxHeader.kt ================================================ package io.legado.app.lib.mobi.entities data class TagxHeader( val magic: String, val length: Int, val numControlBytes: Int ) ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/entities/TagxTag.kt ================================================ package io.legado.app.lib.mobi.entities data class TagxTag( val tag: Int, val numValues: Int, val bitmask: Int, val controlByte: Int, ) ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/utils/BitwiseExtensions.kt ================================================ package io.legado.app.lib.mobi.utils internal infix fun Byte.and(mask: Int): Int = toInt() and mask internal infix fun Short.and(mask: Int): Int = toInt() and mask internal infix fun Int.and(mask: Long): Long = toLong() and mask ================================================ FILE: app/src/main/java/io/legado/app/lib/mobi/utils/ByteBufferExtensions.kt ================================================ package io.legado.app.lib.mobi.utils import java.nio.ByteBuffer import java.nio.charset.Charset fun ByteBuffer.readByteArray(offset: Int, len: Int): ByteArray { position(offset) val b = ByteArray(len) get(b) return b } fun ByteBuffer.readByteArray(len: Int): ByteArray { val b = ByteArray(len) get(b) return b } fun ByteBuffer.readIntArray(offset: Int, len: Int): IntArray { position(offset) return IntArray(len) { getInt() } } fun ByteBuffer.readUInt16Array(offset: Int, len: Int): IntArray { position(offset) return IntArray(len) { getShort() and 0xFFFF } } fun ByteBuffer.readString(len: Int): String { return String(readByteArray(len)) } fun ByteBuffer.readString(offset: Int, len: Int): String { return String(readByteArray(offset, len)) } fun ByteBuffer.readString(offset: Int, len: Int, charset: Charset): String { return String(readByteArray(offset, len), charset) } fun ByteBuffer.readUInt8(offset: Int): Int { position(offset) return get() and 0xFF } fun ByteBuffer.readUInt8(): Int { return get() and 0xFF } fun ByteBuffer.readUInt16(offset: Int): Int { position(offset) return getShort() and 0xFFFF } fun ByteBuffer.readUInt32(offset: Int): Int { position(offset) return getInt() } fun ByteBuffer.readUInt32(): Int { return getInt() } fun ByteBuffer.readUInt64(offset: Int): Long { position(offset) return getLong() } ================================================ FILE: app/src/main/java/io/legado/app/lib/permission/OnErrorCallback.kt ================================================ package io.legado.app.lib.permission interface OnErrorCallback { fun onError(e: Exception) } ================================================ FILE: app/src/main/java/io/legado/app/lib/permission/OnPermissionsDeniedCallback.kt ================================================ package io.legado.app.lib.permission interface OnPermissionsDeniedCallback { fun onPermissionsDenied(deniedPermissions: Array) } ================================================ FILE: app/src/main/java/io/legado/app/lib/permission/OnPermissionsGrantedCallback.kt ================================================ package io.legado.app.lib.permission interface OnPermissionsGrantedCallback { fun onPermissionsGranted() } ================================================ FILE: app/src/main/java/io/legado/app/lib/permission/OnPermissionsResultCallback.kt ================================================ package io.legado.app.lib.permission interface OnPermissionsResultCallback { fun onPermissionsGranted() fun onPermissionsDenied(deniedPermissions: Array?) fun onError(e: Exception) } ================================================ FILE: app/src/main/java/io/legado/app/lib/permission/OnRequestPermissionsResultCallback.kt ================================================ package io.legado.app.lib.permission interface OnRequestPermissionsResultCallback { fun onRequestPermissionsResult(permissions: Array, grantResults: IntArray) fun onSettingActivityResult() fun onError(e: Exception) } ================================================ FILE: app/src/main/java/io/legado/app/lib/permission/PermissionActivity.kt ================================================ package io.legado.app.lib.permission import android.annotation.SuppressLint import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.Settings import androidx.activity.addCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import io.legado.app.R import io.legado.app.constant.AppLog import io.legado.app.exception.NoStackTraceException import io.legado.app.utils.registerForActivityResult import io.legado.app.utils.toastOnUi import kotlinx.coroutines.launch class PermissionActivity : AppCompatActivity() { private var rationaleDialog: AlertDialog? = null private val settingActivityResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { onRequestPermissionFinish() } private val settingActivityResultAwait = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) private val requestPermissionResult = registerForActivityResult(ActivityResultContracts.RequestPermission()) private val requestPermissionsResult = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) @SuppressLint("BatteryLife") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val rationale = intent.getStringExtra(KEY_RATIONALE) val requestCode = intent.getIntExtra(KEY_INPUT_PERMISSIONS_CODE, 1000) val permissions = intent.getStringArrayExtra(KEY_INPUT_PERMISSIONS)!! when (intent.getIntExtra(KEY_INPUT_REQUEST_TYPE, Request.TYPE_REQUEST_PERMISSION)) { //权限请求 Request.TYPE_REQUEST_PERMISSION -> showSettingDialog(permissions, rationale) { lifecycleScope.launch { try { val result = requestPermissionsResult.launch(permissions) if (result.values.all { it }) { onRequestPermissionFinish() } else { openSettingsActivity() } } catch (e: Exception) { AppLog.put("请求权限出错\n$e", e, true) RequestPlugins.sRequestCallback?.onError(e) finish() } } } //跳转到设置界面 Request.TYPE_REQUEST_SETTING -> showSettingDialog(permissions, rationale) { openSettingsActivity() } //所有文件的管理权限 Request.TYPE_MANAGE_ALL_FILES_ACCESS -> showSettingDialog(permissions, rationale) { try { if (Permissions.isManageExternalStorage()) { val settingIntent = Intent( Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, Uri.parse("package:$packageName") ) settingActivityResult.launch(settingIntent) } else { throw NoStackTraceException("no MANAGE_ALL_FILES_ACCESS_PERMISSION") } } catch (e: Exception) { AppLog.put("请求所有文件的管理权限出错\n$e", e, true) RequestPlugins.sRequestCallback?.onError(e) finish() } } Request.TYPE_REQUEST_NOTIFICATIONS -> showSettingDialog(permissions, rationale) { lifecycleScope.launch { try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && requestPermissionResult.launch(Permissions.POST_NOTIFICATIONS) ) { onRequestPermissionFinish() } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { //这种方案适用于 API 26, 即8.0(含8.0)以上可以用 val intent = Intent() intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS intent.putExtra(Settings.EXTRA_APP_PACKAGE, packageName) intent.putExtra(Settings.EXTRA_CHANNEL_ID, applicationInfo.uid) settingActivityResult.launch(intent) } else { openSettingsActivity() } } catch (e: Exception) { AppLog.put("请求通知权限出错\n$e", e, true) RequestPlugins.sRequestCallback?.onError(e) finish() } } } Request.TYPE_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS -> showSettingDialog( permissions, rationale ) { lifecycleScope.launch { try { val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) intent.setData(Uri.parse("package:$packageName")) val className = "com.android.settings.fuelgauge.RequestIgnoreBatteryOptimizations" val activities = packageManager.queryIntentActivities( intent, PackageManager.MATCH_DEFAULT_ONLY ) if (activities.any { it.activityInfo.name == className }) { val component = intent.resolveActivity(packageManager) if (component.className != className) { intent.setClassName("com.android.settings", className) settingActivityResultAwait.launch(intent) } } intent.component = null settingActivityResult.launch(intent) } catch (e: Exception) { AppLog.put("请求后台权限出错\n$e", e, true) RequestPlugins.sRequestCallback?.onError(e) finish() } } } } onBackPressedDispatcher.addCallback(this) { } } private fun onRequestPermissionFinish() { RequestPlugins.sRequestCallback?.onSettingActivityResult() finish() } private fun openSettingsActivity() { try { val settingIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) settingIntent.data = Uri.fromParts("package", packageName, null) settingActivityResult.launch(settingIntent) } catch (e: Exception) { toastOnUi(R.string.tip_cannot_jump_setting_page) RequestPlugins.sRequestCallback?.onError(e) finish() } } override fun onRequestPermissionsResult( requestCode: Int, permissions: Array, grantResults: IntArray ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) RequestPlugins.sRequestCallback?.onRequestPermissionsResult( permissions, grantResults ) finish() } override fun startActivity(intent: Intent) { super.startActivity(intent) @Suppress("DEPRECATION") overridePendingTransition(0, 0) } override fun finish() { super.finish() @Suppress("DEPRECATION") overridePendingTransition(0, 0) } private fun showSettingDialog( permissions: Array, rationale: CharSequence?, onOk: () -> Unit ) { rationaleDialog?.dismiss() if (rationale.isNullOrEmpty()) { finish() return } rationaleDialog = AlertDialog.Builder(this) .setTitle(R.string.dialog_title) .setMessage(rationale) .setPositiveButton(R.string.dialog_setting) { _, _ -> onOk.invoke() } .setNegativeButton(R.string.dialog_cancel) { _, _ -> RequestPlugins.sRequestCallback?.onRequestPermissionsResult( permissions, IntArray(0) ) finish() }.setOnCancelListener { RequestPlugins.sRequestCallback?.onRequestPermissionsResult( permissions, IntArray(0) ) finish() } .show() } companion object { const val KEY_RATIONALE = "KEY_RATIONALE" const val KEY_INPUT_REQUEST_TYPE = "KEY_INPUT_REQUEST_TYPE" const val KEY_INPUT_PERMISSIONS_CODE = "KEY_INPUT_PERMISSIONS_CODE" const val KEY_INPUT_PERMISSIONS = "KEY_INPUT_PERMISSIONS" } } ================================================ FILE: app/src/main/java/io/legado/app/lib/permission/Permissions.kt ================================================ package io.legado.app.lib.permission import android.os.Build @Suppress("unused") object Permissions { const val POST_NOTIFICATIONS = "android.permission.POST_NOTIFICATIONS" const val READ_CALENDAR = "android.permission.READ_CALENDAR" const val WRITE_CALENDAR = "android.permission.WRITE_CALENDAR" const val CAMERA = "android.permission.CAMERA" const val READ_CONTACTS = "android.permission.READ_CONTACTS" const val WRITE_CONTACTS = "android.permission.WRITE_CONTACTS" const val GET_ACCOUNTS = "android.permission.GET_ACCOUNTS" const val ACCESS_FINE_LOCATION = "android.permission.ACCESS_FINE_LOCATION" const val ACCESS_COARSE_LOCATION = "android.permission.ACCESS_COARSE_LOCATION" const val RECORD_AUDIO = "android.permission.RECORD_AUDIO" const val READ_PHONE_STATE = "android.permission.READ_PHONE_STATE" const val CALL_PHONE = "android.permission.CALL_PHONE" const val READ_CALL_LOG = "android.permission.READ_CALL_LOG" const val WRITE_CALL_LOG = "android.permission.WRITE_CALL_LOG" const val ADD_VOICEMAIL = "com.android.voicemail.permission.ADD_VOICEMAIL" const val USE_SIP = "android.permission.USE_SIP" const val PROCESS_OUTGOING_CALLS = "android.permission.PROCESS_OUTGOING_CALLS" const val BODY_SENSORS = "android.permission.BODY_SENSORS" const val SEND_SMS = "android.permission.SEND_SMS" const val RECEIVE_SMS = "android.permission.RECEIVE_SMS" const val READ_SMS = "android.permission.READ_SMS" const val RECEIVE_WAP_PUSH = "android.permission.RECEIVE_WAP_PUSH" const val RECEIVE_MMS = "android.permission.RECEIVE_MMS" const val READ_EXTERNAL_STORAGE = "android.permission.READ_EXTERNAL_STORAGE" const val WRITE_EXTERNAL_STORAGE = "android.permission.WRITE_EXTERNAL_STORAGE" const val MANAGE_EXTERNAL_STORAGE = "android.permission.MANAGE_EXTERNAL_STORAGE" const val ACCESS_MEDIA_LOCATION = "android.permission.ACCESS_MEDIA_LOCATION" const val REQUEST_IGNORE_BATTERY_OPTIMIZATIONS = "android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" object Group { val STORAGE = if (isManageExternalStorage()) { arrayOf(MANAGE_EXTERNAL_STORAGE) } else { arrayOf(READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE) } val CAMERA = arrayOf(Permissions.CAMERA) val CALENDAR = arrayOf(READ_CALENDAR, WRITE_CALENDAR) val CONTACTS = arrayOf(READ_CONTACTS, WRITE_CONTACTS, GET_ACCOUNTS) val LOCATION = arrayOf(ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION) val MICROPHONE = arrayOf(RECORD_AUDIO) val PHONE = arrayOf( READ_PHONE_STATE, CALL_PHONE, READ_CALL_LOG, WRITE_CALL_LOG, ADD_VOICEMAIL, USE_SIP, PROCESS_OUTGOING_CALLS ) val SENSORS = arrayOf(BODY_SENSORS) val SMS = arrayOf( SEND_SMS, RECEIVE_SMS, READ_SMS, RECEIVE_WAP_PUSH, RECEIVE_MMS ) } fun isManageExternalStorage(): Boolean { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.R } } ================================================ FILE: app/src/main/java/io/legado/app/lib/permission/PermissionsCompat.kt ================================================ package io.legado.app.lib.permission import androidx.annotation.StringRes @Suppress("unused") class PermissionsCompat private constructor() { private var request: Request? = null fun request() { RequestManager.pushRequest(request) } class Builder { private val request: Request = Request() fun addPermissions(vararg permissions: String): Builder { request.addPermissions(*permissions) return this } fun onGranted(callback: () -> Unit): Builder { request.setOnGrantedCallback(object : OnPermissionsGrantedCallback { override fun onPermissionsGranted() { callback() } }) return this } fun onDenied(callback: (deniedPermissions: Array) -> Unit): Builder { request.setOnDeniedCallback(object : OnPermissionsDeniedCallback { override fun onPermissionsDenied(deniedPermissions: Array) { callback(deniedPermissions) } }) return this } fun onError(callback: (e: Exception) -> Unit): Builder { request.setOnErrorCallBack(object : OnErrorCallback{ override fun onError(e: Exception) { callback(e) } }) return this } fun rationale(rationale: CharSequence): Builder { request.setRationale(rationale) return this } fun rationale(@StringRes resId: Int): Builder { request.setRationale(resId) return this } fun build(): PermissionsCompat { val compat = PermissionsCompat() compat.request = request return compat } fun request(): PermissionsCompat { val compat = build() compat.request() return compat } } } ================================================ FILE: app/src/main/java/io/legado/app/lib/permission/Request.kt ================================================ package io.legado.app.lib.permission import android.annotation.SuppressLint import android.content.pm.PackageManager import android.os.Build import android.os.Environment import androidx.annotation.StringRes import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import io.legado.app.utils.startActivity import splitties.init.appCtx import splitties.systemservices.powerManager @Suppress("MemberVisibilityCanBePrivate") internal class Request : OnRequestPermissionsResultCallback { internal val requestTime: Long = System.currentTimeMillis() private var requestCode: Int = TYPE_REQUEST_PERMISSION private var permissions: ArrayList = ArrayList() private var grantedCallback: OnPermissionsGrantedCallback? = null private var deniedCallback: OnPermissionsDeniedCallback? = null private var errorCallback: OnErrorCallback? = null private var rationale: CharSequence? = null private val deniedPermissions: Array? get() { return getDeniedPermissions(this.permissions.toTypedArray()) } fun addPermissions(vararg permissions: String) { this.permissions.addAll(listOf(*permissions)) } fun setOnGrantedCallback(callback: OnPermissionsGrantedCallback) { grantedCallback = callback } fun setOnDeniedCallback(callback: OnPermissionsDeniedCallback) { deniedCallback = callback } fun setOnErrorCallBack(callback: OnErrorCallback) { errorCallback = callback } fun setRationale(@StringRes resId: Int) { rationale = appCtx.getString(resId) } fun setRationale(rationale: CharSequence) { this.rationale = rationale } @SuppressLint("ObsoleteSdkInt") fun start() { RequestPlugins.setOnRequestPermissionsCallback(this) val deniedPermissions = deniedPermissions val rationale = this.rationale if (deniedPermissions == null) { onPermissionsGranted() return } if (rationale == null) { onPermissionsDenied(deniedPermissions) return } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { toSetting() } else { if (deniedPermissions.contains(Permissions.MANAGE_EXTERNAL_STORAGE)) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { toManageFileSetting(deniedPermissions) } } else if (deniedPermissions.contains(Permissions.POST_NOTIFICATIONS)) { toNotificationSetting(deniedPermissions) } else if (deniedPermissions.contains(Permissions.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { toIgnoreBatterySetting(deniedPermissions) } } else if (deniedPermissions.isNotEmpty()) { appCtx.startActivity { putExtra(PermissionActivity.KEY_RATIONALE, rationale) putExtra(PermissionActivity.KEY_INPUT_REQUEST_TYPE, TYPE_REQUEST_PERMISSION) putExtra(PermissionActivity.KEY_INPUT_PERMISSIONS_CODE, requestCode) putExtra(PermissionActivity.KEY_INPUT_PERMISSIONS, deniedPermissions) } } } } fun clear() { grantedCallback = null deniedCallback = null } fun getDeniedPermissions(permissions: Array): Array? { val deniedPermissionList = ArrayList() for (permission in permissions) { when (permission) { Permissions.POST_NOTIFICATIONS -> { if (!NotificationManagerCompat.from(appCtx).areNotificationsEnabled()) { deniedPermissionList.add(permission) } } Permissions.MANAGE_EXTERNAL_STORAGE -> { if (Permissions.isManageExternalStorage()) { if (!Environment.isExternalStorageManager()) { deniedPermissionList.add(permission) } } } Permissions.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS -> { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (!powerManager.isIgnoringBatteryOptimizations(appCtx.packageName)) { deniedPermissionList.add(permission) } } } else -> { if ( ContextCompat.checkSelfPermission(appCtx, permission) != PackageManager.PERMISSION_GRANTED ) { deniedPermissionList.add(permission) } } } } val size = deniedPermissionList.size if (size > 0) { return deniedPermissionList.toTypedArray() } return null } private fun onPermissionsGranted() { try { grantedCallback?.onPermissionsGranted() } catch (ignore: Exception) { } RequestPlugins.sResultCallback?.onPermissionsGranted() } private fun onPermissionsDenied(deniedPermissions: Array) { try { deniedCallback?.onPermissionsDenied(deniedPermissions) } catch (ignore: Exception) { } RequestPlugins.sResultCallback?.onPermissionsDenied(deniedPermissions) } private fun toSetting() { appCtx.startActivity { putExtra(PermissionActivity.KEY_RATIONALE, rationale) putExtra(PermissionActivity.KEY_INPUT_REQUEST_TYPE, TYPE_REQUEST_SETTING) } } private fun toManageFileSetting(deniedPermissions: Array) { appCtx.startActivity { putExtra(PermissionActivity.KEY_RATIONALE, rationale) putExtra(PermissionActivity.KEY_INPUT_REQUEST_TYPE, TYPE_MANAGE_ALL_FILES_ACCESS) putExtra(PermissionActivity.KEY_INPUT_PERMISSIONS_CODE, requestCode) putExtra(PermissionActivity.KEY_INPUT_PERMISSIONS, deniedPermissions) } } private fun toNotificationSetting(deniedPermissions: Array) { appCtx.startActivity { putExtra(PermissionActivity.KEY_RATIONALE, rationale) putExtra(PermissionActivity.KEY_INPUT_REQUEST_TYPE, TYPE_REQUEST_NOTIFICATIONS) putExtra(PermissionActivity.KEY_INPUT_PERMISSIONS_CODE, requestCode) putExtra(PermissionActivity.KEY_INPUT_PERMISSIONS, deniedPermissions) } } private fun toIgnoreBatterySetting(deniedPermissions: Array) { appCtx.startActivity { putExtra(PermissionActivity.KEY_RATIONALE, rationale) putExtra( PermissionActivity.KEY_INPUT_REQUEST_TYPE, TYPE_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS ) putExtra(PermissionActivity.KEY_INPUT_PERMISSIONS_CODE, requestCode) putExtra(PermissionActivity.KEY_INPUT_PERMISSIONS, deniedPermissions) } } override fun onRequestPermissionsResult( permissions: Array, grantResults: IntArray ) { val deniedPermissions = getDeniedPermissions(permissions) if (deniedPermissions != null) { onPermissionsDenied(deniedPermissions) } else { onPermissionsGranted() } } override fun onSettingActivityResult() { val deniedPermissions = deniedPermissions if (deniedPermissions == null) { onPermissionsGranted() } else { onPermissionsDenied(deniedPermissions) } } override fun onError(e: Exception) { errorCallback?.onError(e) RequestPlugins.sResultCallback?.onError(e) } companion object { const val TYPE_REQUEST_PERMISSION = 1 const val TYPE_REQUEST_SETTING = 2 const val TYPE_MANAGE_ALL_FILES_ACCESS = 3 const val TYPE_REQUEST_NOTIFICATIONS = 4 const val TYPE_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS = 5 } } ================================================ FILE: app/src/main/java/io/legado/app/lib/permission/RequestManager.kt ================================================ package io.legado.app.lib.permission import android.os.Handler import android.os.Looper import java.util.* internal object RequestManager : OnPermissionsResultCallback { private var requests: Stack? = null private var request: Request? = null private val handler = Handler(Looper.getMainLooper()) private val requestRunnable = Runnable { request?.start() } private val isCurrentRequestInvalid: Boolean get() = request?.let { System.currentTimeMillis() - it.requestTime > 5 * 1000L } ?: true init { RequestPlugins.setOnPermissionsResultCallback(this) } fun pushRequest(request: Request?) { if (request == null) return if (requests == null) { requests = Stack() } requests?.let { val index = it.indexOf(request) if (index >= 0) { val to = it.size - 1 if (index != to) { @Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS") Collections.swap(requests, index, to) } } else { it.push(request) } if (!it.empty() && isCurrentRequestInvalid) { this.request = it.pop() handler.post(requestRunnable) } } } private fun startNextRequest() { request?.clear() request = null requests?.let { request = if (it.empty()) null else it.pop() request?.let { handler.post(requestRunnable) } } } override fun onPermissionsGranted() { startNextRequest() } override fun onPermissionsDenied(deniedPermissions: Array?) { startNextRequest() } override fun onError(e: Exception) { startNextRequest() } } ================================================ FILE: app/src/main/java/io/legado/app/lib/permission/RequestPlugins.kt ================================================ package io.legado.app.lib.permission internal object RequestPlugins { @Volatile var sRequestCallback: OnRequestPermissionsResultCallback? = null @Volatile var sResultCallback: OnPermissionsResultCallback? = null fun setOnRequestPermissionsCallback(callback: OnRequestPermissionsResultCallback) { sRequestCallback = callback } fun setOnPermissionsResultCallback(callback: OnPermissionsResultCallback) { sResultCallback = callback } } ================================================ FILE: app/src/main/java/io/legado/app/lib/prefs/ColorPreference.kt ================================================ package io.legado.app.lib.prefs import android.content.Context import android.content.ContextWrapper import android.content.res.TypedArray import android.graphics.Color import android.os.Bundle import android.util.AttributeSet import androidx.annotation.ColorInt import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.fragment.app.FragmentActivity import androidx.preference.PreferenceViewHolder import com.jaredrummler.android.colorpicker.* import io.legado.app.utils.ColorUtils import io.legado.app.utils.applyTint @Suppress("MemberVisibilityCanBePrivate", "unused") class ColorPreference(context: Context, attrs: AttributeSet) : Preference(context, attrs), ColorPickerDialogListener { var onSaveColor: ((color: Int) -> Boolean)? = null private val sizeNormal = 0 private val sizeLarge = 1 private var onShowDialogListener: OnShowDialogListener? = null private var mColor = Color.BLACK private var showDialog: Boolean = false @ColorPickerDialog.DialogType private var dialogType: Int = 0 private var colorShape: Int = 0 private var allowPresets: Boolean = false private var allowCustom: Boolean = false private var showAlphaSlider: Boolean = false private var showColorShades: Boolean = false private var previewSize: Int = 0 private var presets: IntArray? = null private var dialogTitle: Int = 0 init { isPersistent = true layoutResource = io.legado.app.R.layout.view_preference val a = context.obtainStyledAttributes(attrs, R.styleable.ColorPreference) showDialog = a.getBoolean(R.styleable.ColorPreference_cpv_showDialog, true) dialogType = a.getInt(R.styleable.ColorPreference_cpv_dialogType, ColorPickerDialog.TYPE_PRESETS) colorShape = a.getInt(R.styleable.ColorPreference_cpv_colorShape, ColorShape.CIRCLE) allowPresets = a.getBoolean(R.styleable.ColorPreference_cpv_allowPresets, true) allowCustom = a.getBoolean(R.styleable.ColorPreference_cpv_allowCustom, true) showAlphaSlider = a.getBoolean(R.styleable.ColorPreference_cpv_showAlphaSlider, false) showColorShades = a.getBoolean(R.styleable.ColorPreference_cpv_showColorShades, true) previewSize = a.getInt(R.styleable.ColorPreference_cpv_previewSize, sizeNormal) val presetsResId = a.getResourceId(R.styleable.ColorPreference_cpv_colorPresets, 0) dialogTitle = a.getResourceId(R.styleable.ColorPreference_cpv_dialogTitle, R.string.cpv_default_title) presets = if (presetsResId != 0) { context.resources.getIntArray(presetsResId) } else { ColorPickerDialog.MATERIAL_COLORS } widgetLayoutResource = if (colorShape == ColorShape.CIRCLE) { if (previewSize == sizeLarge) R.layout.cpv_preference_circle_large else R.layout.cpv_preference_circle } else { if (previewSize == sizeLarge) R.layout.cpv_preference_square_large else R.layout.cpv_preference_square } a.recycle() } override fun onClick() { super.onClick() if (onShowDialogListener != null) { onShowDialogListener!!.onShowColorPickerDialog(title as String, mColor) } else if (showDialog) { val dialog = ColorPickerDialogCompat.newBuilder() .setDialogType(dialogType) .setDialogTitle(dialogTitle) .setColorShape(colorShape) .setPresets(presets!!) .setAllowPresets(allowPresets) .setAllowCustom(allowCustom) .setShowAlphaSlider(showAlphaSlider) .setShowColorShades(showColorShades) .setColor(mColor) .create() dialog.setColorPickerDialogListener(this) getActivity().supportFragmentManager .beginTransaction() .add(dialog, getFragmentTag()) .commitAllowingStateLoss() } } private fun getActivity(): FragmentActivity { val context = context if (context is FragmentActivity) { return context } else if (context is ContextWrapper) { val baseContext = context.baseContext if (baseContext is FragmentActivity) { return baseContext } } throw IllegalStateException("Error getting activity from context") } override fun onAttached() { super.onAttached() if (showDialog) { val fragment = getActivity().supportFragmentManager.findFragmentByTag(getFragmentTag()) as ColorPickerDialog? fragment?.setColorPickerDialogListener(this) } } override fun onBindView(holder: PreferenceViewHolder) { val v = bindView( context, holder, icon, title, summary, widgetLayoutResource, R.id.cpv_preference_preview_color_panel, 30, 30 ) if (v is ColorPanelView) { v.color = mColor } } override fun onSetInitialValue(defaultValue: Any?) { super.onSetInitialValue(defaultValue) if (defaultValue is Int) { mColor = if (!showAlphaSlider) ColorUtils.withAlpha(defaultValue, 1f) else defaultValue persistInt(mColor) } else { mColor = getPersistedInt(-0x1000000) } } override fun onGetDefaultValue(a: TypedArray, index: Int): Any { return a.getInteger(index, Color.BLACK) } override fun onColorSelected(dialogId: Int, @ColorInt color: Int) { //返回值为true时说明已经处理过,不再处理 if (onSaveColor?.invoke(color) == true) { return } saveValue(color) } override fun onDialogDismissed(dialogId: Int) { // no-op } /** * Set the new color * * @param color The newly selected color */ fun saveValue(@ColorInt color: Int) { mColor = if (showAlphaSlider) color else ColorUtils.withAlpha(color, 1f) persistInt(mColor) notifyChanged() callChangeListener(color) } /** * Get the colors that will be shown in the [ColorPickerDialog]. * * @return An array of color ints */ fun getPresets(): IntArray? { return presets } /** * Set the colors shown in the [ColorPickerDialog]. * * @param presets An array of color ints */ fun setPresets(presets: IntArray) { this.presets = presets } /** * The listener used for showing the [ColorPickerDialog]. * Call [.saveValue] after the user chooses a color. * If this is set then it is up to you to show the dialog. * * @param listener The listener to show the dialog */ fun setOnShowDialogListener(listener: OnShowDialogListener) { onShowDialogListener = listener } /** * The tag used for the [ColorPickerDialog]. * * @return The tag */ fun getFragmentTag(): String { return "color_$key" } interface OnShowDialogListener { fun onShowColorPickerDialog(title: String, currentColor: Int) } internal class ColorPickerDialogCompat : ColorPickerDialog() { override fun onStart() { super.onStart() val alertDialog = dialog as? AlertDialog alertDialog?.applyTint() } companion object { fun newBuilder(): Builder { return Builder() } private const val ARG_ID = "id" private const val ARG_TYPE = "dialogType" private const val ARG_COLOR = "color" private const val ARG_ALPHA = "alpha" private const val ARG_PRESETS = "presets" private const val ARG_ALLOW_PRESETS = "allowPresets" private const val ARG_ALLOW_CUSTOM = "allowCustom" private const val ARG_DIALOG_TITLE = "dialogTitle" private const val ARG_SHOW_COLOR_SHADES = "showColorShades" private const val ARG_COLOR_SHAPE = "colorShape" private const val ARG_PRESETS_BUTTON_TEXT = "presetsButtonText" private const val ARG_CUSTOM_BUTTON_TEXT = "customButtonText" private const val ARG_SELECTED_BUTTON_TEXT = "selectedButtonText" } class Builder internal constructor() { internal var colorPickerDialogListener: ColorPickerDialogListener? = null @StringRes internal var dialogTitle = R.string.cpv_default_title @StringRes internal var presetsButtonText = R.string.cpv_presets @StringRes internal var customButtonText = R.string.cpv_custom @StringRes internal var selectedButtonText = R.string.cpv_select @DialogType internal var dialogType = TYPE_PRESETS internal var presets = MATERIAL_COLORS @ColorInt internal var color = Color.BLACK internal var dialogId = 0 internal var showAlphaSlider = false internal var allowPresets = true internal var allowCustom = true internal var showColorShades = true @ColorShape internal var colorShape = ColorShape.CIRCLE /** * Set the dialog title string resource id * * @param dialogTitle The string resource used for the dialog title * @return This builder object for chaining method calls */ fun setDialogTitle(@StringRes dialogTitle: Int): Builder { this.dialogTitle = dialogTitle return this } /** * Set the selected button text string resource id * * @param selectedButtonText The string resource used for the selected button text * @return This builder object for chaining method calls */ fun setSelectedButtonText(@StringRes selectedButtonText: Int): Builder { this.selectedButtonText = selectedButtonText return this } /** * Set the presets button text string resource id * * @param presetsButtonText The string resource used for the presets button text * @return This builder object for chaining method calls */ fun setPresetsButtonText(@StringRes presetsButtonText: Int): Builder { this.presetsButtonText = presetsButtonText return this } /** * Set the custom button text string resource id * * @param customButtonText The string resource used for the custom button text * @return This builder object for chaining method calls */ fun setCustomButtonText(@StringRes customButtonText: Int): Builder { this.customButtonText = customButtonText return this } /** * Set which dialog view to show. * * @param dialogType Either [ColorPickerDialog.TYPE_CUSTOM] or [ColorPickerDialog.TYPE_PRESETS]. * @return This builder object for chaining method calls */ fun setDialogType(@DialogType dialogType: Int): Builder { this.dialogType = dialogType return this } /** * Set the colors used for the presets * * @param presets An array of color ints. * @return This builder object for chaining method calls */ fun setPresets(presets: IntArray): Builder { this.presets = presets return this } /** * Set the original color * * @param color The default color for the color picker * @return This builder object for chaining method calls */ fun setColor(color: Int): Builder { this.color = color return this } /** * Set the dialog id used for callbacks * * @param dialogId The id that is sent back to the [ColorPickerDialogListener]. * @return This builder object for chaining method calls */ fun setDialogId(dialogId: Int): Builder { this.dialogId = dialogId return this } /** * Show the alpha slider * * @param showAlphaSlider `true` to show the alpha slider. Currently only supported with the [ ]. * @return This builder object for chaining method calls */ fun setShowAlphaSlider(showAlphaSlider: Boolean): Builder { this.showAlphaSlider = showAlphaSlider return this } /** * Show/Hide a neutral button to select preset colors. * * @param allowPresets `false` to disable showing the presets button. * @return This builder object for chaining method calls */ fun setAllowPresets(allowPresets: Boolean): Builder { this.allowPresets = allowPresets return this } /** * Show/Hide the neutral button to select a custom color. * * @param allowCustom `false` to disable showing the custom button. * @return This builder object for chaining method calls */ fun setAllowCustom(allowCustom: Boolean): Builder { this.allowCustom = allowCustom return this } /** * Show/Hide the color shades in the presets picker * * @param showColorShades `false` to hide the color shades. * @return This builder object for chaining method calls */ fun setShowColorShades(showColorShades: Boolean): Builder { this.showColorShades = showColorShades return this } /** * Set the shape of the color panel view. * * @param colorShape Either [ColorShape.CIRCLE] or [ColorShape.SQUARE]. * @return This builder object for chaining method calls */ fun setColorShape(colorShape: Int): Builder { this.colorShape = colorShape return this } /** * Create the [ColorPickerDialog] instance. * * @return A new [ColorPickerDialog]. * @see .show */ fun create(): ColorPickerDialog { val dialog = ColorPickerDialogCompat() val args = Bundle() args.putInt(ARG_ID, dialogId) args.putInt(ARG_TYPE, dialogType) args.putInt(ARG_COLOR, color) args.putIntArray(ARG_PRESETS, presets) args.putBoolean(ARG_ALPHA, showAlphaSlider) args.putBoolean(ARG_ALLOW_CUSTOM, allowCustom) args.putBoolean(ARG_ALLOW_PRESETS, allowPresets) args.putInt(ARG_DIALOG_TITLE, dialogTitle) args.putBoolean(ARG_SHOW_COLOR_SHADES, showColorShades) args.putInt(ARG_COLOR_SHAPE, colorShape) args.putInt(ARG_PRESETS_BUTTON_TEXT, presetsButtonText) args.putInt(ARG_CUSTOM_BUTTON_TEXT, customButtonText) args.putInt(ARG_SELECTED_BUTTON_TEXT, selectedButtonText) dialog.arguments = args return dialog } } } } ================================================ FILE: app/src/main/java/io/legado/app/lib/prefs/EditTextPreference.kt ================================================ package io.legado.app.lib.prefs import android.content.Context import android.util.AttributeSet import android.widget.TextView import androidx.preference.EditTextPreference.OnBindEditTextListener import androidx.preference.PreferenceViewHolder import io.legado.app.R import io.legado.app.lib.theme.accentColor import io.legado.app.utils.applyTint class EditTextPreference(context: Context, attrs: AttributeSet) : androidx.preference.EditTextPreference(context, attrs) { private var mOnBindEditTextListener: OnBindEditTextListener? = null private val onBindEditTextListener = OnBindEditTextListener { editText -> editText.applyTint(context.accentColor) mOnBindEditTextListener?.onBindEditText(editText) } init { // isPersistent = true layoutResource = R.layout.view_preference super.setOnBindEditTextListener(onBindEditTextListener) } override fun onBindViewHolder(holder: PreferenceViewHolder) { Preference.bindView(context, holder, icon, title, summary, null, null) super.onBindViewHolder(holder) } override fun setOnBindEditTextListener(onBindEditTextListener: OnBindEditTextListener?) { mOnBindEditTextListener = onBindEditTextListener } } ================================================ FILE: app/src/main/java/io/legado/app/lib/prefs/EditTextPreferenceDialog.kt ================================================ package io.legado.app.lib.prefs import android.app.Dialog import android.os.Bundle import android.view.Gravity import android.view.WindowManager import androidx.appcompat.app.AlertDialog import androidx.preference.EditTextPreferenceDialogFragmentCompat import androidx.preference.PreferenceDialogFragmentCompat import io.legado.app.R import io.legado.app.help.config.AppConfig import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.filletBackground import io.legado.app.utils.dpToPx class EditTextPreferenceDialog : EditTextPreferenceDialogFragmentCompat() { companion object { fun newInstance(key: String): EditTextPreferenceDialog { val fragment = EditTextPreferenceDialog() val b = Bundle(1) b.putString(PreferenceDialogFragmentCompat.ARG_KEY, key) fragment.arguments = b return fragment } } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val dialog = super.onCreateDialog(savedInstanceState) dialog.window?.setBackgroundDrawable(requireContext().filletBackground) dialog.window?.decorView?.post { (dialog as AlertDialog).run { getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor(accentColor) getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(accentColor) getButton(AlertDialog.BUTTON_NEUTRAL)?.setTextColor(accentColor) } } return dialog } override fun onStart() { super.onStart() if (AppConfig.isEInkMode) { dialog?.window?.let { it.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) val attr = it.attributes attr.dimAmount = 0.0f attr.windowAnimations = 0 it.attributes = attr it.setBackgroundDrawableResource(R.color.transparent) when (attr.gravity) { Gravity.TOP -> it.decorView.setBackgroundResource(R.drawable.bg_eink_border_bottom) Gravity.BOTTOM -> it.decorView.setBackgroundResource(R.drawable.bg_eink_border_top) else -> { val padding = 2.dpToPx(); it.decorView.setPadding(padding, padding, padding, padding) it.decorView.setBackgroundResource(R.drawable.bg_eink_border_dialog) } } } } } } ================================================ FILE: app/src/main/java/io/legado/app/lib/prefs/IconListPreference.kt ================================================ package io.legado.app.lib.prefs import android.content.Context import android.content.ContextWrapper import android.graphics.drawable.Drawable import android.os.Bundle import android.util.AttributeSet import android.view.View import android.view.ViewGroup import android.widget.ImageView import androidx.fragment.app.FragmentActivity import androidx.preference.ListPreference import androidx.preference.PreferenceViewHolder import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.databinding.DialogRecyclerViewBinding import io.legado.app.databinding.ItemIconPreferenceBinding import io.legado.app.lib.theme.primaryColor import io.legado.app.utils.getCompatDrawable import io.legado.app.utils.setLayout import io.legado.app.utils.viewbindingdelegate.viewBinding class IconListPreference(context: Context, attrs: AttributeSet) : ListPreference(context, attrs) { private var iconNames: Array private val mEntryDrawables = arrayListOf() init { layoutResource = R.layout.view_preference widgetLayoutResource = R.layout.view_icon val a = context.theme.obtainStyledAttributes(attrs, R.styleable.IconListPreference, 0, 0) iconNames = try { a.getTextArray(R.styleable.IconListPreference_icons) } finally { a.recycle() } for (iconName in iconNames) { val resId = context.resources .getIdentifier(iconName.toString(), "mipmap", context.packageName) var d: Drawable? = null kotlin.runCatching { d = context.getCompatDrawable(resId) } mEntryDrawables.add(d) } } override fun onBindViewHolder(holder: PreferenceViewHolder) { super.onBindViewHolder(holder) val v = Preference.bindView( context, holder, icon, title, summary, widgetLayoutResource, R.id.preview, 50, 50 ) if (v is ImageView) { val selectedIndex = findIndexOfValue(value) if (selectedIndex >= 0) { val drawable = mEntryDrawables[selectedIndex] v.setImageDrawable(drawable) } } } override fun onClick() { getActivity()?.let { val dialog = IconDialog().apply { val args = Bundle() args.putString("value", value) args.putCharSequenceArray("entries", entries) args.putCharSequenceArray("entryValues", entryValues) args.putCharSequenceArray("iconNames", iconNames) arguments = args onChanged = { value -> this@IconListPreference.value = value } } it.supportFragmentManager .beginTransaction() .add(dialog, getFragmentTag()) .commitAllowingStateLoss() } } override fun onAttached() { super.onAttached() val fragment = getActivity()?.supportFragmentManager?.findFragmentByTag(getFragmentTag()) as IconDialog? fragment?.onChanged = { value -> this@IconListPreference.value = value } } private fun getActivity(): FragmentActivity? { val context = context if (context is FragmentActivity) { return context } else if (context is ContextWrapper) { val baseContext = context.baseContext if (baseContext is FragmentActivity) { return baseContext } } return null } private fun getFragmentTag(): String { return "icon_$key" } class IconDialog : BaseDialogFragment(R.layout.dialog_recycler_view) { var onChanged: ((value: String) -> Unit)? = null var dialogValue: String? = null var dialogEntries: Array? = null var dialogEntryValues: Array? = null var dialogIconNames: Array? = null private val binding by viewBinding(DialogRecyclerViewBinding::bind) override fun onStart() { super.onStart() setLayout(0.8f, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) binding.toolBar.setTitle(R.string.change_icon) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) val adapter = Adapter(requireContext()) binding.recyclerView.adapter = adapter arguments?.let { dialogValue = it.getString("value") dialogEntries = it.getCharSequenceArray("entries") dialogEntryValues = it.getCharSequenceArray("entryValues") dialogIconNames = it.getCharSequenceArray("iconNames") dialogEntryValues?.let { values -> adapter.setItems(values.toList()) } } } inner class Adapter(context: Context) : RecyclerAdapter(context) { override fun getViewBinding(parent: ViewGroup): ItemIconPreferenceBinding { return ItemIconPreferenceBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemIconPreferenceBinding, item: CharSequence, payloads: MutableList ) { binding.run { val index = findIndexOfValue(item.toString()) dialogEntries?.let { label.text = it[index] } dialogIconNames?.let { val resId = context.resources .getIdentifier(it[index].toString(), "mipmap", context.packageName) val d = try { context.getCompatDrawable(resId) } catch (e: Exception) { null } d?.let { icon.setImageDrawable(d) } } label.isChecked = item.toString() == dialogValue root.setOnClickListener { onChanged?.invoke(item.toString()) this@IconDialog.dismissAllowingStateLoss() } } } override fun registerListener( holder: ItemViewHolder, binding: ItemIconPreferenceBinding ) { holder.itemView.setOnClickListener { getItem(holder.layoutPosition)?.let { onChanged?.invoke(it.toString()) } } } private fun findIndexOfValue(value: String?): Int { dialogEntryValues?.let { values -> for (i in values.indices.reversed()) { if (values[i] == value) { return i } } } return -1 } } } } ================================================ FILE: app/src/main/java/io/legado/app/lib/prefs/ListPreferenceDialog.kt ================================================ package io.legado.app.lib.prefs import android.app.Dialog import android.os.Bundle import android.view.Gravity import android.view.WindowManager import androidx.appcompat.app.AlertDialog import androidx.core.view.forEach import androidx.preference.ListPreferenceDialogFragmentCompat import androidx.preference.PreferenceDialogFragmentCompat import io.legado.app.R import io.legado.app.help.config.AppConfig import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.filletBackground import io.legado.app.utils.applyTint import io.legado.app.utils.dpToPx class ListPreferenceDialog : ListPreferenceDialogFragmentCompat() { companion object { fun newInstance(key: String?): ListPreferenceDialog { val fragment = ListPreferenceDialog() val b = Bundle(1) b.putString(PreferenceDialogFragmentCompat.ARG_KEY, key) fragment.arguments = b return fragment } } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val dialog = super.onCreateDialog(savedInstanceState) dialog.window?.setBackgroundDrawable(requireContext().filletBackground) dialog.window?.decorView?.post { (dialog as AlertDialog).run { getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor(accentColor) getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(accentColor) getButton(AlertDialog.BUTTON_NEUTRAL)?.setTextColor(accentColor) listView?.forEach { it.applyTint(accentColor) } } } return dialog } override fun onStart() { super.onStart() if (AppConfig.isEInkMode) { dialog?.window?.let { it.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) val attr = it.attributes attr.dimAmount = 0.0f attr.windowAnimations = 0 it.attributes = attr it.setBackgroundDrawableResource(R.color.transparent) when (attr.gravity) { Gravity.TOP -> it.decorView.setBackgroundResource(R.drawable.bg_eink_border_bottom) Gravity.BOTTOM -> it.decorView.setBackgroundResource(R.drawable.bg_eink_border_top) else -> { val padding = 2.dpToPx(); it.decorView.setPadding(padding, padding, padding, padding) it.decorView.setBackgroundResource(R.drawable.bg_eink_border_dialog) } } } } } } ================================================ FILE: app/src/main/java/io/legado/app/lib/prefs/MultiSelectListPreferenceDialog.kt ================================================ package io.legado.app.lib.prefs import android.app.Dialog import android.os.Bundle import android.view.Gravity import android.view.WindowManager import androidx.appcompat.app.AlertDialog import androidx.core.view.forEach import androidx.preference.MultiSelectListPreferenceDialogFragmentCompat import androidx.preference.PreferenceDialogFragmentCompat import io.legado.app.R import io.legado.app.help.config.AppConfig import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.filletBackground import io.legado.app.utils.applyTint import io.legado.app.utils.dpToPx class MultiSelectListPreferenceDialog : MultiSelectListPreferenceDialogFragmentCompat() { companion object { fun newInstance(key: String?): MultiSelectListPreferenceDialog { val fragment = MultiSelectListPreferenceDialog() val b = Bundle(1) b.putString(PreferenceDialogFragmentCompat.ARG_KEY, key) fragment.arguments = b return fragment } } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val dialog = super.onCreateDialog(savedInstanceState) dialog.window?.setBackgroundDrawable(requireContext().filletBackground) dialog.window?.decorView?.post { (dialog as AlertDialog).run { getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor(accentColor) getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(accentColor) getButton(AlertDialog.BUTTON_NEUTRAL)?.setTextColor(accentColor) listView?.forEach { it.applyTint(accentColor) } } } return dialog } override fun onStart() { super.onStart() if (AppConfig.isEInkMode) { dialog?.window?.let { it.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) val attr = it.attributes attr.dimAmount = 0.0f attr.windowAnimations = 0 it.attributes = attr it.setBackgroundDrawableResource(R.color.transparent) when (attr.gravity) { Gravity.TOP -> it.decorView.setBackgroundResource(R.drawable.bg_eink_border_bottom) Gravity.BOTTOM -> it.decorView.setBackgroundResource(R.drawable.bg_eink_border_top) else -> { val padding = 2.dpToPx(); it.decorView.setPadding(padding, padding, padding, padding) it.decorView.setBackgroundResource(R.drawable.bg_eink_border_dialog) } } } } } } ================================================ FILE: app/src/main/java/io/legado/app/lib/prefs/NameListPreference.kt ================================================ package io.legado.app.lib.prefs import android.content.Context import android.util.AttributeSet import android.widget.TextView import androidx.preference.ListPreference import androidx.preference.PreferenceViewHolder import io.legado.app.R import io.legado.app.lib.theme.bottomBackground import io.legado.app.lib.theme.getPrimaryTextColor import io.legado.app.utils.ColorUtils class NameListPreference(context: Context, attrs: AttributeSet) : ListPreference(context, attrs) { private val isBottomBackground: Boolean init { layoutResource = R.layout.view_preference widgetLayoutResource = R.layout.item_fillet_text val typedArray = context.obtainStyledAttributes(attrs, R.styleable.Preference) isBottomBackground = typedArray.getBoolean(R.styleable.Preference_isBottomBackground, false) typedArray.recycle() } override fun onBindViewHolder(holder: PreferenceViewHolder) { val v = Preference.bindView( context, holder, icon, title, summary, widgetLayoutResource, R.id.text_view, isBottomBackground = isBottomBackground ) if (v is TextView) { v.text = entry if (isBottomBackground) { val bgColor = context.bottomBackground val pTextColor = context.getPrimaryTextColor(ColorUtils.isColorLight(bgColor)) v.setTextColor(pTextColor) } } super.onBindViewHolder(holder) } } ================================================ FILE: app/src/main/java/io/legado/app/lib/prefs/Preference.kt ================================================ package io.legado.app.lib.prefs import android.content.Context import android.graphics.drawable.Drawable import android.util.AttributeSet import android.view.LayoutInflater import android.view.View import android.widget.FrameLayout import android.widget.ImageView import android.widget.TextView import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.preference.PreferenceViewHolder import io.legado.app.R import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.bottomBackground import io.legado.app.lib.theme.getPrimaryTextColor import io.legado.app.lib.theme.getSecondaryTextColor import io.legado.app.utils.ColorUtils import splitties.views.onLongClick import kotlin.math.roundToInt open class Preference(context: Context, attrs: AttributeSet) : androidx.preference.Preference(context, attrs) { private var onLongClick: ((preference: Preference) -> Boolean)? = null private val isBottomBackground: Boolean init { layoutResource = R.layout.view_preference val typedArray = context.obtainStyledAttributes(attrs, R.styleable.Preference) isBottomBackground = typedArray.getBoolean(R.styleable.Preference_isBottomBackground, false) typedArray.recycle() } companion object { fun bindView( context: Context, viewHolder: PreferenceViewHolder?, icon: Drawable?, title: CharSequence?, summary: CharSequence?, weightLayoutRes: Int? = null, viewId: Int? = null, weightWidth: Int = 0, weightHeight: Int = 0, isBottomBackground: Boolean = false ): T? { if (viewHolder == null) return null val tvTitle = viewHolder.findViewById(R.id.preference_title) as? TextView tvTitle?.let { tvTitle.text = title tvTitle.isVisible = !title.isNullOrEmpty() } val tvSummary = viewHolder.findViewById(R.id.preference_desc) as? TextView tvSummary?.let { tvSummary.text = summary tvSummary.isGone = summary.isNullOrEmpty() } if (isBottomBackground && !viewHolder.itemView.isInEditMode) { val isLight = ColorUtils.isColorLight(context.bottomBackground) val pTextColor = context.getPrimaryTextColor(isLight) tvTitle?.setTextColor(pTextColor) val sTextColor = context.getSecondaryTextColor(isLight) tvSummary?.setTextColor(sTextColor) } val iconView = viewHolder.findViewById(R.id.preference_icon) if (iconView is ImageView) { iconView.isVisible = icon != null iconView.setImageDrawable(icon) iconView.setColorFilter(context.accentColor) } if (weightLayoutRes != null && weightLayoutRes != 0 && viewId != null && viewId != 0) { val lay = viewHolder.findViewById(R.id.preference_widget) if (lay is FrameLayout) { var needRequestLayout = false var v = viewHolder.itemView.findViewById(viewId) if (v == null) { val inflater: LayoutInflater = LayoutInflater.from(context) val childView = inflater.inflate(weightLayoutRes, null) lay.removeAllViews() lay.addView(childView) lay.isVisible = true v = lay.findViewById(viewId) } else needRequestLayout = true if (weightWidth > 0 || weightHeight > 0) { val lp = lay.layoutParams if (weightHeight > 0) lp.height = (context.resources.displayMetrics.density * weightHeight).roundToInt() if (weightWidth > 0) lp.width = (context.resources.displayMetrics.density * weightWidth).roundToInt() lay.layoutParams = lp } else if (needRequestLayout) v.requestLayout() return v } } return null } } final override fun onBindViewHolder(holder: PreferenceViewHolder) { super.onBindViewHolder(holder) onBindView(holder) onLongClick?.let { listener -> holder.itemView.onLongClick { listener.invoke(this) } } } open fun onBindView(holder: PreferenceViewHolder) { bindView( context, holder, icon, title, summary, isBottomBackground = isBottomBackground ) } fun onLongClick(listener: (preference: Preference) -> Boolean) { onLongClick = listener } } ================================================ FILE: app/src/main/java/io/legado/app/lib/prefs/PreferenceCategory.kt ================================================ package io.legado.app.lib.prefs import android.content.Context import android.util.AttributeSet import android.view.View import android.widget.TextView import androidx.core.view.isVisible import androidx.preference.PreferenceCategory import androidx.preference.PreferenceViewHolder import io.legado.app.R import io.legado.app.help.config.AppConfig import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.backgroundColor import io.legado.app.utils.ColorUtils class PreferenceCategory(context: Context, attrs: AttributeSet) : PreferenceCategory(context, attrs) { init { isPersistent = true layoutResource = R.layout.view_preference_category } override fun onBindViewHolder(holder: PreferenceViewHolder) { super.onBindViewHolder(holder) val view = holder.findViewById(R.id.preference_title) if (view is TextView) { // && !view.isInEditMode view.text = title if (view.isInEditMode) return view.setTextColor(context.accentColor) view.isVisible = !title.isNullOrEmpty() val da = holder.findViewById(R.id.preference_divider_above) val dividerColor = if (AppConfig.isNightTheme) { ColorUtils.withAlpha( ColorUtils.shiftColor(context.backgroundColor, 1.05f), 0.5f ) } else { ColorUtils.withAlpha( ColorUtils.shiftColor(context.backgroundColor, 0.95f), 0.5f ) } if (da is View) { da.setBackgroundColor(dividerColor) da.isVisible = holder.isDividerAllowedAbove } val db = holder.findViewById(R.id.preference_divider_below) if (db is View) { db.setBackgroundColor(dividerColor) db.isVisible = holder.isDividerAllowedBelow } } } } ================================================ FILE: app/src/main/java/io/legado/app/lib/prefs/SwitchPreference.kt ================================================ package io.legado.app.lib.prefs import android.content.Context import android.util.AttributeSet import androidx.appcompat.widget.SwitchCompat import androidx.preference.PreferenceViewHolder import androidx.preference.SwitchPreferenceCompat import io.legado.app.R import io.legado.app.lib.theme.accentColor import io.legado.app.utils.applyTint class SwitchPreference(context: Context, attrs: AttributeSet) : SwitchPreferenceCompat(context, attrs) { private val isBottomBackground: Boolean private var onLongClick: ((preference: SwitchPreference) -> Boolean)? = null init { layoutResource = R.layout.view_preference val typedArray = context.obtainStyledAttributes(attrs, R.styleable.Preference) isBottomBackground = typedArray.getBoolean(R.styleable.Preference_isBottomBackground, false) typedArray.recycle() } override fun onBindViewHolder(holder: PreferenceViewHolder) { val v = Preference.bindView( context, holder, icon, title, summary, widgetLayoutResource, androidx.preference.R.id.switchWidget, isBottomBackground = isBottomBackground ) if (v is SwitchCompat && !v.isInEditMode) { v.applyTint(context.accentColor) } super.onBindViewHolder(holder) onLongClick?.let { listener -> holder.itemView.setOnLongClickListener { listener.invoke(this) } } } fun onLongClick(listener: (preference: SwitchPreference) -> Boolean) { onLongClick = listener } } ================================================ FILE: app/src/main/java/io/legado/app/lib/prefs/fragment/PreferenceFragment.kt ================================================ package io.legado.app.lib.prefs.fragment import android.annotation.SuppressLint import android.os.Bundle import android.view.View import androidx.fragment.app.DialogFragment import androidx.preference.EditTextPreference import androidx.preference.ListPreference import androidx.preference.MultiSelectListPreference import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import io.legado.app.lib.prefs.EditTextPreferenceDialog import io.legado.app.lib.prefs.ListPreferenceDialog import io.legado.app.lib.prefs.MultiSelectListPreferenceDialog import io.legado.app.utils.applyNavigationBarPadding abstract class PreferenceFragment : PreferenceFragmentCompat() { private val dialogFragmentTag = "androidx.preference.PreferenceFragment.DIALOG" override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) listView.clipToPadding = false listView.applyNavigationBarPadding() } @SuppressLint("RestrictedApi") override fun onDisplayPreferenceDialog(preference: Preference) { var handled = false if (callbackFragment is OnPreferenceDisplayDialogCallback) { handled = (callbackFragment as OnPreferenceDisplayDialogCallback) .onPreferenceDisplayDialog(this, preference) } if (!handled && activity is OnPreferenceDisplayDialogCallback) { handled = (activity as OnPreferenceDisplayDialogCallback) .onPreferenceDisplayDialog(this, preference) } if (handled) { return } // check if dialog is already showing if (parentFragmentManager.findFragmentByTag(dialogFragmentTag) != null) { return } val dialogFragment: DialogFragment = when (preference) { is EditTextPreference -> { EditTextPreferenceDialog.newInstance(preference.getKey()) } is ListPreference -> { ListPreferenceDialog.newInstance(preference.getKey()) } is MultiSelectListPreference -> { MultiSelectListPreferenceDialog.newInstance(preference.getKey()) } else -> { throw IllegalArgumentException( "Cannot display dialog for an unknown Preference type: " + preference.javaClass.simpleName + ". Make sure to implement onPreferenceDisplayDialog() to handle " + "displaying a custom dialog for this Preference." ) } } @Suppress("DEPRECATION") dialogFragment.setTargetFragment(this, 0) dialogFragment.show(parentFragmentManager, dialogFragmentTag) } } ================================================ FILE: app/src/main/java/io/legado/app/lib/theme/MaterialValueHelper.kt ================================================ @file:Suppress("unused") package io.legado.app.lib.theme import android.annotation.SuppressLint import android.content.Context import android.graphics.drawable.GradientDrawable import androidx.annotation.ColorInt import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import io.legado.app.R import io.legado.app.help.config.AppConfig import io.legado.app.utils.ColorUtils import io.legado.app.utils.dpToPx /** * @author Karim Abou Zeid (kabouzeid) */ @ColorInt fun Context.getPrimaryTextColor(dark: Boolean): Int { return if (dark) { ContextCompat.getColor(this, R.color.md_light_primary_text) } else { ContextCompat.getColor(this, R.color.md_dark_primary_text) } } @ColorInt fun Context.getSecondaryTextColor(dark: Boolean): Int { return if (dark) { ContextCompat.getColor(this, R.color.md_light_secondary) } else { ContextCompat.getColor(this, R.color.md_dark_primary_text) } } @ColorInt fun Context.getPrimaryDisabledTextColor(dark: Boolean): Int { return if (dark) { ContextCompat.getColor(this, R.color.md_light_disabled) } else { ContextCompat.getColor(this, R.color.md_dark_disabled) } } @ColorInt fun Context.getSecondaryDisabledTextColor(dark: Boolean): Int { return if (dark) { ContextCompat.getColor( this, androidx.appcompat.R.color.secondary_text_disabled_material_light ) } else { ContextCompat.getColor( this, androidx.appcompat.R.color.secondary_text_disabled_material_dark ) } } val Context.primaryColor: Int get() = ThemeStore.primaryColor(this) val Context.primaryColorDark: Int get() = ThemeStore.primaryColorDark(this) val Context.accentColor: Int get() = ThemeStore.accentColor(this) val Context.backgroundColor: Int get() = ThemeStore.backgroundColor(this) val Context.bottomBackground: Int get() = ThemeStore.bottomBackground(this) val Context.primaryTextColor: Int get() = getPrimaryTextColor(isDarkTheme) val Context.secondaryTextColor: Int get() = getSecondaryTextColor(isDarkTheme) val Context.primaryDisabledTextColor: Int get() = getPrimaryDisabledTextColor(isDarkTheme) val Context.secondaryDisabledTextColor: Int get() = getSecondaryDisabledTextColor(isDarkTheme) val Fragment.primaryColor: Int get() = ThemeStore.primaryColor(requireContext()) val Fragment.primaryColorDark: Int get() = ThemeStore.primaryColorDark(requireContext()) val Fragment.accentColor: Int get() = ThemeStore.accentColor(requireContext()) val Fragment.backgroundColor: Int get() = ThemeStore.backgroundColor(requireContext()) val Fragment.bottomBackground: Int get() = ThemeStore.bottomBackground(requireContext()) val Fragment.primaryTextColor: Int get() = requireContext().getPrimaryTextColor(isDarkTheme) val Fragment.secondaryTextColor: Int get() = requireContext().getSecondaryTextColor(isDarkTheme) val Fragment.primaryDisabledTextColor: Int get() = requireContext().getPrimaryDisabledTextColor(isDarkTheme) val Fragment.secondaryDisabledTextColor: Int get() = requireContext().getSecondaryDisabledTextColor(isDarkTheme) val Context.buttonDisabledColor: Int get() = if (isDarkTheme) { ContextCompat.getColor(this, R.color.md_dark_disabled) } else { ContextCompat.getColor(this, R.color.md_light_disabled) } val Context.isDarkTheme: Boolean get() = ColorUtils.isColorLight(ThemeStore.primaryColor(this)) val Fragment.isDarkTheme: Boolean get() = requireContext().isDarkTheme val Context.elevation: Float @SuppressLint("PrivateResource") get() { return if (AppConfig.elevation < 0) { ThemeUtils.resolveFloat( this, android.R.attr.elevation, resources.getDimension(com.google.android.material.R.dimen.design_appbar_elevation) ) } else { AppConfig.elevation.toFloat().dpToPx() } } val Context.filletBackground: GradientDrawable get() { val background = GradientDrawable() background.cornerRadius = 3f.dpToPx() background.setColor(backgroundColor) return background } ================================================ FILE: app/src/main/java/io/legado/app/lib/theme/Selector.kt ================================================ package io.legado.app.lib.theme import android.content.Context import android.content.res.ColorStateList import android.graphics.Color import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.GradientDrawable import android.graphics.drawable.StateListDrawable import androidx.annotation.ColorInt import androidx.annotation.Dimension import androidx.annotation.DrawableRes import androidx.annotation.IntDef import androidx.core.content.ContextCompat @Suppress("unused") object Selector { fun shapeBuild(): ShapeSelector { return ShapeSelector() } fun colorBuild(): ColorSelector { return ColorSelector() } fun drawableBuild(): DrawableSelector { return DrawableSelector() } /** * 形状ShapeSelector * * @author hjy * created at 2017/12/11 22:26 */ class ShapeSelector { private var mShape: Int = 0 //the shape of background private var mDefaultBgColor: Int = 0 //default background color private var mDisabledBgColor: Int = 0 //state_enabled = false private var mPressedBgColor: Int = 0 //state_pressed = true private var mSelectedBgColor: Int = 0 //state_selected = true private var mFocusedBgColor: Int = 0 //state_focused = true private var mCheckedBgColor: Int = 0 //state_checked = true private var mStrokeWidth: Int = 0 //stroke width in pixel private var mDefaultStrokeColor: Int = 0 //default stroke color private var mDisabledStrokeColor: Int = 0 //state_enabled = false private var mPressedStrokeColor: Int = 0 //state_pressed = true private var mSelectedStrokeColor: Int = 0 //state_selected = true private var mFocusedStrokeColor: Int = 0 //state_focused = true private var mCheckedStrokeColor: Int = 0 //state_checked = true private var mCornerRadius: Int = 0 //corner radius private var hasSetDisabledBgColor = false private var hasSetPressedBgColor = false private var hasSetSelectedBgColor = false private val hasSetFocusedBgColor = false private var hasSetCheckedBgColor = false private var hasSetDisabledStrokeColor = false private var hasSetPressedStrokeColor = false private var hasSetSelectedStrokeColor = false private var hasSetFocusedStrokeColor = false private var hasSetCheckedStrokeColor = false @IntDef( GradientDrawable.RECTANGLE, GradientDrawable.OVAL, GradientDrawable.LINE, GradientDrawable.RING ) private annotation class Shape init { //initialize default values mShape = GradientDrawable.RECTANGLE mDefaultBgColor = Color.TRANSPARENT mDisabledBgColor = Color.TRANSPARENT mPressedBgColor = Color.TRANSPARENT mSelectedBgColor = Color.TRANSPARENT mFocusedBgColor = Color.TRANSPARENT mStrokeWidth = 0 mDefaultStrokeColor = Color.TRANSPARENT mDisabledStrokeColor = Color.TRANSPARENT mPressedStrokeColor = Color.TRANSPARENT mSelectedStrokeColor = Color.TRANSPARENT mFocusedStrokeColor = Color.TRANSPARENT mCornerRadius = 0 } fun setShape(@Shape shape: Int): ShapeSelector { mShape = shape return this } fun setDefaultBgColor(@ColorInt color: Int): ShapeSelector { mDefaultBgColor = color if (!hasSetDisabledBgColor) mDisabledBgColor = color if (!hasSetPressedBgColor) mPressedBgColor = color if (!hasSetSelectedBgColor) mSelectedBgColor = color if (!hasSetFocusedBgColor) mFocusedBgColor = color return this } fun setDisabledBgColor(@ColorInt color: Int): ShapeSelector { mDisabledBgColor = color hasSetDisabledBgColor = true return this } fun setPressedBgColor(@ColorInt color: Int): ShapeSelector { mPressedBgColor = color hasSetPressedBgColor = true return this } fun setSelectedBgColor(@ColorInt color: Int): ShapeSelector { mSelectedBgColor = color hasSetSelectedBgColor = true return this } fun setFocusedBgColor(@ColorInt color: Int): ShapeSelector { mFocusedBgColor = color hasSetPressedBgColor = true return this } fun setCheckedBgColor(@ColorInt color: Int): ShapeSelector { mCheckedBgColor = color hasSetCheckedBgColor = true return this } fun setStrokeWidth(@Dimension width: Int): ShapeSelector { mStrokeWidth = width return this } fun setDefaultStrokeColor(@ColorInt color: Int): ShapeSelector { mDefaultStrokeColor = color if (!hasSetDisabledStrokeColor) mDisabledStrokeColor = color if (!hasSetPressedStrokeColor) mPressedStrokeColor = color if (!hasSetSelectedStrokeColor) mSelectedStrokeColor = color if (!hasSetFocusedStrokeColor) mFocusedStrokeColor = color return this } fun setDisabledStrokeColor(@ColorInt color: Int): ShapeSelector { mDisabledStrokeColor = color hasSetDisabledStrokeColor = true return this } fun setPressedStrokeColor(@ColorInt color: Int): ShapeSelector { mPressedStrokeColor = color hasSetPressedStrokeColor = true return this } fun setSelectedStrokeColor(@ColorInt color: Int): ShapeSelector { mSelectedStrokeColor = color hasSetSelectedStrokeColor = true return this } fun setCheckedStrokeColor(@ColorInt color: Int): ShapeSelector { mCheckedStrokeColor = color hasSetCheckedStrokeColor = true return this } fun setFocusedStrokeColor(@ColorInt color: Int): ShapeSelector { mFocusedStrokeColor = color hasSetFocusedStrokeColor = true return this } fun setCornerRadius(@Dimension radius: Int): ShapeSelector { mCornerRadius = radius return this } fun create(): StateListDrawable { val selector = StateListDrawable() //enabled = false if (hasSetDisabledBgColor || hasSetDisabledStrokeColor) { val disabledShape = getItemShape( mShape, mCornerRadius, mDisabledBgColor, mStrokeWidth, mDisabledStrokeColor ) selector.addState(intArrayOf(-android.R.attr.state_enabled), disabledShape) } //pressed = true if (hasSetPressedBgColor || hasSetPressedStrokeColor) { val pressedShape = getItemShape( mShape, mCornerRadius, mPressedBgColor, mStrokeWidth, mPressedStrokeColor ) selector.addState(intArrayOf(android.R.attr.state_pressed), pressedShape) } //selected = true if (hasSetSelectedBgColor || hasSetSelectedStrokeColor) { val selectedShape = getItemShape( mShape, mCornerRadius, mSelectedBgColor, mStrokeWidth, mSelectedStrokeColor ) selector.addState(intArrayOf(android.R.attr.state_selected), selectedShape) } //focused = true if (hasSetFocusedBgColor || hasSetFocusedStrokeColor) { val focusedShape = getItemShape( mShape, mCornerRadius, mFocusedBgColor, mStrokeWidth, mFocusedStrokeColor ) selector.addState(intArrayOf(android.R.attr.state_focused), focusedShape) } //checked = true if (hasSetCheckedBgColor || hasSetCheckedStrokeColor) { val checkedShape = getItemShape( mShape, mCornerRadius, mCheckedBgColor, mStrokeWidth, mCheckedStrokeColor ) selector.addState(intArrayOf(android.R.attr.state_checked), checkedShape) } //default val defaultShape = getItemShape( mShape, mCornerRadius, mDefaultBgColor, mStrokeWidth, mDefaultStrokeColor ) selector.addState(intArrayOf(), defaultShape) return selector } private fun getItemShape( shape: Int, cornerRadius: Int, solidColor: Int, strokeWidth: Int, strokeColor: Int ): GradientDrawable { val drawable = GradientDrawable() drawable.shape = shape drawable.setStroke(strokeWidth, strokeColor) drawable.cornerRadius = cornerRadius.toFloat() drawable.setColor(solidColor) return drawable } } /** * 资源DrawableSelector * * @author hjy * created at 2017/12/11 22:34 */ @Suppress("MemberVisibilityCanBePrivate") class DrawableSelector { private var mDefaultDrawable: Drawable? = null private var mDisabledDrawable: Drawable? = null private var mPressedDrawable: Drawable? = null private var mSelectedDrawable: Drawable? = null private var mFocusedDrawable: Drawable? = null private var hasSetDisabledDrawable = false private var hasSetPressedDrawable = false private var hasSetSelectedDrawable = false private var hasSetFocusedDrawable = false init { mDefaultDrawable = ColorDrawable(Color.TRANSPARENT) } fun setDefaultDrawable(drawable: Drawable?): DrawableSelector { mDefaultDrawable = drawable if (!hasSetDisabledDrawable) mDisabledDrawable = drawable if (!hasSetPressedDrawable) mPressedDrawable = drawable if (!hasSetSelectedDrawable) mSelectedDrawable = drawable if (!hasSetFocusedDrawable) mFocusedDrawable = drawable return this } fun setDisabledDrawable(drawable: Drawable?): DrawableSelector { mDisabledDrawable = drawable hasSetDisabledDrawable = true return this } fun setPressedDrawable(drawable: Drawable?): DrawableSelector { mPressedDrawable = drawable hasSetPressedDrawable = true return this } fun setSelectedDrawable(drawable: Drawable?): DrawableSelector { mSelectedDrawable = drawable hasSetSelectedDrawable = true return this } fun setFocusedDrawable(drawable: Drawable?): DrawableSelector { mFocusedDrawable = drawable hasSetFocusedDrawable = true return this } fun create(): StateListDrawable { val selector = StateListDrawable() if (hasSetDisabledDrawable) selector.addState(intArrayOf(-android.R.attr.state_enabled), mDisabledDrawable) if (hasSetPressedDrawable) selector.addState(intArrayOf(android.R.attr.state_pressed), mPressedDrawable) if (hasSetSelectedDrawable) selector.addState(intArrayOf(android.R.attr.state_selected), mSelectedDrawable) if (hasSetFocusedDrawable) selector.addState(intArrayOf(android.R.attr.state_focused), mFocusedDrawable) selector.addState(intArrayOf(), mDefaultDrawable) return selector } fun setDefaultDrawable(context: Context, @DrawableRes drawableRes: Int): DrawableSelector { return setDefaultDrawable(ContextCompat.getDrawable(context, drawableRes)) } fun setDisabledDrawable(context: Context, @DrawableRes drawableRes: Int): DrawableSelector { return setDisabledDrawable(ContextCompat.getDrawable(context, drawableRes)) } fun setPressedDrawable(context: Context, @DrawableRes drawableRes: Int): DrawableSelector { return setPressedDrawable(ContextCompat.getDrawable(context, drawableRes)) } fun setSelectedDrawable(context: Context, @DrawableRes drawableRes: Int): DrawableSelector { return setSelectedDrawable(ContextCompat.getDrawable(context, drawableRes)) } fun setFocusedDrawable(context: Context, @DrawableRes drawableRes: Int): DrawableSelector { return setFocusedDrawable(ContextCompat.getDrawable(context, drawableRes)) } } /** * 颜色ColorSelector * * @author hjy * created at 2017/12/11 22:26 */ class ColorSelector { private var mDefaultColor: Int = 0 private var mDisabledColor: Int = 0 private var mPressedColor: Int = 0 private var mSelectedColor: Int = 0 private var mFocusedColor: Int = 0 private var mCheckedColor: Int = 0 private var hasSetDisabledColor = false private var hasSetPressedColor = false private var hasSetSelectedColor = false private var hasSetFocusedColor = false private var hasSetCheckedColor = false init { mDefaultColor = Color.BLACK mDisabledColor = Color.GRAY mPressedColor = Color.BLACK mSelectedColor = Color.BLACK mFocusedColor = Color.BLACK } fun setDefaultColor(@ColorInt color: Int): ColorSelector { mDefaultColor = color if (!hasSetDisabledColor) mDisabledColor = color if (!hasSetPressedColor) mPressedColor = color if (!hasSetSelectedColor) mSelectedColor = color if (!hasSetFocusedColor) mFocusedColor = color return this } fun setDisabledColor(@ColorInt color: Int): ColorSelector { mDisabledColor = color hasSetDisabledColor = true return this } fun setPressedColor(@ColorInt color: Int): ColorSelector { mPressedColor = color hasSetPressedColor = true return this } fun setSelectedColor(@ColorInt color: Int): ColorSelector { mSelectedColor = color hasSetSelectedColor = true return this } fun setFocusedColor(@ColorInt color: Int): ColorSelector { mFocusedColor = color hasSetFocusedColor = true return this } fun setCheckedColor(@ColorInt color: Int): ColorSelector { mCheckedColor = color hasSetCheckedColor = true return this } fun create(): ColorStateList { val colors = intArrayOf( if (hasSetDisabledColor) mDisabledColor else mDefaultColor, if (hasSetPressedColor) mPressedColor else mDefaultColor, if (hasSetSelectedColor) mSelectedColor else mDefaultColor, if (hasSetFocusedColor) mFocusedColor else mDefaultColor, if (hasSetCheckedColor) mCheckedColor else mDefaultColor, mDefaultColor ) val states = arrayOfNulls(6) states[0] = intArrayOf(-android.R.attr.state_enabled) states[1] = intArrayOf(android.R.attr.state_pressed) states[2] = intArrayOf(android.R.attr.state_selected) states[3] = intArrayOf(android.R.attr.state_focused) states[4] = intArrayOf(android.R.attr.state_checked) states[5] = intArrayOf() return ColorStateList(states, colors) } } } ================================================ FILE: app/src/main/java/io/legado/app/lib/theme/ThemeStore.kt ================================================ package io.legado.app.lib.theme import android.annotation.SuppressLint import android.content.Context import android.content.SharedPreferences import android.graphics.Color import androidx.annotation.AttrRes import androidx.annotation.CheckResult import androidx.annotation.ColorInt import androidx.annotation.ColorRes import androidx.core.content.ContextCompat import io.legado.app.utils.ColorUtils import io.legado.app.utils.LogUtils import splitties.init.appCtx /** * @author Aidan Follestad (afollestad), Karim Abou Zeid (kabouzeid) */ @Suppress("unused") class ThemeStore @SuppressLint("CommitPrefEdits") private constructor(private val mContext: Context) : ThemeStoreInterface { private val mEditor = prefs(mContext).edit() override fun primaryColor(@ColorInt color: Int): ThemeStore { mEditor.putInt(ThemeStorePrefKeys.KEY_PRIMARY_COLOR, color) if (autoGeneratePrimaryDark(mContext)) primaryColorDark(ColorUtils.darkenColor(color)) return this } override fun primaryColorRes(@ColorRes colorRes: Int): ThemeStore { return primaryColor(ContextCompat.getColor(mContext, colorRes)) } override fun primaryColorAttr(@AttrRes colorAttr: Int): ThemeStore { return primaryColor(ThemeUtils.resolveColor(mContext, colorAttr)) } override fun primaryColorDark(@ColorInt color: Int): ThemeStore { mEditor.putInt(ThemeStorePrefKeys.KEY_PRIMARY_COLOR_DARK, color) return this } override fun primaryColorDarkRes(@ColorRes colorRes: Int): ThemeStore { return primaryColorDark(ContextCompat.getColor(mContext, colorRes)) } override fun primaryColorDarkAttr(@AttrRes colorAttr: Int): ThemeStore { return primaryColorDark(ThemeUtils.resolveColor(mContext, colorAttr)) } override fun accentColor(@ColorInt color: Int): ThemeStore { mEditor.putInt(ThemeStorePrefKeys.KEY_ACCENT_COLOR, color) return this } override fun accentColorRes(@ColorRes colorRes: Int): ThemeStore { return accentColor(ContextCompat.getColor(mContext, colorRes)) } override fun accentColorAttr(@AttrRes colorAttr: Int): ThemeStore { return accentColor(ThemeUtils.resolveColor(mContext, colorAttr)) } override fun statusBarColor(@ColorInt color: Int): ThemeStore { mEditor.putInt(ThemeStorePrefKeys.KEY_STATUS_BAR_COLOR, color) return this } override fun statusBarColorRes(@ColorRes colorRes: Int): ThemeStore { return statusBarColor(ContextCompat.getColor(mContext, colorRes)) } override fun statusBarColorAttr(@AttrRes colorAttr: Int): ThemeStore { return statusBarColor(ThemeUtils.resolveColor(mContext, colorAttr)) } override fun navigationBarColor(@ColorInt color: Int): ThemeStore { mEditor.putInt(ThemeStorePrefKeys.KEY_NAVIGATION_BAR_COLOR, color) return this } override fun navigationBarColorRes(@ColorRes colorRes: Int): ThemeStore { return navigationBarColor(ContextCompat.getColor(mContext, colorRes)) } override fun navigationBarColorAttr(@AttrRes colorAttr: Int): ThemeStore { return navigationBarColor(ThemeUtils.resolveColor(mContext, colorAttr)) } override fun textColorPrimary(@ColorInt color: Int): ThemeStore { mEditor.putInt(ThemeStorePrefKeys.KEY_TEXT_COLOR_PRIMARY, color) return this } override fun textColorPrimaryRes(@ColorRes colorRes: Int): ThemeStore { return textColorPrimary(ContextCompat.getColor(mContext, colorRes)) } override fun textColorPrimaryAttr(@AttrRes colorAttr: Int): ThemeStore { return textColorPrimary(ThemeUtils.resolveColor(mContext, colorAttr)) } override fun textColorPrimaryInverse(@ColorInt color: Int): ThemeStore { mEditor.putInt(ThemeStorePrefKeys.KEY_TEXT_COLOR_PRIMARY_INVERSE, color) return this } override fun textColorPrimaryInverseRes(@ColorRes colorRes: Int): ThemeStore { return textColorPrimaryInverse(ContextCompat.getColor(mContext, colorRes)) } override fun textColorPrimaryInverseAttr(@AttrRes colorAttr: Int): ThemeStore { return textColorPrimaryInverse(ThemeUtils.resolveColor(mContext, colorAttr)) } override fun textColorSecondary(@ColorInt color: Int): ThemeStore { mEditor.putInt(ThemeStorePrefKeys.KEY_TEXT_COLOR_SECONDARY, color) return this } override fun textColorSecondaryRes(@ColorRes colorRes: Int): ThemeStore { return textColorSecondary(ContextCompat.getColor(mContext, colorRes)) } override fun textColorSecondaryAttr(@AttrRes colorAttr: Int): ThemeStore { return textColorSecondary(ThemeUtils.resolveColor(mContext, colorAttr)) } override fun textColorSecondaryInverse(@ColorInt color: Int): ThemeStore { mEditor.putInt(ThemeStorePrefKeys.KEY_TEXT_COLOR_SECONDARY_INVERSE, color) return this } override fun textColorSecondaryInverseRes(@ColorRes colorRes: Int): ThemeStore { return textColorSecondaryInverse(ContextCompat.getColor(mContext, colorRes)) } override fun textColorSecondaryInverseAttr(@AttrRes colorAttr: Int): ThemeStore { return textColorSecondaryInverse(ThemeUtils.resolveColor(mContext, colorAttr)) } override fun backgroundColor(color: Int): ThemeStore { mEditor.putInt(ThemeStorePrefKeys.KEY_BACKGROUND_COLOR, color) return this } override fun bottomBackground(color: Int): ThemeStore { mEditor.putInt(ThemeStorePrefKeys.KEY_BOTTOM_BACKGROUND, color) return this } override fun autoGeneratePrimaryDark(autoGenerate: Boolean): ThemeStore { mEditor.putBoolean(ThemeStorePrefKeys.KEY_AUTO_GENERATE_PRIMARYDARK, autoGenerate) return this } // Commit method override fun apply() { mEditor.putLong(ThemeStorePrefKeys.VALUES_CHANGED, System.currentTimeMillis()) .putBoolean(ThemeStorePrefKeys.IS_CONFIGURED_KEY, true) .apply() accentColor = accentColor() } companion object { var accentColor = accentColor() fun editTheme(context: Context): ThemeStore { return ThemeStore(context) } // Static getters @CheckResult internal fun prefs(context: Context): SharedPreferences { return context.getSharedPreferences( ThemeStorePrefKeys.CONFIG_PREFS_KEY_DEFAULT, Context.MODE_PRIVATE ) } fun markChanged(context: Context) { ThemeStore(context).apply() } @CheckResult @ColorInt fun primaryColor(context: Context = appCtx): Int { return prefs(context).getInt( ThemeStorePrefKeys.KEY_PRIMARY_COLOR, ThemeUtils.resolveColor( context, androidx.appcompat.R.attr.colorPrimary, Color.parseColor("#455A64") ) ) } @CheckResult @ColorInt fun primaryColorDark(context: Context): Int { return prefs(context).getInt( ThemeStorePrefKeys.KEY_PRIMARY_COLOR_DARK, ThemeUtils.resolveColor( context, androidx.appcompat.R.attr.colorPrimaryDark, Color.parseColor("#37474F") ) ) } @CheckResult @ColorInt fun accentColor(context: Context = appCtx): Int { return prefs(context).getInt( ThemeStorePrefKeys.KEY_ACCENT_COLOR, ThemeUtils.resolveColor( context, androidx.appcompat.R.attr.colorAccent, Color.parseColor("#263238") ) ) } @CheckResult @ColorInt fun statusBarColor(context: Context, transparent: Boolean): Int { return if (transparent) { prefs(context).getInt( ThemeStorePrefKeys.KEY_STATUS_BAR_COLOR, primaryColor(context) ) } else { prefs(context).getInt( ThemeStorePrefKeys.KEY_STATUS_BAR_COLOR, primaryColorDark(context) ) } } @CheckResult @ColorInt fun navigationBarColor(context: Context): Int { return prefs(context).getInt( ThemeStorePrefKeys.KEY_NAVIGATION_BAR_COLOR, bottomBackground(context) ) } @CheckResult @ColorInt fun textColorPrimary(context: Context): Int { return prefs(context).getInt( ThemeStorePrefKeys.KEY_TEXT_COLOR_PRIMARY, ThemeUtils.resolveColor(context, android.R.attr.textColorPrimary) ) } @CheckResult @ColorInt fun textColorPrimaryInverse(context: Context): Int { return prefs(context).getInt( ThemeStorePrefKeys.KEY_TEXT_COLOR_PRIMARY_INVERSE, ThemeUtils.resolveColor(context, android.R.attr.textColorPrimaryInverse) ) } @CheckResult @ColorInt fun textColorSecondary(context: Context): Int { return prefs(context).getInt( ThemeStorePrefKeys.KEY_TEXT_COLOR_SECONDARY, ThemeUtils.resolveColor(context, android.R.attr.textColorSecondary) ) } @CheckResult @ColorInt fun textColorSecondaryInverse(context: Context): Int { return prefs(context).getInt( ThemeStorePrefKeys.KEY_TEXT_COLOR_SECONDARY_INVERSE, ThemeUtils.resolveColor(context, android.R.attr.textColorSecondaryInverse) ) } @CheckResult @ColorInt fun backgroundColor(context: Context = appCtx): Int { return prefs(context).getInt( ThemeStorePrefKeys.KEY_BACKGROUND_COLOR, ThemeUtils.resolveColor(context, android.R.attr.colorBackground) ) } @CheckResult @ColorInt fun bottomBackground(context: Context = appCtx): Int { return prefs(context).getInt( ThemeStorePrefKeys.KEY_BOTTOM_BACKGROUND, ThemeUtils.resolveColor(context, android.R.attr.colorBackground) ) } @CheckResult fun coloredStatusBar(context: Context): Boolean { return prefs(context).getBoolean( ThemeStorePrefKeys.KEY_APPLY_PRIMARYDARK_STATUSBAR, true ) } @CheckResult fun coloredNavigationBar(context: Context): Boolean { return prefs(context).getBoolean(ThemeStorePrefKeys.KEY_APPLY_PRIMARY_NAVBAR, false) } @CheckResult fun autoGeneratePrimaryDark(context: Context): Boolean { return prefs(context).getBoolean(ThemeStorePrefKeys.KEY_AUTO_GENERATE_PRIMARYDARK, true) } @CheckResult fun isConfigured(context: Context): Boolean { return prefs(context).getBoolean(ThemeStorePrefKeys.IS_CONFIGURED_KEY, false) } @SuppressLint("CommitPrefEdits") fun isConfigured(context: Context, version: Int): Boolean { val prefs = prefs(context) val lastVersion = prefs.getInt(ThemeStorePrefKeys.IS_CONFIGURED_VERSION_KEY, -1) if (version > lastVersion) { prefs.edit().putInt(ThemeStorePrefKeys.IS_CONFIGURED_VERSION_KEY, version).apply() return false } return true } } } ================================================ FILE: app/src/main/java/io/legado/app/lib/theme/ThemeStoreInterface.kt ================================================ package io.legado.app.lib.theme import androidx.annotation.AttrRes import androidx.annotation.ColorInt import androidx.annotation.ColorRes /** * @author Aidan Follestad (afollestad), Karim Abou Zeid (kabouzeid) */ internal interface ThemeStoreInterface { // Primary colors fun primaryColor(@ColorInt color: Int): ThemeStore fun primaryColorRes(@ColorRes colorRes: Int): ThemeStore fun primaryColorAttr(@AttrRes colorAttr: Int): ThemeStore fun autoGeneratePrimaryDark(autoGenerate: Boolean): ThemeStore fun primaryColorDark(@ColorInt color: Int): ThemeStore fun primaryColorDarkRes(@ColorRes colorRes: Int): ThemeStore fun primaryColorDarkAttr(@AttrRes colorAttr: Int): ThemeStore // Accent colors fun accentColor(@ColorInt color: Int): ThemeStore fun accentColorRes(@ColorRes colorRes: Int): ThemeStore fun accentColorAttr(@AttrRes colorAttr: Int): ThemeStore // Status bar color fun statusBarColor(@ColorInt color: Int): ThemeStore fun statusBarColorRes(@ColorRes colorRes: Int): ThemeStore fun statusBarColorAttr(@AttrRes colorAttr: Int): ThemeStore // Navigation bar color fun navigationBarColor(@ColorInt color: Int): ThemeStore fun navigationBarColorRes(@ColorRes colorRes: Int): ThemeStore fun navigationBarColorAttr(@AttrRes colorAttr: Int): ThemeStore // Primary text color fun textColorPrimary(@ColorInt color: Int): ThemeStore fun textColorPrimaryRes(@ColorRes colorRes: Int): ThemeStore fun textColorPrimaryAttr(@AttrRes colorAttr: Int): ThemeStore fun textColorPrimaryInverse(@ColorInt color: Int): ThemeStore fun textColorPrimaryInverseRes(@ColorRes colorRes: Int): ThemeStore fun textColorPrimaryInverseAttr(@AttrRes colorAttr: Int): ThemeStore // Secondary text color fun textColorSecondary(@ColorInt color: Int): ThemeStore fun textColorSecondaryRes(@ColorRes colorRes: Int): ThemeStore fun textColorSecondaryAttr(@AttrRes colorAttr: Int): ThemeStore fun textColorSecondaryInverse(@ColorInt color: Int): ThemeStore fun textColorSecondaryInverseRes(@ColorRes colorRes: Int): ThemeStore fun textColorSecondaryInverseAttr(@AttrRes colorAttr: Int): ThemeStore // Background fun backgroundColor(@ColorInt color: Int): ThemeStore fun bottomBackground(@ColorInt color: Int): ThemeStore // Commit/apply fun apply() } ================================================ FILE: app/src/main/java/io/legado/app/lib/theme/ThemeStorePrefKeys.kt ================================================ package io.legado.app.lib.theme /** * @author Aidan Follestad (afollestad), Karim Abou Zeid (kabouzeid) */ object ThemeStorePrefKeys { const val CONFIG_PREFS_KEY_DEFAULT = "app_themes" const val IS_CONFIGURED_KEY = "is_configured" const val IS_CONFIGURED_VERSION_KEY = "is_configured_version" const val VALUES_CHANGED = "values_changed" const val KEY_PRIMARY_COLOR = "primary_color" const val KEY_PRIMARY_COLOR_DARK = "primary_color_dark" const val KEY_ACCENT_COLOR = "accent_color" const val KEY_STATUS_BAR_COLOR = "status_bar_color" const val KEY_NAVIGATION_BAR_COLOR = "navigation_bar_color" const val KEY_TEXT_COLOR_PRIMARY = "text_color_primary" const val KEY_TEXT_COLOR_PRIMARY_INVERSE = "text_color_primary_inverse" const val KEY_TEXT_COLOR_SECONDARY = "text_color_secondary" const val KEY_TEXT_COLOR_SECONDARY_INVERSE = "text_color_secondary_inverse" const val KEY_BACKGROUND_COLOR = "backgroundColor" const val KEY_BOTTOM_BACKGROUND = "bottomBackground" const val KEY_APPLY_PRIMARYDARK_STATUSBAR = "apply_primarydark_statusbar" const val KEY_APPLY_PRIMARY_NAVBAR = "apply_primary_navbar" const val KEY_AUTO_GENERATE_PRIMARYDARK = "auto_generate_primarydark" } ================================================ FILE: app/src/main/java/io/legado/app/lib/theme/ThemeUtils.kt ================================================ package io.legado.app.lib.theme import android.content.Context import android.graphics.drawable.Drawable import androidx.annotation.AttrRes /** * @author Aidan Follestad (afollestad) */ object ThemeUtils { @JvmOverloads fun resolveColor(context: Context, @AttrRes attr: Int, fallback: Int = 0): Int { val a = context.theme.obtainStyledAttributes(intArrayOf(attr)) return try { a.getColor(0, fallback) } catch (e: Exception) { fallback } finally { a.recycle() } } @JvmOverloads fun resolveFloat(context: Context, @AttrRes attr: Int, fallback: Float = 0.0f): Float { val a = context.theme.obtainStyledAttributes(intArrayOf(attr)) return try { a.getFloat(0, fallback) } catch (e: Exception) { fallback } finally { a.recycle() } } fun resolveDrawable(context: Context, @AttrRes attr: Int): Drawable? { val a = context.theme.obtainStyledAttributes(intArrayOf(attr)) return try { a.getDrawable(0) } finally { a.recycle() } } } ================================================ FILE: app/src/main/java/io/legado/app/lib/theme/TintHelper.kt ================================================ package io.legado.app.lib.theme import android.annotation.SuppressLint import android.content.Context import android.content.res.ColorStateList import android.graphics.PorterDuff import android.graphics.drawable.Drawable import android.graphics.drawable.RippleDrawable import android.view.View import android.widget.Button import android.widget.CheckBox import android.widget.CheckedTextView import android.widget.EditText import android.widget.ImageView import android.widget.ProgressBar import android.widget.RadioButton import android.widget.SeekBar import android.widget.Switch import android.widget.TextView import androidx.annotation.CheckResult import androidx.annotation.ColorInt import androidx.appcompat.widget.AppCompatEditText import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SwitchCompat import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.DrawableCompat import androidx.core.widget.TextViewCompat import com.google.android.material.floatingactionbutton.FloatingActionButton import io.legado.app.R import io.legado.app.utils.ColorUtils /** * @author afollestad, plusCubed */ @Suppress("MemberVisibilityCanBePrivate") object TintHelper { @SuppressLint("PrivateResource") @ColorInt private fun getDefaultRippleColor(context: Context, useDarkRipple: Boolean): Int { // Light ripple is actually translucent black, and vice versa return ContextCompat.getColor( context, if (useDarkRipple) androidx.appcompat.R.color.ripple_material_light else androidx.appcompat.R.color.ripple_material_dark ) } private fun getDisabledColorStateList( @ColorInt normal: Int, @ColorInt disabled: Int ): ColorStateList { return ColorStateList( arrayOf( intArrayOf(-android.R.attr.state_enabled), intArrayOf(android.R.attr.state_enabled) ), intArrayOf(disabled, normal) ) } fun setTintSelector(view: View, @ColorInt color: Int, darker: Boolean, useDarkTheme: Boolean) { val isColorLight = ColorUtils.isColorLight(color) val disabled = ContextCompat.getColor( view.context, if (useDarkTheme) R.color.ate_button_disabled_dark else R.color.ate_button_disabled_light ) val pressed = ColorUtils.shiftColor(color, if (darker) 0.9f else 1.1f) val activated = ColorUtils.shiftColor(color, if (darker) 1.1f else 0.9f) val rippleColor = getDefaultRippleColor(view.context, isColorLight) val textColor = ContextCompat.getColor( view.context, if (isColorLight) R.color.ate_primary_text_light else R.color.ate_primary_text_dark ) val sl: ColorStateList when (view) { is Button -> { sl = getDisabledColorStateList(color, disabled) if (view.getBackground() is RippleDrawable) { val rd = view.getBackground() as RippleDrawable rd.setColor(ColorStateList.valueOf(rippleColor)) } // Disabled text color state for buttons, may get overridden later by ATE tags view.setTextColor( getDisabledColorStateList( textColor, ContextCompat.getColor( view.getContext(), if (useDarkTheme) R.color.ate_button_text_disabled_dark else R.color.ate_button_text_disabled_light ) ) ) } is FloatingActionButton -> { // FloatingActionButton doesn't support disabled state? sl = ColorStateList( arrayOf( intArrayOf(-android.R.attr.state_pressed), intArrayOf(android.R.attr.state_pressed) ), intArrayOf(color, pressed) ) view.rippleColor = rippleColor view.backgroundTintList = sl if (view.drawable != null) view.setImageDrawable(createTintedDrawable(view.drawable, textColor)) return } else -> { sl = ColorStateList( arrayOf( intArrayOf(-android.R.attr.state_enabled), intArrayOf(android.R.attr.state_enabled), intArrayOf(android.R.attr.state_enabled, android.R.attr.state_pressed), intArrayOf(android.R.attr.state_enabled, android.R.attr.state_activated), intArrayOf(android.R.attr.state_enabled, android.R.attr.state_checked) ), intArrayOf(disabled, color, pressed, activated, activated) ) } } var drawable: Drawable? = view.background if (drawable != null) { drawable = createTintedDrawable(drawable, sl) ViewUtils.setBackgroundCompat(view, drawable) } if (view is TextView && view !is Button) { view.setTextColor( getDisabledColorStateList( textColor, ContextCompat.getColor( view.getContext(), if (isColorLight) R.color.ate_text_disabled_light else R.color.ate_text_disabled_dark ) ) ) } } fun setTintAuto( view: View, @ColorInt color: Int, isBackground: Boolean, isDark: Boolean ) { var isBg = isBackground if (!isBg) { when (view) { is RadioButton -> setTint(view, color, isDark) is SeekBar -> setTint(view, color, isDark) is ProgressBar -> setTint(view, color) is AppCompatEditText -> setTint(view, color, isDark) is CheckBox -> setTint(view, color, isDark) is CheckedTextView -> setTint(view, color, isDark) is ImageView -> setTint(view, color) is Switch -> setTint(view, color, isDark) is SwitchCompat -> setTint(view, color, isDark) is SearchView -> { val iconIdS = intArrayOf( androidx.appcompat.R.id.search_button, androidx.appcompat.R.id.search_close_btn, androidx.appcompat.R.id.search_go_btn, androidx.appcompat.R.id.search_voice_btn, androidx.appcompat.R.id.search_mag_icon ) for (iconId in iconIdS) { val icon = view.findViewById(iconId) if (icon != null) { setTint(icon, color) } } } else -> isBg = true } if (!isBg && view.background is RippleDrawable) { // Ripples for the above views (e.g. when you tap and hold a switch or checkbox) val rd = view.background as RippleDrawable @SuppressLint("PrivateResource") val unchecked = ContextCompat.getColor( view.context, if (isDark) androidx.appcompat.R.color.ripple_material_dark else androidx.appcompat.R.color.ripple_material_light ) val checked = ColorUtils.adjustAlpha(color, 0.4f) val sl = ColorStateList( arrayOf( intArrayOf(-android.R.attr.state_activated, -android.R.attr.state_checked), intArrayOf(android.R.attr.state_activated), intArrayOf(android.R.attr.state_checked) ), intArrayOf(unchecked, checked, checked) ) rd.setColor(sl) } } if (isBg) { // Need to tint the isBackground of a view if (view is FloatingActionButton || view is Button) { setTintSelector(view, color, false, isDark) } else if (view.background != null) { var drawable: Drawable? = view.background if (drawable != null) { drawable = createTintedDrawable(drawable, color) ViewUtils.setBackgroundCompat(view, drawable) } } } } @SuppressLint("PrivateResource") fun setTint(radioButton: RadioButton, @ColorInt color: Int, useDarker: Boolean) { val sl = ColorStateList( arrayOf( intArrayOf(-android.R.attr.state_enabled), intArrayOf(android.R.attr.state_enabled, -android.R.attr.state_checked), intArrayOf(android.R.attr.state_enabled, android.R.attr.state_checked) ), intArrayOf( // Radio button includes own alpha for disabled state ColorUtils.stripAlpha( ContextCompat.getColor( radioButton.context, if (useDarker) R.color.ate_control_disabled_dark else R.color.ate_control_disabled_light ) ), ContextCompat.getColor( radioButton.context, if (useDarker) R.color.ate_control_normal_dark else R.color.ate_control_normal_light ), color ) ) radioButton.buttonTintList = sl } fun setTint(seekBar: SeekBar, @ColorInt color: Int, useDarker: Boolean) { val s1 = getDisabledColorStateList( color, ContextCompat.getColor( seekBar.context, if (useDarker) R.color.ate_control_disabled_dark else R.color.ate_control_disabled_light ) ) seekBar.thumbTintList = s1 seekBar.progressTintList = s1 } @JvmOverloads fun setTint( progressBar: ProgressBar, @ColorInt color: Int, skipIndeterminate: Boolean = false ) { val sl = ColorStateList.valueOf(color) progressBar.progressTintList = sl progressBar.secondaryProgressTintList = sl if (!skipIndeterminate) progressBar.indeterminateTintList = sl } @SuppressLint("RestrictedApi") fun setTint(editText: AppCompatEditText, @ColorInt color: Int, useDarker: Boolean) { val editTextColorStateList = ColorStateList( arrayOf( intArrayOf(-android.R.attr.state_enabled), intArrayOf( android.R.attr.state_enabled, -android.R.attr.state_pressed, -android.R.attr.state_focused ), intArrayOf() ), intArrayOf( ContextCompat.getColor( editText.context, if (useDarker) R.color.ate_text_disabled_dark else R.color.ate_text_disabled_light ), ContextCompat.getColor( editText.context, if (useDarker) R.color.ate_control_normal_dark else R.color.ate_control_normal_light ), color ) ) editText.supportBackgroundTintList = editTextColorStateList setCursorTint(editText, color) } @SuppressLint("PrivateResource") fun setTint(box: CheckBox, @ColorInt color: Int, useDarker: Boolean) { val sl = ColorStateList( arrayOf( intArrayOf(-android.R.attr.state_enabled), intArrayOf(android.R.attr.state_enabled, -android.R.attr.state_checked), intArrayOf(android.R.attr.state_enabled, android.R.attr.state_checked) ), intArrayOf( ContextCompat.getColor( box.context, if (useDarker) R.color.ate_control_disabled_dark else R.color.ate_control_disabled_light ), ContextCompat.getColor( box.context, if (useDarker) R.color.ate_control_normal_dark else R.color.ate_control_normal_light ), color ) ) box.buttonTintList = sl } @SuppressLint("PrivateResource") fun setTint(checkedTextView: CheckedTextView, @ColorInt color: Int, useDarker: Boolean) { val sl = ColorStateList( arrayOf( intArrayOf(-android.R.attr.state_enabled), intArrayOf(android.R.attr.state_enabled, -android.R.attr.state_checked), intArrayOf(android.R.attr.state_enabled, android.R.attr.state_checked) ), intArrayOf( ContextCompat.getColor( checkedTextView.context, if (useDarker) R.color.ate_control_disabled_dark else R.color.ate_control_disabled_light ), ContextCompat.getColor( checkedTextView.context, if (useDarker) R.color.ate_control_normal_dark else R.color.ate_control_normal_light ), color ) ) checkedTextView.checkMarkTintList = sl TextViewCompat.setCompoundDrawableTintList(checkedTextView, sl) } fun setTint(image: ImageView, @ColorInt color: Int) { image.setColorFilter(color, PorterDuff.Mode.SRC_ATOP) } private fun modifySwitchDrawable( context: Context, from: Drawable, @ColorInt tint: Int, thumb: Boolean, compatSwitch: Boolean, useDarker: Boolean ): Drawable? { var tint1 = tint if (useDarker) { tint1 = ColorUtils.shiftColor(tint1, 1.1f) } tint1 = ColorUtils.adjustAlpha(tint1, if (compatSwitch && !thumb) 0.5f else 1.0f) val disabled: Int var normal: Int if (thumb) { disabled = ContextCompat.getColor( context, if (useDarker) R.color.ate_switch_thumb_disabled_dark else R.color.ate_switch_thumb_disabled_light ) normal = ContextCompat.getColor( context, if (useDarker) R.color.ate_switch_thumb_normal_dark else R.color.ate_switch_thumb_normal_light ) } else { disabled = ContextCompat.getColor( context, if (useDarker) R.color.ate_switch_track_disabled_dark else R.color.ate_switch_track_disabled_light ) normal = ContextCompat.getColor( context, if (useDarker) R.color.ate_switch_track_normal_dark else R.color.ate_switch_track_normal_light ) } // Stock switch includes its own alpha if (!compatSwitch) { normal = ColorUtils.stripAlpha(normal) } val sl = ColorStateList( arrayOf( intArrayOf(-android.R.attr.state_enabled), intArrayOf( android.R.attr.state_enabled, -android.R.attr.state_activated, -android.R.attr.state_checked ), intArrayOf(android.R.attr.state_enabled, android.R.attr.state_activated), intArrayOf(android.R.attr.state_enabled, android.R.attr.state_checked) ), intArrayOf(disabled, normal, tint1, tint1) ) return createTintedDrawable(from, sl) } fun setTint( @SuppressLint("UseSwitchCompatOrMaterialCode") switchView: Switch, @ColorInt color: Int, useDarker: Boolean ) { if (switchView.trackDrawable != null) { switchView.trackDrawable = modifySwitchDrawable( switchView.context, switchView.trackDrawable, color, thumb = false, compatSwitch = false, useDarker = useDarker ) } if (switchView.thumbDrawable != null) { switchView.thumbDrawable = modifySwitchDrawable( switchView.context, switchView.thumbDrawable, color, thumb = true, compatSwitch = false, useDarker = useDarker ) } } fun setTint(switchView: SwitchCompat, @ColorInt color: Int, useDarker: Boolean) { if (switchView.trackDrawable != null) { switchView.trackDrawable = modifySwitchDrawable( switchView.context, switchView.trackDrawable, color, thumb = false, compatSwitch = true, useDarker = useDarker ) } if (switchView.thumbDrawable != null) { switchView.thumbDrawable = modifySwitchDrawable( switchView.context, switchView.thumbDrawable, color, thumb = true, compatSwitch = true, useDarker = useDarker ) } } // This returns a NEW Drawable because of the mutate() call. The mutate() call is necessary because Drawables with the same resource have shared states otherwise. @CheckResult fun createTintedDrawable(drawable: Drawable?, @ColorInt color: Int): Drawable? { var drawable1: Drawable? = drawable ?: return null drawable1 = DrawableCompat.wrap(drawable1!!.mutate()) DrawableCompat.setTintMode(drawable1, PorterDuff.Mode.SRC_IN) DrawableCompat.setTint(drawable1, color) return drawable1 } // This returns a NEW Drawable because of the mutate() call. The mutate() call is necessary because Drawables with the same resource have shared states otherwise. @CheckResult fun createTintedDrawable(drawable: Drawable?, sl: ColorStateList): Drawable? { var drawable1: Drawable? = drawable ?: return null drawable1 = DrawableCompat.wrap(drawable1!!.mutate()) DrawableCompat.setTintList(drawable1, sl) return drawable1 } @SuppressLint("DiscouragedPrivateApi", "SoonBlockedPrivateApi") fun setCursorTint(editText: EditText, @ColorInt color: Int) { try { val fCursorDrawableRes = TextView::class.java.getDeclaredField("mCursorDrawableRes") fCursorDrawableRes.isAccessible = true val mCursorDrawableRes = fCursorDrawableRes.getInt(editText) val fEditor = TextView::class.java.getDeclaredField("mEditor") fEditor.isAccessible = true val editor = fEditor.get(editText) val clazz = editor.javaClass val fCursorDrawable = clazz.getDeclaredField("mCursorDrawable") fCursorDrawable.isAccessible = true val drawables = arrayOfNulls(2) drawables[0] = ContextCompat.getDrawable(editText.context, mCursorDrawableRes) drawables[0] = createTintedDrawable(drawables[0], color) drawables[1] = ContextCompat.getDrawable(editText.context, mCursorDrawableRes) drawables[1] = createTintedDrawable(drawables[1], color) fCursorDrawable.set(editor, drawables) } catch (ignored: Exception) { } } } ================================================ FILE: app/src/main/java/io/legado/app/lib/theme/ViewUtils.kt ================================================ package io.legado.app.lib.theme import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.TransitionDrawable import android.view.View import android.view.ViewTreeObserver import androidx.annotation.ColorInt import io.legado.app.utils.DrawableUtils /** * @author Karim Abou Zeid (kabouzeid) */ @Suppress("unused") object ViewUtils { fun removeOnGlobalLayoutListener(v: View, listener: ViewTreeObserver.OnGlobalLayoutListener) { v.viewTreeObserver.removeOnGlobalLayoutListener(listener) } fun setBackgroundCompat(view: View, drawable: Drawable?) { view.background = drawable } fun setBackgroundTransition(view: View, newDrawable: Drawable): TransitionDrawable { val transition = DrawableUtils.createTransitionDrawable(view.background, newDrawable) setBackgroundCompat(view, transition) return transition } fun setBackgroundColorTransition(view: View, @ColorInt newColor: Int): TransitionDrawable { val oldColor = view.background val start = oldColor ?: ColorDrawable(view.solidColor) val end = ColorDrawable(newColor) val transition = DrawableUtils.createTransitionDrawable(start, end) setBackgroundCompat(view, transition) return transition } } ================================================ FILE: app/src/main/java/io/legado/app/lib/theme/view/ThemeBottomNavigationVIew.kt ================================================ package io.legado.app.lib.theme.view import android.content.Context import android.graphics.Color import android.graphics.drawable.ColorDrawable import android.util.AttributeSet import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.view.ViewCompat import com.google.android.material.bottomnavigation.BottomNavigationView import io.legado.app.databinding.ViewNavigationBadgeBinding import io.legado.app.help.config.AppConfig import io.legado.app.lib.theme.Selector import io.legado.app.lib.theme.ThemeStore import io.legado.app.lib.theme.bottomBackground import io.legado.app.lib.theme.getSecondaryTextColor import io.legado.app.ui.widget.text.BadgeView import io.legado.app.utils.ColorUtils class ThemeBottomNavigationVIew(context: Context, attrs: AttributeSet) : BottomNavigationView(context, attrs) { init { val bgColor = context.bottomBackground setBackgroundColor(bgColor) val textIsDark = ColorUtils.isColorLight(bgColor) val textColor = context.getSecondaryTextColor(textIsDark) val colorStateList = Selector.colorBuild() .setDefaultColor(textColor) .setSelectedColor(ThemeStore.accentColor(context)).create() itemIconTintList = colorStateList itemTextColor = colorStateList if (AppConfig.isEInkMode) { isItemHorizontalTranslationEnabled = false itemBackground = ColorDrawable(Color.TRANSPARENT) } ViewCompat.setOnApplyWindowInsetsListener(this, null) } fun addBadgeView(index: Int): BadgeView { //获取底部菜单view val menuView = getChildAt(0) as ViewGroup //获取第index个itemView val itemView = menuView.getChildAt(index) as ViewGroup val badgeBinding = ViewNavigationBadgeBinding.inflate(LayoutInflater.from(context)) itemView.addView(badgeBinding.root) return badgeBinding.viewBadge } } ================================================ FILE: app/src/main/java/io/legado/app/lib/theme/view/ThemeCheckBox.kt ================================================ package io.legado.app.lib.theme.view import android.content.Context import android.util.AttributeSet import androidx.appcompat.widget.AppCompatCheckBox import io.legado.app.lib.theme.accentColor import io.legado.app.utils.applyTint class ThemeCheckBox(context: Context, attrs: AttributeSet) : AppCompatCheckBox(context, attrs) { private var isUserAction = false init { if (!isInEditMode) { applyTint(context.accentColor) } } override fun performClick(): Boolean { isUserAction = true val result = super.performClick() isUserAction = false return result } fun setOnUserCheckedChangeListener(listener: ((Boolean) -> Unit)?) { if (listener == null) { return super.setOnCheckedChangeListener(null) } super.setOnCheckedChangeListener { _, isChecked -> if (isUserAction) { listener.invoke(isChecked) } } } } ================================================ FILE: app/src/main/java/io/legado/app/lib/theme/view/ThemeEditText.kt ================================================ package io.legado.app.lib.theme.view import android.content.Context import android.os.Build import android.util.AttributeSet import androidx.appcompat.widget.AppCompatEditText import io.legado.app.lib.theme.accentColor import io.legado.app.utils.applyTint class ThemeEditText @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : AppCompatEditText(context, attrs) { init { if (!isInEditMode) { applyTint(context.accentColor) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { isLocalePreferredLineHeightForMinimumUsed = false } } } ================================================ FILE: app/src/main/java/io/legado/app/lib/theme/view/ThemeProgressBar.kt ================================================ package io.legado.app.lib.theme.view import android.content.Context import android.util.AttributeSet import android.widget.ProgressBar import io.legado.app.lib.theme.accentColor import io.legado.app.utils.applyTint class ThemeProgressBar(context: Context, attrs: AttributeSet) : ProgressBar(context, attrs) { init { if (!isInEditMode) { applyTint(context.accentColor) } } } ================================================ FILE: app/src/main/java/io/legado/app/lib/theme/view/ThemeRadioButton.kt ================================================ package io.legado.app.lib.theme.view import android.content.Context import android.util.AttributeSet import androidx.appcompat.widget.AppCompatRadioButton import io.legado.app.lib.theme.accentColor import io.legado.app.utils.applyTint class ThemeRadioButton(context: Context, attrs: AttributeSet) : AppCompatRadioButton(context, attrs) { private var isUserAction = false init { if (!isInEditMode) { applyTint(context.accentColor) } } override fun performClick(): Boolean { isUserAction = true val result = super.performClick() isUserAction = false return result } fun setOnUserCheckedChangeListener(listener: ((Boolean) -> Unit)?) { if (listener == null) { return super.setOnCheckedChangeListener(null) } super.setOnCheckedChangeListener { _, isChecked -> if (isUserAction) { listener.invoke(isChecked) } } } } ================================================ FILE: app/src/main/java/io/legado/app/lib/theme/view/ThemeRadioNoButton.kt ================================================ package io.legado.app.lib.theme.view import android.content.Context import android.graphics.Color import android.util.AttributeSet import androidx.appcompat.widget.AppCompatRadioButton import androidx.appcompat.widget.TooltipCompat import io.legado.app.R import io.legado.app.lib.theme.Selector import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.bottomBackground import io.legado.app.lib.theme.getPrimaryTextColor import io.legado.app.utils.ColorUtils import io.legado.app.utils.dpToPx import io.legado.app.utils.getCompatColor class ThemeRadioNoButton(context: Context, attrs: AttributeSet) : AppCompatRadioButton(context, attrs) { private val isBottomBackground: Boolean init { val typedArray = context.obtainStyledAttributes(attrs, R.styleable.ThemeRadioNoButton) isBottomBackground = typedArray.getBoolean(R.styleable.ThemeRadioNoButton_isBottomBackground, false) typedArray.recycle() initTheme() TooltipCompat.setTooltipText(this, text) } private fun initTheme() { when { isInEditMode -> Unit isBottomBackground -> { val accentColor = context.accentColor val isLight = ColorUtils.isColorLight(context.bottomBackground) val textColor = context.getPrimaryTextColor(isLight) val checkedTextColor = if (ColorUtils.isColorLight(accentColor)) { Color.BLACK } else { Color.WHITE } background = Selector.shapeBuild() .setCornerRadius(2.dpToPx()) .setStrokeWidth(2.dpToPx()) .setCheckedBgColor(accentColor) .setCheckedStrokeColor(accentColor) .setDefaultStrokeColor(textColor) .create() setTextColor( Selector.colorBuild() .setDefaultColor(textColor) .setCheckedColor(checkedTextColor) .create() ) } else -> { val accentColor = context.accentColor val defaultTextColor = context.getCompatColor(R.color.primaryText) val checkedTextColor = if (ColorUtils.isColorLight(accentColor)) { Color.BLACK } else { Color.WHITE } background = Selector.shapeBuild() .setCornerRadius(2.dpToPx()) .setStrokeWidth(2.dpToPx()) .setCheckedBgColor(accentColor) .setCheckedStrokeColor(accentColor) .setDefaultStrokeColor(defaultTextColor) .create() setTextColor( Selector.colorBuild() .setDefaultColor(defaultTextColor) .setCheckedColor(checkedTextColor) .create() ) } } } } ================================================ FILE: app/src/main/java/io/legado/app/lib/theme/view/ThemeSeekBar.kt ================================================ package io.legado.app.lib.theme.view import android.content.Context import android.util.AttributeSet import androidx.appcompat.widget.AppCompatSeekBar import io.legado.app.lib.theme.accentColor import io.legado.app.utils.applyTint /** * @author Aidan Follestad (afollestad) */ class ThemeSeekBar(context: Context, attrs: AttributeSet) : AppCompatSeekBar(context, attrs) { init { if (!isInEditMode) { applyTint(context.accentColor) } } } ================================================ FILE: app/src/main/java/io/legado/app/lib/theme/view/ThemeSwitch.kt ================================================ package io.legado.app.lib.theme.view import android.content.Context import android.util.AttributeSet import androidx.appcompat.widget.SwitchCompat import io.legado.app.lib.theme.accentColor import io.legado.app.utils.applyTint /** * @author Aidan Follestad (afollestad) */ class ThemeSwitch(context: Context, attrs: AttributeSet) : SwitchCompat(context, attrs) { private var isUserAction = false init { if (!isInEditMode) { applyTint(context.accentColor) } } override fun performClick(): Boolean { isUserAction = true val result = super.performClick() isUserAction = false return result } fun setOnUserCheckedChangeListener(listener: ((Boolean) -> Unit)?) { if (listener == null) { return super.setOnCheckedChangeListener(null) } super.setOnCheckedChangeListener { _, isChecked -> if (isUserAction) { listener.invoke(isChecked) } } } } ================================================ FILE: app/src/main/java/io/legado/app/lib/webdav/Authorization.kt ================================================ package io.legado.app.lib.webdav import io.legado.app.data.appDb import io.legado.app.data.entities.Server.WebDavConfig import okhttp3.Credentials import java.nio.charset.Charset import java.nio.charset.StandardCharsets data class Authorization( val username: String, val password: String, val charset: Charset = StandardCharsets.ISO_8859_1 ) { var name = "Authorization" private set var data: String = Credentials.basic(username, password, charset) private set override fun toString(): String { return "$username:$password" } constructor(serverID: Long) : this( appDb.serverDao.get(serverID)?.getWebDavConfig() ?: throw WebDavException("Unexpected WebDav Authorization") ) constructor(webDavConfig: WebDavConfig) : this(webDavConfig.username, webDavConfig.password) } ================================================ FILE: app/src/main/java/io/legado/app/lib/webdav/WebDav.kt ================================================ package io.legado.app.lib.webdav import android.annotation.SuppressLint import android.net.Uri import cn.hutool.core.net.URLDecoder import io.legado.app.constant.AppLog import io.legado.app.exception.NoStackTraceException import io.legado.app.help.http.newCallResponse import io.legado.app.help.http.okHttpClient import io.legado.app.help.http.text import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.model.analyzeRule.CustomUrl import io.legado.app.utils.NetworkUtils import io.legado.app.utils.findNS import io.legado.app.utils.findNSPrefix import io.legado.app.utils.printOnDebug import io.legado.app.utils.toRequestBody import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import org.intellij.lang.annotations.Language import org.jsoup.Jsoup import org.jsoup.parser.Parser import java.io.File import java.io.FileOutputStream import java.io.InputStream import java.net.MalformedURLException import java.net.URL import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.util.concurrent.TimeUnit @Suppress("unused", "MemberVisibilityCanBePrivate") open class WebDav( val path: String, val authorization: Authorization ) { companion object { fun fromPath(path: String): WebDav { val id = AnalyzeUrl(path).serverID ?: throw WebDavException("没有serverID") val authorization = Authorization(id) return WebDav(path, authorization) } @SuppressLint("DateTimeFormatter") private val dateTimeFormatter = DateTimeFormatter.RFC_1123_DATE_TIME // 指定返回哪些属性 @Language("xml") private const val DIR = """ %s """ @Language("xml") private const val EXISTS = """ """ private const val DEFAULT_CONTENT_TYPE = "application/octet-stream" } private val url: URL = URL(CustomUrl(path).getUrl()) private val httpUrl: String? by lazy { val raw = url.toString() .replace("davs://", "https://") .replace("dav://", "http://") return@lazy kotlin.runCatching { raw.toHttpUrl().toString() }.getOrNull() } private val webDavClient by lazy { val authInterceptor = Interceptor { chain -> var request = chain.request() if (request.url.host.equals(host, true)) { request = request .newBuilder() .header(authorization.name, authorization.data) .build() } chain.proceed(request) } okHttpClient.newBuilder().run { callTimeout(0, TimeUnit.SECONDS) interceptors().add(0, authInterceptor) addNetworkInterceptor(authInterceptor) build() } } private val host: String? get() = url.host?.let { if (it.startsWith("[")) { it.substring(1, it.lastIndex) } else { it } } /** * 获取当前url文件信息 */ @Throws(WebDavException::class) suspend fun getWebDavFile(): WebDavFile? { return propFindResponse(depth = 0)?.let { parseBody(it).firstOrNull() } } /** * 列出当前路径下的文件 * @return 文件列表 */ @Throws(WebDavException::class) suspend fun listFiles(): List { propFindResponse()?.let { body -> return parseBody(body).filter { it.path != path } } return emptyList() } /** * @param propsList 指定列出文件的哪些属性 */ @Throws(WebDavException::class) private suspend fun propFindResponse( propsList: List = emptyList(), depth: Int = 1 ): String? { val requestProps = StringBuilder() for (p in propsList) { requestProps.append("\n") } val requestPropsStr: String = if (requestProps.toString().isEmpty()) { DIR.replace("%s", "") } else { String.format(DIR, requestProps.toString() + "\n") } val url = httpUrl ?: return null return webDavClient.newCallResponse { url(url) addHeader("Depth", depth.toString()) // 添加RequestBody对象,可以只返回的属性。如果设为null,则会返回全部属性 // 注意:尽量手动指定需要返回的属性。若返回全部属性,可能后由于Prop.java里没有该属性名,而崩溃。 val requestBody = requestPropsStr.toRequestBody("text/plain".toMediaType()) method("PROPFIND", requestBody) }.apply { checkResult(this) }.body.text() } /** * 解析webDav返回的xml */ private fun parseBody(s: String): List { val list = ArrayList() val document = kotlin.runCatching { Jsoup.parse(s, Parser.xmlParser()) }.getOrElse { Jsoup.parse(s) } val ns = document.findNSPrefix("DAV:") val elements = document.findNS("response", ns) val urlStr = httpUrl ?: return list val baseUrl = NetworkUtils.getBaseUrl(urlStr) for (element in elements) { //依然是优化支持 caddy 自建的 WebDav ,其目录后缀都为“/”, 所以删除“/”的判定,不然无法获取该目录项 val href = element.findNS("href", ns)[0].text() val hrefDecode = URLDecoder.decodeForPath(href, Charsets.UTF_8) val fileName = hrefDecode.removeSuffix("/").substringAfterLast("/") val webDavFile: WebDav try { val urlName = hrefDecode.ifEmpty { url.file.replace("/", "") } val displayName = element .findNS("displayname", ns) .firstOrNull()?.text()?.takeIf { it.isNotEmpty() } ?.let { URLDecoder.decodeForPath(it, Charsets.UTF_8) } ?: fileName val contentType = element .findNS("getcontenttype", ns) .firstOrNull()?.text().orEmpty() val resourceType = element .findNS("resourcetype", ns) .firstOrNull()?.html()?.trim().orEmpty() val size = kotlin.runCatching { element.findNS("getcontentlength", ns) .firstOrNull()?.text()?.toLong() ?: 0 }.getOrDefault(0) val lastModify: Long = kotlin.runCatching { element.findNS("getlastmodified", ns) .firstOrNull()?.text()?.let { ZonedDateTime.parse(it, dateTimeFormatter) .toInstant().toEpochMilli() } }.getOrNull() ?: 0 var fullURL = NetworkUtils.getAbsoluteURL(baseUrl, hrefDecode) if (WebDavFile.isDir(contentType, resourceType) && !fullURL.endsWith("/")) { fullURL += "/" } webDavFile = WebDavFile( fullURL, authorization, displayName = displayName, urlName = urlName, size = size, contentType = contentType, resourceType = resourceType, lastModify = lastModify ) list.add(webDavFile) } catch (e: MalformedURLException) { e.printOnDebug() } } return list } /** * 文件是否存在 */ suspend fun exists(): Boolean { val url = httpUrl ?: return false return kotlin.runCatching { return webDavClient.newCallResponse { url(url) addHeader("Depth", "0") val requestBody = EXISTS.toRequestBody("application/xml".toMediaType()) method("PROPFIND", requestBody) }.use { it.isSuccessful } }.onFailure { currentCoroutineContext().ensureActive() }.getOrDefault(false) } /** * 检查用户名密码是否有效 */ suspend fun check(): Boolean { return kotlin.runCatching { webDavClient.newCallResponse { url(url) addHeader("Depth", "0") val requestBody = EXISTS.toRequestBody("application/xml".toMediaType()) method("PROPFIND", requestBody) }.use { it.code != 401 } }.onFailure { currentCoroutineContext().ensureActive() }.getOrDefault(true) } /** * 根据自己的URL,在远程处创建对应的文件夹 * @return 是否创建成功 */ suspend fun makeAsDir(): Boolean { val url = httpUrl ?: return false //防止报错 return kotlin.runCatching { if (!exists()) { webDavClient.newCallResponse { url(url) method("MKCOL", null) }.use { checkResult(it) } } }.onFailure { currentCoroutineContext().ensureActive() AppLog.put("WebDav创建目录失败\n${it.localizedMessage}", it) }.isSuccess } /** * 下载到本地 * @param savedPath 本地的完整路径,包括最后的文件名 * @param replaceExisting 是否替换本地的同名文件 */ @Throws(WebDavException::class) suspend fun downloadTo(savedPath: String, replaceExisting: Boolean) { val file = File(savedPath) if (file.exists() && !replaceExisting) { return } downloadInputStream().use { byteStream -> FileOutputStream(file).use { byteStream.copyTo(it) } } } /** * 下载文件,返回ByteArray */ @Throws(WebDavException::class) suspend fun download(): ByteArray { return downloadInputStream().use { it.readBytes() } } /** * 上传文件 */ @Throws(WebDavException::class) suspend fun upload(localPath: String, contentType: String = DEFAULT_CONTENT_TYPE) { upload(File(localPath), contentType) } @Throws(WebDavException::class) suspend fun upload(file: File, contentType: String = DEFAULT_CONTENT_TYPE) { kotlin.runCatching { withContext(IO) { if (!file.exists()) throw WebDavException("文件不存在") // 务必注意RequestBody不要嵌套,不然上传时内容可能会被追加多余的文件信息 val fileBody = file.asRequestBody(contentType.toMediaType()) val url = httpUrl ?: throw WebDavException("url不能为空") webDavClient.newCallResponse { url(url) put(fileBody) }.use { checkResult(it) } } }.onFailure { currentCoroutineContext().ensureActive() AppLog.put("WebDav上传失败\n${it.localizedMessage}", it) throw WebDavException("WebDav上传失败\n${it.localizedMessage}") } } @Throws(WebDavException::class) suspend fun upload(byteArray: ByteArray, contentType: String = DEFAULT_CONTENT_TYPE) { // 务必注意RequestBody不要嵌套,不然上传时内容可能会被追加多余的文件信息 kotlin.runCatching { withContext(IO) { val fileBody = byteArray.toRequestBody(contentType.toMediaType()) val url = httpUrl ?: throw NoStackTraceException("url不能为空") webDavClient.newCallResponse { url(url) put(fileBody) }.use { checkResult(it) } } }.onFailure { currentCoroutineContext().ensureActive() AppLog.put("WebDav上传失败\n${it.localizedMessage}", it) throw WebDavException("WebDav上传失败\n${it.localizedMessage}") } } @Throws(WebDavException::class) suspend fun upload(uri: Uri, contentType: String = DEFAULT_CONTENT_TYPE) { // 务必注意RequestBody不要嵌套,不然上传时内容可能会被追加多余的文件信息 kotlin.runCatching { withContext(IO) { val fileBody = uri.toRequestBody(contentType.toMediaType()) val url = httpUrl ?: throw NoStackTraceException("url不能为空") webDavClient.newCallResponse { url(url) put(fileBody) }.use { checkResult(it) } } }.onFailure { currentCoroutineContext().ensureActive() AppLog.put("WebDav上传失败\n${it.localizedMessage}", it) throw WebDavException("WebDav上传失败\n${it.localizedMessage}") } } @Throws(WebDavException::class) suspend fun downloadInputStream(): InputStream { val url = httpUrl ?: throw WebDavException("WebDav下载出错\nurl为空") val byteStream = webDavClient.newCallResponse { url(url) }.apply { checkResult(this) }.body.byteStream() return byteStream } /** * 移除文件/文件夹 */ suspend fun delete(): Boolean { val url = httpUrl ?: return false //防止报错 return kotlin.runCatching { webDavClient.newCallResponse { url(url) method("DELETE", null) }.use { checkResult(it) } }.onFailure { currentCoroutineContext().ensureActive() AppLog.put("WebDav删除失败\n${it.localizedMessage}", it) }.isSuccess } /** * 检测返回结果是否正确 */ private fun checkResult(response: Response) { if (!response.isSuccessful) { val body = response.body.string() if (response.code == 401) { val headers = response.headers("WWW-Authenticate") val supportBasicAuth = headers.any { it.startsWith("Basic", ignoreCase = true) } if (headers.isNotEmpty() && !supportBasicAuth) { AppLog.put("服务器不支持BasicAuth认证") } } if (response.message.isNotBlank() || body.isBlank()) { throw WebDavException("${url}\n${response.code}:${response.message}") } val document = Jsoup.parse(body) val exception = document.getElementsByTag("s:exception").firstOrNull()?.text() val message = document.getElementsByTag("s:message").firstOrNull()?.text() if (exception == "ObjectNotFound") { throw ObjectNotFoundException( message ?: "$path doesn't exist. code:${response.code}" ) } throw WebDavException(message ?: "未知错误 code:${response.code}") } } } ================================================ FILE: app/src/main/java/io/legado/app/lib/webdav/WebDavException.kt ================================================ package io.legado.app.lib.webdav open class WebDavException(msg: String) : Exception(msg) { override fun fillInStackTrace(): Throwable { return this } } class ObjectNotFoundException(msg: String) : WebDavException(msg) ================================================ FILE: app/src/main/java/io/legado/app/lib/webdav/WebDavFile.kt ================================================ package io.legado.app.lib.webdav /** * webDavFile */ @Suppress("unused") class WebDavFile( urlStr: String, authorization: Authorization, val displayName: String, val urlName: String, val size: Long, val contentType: String, val resourceType: String, val lastModify: Long ) : WebDav(urlStr, authorization) { val isDir by lazy { isDir(contentType, resourceType) } companion object { fun isDir(contentType: String, resourceType: String): Boolean { return contentType == "httpd/unix-directory" || resourceType.lowercase().contains("collection") } } } ================================================ FILE: app/src/main/java/io/legado/app/model/AudioPlay.kt ================================================ package io.legado.app.model import android.annotation.SuppressLint import android.content.Context import android.content.Intent import io.legado.app.R import io.legado.app.constant.AppLog import io.legado.app.constant.EventBus import io.legado.app.constant.IntentAction import io.legado.app.constant.Status import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookSource import io.legado.app.help.book.ContentProcessor import io.legado.app.help.book.getBookSource import io.legado.app.help.book.readSimulating import io.legado.app.help.book.simulatedTotalChapterNum import io.legado.app.help.book.update import io.legado.app.help.coroutine.Coroutine import io.legado.app.model.webBook.WebBook import io.legado.app.service.AudioPlayService import io.legado.app.utils.postEvent import io.legado.app.utils.startService import io.legado.app.utils.toastOnUi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancelChildren import splitties.init.appCtx @SuppressLint("StaticFieldLeak") @Suppress("unused") object AudioPlay : CoroutineScope by MainScope() { /** * 播放模式枚举 */ enum class PlayMode(val iconRes: Int) { LIST_END_STOP(R.drawable.ic_play_mode_list_end_stop), SINGLE_LOOP(R.drawable.ic_play_mode_single_loop), RANDOM(R.drawable.ic_play_mode_random), LIST_LOOP(R.drawable.ic_play_mode_list_loop); fun next(): PlayMode { return when (this) { LIST_END_STOP -> SINGLE_LOOP SINGLE_LOOP -> RANDOM RANDOM -> LIST_LOOP LIST_LOOP -> LIST_END_STOP } } } var playMode = PlayMode.LIST_END_STOP var status = Status.STOP private var activityContext: Context? = null private var serviceContext: Context? = null private val context: Context get() = activityContext ?: serviceContext ?: appCtx var callback: CallBack? = null var book: Book? = null var chapterSize = 0 var simulatedChapterSize = 0 var durChapterIndex = 0 var durChapterPos = 0 var durChapter: BookChapter? = null var durPlayUrl = "" var durAudioSize = 0 var inBookshelf = false var bookSource: BookSource? = null val loadingChapters = arrayListOf() fun changePlayMode() { playMode = playMode.next() postEvent(EventBus.PLAY_MODE_CHANGED, playMode) } fun upData(book: Book) { AudioPlay.book = book chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl) simulatedChapterSize = if (book.readSimulating()) { book.simulatedTotalChapterNum() } else { chapterSize } if (durChapterIndex != book.durChapterIndex) { stopPlay() durChapterIndex = book.durChapterIndex durChapterPos = book.durChapterPos durPlayUrl = "" durAudioSize = 0 } upDurChapter() } fun resetData(book: Book) { stop() AudioPlay.book = book chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl) simulatedChapterSize = if (book.readSimulating()) { book.simulatedTotalChapterNum() } else { chapterSize } bookSource = book.getBookSource() durChapterIndex = book.durChapterIndex durChapterPos = book.durChapterPos durPlayUrl = "" durAudioSize = 0 upDurChapter() postEvent(EventBus.AUDIO_BUFFER_PROGRESS, 0) } private fun addLoading(index: Int): Boolean { synchronized(this) { if (loadingChapters.contains(index)) return false loadingChapters.add(index) return true } } private fun removeLoading(index: Int) { synchronized(this) { loadingChapters.remove(index) } } fun loadOrUpPlayUrl() { if (durPlayUrl.isEmpty()) { loadPlayUrl() } else { upPlayUrl() } } /** * 加载播放URL */ private fun loadPlayUrl() { val index = durChapterIndex if (addLoading(index)) { val book = book val bookSource = bookSource if (book != null && bookSource != null) { upDurChapter() val chapter = durChapter if (chapter == null) { removeLoading(index) return } if (chapter.isVolume) { skipTo(index + 1) removeLoading(index) return } upLoading(true) WebBook.getContent(this, bookSource, book, chapter) .onSuccess { content -> if (content.isEmpty()) { appCtx.toastOnUi("未获取到资源链接") } else { contentLoadFinish(chapter, content) } }.onError { AppLog.put("获取资源链接出错\n$it", it, true) upLoading(false) }.onCancel { removeLoading(index) }.onFinally { removeLoading(index) } } else { removeLoading(index) appCtx.toastOnUi("book or source is null") } } } /** * 加载完成 */ private fun contentLoadFinish(chapter: BookChapter, content: String) { if (chapter.index == book?.durChapterIndex) { durPlayUrl = content upPlayUrl() } } private fun upPlayUrl() { if (isPlayToEnd()) { playNew() } else { play() } } /** * 播放当前章节 */ fun play() { context.startService { action = IntentAction.play } } /** * 从头播放新章节 */ private fun playNew() { context.startService { action = IntentAction.playNew } } /** * 更新当前章节 */ fun upDurChapter() { val book = book ?: return durChapter = appDb.bookChapterDao.getChapter(book.bookUrl, durChapterIndex) durAudioSize = durChapter?.end?.toInt() ?: 0 val title = durChapter?.title ?: appCtx.getString(R.string.data_loading) postEvent(EventBus.AUDIO_SUB_TITLE, title) postEvent(EventBus.AUDIO_SIZE, durAudioSize) postEvent(EventBus.AUDIO_PROGRESS, durChapterPos) } fun pause(context: Context) { if (AudioPlayService.isRun) { context.startService { action = IntentAction.pause } } } fun resume(context: Context) { if (AudioPlayService.isRun) { context.startService { action = IntentAction.resume } } } fun stop() { if (AudioPlayService.isRun) { context.startService { action = IntentAction.stop } } } fun adjustSpeed(adjust: Float) { if (AudioPlayService.isRun) { context.startService { action = IntentAction.adjustSpeed putExtra("adjust", adjust) } } } fun adjustProgress(position: Int) { durChapterPos = position saveRead() if (AudioPlayService.isRun) { context.startService { action = IntentAction.adjustProgress putExtra("position", position) } } } fun skipTo(index: Int) { Coroutine.async { stopPlay() if (index in 0.. 0) { durChapterIndex -= 1 durChapterPos = 0 durPlayUrl = "" saveRead() loadPlayUrl() } } } fun next() { stopPlay() when (playMode) { PlayMode.LIST_END_STOP -> { if (durChapterIndex + 1 < simulatedChapterSize) { durChapterIndex += 1 durChapterPos = 0 durPlayUrl = "" saveRead() loadPlayUrl() } } PlayMode.SINGLE_LOOP -> { durChapterPos = 0 durPlayUrl = "" saveRead() loadPlayUrl() } PlayMode.RANDOM -> { durChapterIndex = (0 until simulatedChapterSize).random() durChapterPos = 0 durPlayUrl = "" saveRead() loadPlayUrl() } PlayMode.LIST_LOOP -> { durChapterIndex = (durChapterIndex + 1) % simulatedChapterSize durChapterPos = 0 durPlayUrl = "" saveRead() loadPlayUrl() } } } fun setTimer(minute: Int) { if (AudioPlayService.isRun) { val intent = Intent(context, AudioPlayService::class.java) intent.action = IntentAction.setTimer intent.putExtra("minute", minute) context.startService(intent) } else { AudioPlayService.timeMinute = minute postEvent(EventBus.AUDIO_DS, minute) } } fun addTimer() { val intent = Intent(context, AudioPlayService::class.java) intent.action = IntentAction.addTimer context.startService(intent) } fun stopPlay() { if (AudioPlayService.isRun) { context.startService { action = IntentAction.stopPlay } } } fun saveRead() { val book = book ?: return Coroutine.async { book.lastCheckCount = 0 book.durChapterTime = System.currentTimeMillis() val chapterChanged = book.durChapterIndex != durChapterIndex book.durChapterIndex = durChapterIndex book.durChapterPos = durChapterPos if (chapterChanged) { appDb.bookChapterDao.getChapter(book.bookUrl, book.durChapterIndex)?.let { book.durChapterTitle = it.getDisplayTitle( ContentProcessor.get(book.name, book.origin).getTitleReplaceRules(), book.getUseReplaceRule() ) } } book.update() } } /** * 保存章节长度 */ fun saveDurChapter(audioSize: Long) { val chapter = durChapter ?: return Coroutine.async { durAudioSize = audioSize.toInt() chapter.end = audioSize appDb.bookChapterDao.update(chapter) } } fun playPositionChanged(position: Int) { durChapterPos = position saveRead() } fun upLoading(loading: Boolean) { callback?.upLoading(loading) } private fun isPlayToEnd(): Boolean { return durChapterIndex + 1 == simulatedChapterSize && durChapterPos == durAudioSize } fun register(context: Context) { activityContext = context callback = context as CallBack } fun unregister(context: Context) { if (activityContext === context) { activityContext = null callback = null } coroutineContext.cancelChildren() } fun registerService(context: Context) { serviceContext = context } fun unregisterService() { serviceContext = null } interface CallBack { fun upLoading(loading: Boolean) } } ================================================ FILE: app/src/main/java/io/legado/app/model/BookCover.kt ================================================ package io.legado.app.model import android.annotation.SuppressLint import android.content.Context import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import androidx.annotation.Keep import com.bumptech.glide.Glide import com.bumptech.glide.RequestBuilder import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.Transformation import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.target.Target import com.bumptech.glide.request.target.Target.SIZE_ORIGINAL import io.legado.app.R import io.legado.app.constant.PreferKey import io.legado.app.data.entities.BaseSource import io.legado.app.data.entities.Book import io.legado.app.help.CacheManager import io.legado.app.help.DefaultData import io.legado.app.help.config.AppConfig import io.legado.app.help.glide.BlurTransformation import io.legado.app.help.glide.ImageLoader import io.legado.app.help.glide.OkHttpModelLoader import io.legado.app.model.analyzeRule.AnalyzeRule import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.utils.BitmapUtils import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonObject import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.getPrefString import kotlinx.coroutines.currentCoroutineContext import splitties.init.appCtx import java.io.File @Keep @Suppress("ConstPropertyName") object BookCover { private const val coverRuleConfigKey = "legadoCoverRuleConfig" const val configFileName = "coverRule.json" var drawBookName = true private set var drawBookAuthor = true private set lateinit var defaultDrawable: Drawable private set init { upDefaultCover() } @SuppressLint("UseCompatLoadingForDrawables") fun upDefaultCover() { val isNightTheme = AppConfig.isNightTheme drawBookName = if (isNightTheme) { appCtx.getPrefBoolean(PreferKey.coverShowNameN, true) } else { appCtx.getPrefBoolean(PreferKey.coverShowName, true) } drawBookAuthor = if (isNightTheme) { appCtx.getPrefBoolean(PreferKey.coverShowAuthorN, true) } else { appCtx.getPrefBoolean(PreferKey.coverShowAuthor, true) } val key = if (isNightTheme) PreferKey.defaultCoverDark else PreferKey.defaultCover val path = appCtx.getPrefString(key) if (path.isNullOrBlank()) { defaultDrawable = appCtx.resources.getDrawable(R.drawable.image_cover_default, null) return } defaultDrawable = kotlin.runCatching { BitmapDrawable(appCtx.resources, BitmapUtils.decodeBitmap(path, 600, 900)) }.getOrDefault(appCtx.resources.getDrawable(R.drawable.image_cover_default, null)) } /** * 加载封面 */ fun load( context: Context, path: String?, loadOnlyWifi: Boolean = false, sourceOrigin: String? = null, onLoadFinish: (() -> Unit)? = null, ): RequestBuilder { if (AppConfig.useDefaultCover) { return ImageLoader.load(context, defaultDrawable) .centerCrop() } var options = RequestOptions().set(OkHttpModelLoader.loadOnlyWifiOption, loadOnlyWifi) if (sourceOrigin != null) { options = options.set(OkHttpModelLoader.sourceOriginOption, sourceOrigin) } var builder = ImageLoader.load(context, path) .apply(options) if (onLoadFinish != null) { builder = builder.addListener(object : RequestListener { override fun onLoadFailed( e: GlideException?, model: Any?, target: Target, isFirstResource: Boolean, ): Boolean { onLoadFinish.invoke() return false } override fun onResourceReady( resource: Drawable, model: Any, target: Target?, dataSource: DataSource, isFirstResource: Boolean, ): Boolean { onLoadFinish.invoke() return false } }) } return builder.placeholder(defaultDrawable) .error(defaultDrawable) .centerCrop() } /** * 加载漫画图片 */ fun loadManga( context: Context, path: String?, loadOnlyWifi: Boolean = false, sourceOrigin: String? = null, transformation: Transformation? = null, ): RequestBuilder { var options = RequestOptions().set(OkHttpModelLoader.loadOnlyWifiOption, loadOnlyWifi) .set(OkHttpModelLoader.mangaOption, true) if (sourceOrigin != null) { options = options.set(OkHttpModelLoader.sourceOriginOption, sourceOrigin) } return ImageLoader.load(context, path) .apply(options) .override(context.resources.displayMetrics.widthPixels, SIZE_ORIGINAL) .diskCacheStrategy(DiskCacheStrategy.ALL) .skipMemoryCache(true).let { if (transformation != null) { it.transform(transformation) } else { it } } } fun preloadManga( context: Context, path: String?, loadOnlyWifi: Boolean = false, sourceOrigin: String? = null, ): RequestBuilder { var options = RequestOptions().set(OkHttpModelLoader.loadOnlyWifiOption, loadOnlyWifi) .set(OkHttpModelLoader.mangaOption, true) if (sourceOrigin != null) { options = options.set(OkHttpModelLoader.sourceOriginOption, sourceOrigin) } return Glide.with(context) .downloadOnly() .apply(options) .load(path) } /** * 加载模糊封面 */ fun loadBlur( context: Context, path: String?, loadOnlyWifi: Boolean = false, sourceOrigin: String? = null, ): RequestBuilder { val loadBlur = ImageLoader.load(context, defaultDrawable) .transform(BlurTransformation(25), CenterCrop()) if (AppConfig.useDefaultCover) { return loadBlur } var options = RequestOptions().set(OkHttpModelLoader.loadOnlyWifiOption, loadOnlyWifi) if (sourceOrigin != null) { options = options.set(OkHttpModelLoader.sourceOriginOption, sourceOrigin) } return ImageLoader.load(context, path) .apply(options) .transform(BlurTransformation(25), CenterCrop()) .transition(DrawableTransitionOptions.withCrossFade(1500)) .thumbnail(loadBlur) } fun getCoverRule(): CoverRule { return getConfig() ?: DefaultData.coverRule } fun getConfig(): CoverRule? { return GSON.fromJsonObject(CacheManager.get(coverRuleConfigKey)) .getOrNull() } suspend fun searchCover(book: Book): String? { val config = getCoverRule() if (!config.enable || config.searchUrl.isBlank() || config.coverRule.isBlank()) { return null } val analyzeUrl = AnalyzeUrl( config.searchUrl, book.name, source = config, coroutineContext = currentCoroutineContext(), hasLoginHeader = false ) val res = analyzeUrl.getStrResponseAwait() val analyzeRule = AnalyzeRule(book) analyzeRule.setCoroutineContext(currentCoroutineContext()) analyzeRule.setContent(res.body) analyzeRule.setRedirectUrl(res.url) return analyzeRule.getString(config.coverRule, isUrl = true) } fun saveCoverRule(config: CoverRule) { val json = GSON.toJson(config) saveCoverRule(json) } fun saveCoverRule(json: String) { CacheManager.put(coverRuleConfigKey, json) } fun delCoverRule() { CacheManager.delete(coverRuleConfigKey) } @Keep data class CoverRule( var enable: Boolean = true, var searchUrl: String, var coverRule: String, override var concurrentRate: String? = null, override var loginUrl: String? = null, override var loginUi: String? = null, override var header: String? = null, override var jsLib: String? = null, override var enabledCookieJar: Boolean? = false, ) : BaseSource { override fun getTag(): String { return searchUrl } override fun getKey(): String { return searchUrl } } } ================================================ FILE: app/src/main/java/io/legado/app/model/CacheBook.kt ================================================ package io.legado.app.model import android.content.Context import io.legado.app.constant.AppLog import io.legado.app.constant.EventBus import io.legado.app.constant.IntentAction import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookSource import io.legado.app.exception.ConcurrentException import io.legado.app.help.book.BookHelp import io.legado.app.help.book.isLocal import io.legado.app.help.config.AppConfig import io.legado.app.help.coroutine.CompositeCoroutine import io.legado.app.help.coroutine.Coroutine import io.legado.app.model.webBook.WebBook import io.legado.app.service.CacheBookService import io.legado.app.utils.onEachParallel import io.legado.app.utils.postEvent import io.legado.app.utils.startService import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.isActive import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock import java.util.concurrent.ConcurrentHashMap import kotlin.coroutines.CoroutineContext object CacheBook { val cacheBookMap = ConcurrentHashMap() private val workingState = MutableStateFlow(true) private val mutex = Mutex() @Synchronized fun getOrCreate(bookUrl: String): CacheBookModel? { val book = appDb.bookDao.getBook(bookUrl) ?: return null val bookSource = appDb.bookSourceDao.getBookSource(book.origin) ?: return null updateBookSource(bookSource) var cacheBook = cacheBookMap[bookUrl] if (cacheBook != null) { //存在时更新,书源可能会变化,必须更新 cacheBook.bookSource = bookSource cacheBook.book = book return cacheBook } cacheBook = CacheBookModel(bookSource, book) cacheBookMap[bookUrl] = cacheBook return cacheBook } @Synchronized fun getOrCreate(bookSource: BookSource, book: Book): CacheBookModel { updateBookSource(bookSource) var cacheBook = cacheBookMap[book.bookUrl] if (cacheBook != null) { //存在时更新,书源可能会变化,必须更新 cacheBook.bookSource = bookSource cacheBook.book = book return cacheBook } cacheBook = CacheBookModel(bookSource, book) cacheBookMap[book.bookUrl] = cacheBook return cacheBook } private fun updateBookSource(newBookSource: BookSource) { cacheBookMap.forEach { val model = it.value if (model.bookSource.bookSourceUrl == newBookSource.bookSourceUrl) { model.bookSource = newBookSource } } } fun start(context: Context, book: Book, start: Int, end: Int) { if (!book.isLocal) { context.startService { action = IntentAction.start putExtra("bookUrl", book.bookUrl) putExtra("start", start) putExtra("end", end) } } } fun remove(context: Context, bookUrl: String) { context.startService { action = IntentAction.remove putExtra("bookUrl", bookUrl) } } fun stop(context: Context) { if (CacheBookService.isRun) { context.startService { action = IntentAction.stop } } } fun close() { cacheBookMap.forEach { it.value.stop() } cacheBookMap.clear() successDownloadSet.clear() errorDownloadMap.clear() } fun setWorkingState(value: Boolean) { workingState.value = value } suspend fun startProcessJob(context: CoroutineContext) = mutex.withLock { setWorkingState(true) flow { while (currentCoroutineContext().isActive && cacheBookMap.isNotEmpty()) { var emitted = false cacheBookMap.forEach { (_, model) -> if (!model.isLoading()) { emit(model) emitted = true } workingState.first { it } } if (!emitted) { delay(1000) } } }.onStart { postEvent(EventBus.UP_DOWNLOAD_STATE, "") }.onEachParallel(AppConfig.threadCount) { coroutineScope { it.download(this, context) } }.onCompletion { postEvent(EventBus.UP_DOWNLOAD_STATE, "") }.collect() } val downloadSummary: String get() { return "正在下载:${onDownloadCount}|等待中:${waitCount}|失败:${errorDownloadMap.count()}|成功:${successDownloadSet.size}" } val isRun: Boolean get() { cacheBookMap.forEach { if (it.value.isRun()) { return true } } return false } private val waitCount: Int get() { var count = 0 cacheBookMap.forEach { count += it.value.waitCount } return count } val onDownloadCount: Int get() { var count = 0 cacheBookMap.forEach { count += it.value.onDownloadCount } return count } val successDownloadSet = linkedSetOf() val errorDownloadMap = hashMapOf() class CacheBookModel(var bookSource: BookSource, var book: Book) { private val waitDownloadSet = linkedSetOf() private val onDownloadSet = linkedSetOf() private val tasks = CompositeCoroutine() private var isStopped = false private var waitingRetry = false private var isLoading = false val waitCount get() = waitDownloadSet.size val onDownloadCount get() = onDownloadSet.size init { postEvent(EventBus.UP_DOWNLOAD, book.bookUrl) } @Synchronized fun isRun(): Boolean { return waitDownloadSet.isNotEmpty() || onDownloadSet.isNotEmpty() || isLoading } @Synchronized fun isStop(): Boolean { return isStopped || (!isRun() && !waitingRetry) } @Synchronized fun isLoading(): Boolean { return isLoading } @Synchronized fun setLoading() { isLoading = true } @Synchronized fun stop() { waitDownloadSet.clear() tasks.clear() isStopped = true isLoading = false postEvent(EventBus.UP_DOWNLOAD, book.bookUrl) } @Synchronized fun addDownload(start: Int, end: Int) { isStopped = false for (i in start..end) { if (!onDownloadSet.contains(i)) { waitDownloadSet.add(i) } } cacheBookMap[book.bookUrl] = this isLoading = false postEvent(EventBus.UP_DOWNLOAD, book.bookUrl) } @Synchronized private fun onSuccess(chapter: BookChapter) { onDownloadSet.remove(chapter.index) successDownloadSet.add(chapter.primaryStr()) errorDownloadMap.remove(chapter.primaryStr()) } @Synchronized private fun onPreError(chapter: BookChapter, error: Throwable) { waitingRetry = true if (error !is ConcurrentException) { errorDownloadMap[chapter.primaryStr()] = (errorDownloadMap[chapter.primaryStr()] ?: 0) + 1 } onDownloadSet.remove(chapter.index) } @Synchronized private fun onPostError(chapter: BookChapter, error: Throwable) { //重试3次 if ((errorDownloadMap[chapter.primaryStr()] ?: 0) < 3 && !isStopped) { waitDownloadSet.add(chapter.index) } else { AppLog.put( "下载${book.name}-${chapter.title}失败\n${error.localizedMessage}", error ) } waitingRetry = false } @Synchronized private fun onError(chapter: BookChapter, error: Throwable) { onPreError(chapter, error) onPostError(chapter, error) } @Synchronized private fun onCancel(index: Int) { onDownloadSet.remove(index) if (!isStopped) waitDownloadSet.add(index) } @Synchronized private fun onFinally() { if (waitDownloadSet.isEmpty() && onDownloadSet.isEmpty()) { cacheBookMap.remove(book.bookUrl) } postEvent(EventBus.UP_DOWNLOAD, book.bookUrl) } /** * 从待下载列表内取第一条下载 */ @Synchronized fun download(scope: CoroutineScope, context: CoroutineContext) { val chapterIndex = waitDownloadSet.firstOrNull() if (chapterIndex == null) { if (!isLoading && onDownloadSet.isEmpty()) { cacheBookMap.remove(book.bookUrl) } return } if (onDownloadSet.contains(chapterIndex)) { waitDownloadSet.remove(chapterIndex) return } val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, chapterIndex) ?: let { waitDownloadSet.remove(chapterIndex) return } if (chapter.isVolume) { /** 修正下载计数 */ postEvent(EventBus.SAVE_CONTENT, Pair(book, chapter)) waitDownloadSet.remove(chapterIndex) return } if (BookHelp.hasImageContent(book, chapter)) { waitDownloadSet.remove(chapterIndex) return } waitDownloadSet.remove(chapterIndex) onDownloadSet.add(chapterIndex) if (BookHelp.hasContent(book, chapter)) { Coroutine.async(scope, context, executeContext = context) { BookHelp.getContent(book, chapter)?.let { BookHelp.saveImages(bookSource, book, chapter, it, 1) } }.onSuccess { onSuccess(chapter) }.onError { onPreError(chapter, it) //出现错误等待一秒后重新加入待下载列表 delay(1000) onPostError(chapter, it) }.onCancel { onCancel(chapterIndex) }.onFinally { onFinally() }.let { tasks.add(it) } return } WebBook.getContent( scope, bookSource, book, chapter, context = context, start = CoroutineStart.LAZY, executeContext = context ).onSuccess { content -> onSuccess(chapter) downloadFinish(chapter, content) }.onError { onPreError(chapter, it) //出现错误等待一秒后重新加入待下载列表 delay(1000) onPostError(chapter, it) downloadFinish(chapter, "获取正文失败\n${it.localizedMessage}") }.onCancel { onCancel(chapterIndex) }.onFinally { onFinally() }.apply { tasks.add(this) }.start() } suspend fun downloadAwait(chapter: BookChapter): String { synchronized(this) { onDownloadSet.add(chapter.index) waitDownloadSet.remove(chapter.index) } try { val content = WebBook.getContentAwait(bookSource, book, chapter) onSuccess(chapter) ReadBook.downloadedChapters.add(chapter.index) ReadBook.downloadFailChapters.remove(chapter.index) return content } catch (e: Exception) { if (e is CancellationException) { onCancel(chapter.index) } onError(chapter, e) ReadBook.downloadFailChapters[chapter.index] = (ReadBook.downloadFailChapters[chapter.index] ?: 0) + 1 return "获取正文失败\n${e.localizedMessage}" } finally { postEvent(EventBus.UP_DOWNLOAD, book.bookUrl) } } @Synchronized fun download( scope: CoroutineScope, chapter: BookChapter, semaphore: Semaphore?, resetPageOffset: Boolean = false ) { if (onDownloadSet.contains(chapter.index)) { return } onDownloadSet.add(chapter.index) waitDownloadSet.remove(chapter.index) WebBook.getContent( scope, bookSource, book, chapter, start = CoroutineStart.LAZY, executeContext = IO, semaphore = semaphore ).onSuccess { content -> onSuccess(chapter) ReadBook.downloadedChapters.add(chapter.index) ReadBook.downloadFailChapters.remove(chapter.index) downloadFinish(chapter, content, resetPageOffset) }.onError { onError(chapter, it) ReadBook.downloadFailChapters[chapter.index] = (ReadBook.downloadFailChapters[chapter.index] ?: 0) + 1 downloadFinish(chapter, "获取正文失败\n${it.localizedMessage}", resetPageOffset) }.onCancel { onCancel(chapter.index) downloadFinish(chapter, "download canceled", resetPageOffset, true) }.onFinally { postEvent(EventBus.UP_DOWNLOAD, book.bookUrl) }.start() } private fun downloadFinish( chapter: BookChapter, content: String, resetPageOffset: Boolean = false, canceled: Boolean = false ) { if (ReadBook.book?.bookUrl == book.bookUrl) { ReadBook.contentLoadFinish( book, chapter, content, resetPageOffset = resetPageOffset, canceled = canceled ) } } } } ================================================ FILE: app/src/main/java/io/legado/app/model/CheckSource.kt ================================================ package io.legado.app.model import android.content.Context import io.legado.app.R import io.legado.app.constant.IntentAction import io.legado.app.data.entities.BookSourcePart import io.legado.app.help.CacheManager import io.legado.app.help.IntentData import io.legado.app.service.CheckSourceService import io.legado.app.utils.startService import splitties.init.appCtx object CheckSource { var keyword = "我的" //校验设置 var timeout = CacheManager.getLong("checkSourceTimeout") ?: 180000L var checkSearch = CacheManager.get("checkSearch")?.toBoolean() ?: true var checkDiscovery = CacheManager.get("checkDiscovery")?.toBoolean() ?: true var checkInfo = CacheManager.get("checkInfo")?.toBoolean() ?: true var checkCategory = CacheManager.get("checkCategory")?.toBoolean() ?: true var checkContent = CacheManager.get("checkContent")?.toBoolean() ?: true val summary get() = upSummary() fun start(context: Context, sources: List) { val selectedIds = sources.map { it.bookSourceUrl } IntentData.put("checkSourceSelectedIds", selectedIds) context.startService { action = IntentAction.start } } fun stop(context: Context) { context.startService { action = IntentAction.stop } } fun resume(context: Context) { context.startService { action = IntentAction.resume } } fun putConfig() { CacheManager.put("checkSourceTimeout", timeout) CacheManager.put("checkSearch", checkSearch) CacheManager.put("checkDiscovery", checkDiscovery) CacheManager.put("checkInfo", checkInfo) CacheManager.put("checkCategory", checkCategory) CacheManager.put("checkContent", checkContent) } private fun upSummary(): String { var checkItem = "" if (checkSearch) checkItem = "$checkItem ${appCtx.getString(R.string.search)}" if (checkDiscovery) checkItem = "$checkItem ${appCtx.getString(R.string.discovery)}" if (checkInfo) checkItem = "$checkItem ${appCtx.getString(R.string.source_tab_info)}" if (checkCategory) checkItem = "$checkItem ${appCtx.getString(R.string.chapter_list)}" if (checkContent) checkItem = "$checkItem ${appCtx.getString(R.string.main_body)}" return appCtx.getString( R.string.check_source_config_summary, (timeout / 1000).toString(), checkItem ) } } ================================================ FILE: app/src/main/java/io/legado/app/model/Debug.kt ================================================ package io.legado.app.model import android.annotation.SuppressLint import android.util.Log import io.legado.app.BuildConfig import io.legado.app.constant.AppPattern import io.legado.app.data.entities.* import io.legado.app.help.book.isWebFile import io.legado.app.help.coroutine.CompositeCoroutine import io.legado.app.help.source.sortUrls import io.legado.app.model.rss.Rss import io.legado.app.model.webBook.WebBook import io.legado.app.utils.HtmlFormatter import io.legado.app.utils.isAbsUrl import io.legado.app.utils.stackTraceStr import kotlinx.coroutines.CoroutineScope import java.text.SimpleDateFormat import java.util.* object Debug { var callback: Callback? = null private var debugSource: String? = null private val tasks: CompositeCoroutine = CompositeCoroutine() val debugMessageMap = HashMap() private val debugTimeMap = HashMap() var isChecking: Boolean = false @SuppressLint("ConstantLocale") private val debugTimeFormat = SimpleDateFormat("[mm:ss.SSS]", Locale.getDefault()) private var startTime: Long = System.currentTimeMillis() @Synchronized fun log( sourceUrl: String?, msg: String = "", print: Boolean = true, isHtml: Boolean = false, showTime: Boolean = true, state: Int = 1 ) { if (BuildConfig.DEBUG) { Log.d("sourceDebug", msg) } //调试信息始终要执行 callback?.let { if ((debugSource != sourceUrl || !print)) return var printMsg = msg if (isHtml) { printMsg = HtmlFormatter.format(msg) } if (showTime) { val time = debugTimeFormat.format(Date(System.currentTimeMillis() - startTime)) printMsg = "$time $printMsg" } it.printLog(state, printMsg) } if (isChecking && sourceUrl != null && (msg).length < 30) { var printMsg = msg if (isHtml) { printMsg = HtmlFormatter.format(msg) } if (showTime && debugTimeMap[sourceUrl] != null) { val time = debugTimeFormat.format(Date(System.currentTimeMillis() - debugTimeMap[sourceUrl]!!)) printMsg = printMsg.replace(AppPattern.debugMessageSymbolRegex, "") debugMessageMap[sourceUrl] = "$time $printMsg" } } } @Synchronized fun log(msg: String?) { log(debugSource, msg ?: "", true) } fun cancelDebug(destroy: Boolean = false) { tasks.clear() if (destroy) { debugSource = null callback = null } } fun startChecking(source: BookSource) { isChecking = true debugTimeMap[source.bookSourceUrl] = System.currentTimeMillis() debugMessageMap[source.bookSourceUrl] = "${debugTimeFormat.format(Date(0))} 开始校验" } fun finishChecking() { isChecking = false } fun getRespondTime(sourceUrl: String): Long { return debugTimeMap[sourceUrl] ?: CheckSource.timeout } fun updateFinalMessage(sourceUrl: String, state: String) { if (debugTimeMap[sourceUrl] != null && debugMessageMap[sourceUrl] != null) { val spendingTime = System.currentTimeMillis() - debugTimeMap[sourceUrl]!! debugTimeMap[sourceUrl] = if (state == "校验成功") spendingTime else CheckSource.timeout + spendingTime val printTime = debugTimeFormat.format(Date(spendingTime)) debugMessageMap[sourceUrl] = "$printTime $state" } } suspend fun startDebug(scope: CoroutineScope, rssSource: RssSource) { cancelDebug() debugSource = rssSource.sourceUrl log(debugSource, "︾开始解析") val sort = rssSource.sortUrls().first() Rss.getArticles(scope, sort.first, sort.second, rssSource, 1) .onSuccess { if (it.first.isEmpty()) { log(debugSource, "⇒列表页解析成功,为空") log(debugSource, "︽解析完成", state = 1000) } else { val ruleContent = rssSource.ruleContent if (!rssSource.ruleArticles.isNullOrBlank() && rssSource.ruleDescription.isNullOrBlank()) { log(debugSource, "︽列表页解析完成") log(debugSource, showTime = false) if (ruleContent.isNullOrEmpty()) { log(debugSource, "⇒内容规则为空,默认获取整个网页", state = 1000) } else { rssContentDebug(scope, it.first[0], ruleContent, rssSource) } } else { log(debugSource, "⇒存在描述规则,不解析内容页") log(debugSource, "︽解析完成", state = 1000) } } } .onError { log(debugSource, it.stackTraceStr, state = -1) } } private fun rssContentDebug( scope: CoroutineScope, rssArticle: RssArticle, ruleContent: String, rssSource: RssSource ) { log(debugSource, "︾开始解析内容页") Rss.getContent(scope, rssArticle, ruleContent, rssSource) .onSuccess { log(debugSource, it) log(debugSource, "︽内容页解析完成", state = 1000) } .onError { log(debugSource, it.stackTraceStr, state = -1) } } fun startDebug(scope: CoroutineScope, bookSource: BookSource, key: String) { cancelDebug() debugSource = bookSource.bookSourceUrl startTime = System.currentTimeMillis() when { key.isAbsUrl() -> { val book = Book() book.origin = bookSource.bookSourceUrl book.bookUrl = key log(bookSource.bookSourceUrl, "⇒开始访问详情页:$key") infoDebug(scope, bookSource, book) } key.contains("::") -> { val url = key.substringAfter("::") log(bookSource.bookSourceUrl, "⇒开始访问发现页:$url") exploreDebug(scope, bookSource, url) } key.startsWith("++") -> { val url = key.substring(2) val book = Book() book.origin = bookSource.bookSourceUrl book.tocUrl = url log(bookSource.bookSourceUrl, "⇒开始访目录页:$url") tocDebug(scope, bookSource, book) } key.startsWith("--") -> { val url = key.substring(2) val book = Book() book.origin = bookSource.bookSourceUrl log(bookSource.bookSourceUrl, "⇒开始访正文页:$url") val chapter = BookChapter() chapter.title = "调试" chapter.url = url contentDebug(scope, bookSource, book, chapter, null) } else -> { log(bookSource.bookSourceUrl, "⇒开始搜索关键字:$key") searchDebug(scope, bookSource, key) } } } private fun exploreDebug(scope: CoroutineScope, bookSource: BookSource, url: String) { log(debugSource, "︾开始解析发现页") val explore = WebBook.exploreBook(scope, bookSource, url, 1) .onSuccess { exploreBooks -> if (exploreBooks.isNotEmpty()) { log(debugSource, "︽发现页解析完成") log(debugSource, showTime = false) infoDebug(scope, bookSource, exploreBooks[0].toBook()) } else { log(debugSource, "︽未获取到书籍", state = -1) } } .onError { log(debugSource, it.stackTraceStr, state = -1) } tasks.add(explore) } private fun searchDebug(scope: CoroutineScope, bookSource: BookSource, key: String) { log(debugSource, "︾开始解析搜索页") val search = WebBook.searchBook(scope, bookSource, key, 1) .onSuccess { searchBooks -> if (searchBooks.isNotEmpty()) { log(debugSource, "︽搜索页解析完成") log(debugSource, showTime = false) infoDebug(scope, bookSource, searchBooks[0].toBook()) } else { log(debugSource, "︽未获取到书籍", state = -1) } } .onError { log(debugSource, it.stackTraceStr, state = -1) } tasks.add(search) } private fun infoDebug(scope: CoroutineScope, bookSource: BookSource, book: Book) { if (book.tocUrl.isNotBlank()) { log(debugSource, "≡已获取目录链接,跳过详情页") log(debugSource, showTime = false) tocDebug(scope, bookSource, book) return } log(debugSource, "︾开始解析详情页") val info = WebBook.getBookInfo(scope, bookSource, book) .onSuccess { log(debugSource, "︽详情页解析完成") log(debugSource, showTime = false) if (!book.isWebFile) { tocDebug(scope, bookSource, book) } else { log(debugSource, "≡文件类书源跳过解析目录", state = 1000) } } .onError { log(debugSource, it.stackTraceStr, state = -1) } tasks.add(info) } private fun tocDebug(scope: CoroutineScope, bookSource: BookSource, book: Book) { log(debugSource, "︾开始解析目录页") val chapterList = WebBook.getChapterList(scope, bookSource, book) .onSuccess { chapters -> log(debugSource, "︽目录页解析完成") log(debugSource, showTime = false) val toc = chapters.filter { !(it.isVolume && it.url.startsWith(it.title)) } if (toc.isEmpty()) { log(debugSource, "≡没有正文章节") return@onSuccess } val nextChapterUrl = toc.getOrNull(1)?.url ?: toc.first().url contentDebug(scope, bookSource, book, toc.first(), nextChapterUrl) } .onError { log(debugSource, it.stackTraceStr, state = -1) } tasks.add(chapterList) } private fun contentDebug( scope: CoroutineScope, bookSource: BookSource, book: Book, bookChapter: BookChapter, nextChapterUrl: String? ) { log(debugSource, "︾开始解析正文页") val content = WebBook.getContent( scope = scope, bookSource = bookSource, book = book, bookChapter = bookChapter, nextChapterUrl = nextChapterUrl, needSave = false ).onSuccess { log(debugSource, "︽正文页解析完成", state = 1000) }.onError { log(debugSource, it.stackTraceStr, state = -1) } tasks.add(content) } interface Callback { fun printLog(state: Int, msg: String) } } ================================================ FILE: app/src/main/java/io/legado/app/model/Download.kt ================================================ package io.legado.app.model import android.content.Context import io.legado.app.constant.IntentAction import io.legado.app.service.DownloadService import io.legado.app.utils.startService object Download { fun start(context: Context, url: String, fileName: String) { context.startService { action = IntentAction.start putExtra("url", url) putExtra("fileName", fileName) } } } ================================================ FILE: app/src/main/java/io/legado/app/model/ImageProvider.kt ================================================ package io.legado.app.model import android.graphics.Bitmap import android.graphics.BitmapFactory import android.util.Size import androidx.collection.LruCache import io.legado.app.R import io.legado.app.constant.AppLog.putDebug import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookSource import io.legado.app.exception.NoStackTraceException import io.legado.app.help.book.BookHelp import io.legado.app.help.book.isEpub import io.legado.app.help.book.isMobi import io.legado.app.help.book.isPdf import io.legado.app.help.config.AppConfig import io.legado.app.model.localBook.EpubFile import io.legado.app.model.localBook.MobiFile import io.legado.app.model.localBook.PdfFile import io.legado.app.utils.BitmapUtils import io.legado.app.utils.FileUtils import io.legado.app.utils.SvgUtils import io.legado.app.utils.toastOnUi import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.withContext import splitties.init.appCtx import java.io.File import java.io.FileOutputStream import kotlin.math.min object ImageProvider { private val errorBitmap: Bitmap by lazy { BitmapFactory.decodeResource(appCtx.resources, R.drawable.image_loading_error) } /** * 缓存bitmap LruCache实现 * filePath bitmap */ private const val M = 1024 * 1024 val cacheSize: Int get() { if (AppConfig.bitmapCacheSize !in 1..1024) { AppConfig.bitmapCacheSize = 50 } return AppConfig.bitmapCacheSize * M } val bitmapLruCache = BitmapLruCache() class BitmapLruCache : LruCache(cacheSize) { private var removeCount = 0 val count get() = putCount() + createCount() - evictionCount() - removeCount override fun sizeOf(key: String, value: Bitmap): Int { return value.byteCount } override fun entryRemoved( evicted: Boolean, key: String, oldValue: Bitmap, newValue: Bitmap? ) { if (!evicted) { synchronized(this) { removeCount++ } } //错误图片不能释放,占位用,防止一直重复获取图片 if (oldValue != errorBitmap) { oldValue.recycle() //putDebug("ImageProvider: trigger bitmap recycle. URI: $filePath") //putDebug("ImageProvider : cacheUsage ${size()}bytes / ${maxSize()}bytes") } } } fun put(key: String, bitmap: Bitmap) { ensureLruCacheSize(bitmap) bitmapLruCache.put(key, bitmap) } fun get(key: String): Bitmap? { return bitmapLruCache[key] } fun remove(key: String): Bitmap? { return bitmapLruCache.remove(key) } private fun getNotRecycled(key: String): Bitmap? { val bitmap = bitmapLruCache[key] ?: return null if (bitmap.isRecycled) { bitmapLruCache.remove(key) return null } return bitmap } private fun ensureLruCacheSize(bitmap: Bitmap) { val lruMaxSize = bitmapLruCache.maxSize() val lruSize = bitmapLruCache.size() val byteCount = bitmap.byteCount val size = if (byteCount > lruMaxSize) { min(256 * M, (byteCount * 1.3).toInt()) } else if (lruSize + byteCount > lruMaxSize && bitmapLruCache.count < 5) { min(256 * M, (lruSize + byteCount * 1.3).toInt()) } else { lruMaxSize } if (size > lruMaxSize) { bitmapLruCache.resize(size) } } /** *缓存网络图片和epub图片 */ suspend fun cacheImage( book: Book, src: String, bookSource: BookSource? ): File { return withContext(IO) { val vFile = BookHelp.getImage(book, src) if (!BookHelp.isImageExist(book, src)) { val inputStream = when { book.isEpub -> EpubFile.getImage(book, src) book.isPdf -> PdfFile.getImage(book, src) book.isMobi -> MobiFile.getImage(book, src) else -> { BookHelp.saveImage(bookSource, book, src) null } } inputStream?.use { input -> val newFile = FileUtils.createFileIfNotExist(vFile.absolutePath) FileOutputStream(newFile).use { output -> input.copyTo(output) } } } return@withContext vFile } } /** *获取图片宽度高度信息 */ suspend fun getImageSize( book: Book, src: String, bookSource: BookSource? ): Size { val file = cacheImage(book, src, bookSource) val op = BitmapFactory.Options() // inJustDecodeBounds如果设置为true,仅仅返回图片实际的宽和高,宽和高是赋值给opts.outWidth,opts.outHeight; op.inJustDecodeBounds = true BitmapFactory.decodeFile(file.absolutePath, op) if (op.outWidth < 1 && op.outHeight < 1) { //svg size val size = SvgUtils.getSize(file.absolutePath) if (size != null) return size putDebug("ImageProvider: $src Unsupported image type") //file.delete() 重复下载 return Size(errorBitmap.width, errorBitmap.height) } return Size(op.outWidth, op.outHeight) } /** *获取bitmap 使用LruCache缓存 */ fun getImage( book: Book, src: String, width: Int, height: Int? = null ): Bitmap { //src为空白时 可能被净化替换掉了 或者规则失效 if (book.getUseReplaceRule() && src.isBlank()) { book.setUseReplaceRule(false) appCtx.toastOnUi(R.string.error_image_url_empty) } val vFile = BookHelp.getImage(book, src) if (!vFile.exists()) return errorBitmap //epub文件提供图片链接是相对链接,同时阅读多个epub文件,缓存命中错误 //bitmapLruCache的key同一改成缓存文件的路径 val cacheBitmap = getNotRecycled(vFile.absolutePath) if (cacheBitmap != null) return cacheBitmap return kotlin.runCatching { val bitmap = BitmapUtils.decodeBitmap(vFile.absolutePath, width, height) ?: SvgUtils.createBitmap(vFile.absolutePath, width, height) ?: throw NoStackTraceException(appCtx.getString(R.string.error_decode_bitmap)) put(vFile.absolutePath, bitmap) bitmap }.onFailure { //错误图片占位,防止重复获取 put(vFile.absolutePath, errorBitmap) }.getOrDefault(errorBitmap) } fun clear() { bitmapLruCache.evictAll() } } ================================================ FILE: app/src/main/java/io/legado/app/model/README.md ================================================ # 放置一些模块类 * analyzeRule 书源规则解析 * localBook 本地书籍解析 * rss 订阅规则解析 * webBook 获取网络书籍 ================================================ FILE: app/src/main/java/io/legado/app/model/ReadAloud.kt ================================================ package io.legado.app.model import android.content.Context import android.content.Intent import android.os.Bundle import io.legado.app.constant.AppLog import io.legado.app.constant.EventBus import io.legado.app.constant.IntentAction import io.legado.app.data.appDb import io.legado.app.data.entities.HttpTTS import io.legado.app.help.config.AppConfig import io.legado.app.service.BaseReadAloudService import io.legado.app.service.HttpReadAloudService import io.legado.app.service.TTSReadAloudService import io.legado.app.utils.LogUtils import io.legado.app.utils.StringUtils import io.legado.app.utils.postEvent import io.legado.app.utils.startForegroundServiceCompat import io.legado.app.utils.toastOnUi import splitties.init.appCtx object ReadAloud { private var aloudClass: Class<*> = getReadAloudClass() val ttsEngine get() = ReadBook.book?.getTtsEngine() ?: AppConfig.ttsEngine var httpTTS: HttpTTS? = null private fun getReadAloudClass(): Class<*> { val ttsEngine = ttsEngine if (ttsEngine.isNullOrBlank()) { return TTSReadAloudService::class.java } if (StringUtils.isNumeric(ttsEngine)) { httpTTS = appDb.httpTTSDao.get(ttsEngine.toLong()) if (httpTTS != null) { return HttpReadAloudService::class.java } } return TTSReadAloudService::class.java } fun upReadAloudClass() { stop(appCtx) aloudClass = getReadAloudClass() } fun play( context: Context, play: Boolean = true, pageIndex: Int = ReadBook.durPageIndex, startPos: Int = 0 ) { val intent = Intent(context, aloudClass) intent.action = IntentAction.play intent.putExtra("play", play) intent.putExtra("pageIndex", pageIndex) intent.putExtra("startPos", startPos) LogUtils.d("ReadAloud", intent.toString()) try { context.startForegroundServiceCompat(intent) } catch (e: Exception) { val msg = "启动朗读服务出错\n${e.localizedMessage}" AppLog.put(msg, e) context.toastOnUi(msg) } } fun playByEventBus( play: Boolean = true, pageIndex: Int = ReadBook.durPageIndex, startPos: Int = 0 ) { val bundle = Bundle().apply { putBoolean("play", play) putInt("pageIndex", pageIndex) putInt("startPos", startPos) } postEvent(EventBus.READ_ALOUD_PLAY, bundle) } fun pause(context: Context) { if (BaseReadAloudService.isRun) { val intent = Intent(context, aloudClass) intent.action = IntentAction.pause context.startForegroundServiceCompat(intent) } } fun resume(context: Context) { if (BaseReadAloudService.isRun) { val intent = Intent(context, aloudClass) intent.action = IntentAction.resume context.startForegroundServiceCompat(intent) } } fun stop(context: Context) { if (BaseReadAloudService.isRun) { val intent = Intent(context, aloudClass) intent.action = IntentAction.stop context.startForegroundServiceCompat(intent) } } fun prevParagraph(context: Context) { if (BaseReadAloudService.isRun) { val intent = Intent(context, aloudClass) intent.action = IntentAction.prevParagraph context.startForegroundServiceCompat(intent) } } fun nextParagraph(context: Context) { if (BaseReadAloudService.isRun) { val intent = Intent(context, aloudClass) intent.action = IntentAction.nextParagraph context.startForegroundServiceCompat(intent) } } fun upTtsSpeechRate(context: Context) { if (BaseReadAloudService.isRun) { val intent = Intent(context, aloudClass) intent.action = IntentAction.upTtsSpeechRate context.startForegroundServiceCompat(intent) } } fun setTimer(context: Context, minute: Int) { if (BaseReadAloudService.isRun) { val intent = Intent(context, aloudClass) intent.action = IntentAction.setTimer intent.putExtra("minute", minute) context.startForegroundServiceCompat(intent) } } } ================================================ FILE: app/src/main/java/io/legado/app/model/ReadBook.kt ================================================ package io.legado.app.model import io.legado.app.constant.AppLog import io.legado.app.constant.EventBus import io.legado.app.constant.PageAnim.scrollPageAnim import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookProgress import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.ReadRecord import io.legado.app.help.AppWebDav import io.legado.app.help.book.BookHelp import io.legado.app.help.book.ContentProcessor import io.legado.app.help.book.isImage import io.legado.app.help.book.isLocal import io.legado.app.help.book.isPdf import io.legado.app.help.book.isSameNameAuthor import io.legado.app.help.book.readSimulating import io.legado.app.help.book.simulatedTotalChapterNum import io.legado.app.help.book.update import io.legado.app.help.config.AppConfig import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.globalExecutor import io.legado.app.model.localBook.TextFile import io.legado.app.model.webBook.WebBook import io.legado.app.service.BaseReadAloudService import io.legado.app.service.CacheBookService import io.legado.app.ui.book.read.page.entities.TextChapter import io.legado.app.ui.book.read.page.provider.ChapterProvider import io.legado.app.ui.book.read.page.provider.LayoutProgressListener import io.legado.app.utils.postEvent import io.legado.app.utils.stackTraceStr import io.legado.app.utils.toastOnUi import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Job import kotlinx.coroutines.MainScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import splitties.init.appCtx import java.util.concurrent.ConcurrentHashMap import kotlin.math.max import kotlin.math.min @Suppress("MemberVisibilityCanBePrivate") object ReadBook : CoroutineScope by MainScope() { var book: Book? = null var callBack: CallBack? = null var inBookshelf = false var chapterSize = 0 var simulatedChapterSize = 0 var durChapterIndex = 0 var durChapterPos = 0 var isLocalBook = true var chapterChanged = false var prevTextChapter: TextChapter? = null var curTextChapter: TextChapter? = null var nextTextChapter: TextChapter? = null var bookSource: BookSource? = null var msg: String? = null private val loadingChapters = arrayListOf() private val readRecord = ReadRecord() private val chapterLoadingJobs = ConcurrentHashMap>() private val prevChapterLoadingLock = Mutex() private val curChapterLoadingLock = Mutex() private val nextChapterLoadingLock = Mutex() var readStartTime: Long = System.currentTimeMillis() /* 跳转进度前进度记录 */ var lastBookProgress: BookProgress? = null /* web端阅读进度记录 */ var webBookProgress: BookProgress? = null var preDownloadTask: Job? = null val downloadedChapters = hashSetOf() val downloadFailChapters = hashMapOf() var contentProcessor: ContentProcessor? = null val downloadScope = CoroutineScope(SupervisorJob() + IO) val preDownloadSemaphore = Semaphore(2) val executor = globalExecutor fun resetData(book: Book) { releaseAndCancel() ReadBook.book = book readRecord.bookName = book.name readRecord.readTime = appDb.readRecordDao.getReadTime(book.name) ?: 0 chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl) simulatedChapterSize = if (book.readSimulating()) { book.simulatedTotalChapterNum() } else { chapterSize } contentProcessor = ContentProcessor.get(book) durChapterIndex = book.durChapterIndex durChapterPos = book.durChapterPos isLocalBook = book.isLocal clearTextChapter() callBack?.upContent() callBack?.upMenuView() callBack?.upPageAnim() upWebBook(book) lastBookProgress = null webBookProgress = null TextFile.clear() synchronized(this) { loadingChapters.clear() downloadedChapters.clear() downloadFailChapters.clear() } } fun upData(book: Book) { releaseAndCancel() ReadBook.book = book chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl) simulatedChapterSize = if (book.readSimulating()) { book.simulatedTotalChapterNum() } else { chapterSize } if (durChapterIndex != book.durChapterIndex) { durChapterIndex = book.durChapterIndex durChapterPos = book.durChapterPos clearTextChapter() } if (curTextChapter?.isCompleted == false) { curTextChapter = null } if (nextTextChapter?.isCompleted == false) { nextTextChapter = null } if (prevTextChapter?.isCompleted == false) { prevTextChapter = null } callBack?.upMenuView() upWebBook(book) synchronized(this) { loadingChapters.clear() downloadedChapters.clear() downloadFailChapters.clear() } } fun upWebBook(book: Book) { if (book.isLocal) { bookSource = null if (book.getImageStyle().isNullOrBlank() && (book.isImage || book.isPdf)) { book.setImageStyle(Book.imgStyleFull) } } else { appDb.bookSourceDao.getBookSource(book.origin)?.let { bookSource = it if (book.getImageStyle().isNullOrBlank()) { var imageStyle = it.getContentRule().imageStyle if (imageStyle.isNullOrBlank() && (book.isImage || book.isPdf)) { imageStyle = Book.imgStyleFull } book.setImageStyle(imageStyle) if (imageStyle.equals(Book.imgStyleSingle, true)) { book.setPageAnim(0) } } } ?: let { bookSource = null } } } fun upReadBookConfig(book: Book) { val oldIndex = ReadBookConfig.styleSelect ReadBookConfig.isComic = book.isImage if (oldIndex != ReadBookConfig.styleSelect) { postEvent(EventBus.UP_CONFIG, arrayListOf(1, 2, 5)) if (AppConfig.readBarStyleFollowPage) { postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) } } } fun setProgress(progress: BookProgress) { if (progress.durChapterIndex < chapterSize && (durChapterIndex != progress.durChapterIndex || durChapterPos != progress.durChapterPos) ) { durChapterIndex = progress.durChapterIndex durChapterPos = progress.durChapterPos saveRead() clearTextChapter() callBack?.upContent() loadContent(resetPageOffset = true) } } //暂时保存跳转前进度 fun saveCurrentBookProgress() { if (lastBookProgress != null) return //避免进度条连续跳转不能覆盖最初的进度记录 lastBookProgress = book?.let { BookProgress(it) } } //恢复跳转前进度 fun restoreLastBookProgress() { lastBookProgress?.let { setProgress(it) lastBookProgress = null } } fun clearTextChapter() { clearExpiredChapterLoadingJob(true) prevTextChapter = null curTextChapter = null nextTextChapter = null } fun clearSearchResult() { curTextChapter?.clearSearchResult() prevTextChapter?.clearSearchResult() nextTextChapter?.clearSearchResult() } fun uploadProgress(toast: Boolean = false, successAction: (() -> Unit)? = null) { book?.let { launch(IO) { AppWebDav.uploadBookProgress(it, toast) { successAction?.invoke() } ensureActive() it.update() } } } /** * 同步阅读进度 * 如果当前进度快于服务器进度或者没有进度进行上传,如果慢与服务器进度则执行传入动作 */ fun syncProgress( newProgressAction: ((progress: BookProgress) -> Unit)? = null, uploadSuccessAction: (() -> Unit)? = null, syncSuccessAction: (() -> Unit)? = null ) { if (!AppConfig.syncBookProgress) return val book = book ?: return Coroutine.async { AppWebDav.getBookProgress(book) }.onError { AppLog.put("拉取阅读进度失败", it) }.onSuccess { progress -> if (progress == null || progress.durChapterIndex < book.durChapterIndex || (progress.durChapterIndex == book.durChapterIndex && progress.durChapterPos < book.durChapterPos) ) { // 服务器没有进度或者进度比服务器快,上传现有进度 Coroutine.async { AppWebDav.uploadBookProgress(BookProgress(book), uploadSuccessAction) book.update() } } else if (progress.durChapterIndex > book.durChapterIndex || progress.durChapterPos > book.durChapterPos ) { // 进度比服务器慢,执行传入动作 newProgressAction?.invoke(progress) } else { syncSuccessAction?.invoke() } } } fun upReadTime() { executor.execute { if (!AppConfig.enableReadRecord) { return@execute } readRecord.readTime = readRecord.readTime + System.currentTimeMillis() - readStartTime readStartTime = System.currentTimeMillis() readRecord.lastRead = System.currentTimeMillis() appDb.readRecordDao.insert(readRecord) } } fun upMsg(msg: String?) { if (ReadBook.msg != msg) { ReadBook.msg = msg callBack?.upContent() } } fun moveToNextPage(): Boolean { var hasNextPage = false curTextChapter?.let { val nextPagePos = it.getNextPageLength(durChapterPos) if (nextPagePos >= 0) { hasNextPage = true it.getPage(durPageIndex)?.removePageAloudSpan() durChapterPos = nextPagePos callBack?.cancelSelect() callBack?.upContent() saveRead(true) } } return hasNextPage } fun moveToPrevPage(): Boolean { var hasPrevPage = false curTextChapter?.let { val prevPagePos = it.getPrevPageLength(durChapterPos) if (prevPagePos >= 0) { hasPrevPage = true durChapterPos = prevPagePos callBack?.upContent() saveRead(true) } } return hasPrevPage } fun moveToNextChapter(upContent: Boolean, upContentInPlace: Boolean = true): Boolean { if (durChapterIndex < simulatedChapterSize - 1) { durChapterPos = 0 durChapterIndex++ clearExpiredChapterLoadingJob() prevTextChapter = curTextChapter curTextChapter = nextTextChapter nextTextChapter = null if (curTextChapter == null) { AppLog.putDebug("moveToNextChapter-章节未加载,开始加载") if (upContentInPlace) callBack?.upContent() loadContent(durChapterIndex, upContent, resetPageOffset = false) } else if (upContent && upContentInPlace) { AppLog.putDebug("moveToNextChapter-章节已加载,刷新视图") callBack?.upContent() } loadContent(durChapterIndex.plus(1), upContent, false) saveRead() callBack?.upMenuView() AppLog.putDebug("moveToNextChapter-curPageChanged()") curPageChanged() return true } else { AppLog.putDebug("跳转下一章失败,没有下一章") return false } } suspend fun moveToNextChapterAwait( upContent: Boolean, upContentInPlace: Boolean = true ): Boolean { if (durChapterIndex < simulatedChapterSize - 1) { durChapterPos = 0 durChapterIndex++ clearExpiredChapterLoadingJob() prevTextChapter = curTextChapter curTextChapter = nextTextChapter nextTextChapter = null if (curTextChapter == null) { AppLog.putDebug("moveToNextChapter-章节未加载,开始加载") if (upContentInPlace) callBack?.upContentAwait() loadContentAwait(durChapterIndex, upContent, resetPageOffset = false) } else if (upContent && upContentInPlace) { AppLog.putDebug("moveToNextChapter-章节已加载,刷新视图") callBack?.upContentAwait() } loadContent(durChapterIndex.plus(1), upContent, false) saveRead() callBack?.upMenuView() AppLog.putDebug("moveToNextChapter-curPageChanged()") curPageChanged() return true } else { AppLog.putDebug("跳转下一章失败,没有下一章") return false } } fun moveToPrevChapter( upContent: Boolean, toLast: Boolean = true, upContentInPlace: Boolean = true ): Boolean { if (durChapterIndex > 0) { durChapterPos = if (toLast) prevTextChapter?.lastReadLength ?: Int.MAX_VALUE else 0 durChapterIndex-- clearExpiredChapterLoadingJob() nextTextChapter = curTextChapter curTextChapter = prevTextChapter prevTextChapter = null if (curTextChapter == null) { if (upContentInPlace) callBack?.upContent() loadContent(durChapterIndex, upContent, resetPageOffset = false) } else if (upContent && upContentInPlace) { callBack?.upContent() } loadContent(durChapterIndex.minus(1), upContent, false) saveRead() callBack?.upMenuView() curPageChanged() return true } else { return false } } fun skipToPage(index: Int, success: (() -> Unit)? = null) { durChapterPos = curTextChapter?.getReadLength(index) ?: index callBack?.upContent { success?.invoke() } curPageChanged() saveRead(true) } fun setPageIndex(index: Int) { recycleRecorders(durPageIndex, index) durChapterPos = curTextChapter?.getReadLength(index) ?: index saveRead(true) curPageChanged(true) } fun recycleRecorders(beforeIndex: Int, afterIndex: Int) { if (!AppConfig.optimizeRender) { return } executor.execute { val textChapter = curTextChapter ?: return@execute if (afterIndex > beforeIndex) { textChapter.getPage(afterIndex - 2)?.recycleRecorders() } if (afterIndex < beforeIndex) { textChapter.getPage(afterIndex + 3)?.recycleRecorders() } } } fun openChapter( index: Int, durChapterPos: Int = 0, upContent: Boolean = true, success: (() -> Unit)? = null ) { if (index < chapterSize) { clearTextChapter() if (upContent) callBack?.upContent() durChapterIndex = index ReadBook.durChapterPos = durChapterPos saveRead() loadContent(resetPageOffset = true) { success?.invoke() } } } /** * 当前页面变化 */ private fun curPageChanged(pageChanged: Boolean = false) { callBack?.pageChanged() curTextChapter?.let { if (BaseReadAloudService.isRun && it.isCompleted) { val scrollPageAnim = pageAnim() == 3 if (scrollPageAnim && pageChanged) { ReadAloud.pause(appCtx) } else { readAloud(!BaseReadAloudService.pause) } } } upReadTime() preDownload() } /** * 朗读 */ fun readAloud(play: Boolean = true, startPos: Int = 0) { book ?: return val textChapter = curTextChapter ?: return if (textChapter.isCompleted) { ReadAloud.play(appCtx, play, startPos = startPos) } } /** * 当前页数 */ val durPageIndex: Int get() { return curTextChapter?.getPageIndexByCharIndex(durChapterPos) ?: durChapterPos } /** * 是否排版到了当前阅读位置 */ val isLayoutAvailable inline get() = durPageIndex >= 0 val isScroll inline get() = pageAnim() == scrollPageAnim val contentLoadFinish get() = curTextChapter != null || msg != null /** * chapterOnDur: 0为当前页,1为下一页,-1为上一页 */ fun textChapter(chapterOnDur: Int = 0): TextChapter? { return when (chapterOnDur) { 0 -> curTextChapter 1 -> nextTextChapter -1 -> prevTextChapter else -> null } } /** * 加载当前章节和前后一章内容 * @param resetPageOffset 滚动阅读是否重置滚动位置 * @param success 当前章节加载完成回调 */ fun loadContent( resetPageOffset: Boolean, success: (() -> Unit)? = null ) { loadContent(durChapterIndex, resetPageOffset = resetPageOffset) { success?.invoke() } loadContent(durChapterIndex + 1, resetPageOffset = resetPageOffset) loadContent(durChapterIndex - 1, resetPageOffset = resetPageOffset) } fun loadOrUpContent() { if (curTextChapter == null) { loadContent(durChapterIndex) } else { callBack?.upContent() } if (nextTextChapter == null) { loadContent(durChapterIndex + 1) } if (prevTextChapter == null) { loadContent(durChapterIndex - 1) } } /** * 加载章节内容 * @param index 章节序号 * @param upContent 是否更新视图 * @param resetPageOffset 滚动阅读是否重置滚动位置 * @param success 加载完成回调 */ fun loadContent( index: Int, upContent: Boolean = true, resetPageOffset: Boolean = false, success: (() -> Unit)? = null ) { Coroutine.async { val book = book!! val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, index) ?: return@async if (addLoading(index)) { BookHelp.getContent(book, chapter)?.let { contentLoadFinish( book, chapter, it, upContent, resetPageOffset, success = success ) } ?: download( downloadScope, chapter, resetPageOffset ) } }.onError { AppLog.put("加载正文出错\n${it.localizedMessage}") } } suspend fun loadContentAwait( index: Int, upContent: Boolean = true, resetPageOffset: Boolean = false, success: (() -> Unit)? = null ) = withContext(IO) { if (addLoading(index)) { try { val book = book!! val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, index)!! val content = BookHelp.getContent(book, chapter) ?: downloadAwait(chapter) contentLoadFinishAwait(book, chapter, content, upContent, resetPageOffset) success?.invoke() } catch (e: Exception) { AppLog.put("加载正文出错\n${e.localizedMessage}") } finally { removeLoading(index) } } } /** * 下载正文 */ private suspend fun downloadIndex(index: Int) { if (index < 0) return if (index > chapterSize - 1) { upToc() return } val book = book ?: return val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, index) ?: return if (BookHelp.hasContent(book, chapter)) { downloadedChapters.add(chapter.index) } else { delay(1000) if (addLoading(index)) { download(downloadScope, chapter, false, preDownloadSemaphore) } } } /** * 下载正文 */ private fun download( scope: CoroutineScope, chapter: BookChapter, resetPageOffset: Boolean, semaphore: Semaphore? = null, success: (() -> Unit)? = null ) { val book = book ?: return removeLoading(chapter.index) val bookSource = bookSource if (bookSource != null) { CacheBook.getOrCreate(bookSource, book).download(scope, chapter, semaphore) } else { val msg = if (book.isLocal) "无内容" else "没有书源" contentLoadFinish( book, chapter, "加载正文失败\n$msg", resetPageOffset = resetPageOffset, success = success ) } } private suspend fun downloadAwait(chapter: BookChapter): String { val book = book!! val bookSource = bookSource if (bookSource != null) { return CacheBook.getOrCreate(bookSource, book).downloadAwait(chapter) } else { val msg = if (book.isLocal) "无内容" else "没有书源" return "加载正文失败\n$msg" } } @Synchronized private fun addLoading(index: Int): Boolean { if (loadingChapters.contains(index)) return false loadingChapters.add(index) return true } @Synchronized fun removeLoading(index: Int) { loadingChapters.remove(index) } /** * 内容加载完成 */ @Synchronized fun contentLoadFinish( book: Book, chapter: BookChapter, content: String, upContent: Boolean = true, resetPageOffset: Boolean, canceled: Boolean = false, success: (() -> Unit)? = null ) { removeLoading(chapter.index) if (canceled || chapter.index !in durChapterIndex - 1..durChapterIndex + 1) { return } chapterLoadingJobs[chapter.index]?.cancel() val job = Coroutine.async(this, start = CoroutineStart.LAZY) { val contentProcessor = ContentProcessor.get(book.name, book.origin) val displayTitle = chapter.getDisplayTitle( contentProcessor.getTitleReplaceRules(), book.getUseReplaceRule() ) val contents = contentProcessor .getContent(book, chapter, content, includeTitle = false) ensureActive() val textChapter = ChapterProvider.getTextChapterAsync( this, book, chapter, displayTitle, contents, simulatedChapterSize ) when (val offset = chapter.index - durChapterIndex) { 0 -> curChapterLoadingLock.withLock { withContext(Main) { ensureActive() curTextChapter = textChapter } callBack?.upMenuView() var available = false for (page in textChapter.layoutChannel) { val index = page.index if (!available && page.containPos(durChapterPos)) { if (upContent) { callBack?.upContent(offset, resetPageOffset) } available = true } if (upContent && isScroll) { if (max(index - 3, 0) < durPageIndex) { callBack?.upContent(offset, false) } } callBack?.onLayoutPageCompleted(index, page) } if (upContent) callBack?.upContent(offset, !available && resetPageOffset) curPageChanged() callBack?.contentLoadFinish() } -1 -> prevChapterLoadingLock.withLock { withContext(Main) { ensureActive() prevTextChapter = textChapter } textChapter.layoutChannel.receiveAsFlow().collect() if (upContent) callBack?.upContent(offset, resetPageOffset) } 1 -> nextChapterLoadingLock.withLock { withContext(Main) { ensureActive() nextTextChapter = textChapter } for (page in textChapter.layoutChannel) { if (page.index > 1) { continue } if (upContent) callBack?.upContent(offset, resetPageOffset) } } } return@async }.onError { if (it is CancellationException) { return@onError } AppLog.put("ChapterProvider ERROR", it) appCtx.toastOnUi("ChapterProvider ERROR:\n${it.stackTraceStr}") }.onSuccess { success?.invoke() } chapterLoadingJobs[chapter.index] = job job.start() } suspend fun contentLoadFinishAwait( book: Book, chapter: BookChapter, content: String, upContent: Boolean = true, resetPageOffset: Boolean ) { removeLoading(chapter.index) if (chapter.index !in durChapterIndex - 1..durChapterIndex + 1) { return } kotlin.runCatching { val contentProcessor = ContentProcessor.get(book.name, book.origin) val displayTitle = chapter.getDisplayTitle( contentProcessor.getTitleReplaceRules(), book.getUseReplaceRule() ) val contents = contentProcessor .getContent(book, chapter, content, includeTitle = false) val textChapter = ChapterProvider.getTextChapterAsync( this@ReadBook, book, chapter, displayTitle, contents, simulatedChapterSize ) when (val offset = chapter.index - durChapterIndex) { 0 -> { curTextChapter?.cancelLayout() withContext(Main) { curTextChapter = textChapter } callBack?.upMenuView() var available = false for (page in textChapter.layoutChannel) { val index = page.index if (!available && page.containPos(durChapterPos)) { if (upContent) { callBack?.upContent(offset, resetPageOffset) } available = true } if (upContent && isScroll) { if (max(index - 3, 0) < durPageIndex) { callBack?.upContent(offset, false) } } callBack?.onLayoutPageCompleted(index, page) } if (upContent) callBack?.upContent(offset, !available && resetPageOffset) curPageChanged() callBack?.contentLoadFinish() } -1 -> { prevTextChapter?.cancelLayout() withContext(Main) { prevTextChapter = textChapter } textChapter.layoutChannel.receiveAsFlow().collect() if (upContent) callBack?.upContent(offset, resetPageOffset) } 1 -> { nextTextChapter?.cancelLayout() withContext(Main) { nextTextChapter = textChapter } for (page in textChapter.layoutChannel) { if (page.index > 1) { continue } if (upContent) callBack?.upContent(offset, resetPageOffset) } } } }.onFailure { if (it is CancellationException) { return@onFailure } AppLog.put("ChapterProvider ERROR", it) appCtx.toastOnUi("ChapterProvider ERROR:\n${it.stackTraceStr}") } } @Synchronized fun upToc() { val bookSource = bookSource ?: return val book = book ?: return if (!book.canUpdate) return if (chapterSize - durChapterIndex - 1 >= 3) return if (System.currentTimeMillis() - book.lastCheckTime < 600000) return book.lastCheckTime = System.currentTimeMillis() val oldBook = book.copy() WebBook.getChapterList(this, bookSource, book).onSuccess(IO) { cList -> ensureActive() if (cList.size > chapterSize) { if (oldBook.bookUrl == book.bookUrl) { appDb.bookDao.update(book) } else { appDb.bookDao.replace(oldBook, book) BookHelp.updateCacheFolder(oldBook, book) } appDb.bookChapterDao.delByBook(oldBook.bookUrl) appDb.bookChapterDao.insert(*cList.toTypedArray()) onChapterListUpdated(book, false) nextTextChapter ?: loadContent(durChapterIndex + 1) } } } fun pageAnim(): Int { return book?.getPageAnim() ?: ReadBookConfig.pageAnim } fun setCharset(charset: String) { book?.let { it.charset = charset callBack?.loadChapterList(it) } saveRead() } fun saveRead(pageChanged: Boolean = false) { executor.execute { kotlin.runCatching { val book = book ?: return@execute book.lastCheckCount = 0 book.durChapterTime = System.currentTimeMillis() val chapterChanged = book.durChapterIndex != durChapterIndex book.durChapterIndex = durChapterIndex book.durChapterPos = durChapterPos if (!pageChanged || chapterChanged) { appDb.bookChapterDao.getChapter(book.bookUrl, durChapterIndex)?.let { book.durChapterTitle = it.getDisplayTitle( ContentProcessor.get(book.name, book.origin).getTitleReplaceRules(), book.getUseReplaceRule() ) } } appDb.bookDao.update(book) }.onFailure { AppLog.put("保存书籍阅读进度信息出错\n$it", it) } } } /** * 预下载 */ private fun preDownload() { if (book?.isLocal == true) return executor.execute { if (AppConfig.preDownloadNum < 2) { upToc() return@execute } preDownloadTask?.cancel() preDownloadTask = launch(IO) { //预下载 launch { val maxChapterIndex = min(durChapterIndex + AppConfig.preDownloadNum, chapterSize) for (i in durChapterIndex.plus(2)..maxChapterIndex) { if (downloadedChapters.contains(i)) continue if ((downloadFailChapters[i] ?: 0) >= 3) continue downloadIndex(i) } } launch { val minChapterIndex = durChapterIndex - min(5, AppConfig.preDownloadNum) for (i in durChapterIndex.minus(2) downTo minChapterIndex) { if (downloadedChapters.contains(i)) continue if ((downloadFailChapters[i] ?: 0) >= 3) continue downloadIndex(i) } } } } } fun cancelPreDownloadTask() { if (contentLoadFinish) { preDownloadTask?.cancel() downloadScope.coroutineContext.cancelChildren() } } fun onChapterListUpdated(newBook: Book, loadContent: Boolean = true) { if (newBook.isSameNameAuthor(book)) { book = newBook chapterSize = newBook.totalChapterNum simulatedChapterSize = newBook.simulatedTotalChapterNum() if (simulatedChapterSize > 0 && durChapterIndex > simulatedChapterSize - 1) { durChapterIndex = simulatedChapterSize - 1 } callBack?.upMenuView() if (callBack == null) { clearTextChapter() } else if (loadContent) { loadContent(true) } } } private fun clearExpiredChapterLoadingJob(clearAll: Boolean = false) { val iterator = chapterLoadingJobs.iterator() while (iterator.hasNext()) { val (index, job) = iterator.next() if (clearAll || index !in durChapterIndex - 1..durChapterIndex + 1) { job.cancel() iterator.remove() } } } /** * 注册回调 */ fun register(cb: CallBack) { callBack?.notifyBookChanged() callBack = cb } /** * 取消注册回调 */ fun unregister(cb: CallBack) { if (callBack === cb) { callBack = null } releaseAndCancel() } private fun releaseAndCancel() { msg = null preDownloadTask?.cancel() downloadScope.coroutineContext.cancelChildren() coroutineContext.cancelChildren() ImageProvider.clear() clearExpiredChapterLoadingJob(true) if (!CacheBookService.isRun) { CacheBook.close() } } interface CallBack : LayoutProgressListener { fun upMenuView() fun loadChapterList(book: Book) fun upContent( relativePosition: Int = 0, resetPageOffset: Boolean = true, success: (() -> Unit)? = null ) suspend fun upContentAwait( relativePosition: Int = 0, resetPageOffset: Boolean = true, success: (() -> Unit)? = null ) fun pageChanged() fun contentLoadFinish() fun upPageAnim(upRecorder: Boolean = false) fun notifyBookChanged() fun sureNewProgress(progress: BookProgress) fun cancelSelect() } } ================================================ FILE: app/src/main/java/io/legado/app/model/ReadManga.kt ================================================ package io.legado.app.model import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookProgress import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.ReadRecord import io.legado.app.help.AppWebDav import io.legado.app.help.ConcurrentRateLimiter import io.legado.app.help.book.BookHelp import io.legado.app.help.book.ContentProcessor import io.legado.app.help.book.isLocal import io.legado.app.help.book.isSameNameAuthor import io.legado.app.help.book.readSimulating import io.legado.app.help.book.simulatedTotalChapterNum import io.legado.app.help.book.update import io.legado.app.help.config.AppConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.globalExecutor import io.legado.app.model.webBook.WebBook import io.legado.app.ui.book.manga.entities.BaseMangaPage import io.legado.app.ui.book.manga.entities.MangaChapter import io.legado.app.ui.book.manga.entities.MangaContent import io.legado.app.ui.book.manga.entities.MangaPage import io.legado.app.ui.book.manga.entities.ReaderLoading import io.legado.app.utils.mapIndexed import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Job import kotlinx.coroutines.MainScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlin.math.min @Suppress("MemberVisibilityCanBePrivate") object ReadManga : CoroutineScope by MainScope() { var inBookshelf = false var book: Book? = null val executor = globalExecutor var durChapterIndex = 0 //章节位置 var chapterSize = 0//总章节 var durChapterPos = 0 var chapterChanged = false var prevMangaChapter: MangaChapter? = null var curMangaChapter: MangaChapter? = null var nextMangaChapter: MangaChapter? = null var bookSource: BookSource? = null var readStartTime: Long = System.currentTimeMillis() private val readRecord = ReadRecord() private val loadingChapters = arrayListOf() var simulatedChapterSize = 0 var mCallback: Callback? = null var preDownloadTask: Job? = null val downloadedChapters = hashSetOf() val downloadFailChapters = hashMapOf() val downloadScope = CoroutineScope(SupervisorJob() + IO) val preDownloadSemaphore = Semaphore(2) var rateLimiter = ConcurrentRateLimiter(null) val mangaContents get() = buildMangaContent() val hasNextChapter get() = durChapterIndex < simulatedChapterSize - 1 fun resetData(book: Book) { ReadManga.book = book readRecord.bookName = book.name readRecord.readTime = appDb.readRecordDao.getReadTime(book.name) ?: 0 chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl) simulatedChapterSize = if (book.readSimulating()) { book.simulatedTotalChapterNum() } else { chapterSize } durChapterIndex = book.durChapterIndex durChapterPos = book.durChapterPos clearMangaChapter() upWebBook(book) synchronized(this) { loadingChapters.clear() downloadedChapters.clear() downloadFailChapters.clear() } } fun upData(book: Book) { ReadManga.book = book chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl) simulatedChapterSize = if (book.readSimulating()) { book.simulatedTotalChapterNum() } else { chapterSize } if (durChapterIndex != book.durChapterIndex) { durChapterIndex = book.durChapterIndex durChapterPos = book.durChapterPos clearMangaChapter() } upWebBook(book) synchronized(this) { loadingChapters.clear() downloadedChapters.clear() downloadFailChapters.clear() } } fun upWebBook(book: Book) { appDb.bookSourceDao.getBookSource(book.origin)?.let { bookSource = it rateLimiter = ConcurrentRateLimiter(it) } ?: let { bookSource = null } } fun clearMangaChapter() { prevMangaChapter = null curMangaChapter = null nextMangaChapter = null } //每次切换章节更新阅读记录 fun upReadTime() { executor.execute { if (!AppConfig.enableReadRecord) { return@execute } readRecord.readTime = readRecord.readTime + System.currentTimeMillis() - readStartTime readStartTime = System.currentTimeMillis() readRecord.lastRead = System.currentTimeMillis() appDb.readRecordDao.insert(readRecord) } } @Synchronized private fun addLoading(index: Int): Boolean { if (loadingChapters.contains(index)) return false loadingChapters.add(index) return true } @Synchronized fun removeLoading(index: Int) { loadingChapters.remove(index) } fun loadContent() { clearMangaChapter() loadContent(durChapterIndex) loadContent(durChapterIndex + 1) loadContent(durChapterIndex - 1) } fun loadOrUpContent() { if (curMangaChapter == null) { loadContent(durChapterIndex) } else { mCallback?.upContent() } if (nextMangaChapter == null) { loadContent(durChapterIndex + 1) } if (prevMangaChapter == null) { loadContent(durChapterIndex - 1) } } private fun loadContent(index: Int) { Coroutine.async { val book = book!! val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, index) ?: return@async if (addLoading(index)) { BookHelp.getContent(book, chapter)?.let { contentLoadFinish(chapter, it) } ?: run { download(downloadScope, chapter) } } }.onError { AppLog.put("加载正文出错\n${it.localizedMessage}") } } /** * 内容加载完成 */ suspend fun contentLoadFinish( chapter: BookChapter, content: String?, errorMsg: String = "加载内容失败", canceled: Boolean = false ) { removeLoading(chapter.index) if (canceled || chapter.index !in durChapterIndex - 1..durChapterIndex + 1) { return } when (val offset = chapter.index - durChapterIndex) { 0 -> { if (content == null) { mCallback?.loadFail(errorMsg) return } if (content.isEmpty() && !chapter.isVolume) { mCallback?.loadFail("正文内容为空") return } val mangaChapter = getManageChapter(chapter, content) if (mangaChapter.imageCount == 0 && !chapter.isVolume) { mCallback?.loadFail("正文没有图片") return } curMangaChapter = mangaChapter mCallback?.upContent() } -1, 1 -> { if (content == null || (!chapter.isVolume && content.isEmpty())) { return } val mangaChapter = getManageChapter(chapter, content) if (mangaChapter.imageCount == 0 && !chapter.isVolume) { return } when (offset) { -1 -> prevMangaChapter = mangaChapter 1 -> nextMangaChapter = mangaChapter } mCallback?.upContent() } } } private fun buildMangaContent(): MangaContent { val items = arrayListOf() var pos = 0 var curFinish = false var nextFinish = false prevMangaChapter?.let { pos += it.pages.size items.addAll(it.pages) } curMangaChapter?.let { curFinish = true items.addAll(it.pages) durChapterPos = if (it.imageCount > 0) { durChapterPos.coerceIn(0, it.imageCount - 1) } else { 0 } pos += durChapterPos if (!AppConfig.hideMangaTitle && it.imageCount > 0) { pos++ } } nextMangaChapter?.let { nextFinish = true items.addAll(it.pages) } return MangaContent(pos, items, curFinish, nextFinish) } /** * 加载下一章 */ fun moveToNextChapter(toFirst: Boolean = false): Boolean { if (durChapterIndex < simulatedChapterSize - 1) { if (toFirst) { mCallback?.showLoading() durChapterPos = 0 } durChapterIndex++ prevMangaChapter = curMangaChapter curMangaChapter = nextMangaChapter nextMangaChapter = null if (curMangaChapter == null) { mCallback?.startLoad() loadContent(durChapterIndex) } else { mCallback?.upContent() } loadContent(durChapterIndex + 1) saveRead() AppLog.putDebug("moveToNextChapter-curPageChanged()") curPageChanged() return true } else { AppLog.putDebug("跳转下一章失败,没有下一章") return false } } fun moveToPrevChapter(toFirst: Boolean = false): Boolean { if (durChapterIndex > 0) { if (toFirst) { mCallback?.showLoading() durChapterPos = 0 } durChapterIndex-- nextMangaChapter = curMangaChapter curMangaChapter = prevMangaChapter prevMangaChapter = null if (curMangaChapter == null) { loadContent(durChapterIndex) } else { mCallback?.upContent() } loadContent(durChapterIndex - 1) saveRead() return true } return false } fun curPageChanged() { upReadTime() preDownload() } fun saveRead(pageChanged: Boolean = false) { executor.execute { kotlin.runCatching { val book = book ?: return@execute book.lastCheckCount = 0 book.durChapterTime = System.currentTimeMillis() val chapterChanged = book.durChapterIndex != durChapterIndex book.durChapterIndex = durChapterIndex book.durChapterPos = durChapterPos if (!pageChanged || chapterChanged) { appDb.bookChapterDao.getChapter(book.bookUrl, durChapterIndex)?.let { book.durChapterTitle = it.getDisplayTitle( ContentProcessor.get(book.name, book.origin).getTitleReplaceRules(), book.getUseReplaceRule() ) } } appDb.bookDao.update(book) }.onFailure { AppLog.put("保存漫画阅读进度信息出错\n$it", it) } } } private fun downloadNetworkContent( bookSource: BookSource, scope: CoroutineScope, chapter: BookChapter, book: Book, semaphore: Semaphore?, success: suspend (String) -> Unit = {}, error: suspend () -> Unit = {}, cancel: suspend () -> Unit = {}, ) { WebBook.getContent( scope, bookSource, book, chapter, start = CoroutineStart.LAZY, executeContext = IO, semaphore = semaphore ).onSuccess { content -> success.invoke(content) }.onError { error.invoke() }.onCancel { cancel.invoke() }.start() } private fun preDownload() { if (book?.isLocal == true) return executor.execute { if (AppConfig.preDownloadNum < 2) { upToc() return@execute } preDownloadTask?.cancel() preDownloadTask = launch(IO) { //预下载 launch { val maxChapterIndex = min(durChapterIndex + AppConfig.preDownloadNum, chapterSize) for (i in durChapterIndex.plus(2)..maxChapterIndex) { if (downloadedChapters.contains(i)) continue if ((downloadFailChapters[i] ?: 0) >= 3) continue downloadIndex(i) } } launch { val minChapterIndex = durChapterIndex - min(5, AppConfig.preDownloadNum) for (i in durChapterIndex.minus(2) downTo minChapterIndex) { if (downloadedChapters.contains(i)) continue if ((downloadFailChapters[i] ?: 0) >= 3) continue downloadIndex(i) } } } } } fun cancelPreDownloadTask() { if (curMangaChapter != null && nextMangaChapter != null) { preDownloadTask?.cancel() downloadScope.coroutineContext.cancelChildren() } } private suspend fun downloadIndex(index: Int) { if (index < 0) return if (index > chapterSize - 1) { upToc() return } val book = book ?: return val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, index) ?: return if (BookHelp.hasContent(book, chapter)) { downloadedChapters.add(chapter.index) } else { delay(1000) if (addLoading(index)) { download(downloadScope, chapter, preDownloadSemaphore) } } } /** * 获取正文 */ private suspend fun download( scope: CoroutineScope, chapter: BookChapter, semaphore: Semaphore? = null, ) { val book = book ?: return removeLoading(chapter.index) val bookSource = bookSource if (bookSource != null) { downloadNetworkContent(bookSource, scope, chapter, book, semaphore, success = { downloadedChapters.add(chapter.index) downloadFailChapters.remove(chapter.index) contentLoadFinish(chapter, it) }, error = { downloadFailChapters[chapter.index] = (downloadFailChapters[chapter.index] ?: 0) + 1 contentLoadFinish(chapter, null) }, cancel = { contentLoadFinish(chapter, null, canceled = true) }) } else { contentLoadFinish(chapter, null, "加载内容失败 没有书源") } } @Synchronized fun upToc() { val bookSource = bookSource ?: return val book = book ?: return if (!book.canUpdate) return if (chapterSize - durChapterIndex - 1 >= 3) return if (System.currentTimeMillis() - book.lastCheckTime < 600000) return book.lastCheckTime = System.currentTimeMillis() val oldBook = book.copy() WebBook.getChapterList(this, bookSource, book).onSuccess(IO) { cList -> ensureActive() if (cList.size > chapterSize) { if (oldBook.bookUrl == book.bookUrl) { appDb.bookDao.update(book) } else { appDb.bookDao.replace(oldBook, book) BookHelp.updateCacheFolder(oldBook, book) } appDb.bookChapterDao.delByBook(oldBook.bookUrl) appDb.bookChapterDao.insert(*cList.toTypedArray()) onChapterListUpdated(book, false) nextMangaChapter ?: loadContent(durChapterIndex + 1) } } } fun uploadProgress(successAction: (() -> Unit)? = null) { book?.let { launch(IO) { AppWebDav.uploadBookProgress(it) { successAction?.invoke() } ensureActive() it.update() } } } /** * 同步阅读进度 * 如果当前进度快于服务器进度或者没有进度进行上传,如果慢与服务器进度则执行传入动作 */ fun syncProgress( newProgressAction: ((progress: BookProgress) -> Unit)? = null, uploadSuccessAction: (() -> Unit)? = null, syncSuccessAction: (() -> Unit)? = null, ) { if (!AppConfig.syncBookProgress) return val book = book ?: return Coroutine.async { AppWebDav.getBookProgress(book) }.onError { AppLog.put("拉取阅读进度失败", it) }.onSuccess { progress -> if (progress == null || progress.durChapterIndex < book.durChapterIndex || (progress.durChapterIndex == book.durChapterIndex && progress.durChapterPos < book.durChapterPos) ) { // 服务器没有进度或者进度比服务器快,上传现有进度 Coroutine.async { AppWebDav.uploadBookProgress(BookProgress(book), uploadSuccessAction) book.update() } } else if (progress.durChapterIndex > book.durChapterIndex || progress.durChapterPos > book.durChapterPos ) { // 进度比服务器慢,执行传入动作 newProgressAction?.invoke(progress) } else { syncSuccessAction?.invoke() } } } fun setProgress(progress: BookProgress) { if (progress.durChapterIndex < chapterSize && (durChapterIndex != progress.durChapterIndex || durChapterPos != progress.durChapterPos) ) { mCallback?.showLoading() if (progress.durChapterIndex == durChapterIndex) { durChapterPos = progress.durChapterPos mCallback?.upContent() } else { durChapterIndex = progress.durChapterIndex durChapterPos = progress.durChapterPos loadContent() } saveRead() } } fun showLoading() { mCallback?.showLoading() } fun loadFail(msg: String, retry: Boolean = true) { mCallback?.loadFail(msg, retry) } fun onChapterListUpdated(newBook: Book, loadContent: Boolean = true) { if (newBook.isSameNameAuthor(book)) { book = newBook chapterSize = newBook.totalChapterNum simulatedChapterSize = newBook.simulatedTotalChapterNum() if (simulatedChapterSize > 0 && durChapterIndex > simulatedChapterSize - 1) { durChapterIndex = simulatedChapterSize - 1 } if (mCallback == null) { clearMangaChapter() } else if (loadContent) { loadContent() } } } /** * 注册回调 */ fun register(cb: Callback) { mCallback = cb } /** * 取消注册回调 */ fun unregister(cb: Callback) { if (mCallback === cb) { mCallback = null } preDownloadTask?.cancel() preDownloadTask = null downloadScope.coroutineContext.cancelChildren() coroutineContext.cancelChildren() } private suspend fun getManageChapter(chapter: BookChapter, content: String): MangaChapter { val list = BookHelp.flowImages(chapter, content) .distinctUntilChanged().mapIndexed { index, src -> MangaPage( chapterIndex = chapter.index, chapterSize = chapterSize, mImageUrl = src, index = index, mChapterName = chapter.title ) }.toList() val imageCount = list.size list.forEach { it.imageCount = imageCount } if (AppConfig.hideMangaTitle && imageCount > 0) { return MangaChapter(chapter, list, imageCount) } val pages = mutableListOf() if (imageCount == 0 && chapter.isVolume) { pages.add(ReaderLoading(chapter.index, -1, chapter.title, true)) } else { pages.add(ReaderLoading(chapter.index, -1, "阅读 ${chapter.title}")) pages.addAll(list) pages.add(ReaderLoading(chapter.index, imageCount, "已读完 ${chapter.title}")) } return MangaChapter(chapter, pages, imageCount) } interface Callback { fun upContent() fun loadFail(msg: String, retry: Boolean = true) fun sureNewProgress(progress: BookProgress) fun showLoading() fun startLoad() } } ================================================ FILE: app/src/main/java/io/legado/app/model/SharedJsScope.kt ================================================ package io.legado.app.model import androidx.collection.LruCache import com.google.gson.reflect.TypeToken import com.script.ScriptBindings import com.script.rhino.RhinoScriptEngine import io.legado.app.exception.NoStackTraceException import io.legado.app.help.http.newCallStrResponse import io.legado.app.help.http.okHttpClient import io.legado.app.utils.ACache import io.legado.app.utils.GSON import io.legado.app.utils.MD5Utils import io.legado.app.utils.isAbsUrl import io.legado.app.utils.isJsonObject import kotlinx.coroutines.runBlocking import org.mozilla.javascript.Scriptable import org.mozilla.javascript.ScriptableObject import splitties.init.appCtx import java.io.File import java.lang.ref.WeakReference import kotlin.coroutines.CoroutineContext object SharedJsScope { private val cacheFolder = File(appCtx.cacheDir, "shareJs") private val aCache = ACache.get(cacheFolder) private val scopeMap = LruCache>(16) fun getScope(jsLib: String?, coroutineContext: CoroutineContext?): Scriptable? { if (jsLib.isNullOrBlank()) { return null } val key = MD5Utils.md5Encode(jsLib) var scope = scopeMap[key]?.get() if (scope == null) { scope = RhinoScriptEngine.run { getRuntimeScope(ScriptBindings()) } if (jsLib.isJsonObject()) { val jsMap: Map = GSON.fromJson( jsLib, TypeToken.getParameterized( Map::class.java, String::class.java, String::class.java ).type ) jsMap.values.forEach { value -> if (value.isAbsUrl()) { val fileName = MD5Utils.md5Encode(value) var js = aCache.getAsString(fileName) if (js == null) { js = runBlocking { okHttpClient.newCallStrResponse { url(value) }.body } if (js != null) { aCache.put(fileName, js) } else { throw NoStackTraceException("下载jsLib-${value}失败") } } RhinoScriptEngine.eval(js, scope, coroutineContext) } } } else { RhinoScriptEngine.eval(jsLib, scope, coroutineContext) } if (scope is ScriptableObject) { scope.sealObject() } scopeMap.put(key, WeakReference(scope)) } return scope } fun remove(jsLib: String?) { if (jsLib.isNullOrBlank()) { return } val key = MD5Utils.md5Encode(jsLib) scopeMap.remove(key) } } ================================================ FILE: app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByJSonPath.kt ================================================ package io.legado.app.model.analyzeRule import androidx.annotation.Keep import com.jayway.jsonpath.JsonPath import com.jayway.jsonpath.ReadContext import io.legado.app.utils.printOnDebug @Suppress("RegExpRedundantEscape") @Keep class AnalyzeByJSonPath(json: Any) { companion object { fun parse(json: Any): ReadContext { return when (json) { is ReadContext -> json is String -> JsonPath.parse(json) //JsonPath.parse(json) else -> JsonPath.parse(json) //JsonPath.parse(json) } } } private var ctx: ReadContext = parse(json) /** * 改进解析方法 * 解决阅读”&&“、”||“与jsonPath支持的”&&“、”||“之间的冲突 * 解决{$.rule}形式规则可能匹配错误的问题,旧规则用正则解析内容含‘}’的json文本时,用规则中的字段去匹配这种内容会匹配错误.现改用平衡嵌套方法解决这个问题 * */ fun getString(rule: String): String? { if (rule.isEmpty()) return null var result: String val ruleAnalyzes = RuleAnalyzer(rule, true) //设置平衡组为代码平衡 val rules = ruleAnalyzes.splitRule("&&", "||") if (rules.size == 1) { ruleAnalyzes.reSetPos() //将pos重置为0,复用解析器 result = ruleAnalyzes.innerRule("{$.") { getString(it) } //替换所有{$.rule...} if (result.isEmpty()) { //st为空,表明无成功替换的内嵌规则 try { val ob = ctx.read(rule) result = if (ob is List<*>) { ob.joinToString("\n") } else { ob.toString() } } catch (e: Exception) { e.printOnDebug() } } return result } else { val textList = arrayListOf() for (rl in rules) { val temp = getString(rl) if (!temp.isNullOrEmpty()) { textList.add(temp) if (ruleAnalyzes.elementsType == "||") { break } } } return textList.joinToString("\n") } } internal fun getStringList(rule: String): List { val result = ArrayList() if (rule.isEmpty()) return result val ruleAnalyzes = RuleAnalyzer(rule, true) //设置平衡组为代码平衡 val rules = ruleAnalyzes.splitRule("&&", "||", "%%") if (rules.size == 1) { ruleAnalyzes.reSetPos() //将pos重置为0,复用解析器 val st = ruleAnalyzes.innerRule("{$.") { getString(it) } //替换所有{$.rule...} if (st.isEmpty()) { //st为空,表明无成功替换的内嵌规则 try { val obj = ctx.read(rule) if (obj is List<*>) { for (o in obj) result.add(o.toString()) } else { result.add(obj.toString()) } } catch (e: Exception) { e.printOnDebug() } } else { result.add(st) } return result } else { val results = ArrayList>() for (rl in rules) { val temp = getStringList(rl) if (temp.isNotEmpty()) { results.add(temp) if (temp.isNotEmpty() && ruleAnalyzes.elementsType == "||") { break } } } if (results.size > 0) { if ("%%" == ruleAnalyzes.elementsType) { for (i in results[0].indices) { for (temp in results) { if (i < temp.size) { result.add(temp[i]) } } } } else { for (temp in results) { result.addAll(temp) } } } return result } } internal fun getObject(rule: String): Any { return ctx.read(rule) } internal fun getList(rule: String): ArrayList? { val result = ArrayList() if (rule.isEmpty()) return result val ruleAnalyzes = RuleAnalyzer(rule, true) //设置平衡组为代码平衡 val rules = ruleAnalyzes.splitRule("&&", "||", "%%") if (rules.size == 1) { ctx.let { try { return it.read>(rules[0]) } catch (e: Exception) { e.printOnDebug() } } } else { val results = ArrayList>() for (rl in rules) { val temp = getList(rl) if (!temp.isNullOrEmpty()) { results.add(temp) if (temp.isNotEmpty() && ruleAnalyzes.elementsType == "||") { break } } } if (results.size > 0) { if ("%%" == ruleAnalyzes.elementsType) { for (i in 0 until results[0].size) { for (temp in results) { if (i < temp.size) { temp[i]?.let { result.add(it) } } } } } else { for (temp in results) { result.addAll(temp) } } } } return result } } ================================================ FILE: app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByJSoup.kt ================================================ package io.legado.app.model.analyzeRule import androidx.annotation.Keep import org.jsoup.Jsoup import org.jsoup.nodes.Element import org.jsoup.parser.Parser import org.jsoup.select.Collector import org.jsoup.select.Elements import org.jsoup.select.Evaluator import org.seimicrawler.xpath.JXNode /** * Created by GKF on 2018/1/25. * 书源规则解析 */ @Keep class AnalyzeByJSoup(doc: Any) { companion object { private val nullSet = setOf(null) } private var element: Element = parse(doc) private fun parse(doc: Any): Element { if (doc is Element) { return doc } if (doc is JXNode) { return if (doc.isElement) doc.asElement() else Jsoup.parse(doc.toString()) } kotlin.runCatching { if (doc.toString().startsWith(" { val textS = ArrayList() if (ruleStr.isEmpty()) return textS //拆分规则 val sourceRule = SourceRule(ruleStr) if (sourceRule.elementsRule.isEmpty()) { textS.add(element.data() ?: "") } else { val ruleAnalyzes = RuleAnalyzer(sourceRule.elementsRule) val ruleStrS = ruleAnalyzes.splitRule("&&", "||", "%%") val results = ArrayList>() for (ruleStrX in ruleStrS) { val temp: ArrayList? = if (sourceRule.isCss) { val lastIndex = ruleStrX.lastIndexOf('@') getResultLast( element.select(ruleStrX.substring(0, lastIndex)), ruleStrX.substring(lastIndex + 1) ) } else { getResultList(ruleStrX) } if (!temp.isNullOrEmpty()) { results.add(temp) if (ruleAnalyzes.elementsType == "||") break } } if (results.size > 0) { if ("%%" == ruleAnalyzes.elementsType) { for (i in results[0].indices) { for (temp in results) { if (i < temp.size) { textS.add(temp[i]) } } } } else { for (temp in results) { textS.addAll(temp) } } } } return textS } /** * 获取Elements */ private fun getElements(temp: Element?, rule: String): Elements { if (temp == null || rule.isEmpty()) return Elements() val elements = Elements() val sourceRule = SourceRule(rule) val ruleAnalyzes = RuleAnalyzer(sourceRule.elementsRule) val ruleStrS = ruleAnalyzes.splitRule("&&", "||", "%%") val elementsList = ArrayList() if (sourceRule.isCss) { for (ruleStr in ruleStrS) { val tempS = temp.select(ruleStr) elementsList.add(tempS) if (tempS.size > 0 && ruleAnalyzes.elementsType == "||") { break } } } else { for (ruleStr in ruleStrS) { val rsRule = RuleAnalyzer(ruleStr) rsRule.trim() // 修剪当前规则之前的"@"或者空白符 val rs = rsRule.splitRule("@") val el = if (rs.size > 1) { val el = Elements() el.add(temp) for (rl in rs) { val es = Elements() for (et in el) { es.addAll(getElements(et, rl)) } el.clear() el.addAll(es) } el } else ElementsSingle().getElementsSingle(temp, ruleStr) elementsList.add(el) if (el.size > 0 && ruleAnalyzes.elementsType == "||") { break } } } if (elementsList.size > 0) { if ("%%" == ruleAnalyzes.elementsType) { for (i in 0 until elementsList[0].size) { for (es in elementsList) { if (i < es.size) { elements.add(es[i]) } } } } else { for (es in elementsList) { elements.addAll(es) } } } return elements } /** * 获取内容列表 */ private fun getResultList(ruleStr: String): ArrayList? { if (ruleStr.isEmpty()) return null var elements = Elements() elements.add(element) val rule = RuleAnalyzer(ruleStr) //创建解析 rule.trim() //修建前置赘余符号 val rules = rule.splitRule("@") // 切割成列表 val last = rules.size - 1 for (i in 0 until last) { val es = Elements() for (elt in elements) { es.addAll(ElementsSingle().getElementsSingle(elt, rules[i])) } elements.clear() elements = es } return if (elements.isEmpty()) null else getResultLast(elements, rules[last]) } /** * 根据最后一个规则获取内容 */ private fun getResultLast(elements: Elements, lastRule: String): ArrayList { val textS = ArrayList() when (lastRule) { "text" -> for (element in elements) { val text = element.text() if (text.isNotEmpty()) { textS.add(text) } } "textNodes" -> for (element in elements) { val tn = arrayListOf() val contentEs = element.textNodes() for (item in contentEs) { val text = item.text().trim { it <= ' ' } if (text.isNotEmpty()) { tn.add(text) } } if (tn.isNotEmpty()) { textS.add(tn.joinToString("\n")) } } "ownText" -> for (element in elements) { val text = element.ownText() if (text.isNotEmpty()) { textS.add(text) } } "html" -> { elements.select("script").remove() elements.select("style").remove() val html = elements.outerHtml() if (html.isNotEmpty()) { textS.add(html) } } "all" -> textS.add(elements.outerHtml()) else -> for (element in elements) { val url = element.attr(lastRule) if (url.isBlank() || textS.contains(url)) continue textS.add(url) } } return textS } /** * 1.支持阅读原有写法,':'分隔索引,!或.表示筛选方式,索引可为负数 * 例如 tag.div.-1:10:2 或 tag.div!0:3 * * 2. 支持与jsonPath类似的[]索引写法 * 格式形如 [it,it,。。。] 或 [!it,it,。。。] 其中[!开头表示筛选方式为排除,it为单个索引或区间。 * 区间格式为 start:end 或 start:end:step,其中start为0可省略,end为-1可省略。 * 索引,区间两端及间隔都支持负数 * 例如 tag.div[-1, 3:-2:-10, 2] * 特殊用法 tag.div[-1:0] 可在任意地方让列表反向 * */ @Suppress("UNCHECKED_CAST") data class ElementsSingle( var split: Char = '.', var beforeRule: String = "", val indexDefault: MutableList = mutableListOf(), val indexes: MutableList = mutableListOf() ) { /** * 获取Elements按照一个规则 */ fun getElementsSingle(temp: Element, rule: String): Elements { findIndexSet(rule) //执行索引列表处理器 /** * 获取所有元素 * */ var elements = if (beforeRule.isEmpty()) temp.children() //允许索引直接作为根元素,此时前置规则为空,效果与children相同 else { val rules = beforeRule.split(".") when (rules[0]) { "children" -> temp.children() //允许索引直接作为根元素,此时前置规则为空,效果与children相同 "class" -> temp.getElementsByClass(rules[1]) "tag" -> temp.getElementsByTag(rules[1]) "id" -> Collector.collect(Evaluator.Id(rules[1]), temp) "text" -> temp.getElementsContainingOwnText(rules[1]) else -> temp.select(beforeRule) } } val len = elements.size val lastIndexes = (indexDefault.size - 1).takeIf { it != -1 } ?: (indexes.size - 1) val indexSet = mutableSetOf() /** * 获取无重且不越界的索引集合 * */ if (indexes.isEmpty()) for (ix in lastIndexes downTo 0) { //indexes为空,表明是非[]式索引,集合是逆向遍历插入的,所以这里也逆向遍历,好还原顺序 val it = indexDefault[ix] if (it in 0 until len) indexSet.add(it) //将正数不越界的索引添加到集合 else if (it < 0 && len >= -it) indexSet.add(it + len) //将负数不越界的索引添加到集合 } else for (ix in lastIndexes downTo 0) { //indexes不空,表明是[]式索引,集合是逆向遍历插入的,所以这里也逆向遍历,好还原顺序 if (indexes[ix] is Triple<*, *, *>) { //区间 val (startX, endX, stepX) = indexes[ix] as Triple //还原储存时的类型 var start = startX ?: 0 // 左端省略表示0 if (start < 0) start += len // 将负索引转正 var end = endX ?: (len - 1) // 右端省略表示 len - 1 if (end < 0) end += len // 将负索引转正 if ((start < 0 && end < 0) || (start >= len && end >= len)) { // start 和 end 同侧左右端越界,无效索引 continue } if (start >= len) start = len - 1 // 右端越界,设置为最大索引 else if (start < 0) start = 0 // 左端越界,设置为最小索引 if (end >= len) end = len - 1 // 右端越界,设置为最大索引 else if (end < 0) end = 0 // 左端越界,设置为最小索引 if (start == end || stepX >= len) { //两端相同,区间里只有一个数。或间隔过大,区间实际上仅有首位 indexSet.add(start) continue } val step = if (stepX > 0) stepX else if (-stepX < len) stepX + len else 1 //最小正数间隔为1 //将区间展开到集合中,允许列表反向。 indexSet.addAll(if (end > start) start..end step step else start downTo end step step) } else {//单个索引 val it = indexes[ix] as Int //还原储存时的类型 if (it in 0 until len) indexSet.add(it) //将正数不越界的索引添加到集合 else if (it < 0 && len >= -it) indexSet.add(it + len) //将负数不越界的索引添加到集合 } } /** * 根据索引集合筛选元素 * */ if (split == '!') { //排除 for (pcInt in indexSet) elements[pcInt] = null elements.removeAll(nullSet) //测试过,这样就行 } else if (split == '.') { //选择 val es = Elements() for (pcInt in indexSet) es.add(elements[pcInt]) elements = es } return elements //返回筛选结果 } private fun findIndexSet(rule: String) { val rus = rule.trim { it <= ' ' } var len = rus.length var curInt: Int? //当前数字 var curMinus = false //当前数字是否为负 val curList = mutableListOf() //当前数字区间 var l = "" //暂存数字字符串 val head = rus.last() == ']' //是否为常规索引写法 if (head) { //常规索引写法[index...] len-- //跳过尾部']' while (len-- >= 0) { //逆向遍历,可以无前置规则 var rl = rus[len] if (rl == ' ') continue //跳过空格 if (rl in '0'..'9') l = rl + l //将数值累接入临时字串中,遇到分界符才取出 else if (rl == '-') curMinus = true else { curInt = if (l.isEmpty()) null else if (curMinus) -l.toInt() else l.toInt() //当前数字 when (rl) { ':' -> curList.add(curInt) //区间右端或区间间隔 else -> { //为保证查找顺序,区间和单个索引都添加到同一集合 if (curList.isEmpty()) { if (curInt == null) break //是jsoup选择器而非索引列表,跳出 indexes.add(curInt) } else { //列表最后压入的是区间右端,若列表有两位则最先压入的是间隔 indexes.add( Triple( curInt, curList.last(), if (curList.size == 2) curList.first() else 1 ) ) curList.clear() //重置临时列表,避免影响到下个区间的处理 } if (rl == '!') { split = '!' do { rl = rus[--len] } while (len > 0 && rl == ' ')//跳过所有空格 } if (rl == '[') { beforeRule = rus.substring(0, len) //遇到索引边界,返回结果 return } if (rl != ',') break //非索引结构,跳出 } } l = "" //清空 curMinus = false //重置 } } } else while (len-- >= 0) { //阅读原本写法,逆向遍历,可以无前置规则 val rl = rus[len] if (rl == ' ') continue //跳过空格 if (rl in '0'..'9') l = rl + l //将数值累接入临时字串中,遇到分界符才取出 else if (rl == '-') curMinus = true else { if (rl == '!' || rl == '.' || rl == ':') { //分隔符或起始符 indexDefault.add(if (curMinus) -l.toInt() else l.toInt()) // 当前数字追加到列表 if (rl != ':') { //rl == '!' || rl == '.' split = rl beforeRule = rus.substring(0, len) return } } else break //非索引结构,跳出循环 l = "" //清空 curMinus = false //重置 } } split = ' ' beforeRule = rus } } internal inner class SourceRule(ruleStr: String) { var isCss = false var elementsRule: String = if (ruleStr.startsWith("@CSS:", true)) { isCss = true ruleStr.substring(5).trim { it <= ' ' } } else { ruleStr } } } ================================================ FILE: app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByRegex.kt ================================================ package io.legado.app.model.analyzeRule import androidx.annotation.Keep import java.util.regex.Pattern @Keep object AnalyzeByRegex { fun getElement(res: String, regs: Array, index: Int = 0): List? { var vIndex = index val resM = Pattern.compile(regs[vIndex]).matcher(res) if (!resM.find()) { return null } // 判断索引的规则是最后一个规则 return if (vIndex + 1 == regs.size) { // 新建容器 val info = arrayListOf() for (groupIndex in 0..resM.groupCount()) { info.add(resM.group(groupIndex)!!) } info } else { val result = StringBuilder() do { result.append(resM.group()) } while (resM.find()) getElement(result.toString(), regs, ++vIndex) } } fun getElements(res: String, regs: Array, index: Int = 0): List> { var vIndex = index val resM = Pattern.compile(regs[vIndex]).matcher(res) if (!resM.find()) { return arrayListOf() } // 判断索引的规则是最后一个规则 if (vIndex + 1 == regs.size) { // 创建书息缓存数组 val books = ArrayList>() // 提取列表 do { // 新建容器 val info = arrayListOf() for (groupIndex in 0..resM.groupCount()) { info.add(resM.group(groupIndex) ?: "") } books.add(info) } while (resM.find()) return books } else { val result = StringBuilder() do { result.append(resM.group()) } while (resM.find()) return getElements(result.toString(), regs, ++vIndex) } } } ================================================ FILE: app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByXPath.kt ================================================ package io.legado.app.model.analyzeRule import android.text.TextUtils import androidx.annotation.Keep import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.jsoup.nodes.Element import org.jsoup.parser.Parser import org.jsoup.select.Elements import org.seimicrawler.xpath.JXDocument import org.seimicrawler.xpath.JXNode @Keep class AnalyzeByXPath(doc: Any) { private var jxNode: Any = parse(doc) private fun parse(doc: Any): Any { return when (doc) { is JXNode -> if (doc.isElement) doc else strToJXDocument(doc.toString()) is Document -> JXDocument.create(doc) is Element -> JXDocument.create(Elements(doc)) is Elements -> JXDocument.create(doc) else -> strToJXDocument(doc.toString()) } } private fun strToJXDocument(html: String): JXDocument { var html1 = html if (html1.endsWith("")) { html1 = "${html1}" } if (html1.endsWith("") || html1.endsWith("")) { html1 = "${html1}
" } kotlin.runCatching { if (html1.trim().startsWith("? { val node = jxNode return if (node is JXNode) { node.sel(xPath) } else { (node as JXDocument).selN(xPath) } } internal fun getElements(xPath: String): List? { if (xPath.isEmpty()) return null val jxNodes = ArrayList() val ruleAnalyzes = RuleAnalyzer(xPath) val rules = ruleAnalyzes.splitRule("&&", "||", "%%") if (rules.size == 1) { return getResult(rules[0]) } else { val results = ArrayList>() for (rl in rules) { val temp = getElements(rl) if (!temp.isNullOrEmpty()) { results.add(temp) if (temp.isNotEmpty() && ruleAnalyzes.elementsType == "||") { break } } } if (results.size > 0) { if ("%%" == ruleAnalyzes.elementsType) { for (i in results[0].indices) { for (temp in results) { if (i < temp.size) { jxNodes.add(temp[i]) } } } } else { for (temp in results) { jxNodes.addAll(temp) } } } } return jxNodes } internal fun getStringList(xPath: String): List { val result = ArrayList() val ruleAnalyzes = RuleAnalyzer(xPath) val rules = ruleAnalyzes.splitRule("&&", "||", "%%") if (rules.size == 1) { getResult(xPath)?.map { result.add(it.asString()) } return result } else { val results = ArrayList>() for (rl in rules) { val temp = getStringList(rl) if (temp.isNotEmpty()) { results.add(temp) if (temp.isNotEmpty() && ruleAnalyzes.elementsType == "||") { break } } } if (results.size > 0) { if ("%%" == ruleAnalyzes.elementsType) { for (i in results[0].indices) { for (temp in results) { if (i < temp.size) { result.add(temp[i]) } } } } else { for (temp in results) { result.addAll(temp) } } } } return result } fun getString(rule: String): String? { val ruleAnalyzes = RuleAnalyzer(rule) val rules = ruleAnalyzes.splitRule("&&", "||") if (rules.size == 1) { getResult(rule)?.let { return TextUtils.join("\n", it) } return null } else { val textList = arrayListOf() for (rl in rules) { val temp = getString(rl) if (!temp.isNullOrEmpty()) { textList.add(temp) if (ruleAnalyzes.elementsType == "||") { break } } } return textList.joinToString("\n") } } } ================================================ FILE: app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt ================================================ package io.legado.app.model.analyzeRule import android.text.TextUtils import androidx.annotation.Keep import com.script.CompiledScript import com.script.buildScriptBindings import com.script.rhino.RhinoScriptEngine import io.legado.app.constant.AppPattern.JS_PATTERN import io.legado.app.data.entities.BaseBook 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.data.entities.BookSource import io.legado.app.data.entities.RssArticle import io.legado.app.exception.NoStackTraceException import io.legado.app.help.CacheManager import io.legado.app.help.JsExtensions import io.legado.app.help.http.CookieStore import io.legado.app.help.source.getShareScope import io.legado.app.model.Debug import io.legado.app.model.webBook.WebBook import io.legado.app.utils.GSON import io.legado.app.utils.GSONStrict import io.legado.app.utils.NetworkUtils import io.legado.app.utils.fromJsonObject import io.legado.app.utils.getOrPutLimit import io.legado.app.utils.isDataUrl import io.legado.app.utils.isJson import io.legado.app.utils.printOnDebug import io.legado.app.utils.splitNotBlank import io.legado.app.utils.stackTraceStr import kotlinx.coroutines.ensureActive import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import org.apache.commons.text.StringEscapeUtils import org.jsoup.nodes.Node import org.mozilla.javascript.NativeObject import org.mozilla.javascript.Scriptable import java.lang.ref.WeakReference import java.net.URL import java.util.Locale import java.util.regex.Pattern import kotlin.coroutines.ContinuationInterceptor import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext /** * 解析规则获取结果 */ @Keep @Suppress("unused", "RegExpRedundantEscape", "MemberVisibilityCanBePrivate") class AnalyzeRule( private var ruleData: RuleDataInterface? = null, private val source: BaseSource? = null, private val preUpdateJs: Boolean = false ) : JsExtensions { private val book get() = ruleData as? BaseBook private val rssArticle get() = ruleData as? RssArticle private var chapter: BookChapter? = null private var nextChapterUrl: String? = null private var content: Any? = null private var baseUrl: String? = null private var redirectUrl: URL? = null private var isJSON: Boolean = false private var isRegex: Boolean = false private var analyzeByXPath: AnalyzeByXPath? = null private var analyzeByJSoup: AnalyzeByJSoup? = null private var analyzeByJSonPath: AnalyzeByJSonPath? = null private val stringRuleCache = hashMapOf>() private val regexCache = hashMapOf() private val scriptCache = hashMapOf() private var topScopeRef: WeakReference? = null private var evalJSCallCount = 0 private var coroutineContext: CoroutineContext = EmptyCoroutineContext private var loggedNonStandardJSON = false @JvmOverloads fun setContent(content: Any?, baseUrl: String? = null): AnalyzeRule { if (content == null) throw AssertionError("内容不可空(Content cannot be null)") this.content = content isJSON = when (content) { is Node -> false else -> content.toString().isJson() } setBaseUrl(baseUrl) analyzeByXPath = null analyzeByJSoup = null analyzeByJSonPath = null return this } fun setBaseUrl(baseUrl: String?): AnalyzeRule { baseUrl?.let { this.baseUrl = baseUrl } return this } fun setRedirectUrl(url: String): URL? { if (url.isDataUrl()) { return redirectUrl } try { redirectUrl = URL(url) } catch (e: Exception) { log("URL($url) error\n${e.localizedMessage}") } return redirectUrl } /** * 获取XPath解析类 */ private fun getAnalyzeByXPath(o: Any): AnalyzeByXPath { return if (o != content) { AnalyzeByXPath(o) } else { if (analyzeByXPath == null) { analyzeByXPath = AnalyzeByXPath(content!!) } analyzeByXPath!! } } /** * 获取JSOUP解析类 */ private fun getAnalyzeByJSoup(o: Any): AnalyzeByJSoup { return if (o != content) { AnalyzeByJSoup(o) } else { if (analyzeByJSoup == null) { analyzeByJSoup = AnalyzeByJSoup(content!!) } analyzeByJSoup!! } } /** * 获取JSON解析类 */ private fun getAnalyzeByJSonPath(o: Any): AnalyzeByJSonPath { return if (o != content) { AnalyzeByJSonPath(o) } else { if (analyzeByJSonPath == null) { analyzeByJSonPath = AnalyzeByJSonPath(content!!) } analyzeByJSonPath!! } } /** * 获取文本列表 */ @JvmOverloads fun getStringList(rule: String?, mContent: Any? = null, isUrl: Boolean = false): List? { if (rule.isNullOrEmpty()) return null val ruleList = splitSourceRuleCacheString(rule) return getStringList(ruleList, mContent, isUrl) } @JvmOverloads fun getStringList( ruleList: List, mContent: Any? = null, isUrl: Boolean = false ): List? { var result: Any? = null val content = mContent ?: this.content if (content != null && ruleList.isNotEmpty()) { result = content if (result is NativeObject) { val sourceRule = ruleList.first() putRule(sourceRule.putMap) sourceRule.makeUpRule(result) result = if (sourceRule.getParamSize() > 1) { // get {{}} sourceRule.rule } else { // 键值直接访问 result[sourceRule.rule] } result?.let { if (sourceRule.replaceRegex.isNotEmpty() && it is List<*>) { result = it.map { o -> replaceRegex(o.toString(), sourceRule) } } else if (sourceRule.replaceRegex.isNotEmpty()) { result = replaceRegex(result.toString(), sourceRule) } } } else { for (sourceRule in ruleList) { putRule(sourceRule.putMap) sourceRule.makeUpRule(result) result ?: continue val rule = sourceRule.rule if (rule.isNotEmpty()) { result = when (sourceRule.mode) { Mode.Js -> evalJS(rule, result) Mode.Json -> getAnalyzeByJSonPath(result).getStringList(rule) Mode.XPath -> getAnalyzeByXPath(result).getStringList(rule) Mode.Default -> getAnalyzeByJSoup(result).getStringList(rule) else -> rule } } if (sourceRule.replaceRegex.isNotEmpty() && result is List<*>) { val newList = ArrayList() for (item in result) { newList.add(replaceRegex(item.toString(), sourceRule)) } result = newList } else if (sourceRule.replaceRegex.isNotEmpty()) { result = replaceRegex(result.toString(), sourceRule) } } } } if (result == null) return null if (result is String) { result = result.split("\n") } if (isUrl) { val urlList = ArrayList() if (result is List<*>) { for (url in result) { val absoluteURL = NetworkUtils.getAbsoluteURL(redirectUrl, url.toString()) if (absoluteURL.isNotEmpty() && !urlList.contains(absoluteURL)) { urlList.add(absoluteURL) } } } return urlList } @Suppress("UNCHECKED_CAST") return result as? List } /** * 获取文本 */ @JvmOverloads fun getString(ruleStr: String?, mContent: Any? = null, isUrl: Boolean = false): String { if (TextUtils.isEmpty(ruleStr)) return "" val ruleList = splitSourceRuleCacheString(ruleStr) return getString(ruleList, mContent, isUrl) } fun getString(ruleStr: String?, unescape: Boolean): String { if (TextUtils.isEmpty(ruleStr)) return "" val ruleList = splitSourceRuleCacheString(ruleStr) return getString(ruleList, unescape = unescape) } @JvmOverloads fun getString( ruleList: List, mContent: Any? = null, isUrl: Boolean = false, unescape: Boolean = true ): String { var result: Any? = null val content = mContent ?: this.content if (content != null && ruleList.isNotEmpty()) { result = content if (result is NativeObject) { val sourceRule = ruleList.first() putRule(sourceRule.putMap) sourceRule.makeUpRule(result) result = if (sourceRule.getParamSize() > 1) { // get {{}} sourceRule.rule } else { // 键值直接访问 result[sourceRule.rule]?.toString() }?.let { replaceRegex(it, sourceRule) } } else { for (sourceRule in ruleList) { putRule(sourceRule.putMap) sourceRule.makeUpRule(result) result ?: continue val rule = sourceRule.rule if (rule.isNotBlank() || sourceRule.replaceRegex.isEmpty()) { result = when (sourceRule.mode) { Mode.Js -> evalJS(rule, result) Mode.Json -> getAnalyzeByJSonPath(result).getString(rule) Mode.XPath -> getAnalyzeByXPath(result).getString(rule) Mode.Default -> if (isUrl) { getAnalyzeByJSoup(result).getString0(rule) } else { getAnalyzeByJSoup(result).getString(rule) } else -> rule } } if (result != null && sourceRule.replaceRegex.isNotEmpty()) { result = replaceRegex(result.toString(), sourceRule) } } } } if (result == null) result = "" val resultStr = result.toString() val str = if (unescape && resultStr.indexOf('&') > -1) { StringEscapeUtils.unescapeHtml4(resultStr) } else { resultStr } if (isUrl) { return if (str.isBlank()) { baseUrl ?: "" } else { NetworkUtils.getAbsoluteURL(redirectUrl, str) } } return str } /** * 获取Element */ fun getElement(ruleStr: String): Any? { if (TextUtils.isEmpty(ruleStr)) return null var result: Any? = null val content = this.content val ruleList = splitSourceRule(ruleStr, true) if (content != null && ruleList.isNotEmpty()) { result = content for (sourceRule in ruleList) { putRule(sourceRule.putMap) sourceRule.makeUpRule(result) result ?: continue val rule = sourceRule.rule result = when (sourceRule.mode) { Mode.Regex -> AnalyzeByRegex.getElement( result.toString(), rule.splitNotBlank("&&") ) Mode.Js -> evalJS(rule, result) Mode.Json -> getAnalyzeByJSonPath(result).getObject(rule) Mode.XPath -> getAnalyzeByXPath(result).getElements(rule) else -> getAnalyzeByJSoup(result).getElements(rule) } if (sourceRule.replaceRegex.isNotEmpty()) { result = replaceRegex(result.toString(), sourceRule) } } } return result } /** * 获取列表 */ @Suppress("UNCHECKED_CAST") fun getElements(ruleStr: String): List { var result: Any? = null val content = this.content val ruleList = splitSourceRule(ruleStr, true) if (content != null && ruleList.isNotEmpty()) { result = content for (sourceRule in ruleList) { putRule(sourceRule.putMap) result ?: continue val rule = sourceRule.rule result = when (sourceRule.mode) { Mode.Regex -> AnalyzeByRegex.getElements( result.toString(), rule.splitNotBlank("&&") ) Mode.Js -> evalJS(rule, result) Mode.Json -> getAnalyzeByJSonPath(result).getList(rule) Mode.XPath -> getAnalyzeByXPath(result).getElements(rule) else -> getAnalyzeByJSoup(result).getElements(rule) } } } result?.let { return it as List } return ArrayList() } /** * 保存变量 */ private fun putRule(map: Map) { for ((key, value) in map) { put(key, getString(value)) } } /** * 分离put规则 */ private fun splitPutRule(ruleStr: String, putMap: HashMap): String { var vRuleStr = ruleStr val putMatcher = putPattern.matcher(vRuleStr) while (putMatcher.find()) { vRuleStr = vRuleStr.replace(putMatcher.group(), "") val putJsonStr = putMatcher.group(1) val putJson = GSONStrict.fromJsonObject>(putJsonStr) .getOrNull() if (putJson != null) { putMap.putAll(putJson) continue } GSON.fromJsonObject>(putJsonStr) .getOrNull() ?.let { if (!loggedNonStandardJSON) { Debug.log("≡@put 规则 JSON 格式不规范,请改为规范格式") loggedNonStandardJSON = true } putMap.putAll(it) } } return vRuleStr } /** * 正则替换 */ private fun replaceRegex(result: String, rule: SourceRule): String { if (rule.replaceRegex.isEmpty()) return result val replaceRegex = rule.replaceRegex val replacement = rule.replacement val regex = compileRegexCache(replaceRegex) if (rule.replaceFirst) { /* ##match##replace### 获取第一个匹配到的结果并进行替换 */ if (regex != null) kotlin.runCatching { val pattern = regex.toPattern() val matcher = pattern.matcher(result) return if (matcher.find()) { matcher.group(0)!!.replaceFirst(regex, replacement) } else { "" } } return replacement } else { /* ##match##replace 替换*/ if (regex != null) kotlin.runCatching { return result.replace(regex, replacement) } return result.replace(replaceRegex, replacement) } } private fun compileRegexCache(regex: String): Regex? { return regexCache.getOrPutLimit(regex, 16) { try { regex.toRegex() } catch (e: Exception) { null } } } /** * getString 类规则缓存 */ private fun splitSourceRuleCacheString(ruleStr: String?): List { if (ruleStr.isNullOrEmpty()) return emptyList() return stringRuleCache.getOrPut(ruleStr) { splitSourceRule(ruleStr) } } /** * 分解规则生成规则列表 */ fun splitSourceRule(ruleStr: String?, allInOne: Boolean = false): List { if (ruleStr.isNullOrEmpty()) return emptyList() val ruleList = ArrayList() var mMode: Mode = Mode.Default var start = 0 //仅首字符为:时为AllInOne,其实:与伪类选择器冲突,建议改成?更合理 if (allInOne && ruleStr.startsWith(":")) { mMode = Mode.Regex isRegex = true start = 1 } else if (isRegex) { mMode = Mode.Regex } var tmp: String val jsMatcher = JS_PATTERN.matcher(ruleStr) while (jsMatcher.find()) { if (jsMatcher.start() > start) { tmp = ruleStr.substring(start, jsMatcher.start()).trim { it <= ' ' } if (tmp.isNotEmpty()) { ruleList.add(SourceRule(tmp, mMode)) } } ruleList.add(SourceRule(jsMatcher.group(2) ?: jsMatcher.group(1), Mode.Js)) start = jsMatcher.end() } if (ruleStr.length > start) { tmp = ruleStr.substring(start).trim { it <= ' ' } if (tmp.isNotEmpty()) { ruleList.add(SourceRule(tmp, mMode)) } } return ruleList } private fun getOrCreateSingleSourceRule(rule: String): List { return stringRuleCache.getOrPutLimit(rule, 16) { listOf(SourceRule(rule)) } } /** * 规则类 */ inner class SourceRule internal constructor( ruleStr: String, internal var mode: Mode = Mode.Default ) { internal var rule: String internal var replaceRegex = "" internal var replacement = "" internal var replaceFirst = false internal val putMap = HashMap() private val ruleParam = ArrayList() private val ruleType = ArrayList() private val getRuleType = -2 private val jsRuleType = -1 private val defaultRuleType = 0 init { rule = when { mode == Mode.Js || mode == Mode.Regex -> ruleStr ruleStr.startsWith("@CSS:", true) -> { mode = Mode.Default ruleStr } ruleStr.startsWith("@@") -> { mode = Mode.Default ruleStr.substring(2) } ruleStr.startsWith("@XPath:", true) -> { mode = Mode.XPath ruleStr.substring(7) } ruleStr.startsWith("@Json:", true) -> { mode = Mode.Json ruleStr.substring(6) } isJSON || ruleStr.startsWith("$.") || ruleStr.startsWith("$[") -> { mode = Mode.Json ruleStr } ruleStr.startsWith("/") -> {//XPath特征很明显,无需配置单独的识别标头 mode = Mode.XPath ruleStr } else -> ruleStr } //分离put rule = splitPutRule(rule, putMap) //@get,{{ }}, 拆分 var start = 0 var tmp: String val evalMatcher = evalPattern.matcher(rule) if (evalMatcher.find()) { tmp = rule.substring(start, evalMatcher.start()) if (mode != Mode.Js && mode != Mode.Regex && (evalMatcher.start() == 0 || !tmp.contains("##")) ) { mode = Mode.Regex } do { if (evalMatcher.start() > start) { tmp = rule.substring(start, evalMatcher.start()) splitRegex(tmp) } tmp = evalMatcher.group() when { tmp.startsWith("@get:", true) -> { ruleType.add(getRuleType) ruleParam.add(tmp.substring(6, tmp.lastIndex)) } tmp.startsWith("{{") -> { ruleType.add(jsRuleType) ruleParam.add(tmp.substring(2, tmp.length - 2)) } else -> { splitRegex(tmp) } } start = evalMatcher.end() } while (evalMatcher.find()) } if (rule.length > start) { tmp = rule.substring(start) splitRegex(tmp) } } /** * 拆分\$\d{1,2} */ private fun splitRegex(ruleStr: String) { var start = 0 var tmp: String val ruleStrArray = ruleStr.split("##") val regexMatcher = regexPattern.matcher(ruleStrArray[0]) if (regexMatcher.find()) { if (mode != Mode.Js && mode != Mode.Regex) { mode = Mode.Regex } do { if (regexMatcher.start() > start) { tmp = ruleStr.substring(start, regexMatcher.start()) ruleType.add(defaultRuleType) ruleParam.add(tmp) } tmp = regexMatcher.group() ruleType.add(tmp.substring(1).toInt()) ruleParam.add(tmp) start = regexMatcher.end() } while (regexMatcher.find()) } if (ruleStr.length > start) { tmp = ruleStr.substring(start) ruleType.add(defaultRuleType) ruleParam.add(tmp) } } /** * 替换@get,{{ }} */ fun makeUpRule(result: Any?) { val infoVal = StringBuilder() if (ruleParam.isNotEmpty()) { var index = ruleParam.size while (index-- > 0) { val regType = ruleType[index] when { regType > defaultRuleType -> { @Suppress("UNCHECKED_CAST") (result as? List)?.run { if (this.size > regType) { this[regType]?.let { infoVal.insert(0, it) } } } ?: infoVal.insert(0, ruleParam[index]) } regType == jsRuleType -> { if (isRule(ruleParam[index])) { val ruleList = getOrCreateSingleSourceRule(ruleParam[index]) getString(ruleList).let { infoVal.insert(0, it) } } else { val jsEval: Any? = evalJS(ruleParam[index], result) when { jsEval == null -> Unit jsEval is String -> infoVal.insert(0, jsEval) jsEval is Double && jsEval % 1.0 == 0.0 -> infoVal.insert( 0, String.format(Locale.ROOT, "%.0f", jsEval) ) else -> infoVal.insert(0, jsEval.toString()) } } } regType == getRuleType -> { infoVal.insert(0, get(ruleParam[index])) } else -> infoVal.insert(0, ruleParam[index]) } } rule = infoVal.toString() } //分离正则表达式 val ruleStrS = rule.split("##") rule = ruleStrS[0].trim() if (ruleStrS.size > 1) { replaceRegex = ruleStrS[1] } if (ruleStrS.size > 2) { replacement = ruleStrS[2] } if (ruleStrS.size > 3) { replaceFirst = true } } private fun isRule(ruleStr: String): Boolean { return ruleStr.startsWith('@') //js首个字符不可能是@,除非是装饰器,所以@开头规定为规则 || ruleStr.startsWith("$.") || ruleStr.startsWith("$[") || ruleStr.startsWith("//") } fun getParamSize(): Int { return ruleParam.size } } enum class Mode { XPath, Json, Default, Js, Regex } /** * 保存数据 */ fun put(key: String, value: String): String { if (key == "bookName" || key == "title") { Debug.log("≡变量 $key 在特定情况下会被覆盖,建议使用其他键名") } chapter?.putVariable(key, value) ?: book?.putVariable(key, value) ?: ruleData?.putVariable(key, value) ?: source?.put(key, value) return value } /** * 获取保存的数据 */ fun get(key: String): String { when (key) { "bookName" -> book?.let { return it.name } "title" -> chapter?.let { return it.title } } return chapter?.getVariable(key)?.takeIf { it.isNotEmpty() } ?: book?.getVariable(key)?.takeIf { it.isNotEmpty() } ?: ruleData?.getVariable(key)?.takeIf { it.isNotEmpty() } ?: source?.get(key)?.takeIf { it.isNotEmpty() } ?: "" } /** * 执行JS */ fun evalJS(jsStr: String, result: Any? = null): Any? { val bindings = buildScriptBindings { bindings -> bindings["java"] = this bindings["cookie"] = CookieStore bindings["cache"] = CacheManager bindings["source"] = source bindings["book"] = book bindings["result"] = result bindings["baseUrl"] = baseUrl bindings["chapter"] = chapter bindings["title"] = chapter?.title bindings["src"] = content bindings["nextChapterUrl"] = nextChapterUrl bindings["rssArticle"] = rssArticle } val topScope = source?.getShareScope(coroutineContext) ?: topScopeRef?.get() val scope = if (topScope == null) { RhinoScriptEngine.getRuntimeScope(bindings).apply { if (evalJSCallCount++ > 16) { topScopeRef = WeakReference(prototype) } } } else { bindings.apply { prototype = topScope } } val script = compileScriptCache(jsStr) val result = script.eval(scope, coroutineContext) return result } private fun compileScriptCache(jsStr: String): CompiledScript { return scriptCache.getOrPutLimit(jsStr, 16) { RhinoScriptEngine.compile(jsStr) } } override fun getSource(): BaseSource? { return source } /** * js实现跨域访问,不能删 */ override fun ajax(url: Any): String? { val urlStr = if (url is List<*>) { url.firstOrNull().toString() } else { url.toString() } val analyzeUrl = AnalyzeUrl( urlStr, source = source, ruleData = book, coroutineContext = coroutineContext ) return kotlin.runCatching { analyzeUrl.getStrResponse().body }.onFailure { coroutineContext.ensureActive() log("ajax(${urlStr}) error\n${it.stackTraceToString()}") it.printOnDebug() }.getOrElse { it.stackTraceStr } } /** * 重新获取book */ fun reGetBook() { if (!preUpdateJs) throw NoStackTraceException("只能在 preUpdateJs 中调用") val bookSource = source as? BookSource val book = book as? Book if (bookSource == null || book == null) return runBlocking(coroutineContext) { withTimeout(1800000) { WebBook.preciseSearchAwait(bookSource, book.name, book.author) .getOrThrow().let { book.bookUrl = it.bookUrl it.variableMap.forEach { entry -> book.putVariable(entry.key, entry.value) } } WebBook.getBookInfoAwait(bookSource, book, false) } } } /** * 更新tocUrl,有些书源目录url定期更新,可以在js调用更新 */ fun refreshTocUrl() { if (!preUpdateJs) throw NoStackTraceException("只能在 preUpdateJs 中调用") val bookSource = source as? BookSource val book = book as? Book if (bookSource == null || book == null) return runBlocking(coroutineContext) { withTimeout(1800000) { WebBook.getBookInfoAwait(bookSource, book, false) } } } companion object { private val putPattern = Pattern.compile("@put:(\\{[^}]+?\\})", Pattern.CASE_INSENSITIVE) private val evalPattern = Pattern.compile("@get:\\{[^}]+?\\}|\\{\\{[\\w\\W]*?\\}\\}", Pattern.CASE_INSENSITIVE) private val regexPattern = Pattern.compile("\\$\\d{1,2}") fun AnalyzeRule.setCoroutineContext(context: CoroutineContext): AnalyzeRule { coroutineContext = context.minusKey(ContinuationInterceptor) return this } fun AnalyzeRule.setRuleData(ruleData: RuleDataInterface?): AnalyzeRule { this.ruleData = ruleData return this } fun AnalyzeRule.setNextChapterUrl(nextChapterUrl: String?): AnalyzeRule { this.nextChapterUrl = nextChapterUrl return this } fun AnalyzeRule.setChapter(chapter: BookChapter?): AnalyzeRule { this.chapter = chapter return this } } } ================================================ FILE: app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeUrl.kt ================================================ package io.legado.app.model.analyzeRule import android.annotation.SuppressLint import android.util.Base64 import androidx.annotation.Keep import androidx.media3.common.MediaItem import cn.hutool.core.codec.PercentCodec import cn.hutool.core.net.RFC3986 import cn.hutool.core.util.HexUtil import com.bumptech.glide.load.model.GlideUrl import com.script.buildScriptBindings 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 import io.legado.app.help.CacheManager import io.legado.app.help.ConcurrentRateLimiter import io.legado.app.help.JsExtensions import io.legado.app.help.config.AppConfig import io.legado.app.help.exoplayer.ExoPlayerHelper import io.legado.app.help.glide.GlideHeaders import io.legado.app.help.http.BackstageWebView import io.legado.app.help.http.CookieManager import io.legado.app.help.http.CookieManager.mergeCookies import io.legado.app.help.http.CookieStore import io.legado.app.help.http.RequestMethod import io.legado.app.help.http.StrResponse import io.legado.app.help.http.addHeaders import io.legado.app.help.http.get import io.legado.app.help.http.getProxyClient import io.legado.app.help.http.newCallResponse import io.legado.app.help.http.newCallStrResponse import io.legado.app.help.http.postForm import io.legado.app.help.http.postJson import io.legado.app.help.http.postMultipart import io.legado.app.help.source.getShareScope import io.legado.app.model.Debug import io.legado.app.utils.EncoderUtils import io.legado.app.utils.GSON import io.legado.app.utils.GSONStrict import io.legado.app.utils.NetworkUtils import io.legado.app.utils.fromJsonArray import io.legado.app.utils.fromJsonObject import io.legado.app.utils.get 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 kotlinx.coroutines.runBlocking import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import java.io.ByteArrayInputStream import java.io.InputStream import java.net.URLEncoder import java.nio.charset.Charset import java.util.concurrent.TimeUnit import java.util.regex.Pattern import kotlin.coroutines.ContinuationInterceptor import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext import kotlin.math.max /** * Created by GKF on 2018/1/24. * 搜索URL规则解析 */ @Suppress("unused", "MemberVisibilityCanBePrivate") @Keep @SuppressLint("DefaultLocale") class AnalyzeUrl( private val mUrl: String, private val key: String? = null, private val page: Int? = null, private val speakText: String? = null, private val speakSpeed: Int? = null, private var baseUrl: String = "", private val source: BaseSource? = null, private val ruleData: RuleDataInterface? = null, private val chapter: BookChapter? = null, private val readTimeout: Long? = null, private val callTimeout: Long? = null, private var coroutineContext: CoroutineContext = EmptyCoroutineContext, headerMapF: Map? = null, hasLoginHeader: Boolean = true ) : JsExtensions { var ruleUrl = "" private set var url: String = "" private set var type: String? = null private set val headerMap = LinkedHashMap() private var body: String? = null private var urlNoQuery: String = "" private var encodedForm: String? = null private var encodedQuery: String? = null private var charset: String? = null private var method = RequestMethod.GET private var proxy: String? = null private var retry: Int = 0 private var useWebView: Boolean = false private var webJs: String? = null private val enabledCookieJar = source?.enabledCookieJar == true private val domain: String private var webViewDelayTime: Long = 0 private val concurrentRateLimiter = ConcurrentRateLimiter(source) // 服务器ID var serverID: Long? = null private set init { coroutineContext = coroutineContext.minusKey(ContinuationInterceptor) val urlMatcher = paramPattern.matcher(baseUrl) if (urlMatcher.find()) baseUrl = baseUrl.substring(0, urlMatcher.start()) (headerMapF ?: runScriptWithContext(coroutineContext) { source?.getHeaderMap(hasLoginHeader) })?.let { headerMap.putAll(it) if (it.containsKey("proxy")) { proxy = it["proxy"] headerMap.remove("proxy") } } initUrl() domain = NetworkUtils.getSubDomain(source?.getKey() ?: url) } /** * 处理url */ fun initUrl() { ruleUrl = mUrl //执行@js, analyzeJs() //替换参数 replaceKeyPageJs() //处理URL analyzeUrl() } /** * 执行@js, */ private fun analyzeJs() { var start = 0 val jsMatcher = JS_PATTERN.matcher(ruleUrl) var result = ruleUrl while (jsMatcher.find()) { if (jsMatcher.start() > start) { ruleUrl.substring(start, jsMatcher.start()).trim().let { if (it.isNotEmpty()) { result = it.replace("@result", result) } } } result = evalJS(jsMatcher.group(2) ?: jsMatcher.group(1), result).toString() start = jsMatcher.end() } if (ruleUrl.length > start) { ruleUrl.substring(start).trim().let { if (it.isNotEmpty()) { result = it.replace("@result", result) } } } ruleUrl = result } /** * 替换关键字,页数,JS */ private fun replaceKeyPageJs() { //先替换内嵌规则再替换页数规则,避免内嵌规则中存在大于小于号时,规则被切错 //js if (ruleUrl.contains("{{") && ruleUrl.contains("}}")) { val analyze = RuleAnalyzer(ruleUrl) //创建解析 //替换所有内嵌{{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) else -> jsEval.toString() } } if (url.isNotEmpty()) ruleUrl = url } //page page?.let { val matcher = pagePattern.matcher(ruleUrl) while (matcher.find()) { val pages = matcher.group(1)!!.split(",") ruleUrl = if (page < pages.size) { //pages[pages.size - 1]等同于pages.last() ruleUrl.replace(matcher.group(), pages[page - 1].trim { it <= ' ' }) } else { ruleUrl.replace(matcher.group(), pages.last().trim { it <= ' ' }) } } } } /** * 解析Url */ private fun analyzeUrl() { //replaceKeyPageJs已经替换掉额外内容,此处url是基础形式,可以直接切首个‘,’之前字符串。 val urlMatcher = paramPattern.matcher(ruleUrl) val urlNoOption = if (urlMatcher.find()) ruleUrl.substring(0, urlMatcher.start()) else ruleUrl url = NetworkUtils.getAbsoluteURL(baseUrl, urlNoOption) NetworkUtils.getBaseUrl(url)?.let { baseUrl = it } if (urlNoOption.length != ruleUrl.length) { val urlOptionStr = ruleUrl.substring(urlMatcher.end()) var urlOption = GSONStrict.fromJsonObject(urlOptionStr).getOrNull() if (urlOption == null) { urlOption = GSON.fromJsonObject(urlOptionStr).getOrNull() if (urlOption != null) { log("链接参数 JSON 格式不规范,请改为规范格式") } } urlOption?.let { option -> option.getMethod()?.let { if (it.equals("POST", true)) method = RequestMethod.POST } option.getHeaderMap()?.forEach { entry -> headerMap[entry.key.toString()] = entry.value.toString() } option.getBody()?.let { body = it } type = option.getType() charset = option.getCharset() retry = option.getRetry() useWebView = option.useWebView() webJs = option.getWebJs() option.getJs()?.let { jsStr -> evalJS(jsStr, url)?.toString()?.let { url = it } } serverID = option.getServerID() webViewDelayTime = max(0, option.getWebViewDelayTime() ?: 0) } } urlNoQuery = url when (method) { RequestMethod.GET -> { val pos = url.indexOf('?') if (pos != -1) { analyzeQuery(url.substring(pos + 1)) urlNoQuery = url.substring(0, pos) } } RequestMethod.POST -> body?.let { if (!it.isJson() && !it.isXml() && headerMap["Content-Type"].isNullOrEmpty()) { analyzeFields(it) } } } } /** * 解析QueryMap = * name= * name=name * name= eg name=bmFtZQ== */ private fun analyzeFields(fieldsTxt: String) { encodedForm = encodeParams(fieldsTxt, charset, false) } private fun analyzeQuery(query: String) { encodedQuery = encodeParams(query, charset, true) } private fun encodeParams(params: String, charset: String?, isQuery: Boolean): String { val checkEncoded = charset.isNullOrEmpty() val charset = when { charset.isNullOrEmpty() -> Charsets.UTF_8 charset == "escape" -> null else -> charset(charset) } if (isQuery && charset != null) { if (NetworkUtils.encodedQuery(params)) { return params } return queryEncoder.encode(params, charset) } val len = params.length val sb = StringBuilder() var pos = 0 while (pos <= len) { if (sb.isNotEmpty()) { sb.append("&") } var ampOffset = params.indexOf("&", pos) if (ampOffset == -1) { ampOffset = len } val eqOffset = params.indexOf("=", pos) val key: String val value: String? if (eqOffset == -1 || eqOffset > ampOffset) { key = params.substring(pos, ampOffset) value = null } else { key = params.substring(pos, eqOffset) value = params.substring(eqOffset + 1, ampOffset) } sb.appendEncoded(key, checkEncoded, charset) if (value != null) { sb.append("=") sb.appendEncoded(value, checkEncoded, charset) } pos = ampOffset + 1 } return sb.toString() } private fun StringBuilder.appendEncoded( value: String, checkEncoded: Boolean, charset: Charset? ) { if (checkEncoded && NetworkUtils.encodedForm(value)) { append(value) } else if (charset == null) { append(EncoderUtils.escape(value)) } else { append(URLEncoder.encode(value, charset)) } } /** * 执行JS */ fun evalJS(jsStr: String, result: Any? = null): Any? { val bindings = buildScriptBindings { bindings -> bindings["java"] = this bindings["baseUrl"] = baseUrl bindings["cookie"] = CookieStore bindings["cache"] = CacheManager bindings["page"] = page bindings["key"] = key bindings["speakText"] = speakText bindings["speakSpeed"] = speakSpeed bindings["book"] = ruleData as? Book bindings["source"] = source bindings["result"] = result } val sharedScope = source?.getShareScope(coroutineContext) val scope = if (sharedScope == null) { RhinoScriptEngine.getRuntimeScope(bindings) } else { bindings.apply { prototype = sharedScope } } return RhinoScriptEngine.eval(jsStr, scope, coroutineContext) } fun put(key: String, value: String): String { if (key == "bookName" || key == "title") { Debug.log("≡变量 $key 在特定情况下会被覆盖,建议使用其他键名") } chapter?.putVariable(key, value) ?: ruleData?.putVariable(key, value) return value } fun get(key: String): String { when (key) { "bookName" -> (ruleData as? Book)?.let { return it.name } "title" -> chapter?.let { return it.title } } return chapter?.getVariable(key)?.takeIf { it.isNotEmpty() } ?: ruleData?.getVariable(key)?.takeIf { it.isNotEmpty() } ?: "" } /** * 访问网站,返回StrResponse */ suspend fun getStrResponseAwait( jsStr: String? = null, sourceRegex: String? = null, useWebView: Boolean = true, ): StrResponse { if (type != null) { return StrResponse(url, HexUtil.encodeHexStr(getByteArrayAwait())) } concurrentRateLimiter.withLimit { setCookie() val strResponse: StrResponse if (this.useWebView && useWebView) { strResponse = when (method) { RequestMethod.POST -> { val res = getClient().newCallStrResponse(retry) { addHeaders(headerMap) url(urlNoQuery) if (!encodedForm.isNullOrEmpty() || body.isNullOrBlank()) { postForm(encodedForm ?: "") } else { postJson(body) } } BackstageWebView( url = res.url, html = res.body, tag = source?.getKey(), javaScript = webJs ?: jsStr, sourceRegex = sourceRegex, headerMap = headerMap, delayTime = webViewDelayTime ).getStrResponse() } else -> BackstageWebView( url = url, tag = source?.getKey(), javaScript = webJs ?: jsStr, sourceRegex = sourceRegex, headerMap = headerMap, delayTime = webViewDelayTime ).getStrResponse() } } else { strResponse = getClient().newCallStrResponse(retry) { addHeaders(headerMap) when (method) { RequestMethod.POST -> { url(urlNoQuery) val contentType = headerMap["Content-Type"] val body = body if (!encodedForm.isNullOrEmpty() || body.isNullOrBlank()) { postForm(encodedForm ?: "") } else if (!contentType.isNullOrBlank()) { val requestBody = body.toRequestBody(contentType.toMediaType()) post(requestBody) } else { postJson(body) } } else -> get(urlNoQuery, encodedQuery) } }.let { val isXml = it.raw.body.contentType()?.toString() ?.matches(AppPattern.xmlContentTypeRegex) == true if (isXml && it.body?.trim()?.startsWith("" + it.body) } else it } } return strResponse } } @JvmOverloads fun getStrResponse( jsStr: String? = null, sourceRegex: String? = null, useWebView: Boolean = true, ): StrResponse { return runBlocking(coroutineContext) { getStrResponseAwait(jsStr, sourceRegex, useWebView) } } /** * 访问网站,返回Response */ suspend fun getResponseAwait(): Response { concurrentRateLimiter.withLimit { setCookie() val response = getClient().newCallResponse(retry) { addHeaders(headerMap) when (method) { RequestMethod.POST -> { url(urlNoQuery) val contentType = headerMap["Content-Type"] val body = body if (!encodedForm.isNullOrEmpty() || body.isNullOrBlank()) { postForm(encodedForm ?: "") } else if (!contentType.isNullOrBlank()) { val requestBody = body.toRequestBody(contentType.toMediaType()) post(requestBody) } else { postJson(body) } } else -> get(urlNoQuery, encodedQuery) } } return response } } private fun getClient(): OkHttpClient { val client = getProxyClient(proxy) if (readTimeout == null && callTimeout == null) { return client } return client.newBuilder().run { if (readTimeout != null) { readTimeout(readTimeout, TimeUnit.MILLISECONDS) callTimeout(max(60 * 1000L, readTimeout * 2), TimeUnit.MILLISECONDS) } if (callTimeout != null) { callTimeout(callTimeout, TimeUnit.MILLISECONDS) } build() } } fun getResponse(): Response { return runBlocking(coroutineContext) { getResponseAwait() } } private fun getByteArrayIfDataUri(): ByteArray? { if (!urlNoQuery.startsWith("data:")) { return null } val dataUriFindResult = dataUriRegex.find(urlNoQuery) if (dataUriFindResult != null) { val dataUriBase64 = dataUriFindResult.groupValues[1] val byteArray = Base64.decode(dataUriBase64, Base64.DEFAULT) return byteArray } return null } /** * 访问网站,返回ByteArray */ suspend fun getByteArrayAwait(): ByteArray { getByteArrayIfDataUri()?.let { return it } return getResponseAwait().body.bytes() } fun getByteArray(): ByteArray { return runBlocking(coroutineContext) { getByteArrayAwait() } } /** * 访问网站,返回InputStream */ suspend fun getInputStreamAwait(): InputStream { getByteArrayIfDataUri()?.let { return ByteArrayInputStream(it) } return getResponseAwait().body.byteStream() } fun getInputStream(): InputStream { return runBlocking(coroutineContext) { getInputStreamAwait() } } /** * 上传文件 */ suspend fun upload(fileName: String, file: Any, contentType: String): StrResponse { return getProxyClient(proxy).newCallStrResponse(retry) { url(urlNoQuery) val bodyMap = GSON.fromJsonObject>(body).getOrNull()!! bodyMap.forEach { entry -> if (entry.value.toString() == "fileRequest") { bodyMap[entry.key] = mapOf( Pair("fileName", fileName), Pair("file", file), Pair("contentType", contentType) ) } } postMultipart(type, bodyMap) } } /** * 设置cookie 优先级 * urlOption临时cookie > 数据库cookie */ private fun setCookie() { val cookie = kotlin.run { /* 每次调用getXX cookieJar已经保存过了 if (enabledCookieJar) { val key = "${domain}_cookieJar" CacheManager.getFromMemory(key)?.let { return@run it } } */ CookieStore.getCookie(domain) } if (cookie.isNotEmpty()) { mergeCookies(cookie, headerMap["Cookie"])?.let { headerMap.put("Cookie", it) } } if (enabledCookieJar) { headerMap[CookieManager.cookieJarHeader] = "1" } else { headerMap.remove(CookieManager.cookieJarHeader) } } /** * 保存cookieJar中的cookie在访问结束时就保存,不等到下次访问 */ private fun saveCookie() { //书源启用保存cookie时 添加内存中的cookie到数据库 if (enabledCookieJar) { val key = "${domain}_cookieJar" CacheManager.getFromMemory(key)?.let { if (it is String) { CookieStore.replaceCookie(domain, it) CacheManager.deleteMemory(key) } } } } /** *获取处理过阅读定义的urlOption和cookie的GlideUrl */ fun getGlideUrl(): GlideUrl { setCookie() return GlideUrl(url, GlideHeaders(headerMap)) } fun getUserAgent(): String { return headerMap.get(UA_NAME, true) ?: AppConfig.userAgent } fun isPost(): Boolean { return method == RequestMethod.POST } override fun getSource(): BaseSource? { return source } companion object { val paramPattern: Pattern = Pattern.compile("\\s*,\\s*(?=\\{)") private val pagePattern = Pattern.compile("<(.*?)>") private val queryEncoder = RFC3986.UNRESERVED.orNew(PercentCodec.of("!$%&()*+,/:;=?@[\\]^`{|}")) fun AnalyzeUrl.getMediaItem(): MediaItem { setCookie() return ExoPlayerHelper.createMediaItem(url, headerMap) } } @Keep data class UrlOption( private var method: String? = null, private var charset: String? = null, private var headers: Any? = null, private var body: Any? = null, /** * 源Url **/ private var origin: String? = null, /** * 重试次数 **/ private var retry: Int? = null, /** * 类型 **/ private var type: String? = null, /** * 是否使用webView **/ private var webView: Any? = null, /** * webView中执行的js **/ private var webJs: String? = null, /** * 解析完url参数时执行的js * 执行结果会赋值给url */ private var js: String? = null, /** * 服务器id */ private var serverID: Long? = null, /** * webview等待页面加载完毕的延迟时间(毫秒) */ private var webViewDelayTime: Long? = null, ) { fun setMethod(value: String?) { method = if (value.isNullOrBlank()) null else value } fun getMethod(): String? { return method } fun setCharset(value: String?) { charset = if (value.isNullOrBlank()) null else value } fun getCharset(): String? { return charset } fun setOrigin(value: String?) { origin = if (value.isNullOrBlank()) null else value } fun getOrigin(): String? { return origin } fun setRetry(value: String?) { retry = if (value.isNullOrEmpty()) null else value.toIntOrNull() } fun getRetry(): Int { return retry ?: 0 } fun setType(value: String?) { type = if (value.isNullOrBlank()) null else value } fun getType(): String? { return type } fun useWebView(): Boolean { return when (webView) { null, "", false, "false" -> false else -> true } } fun useWebView(boolean: Boolean) { webView = if (boolean) true else null } fun setHeaders(value: String?) { headers = if (value.isNullOrBlank()) { null } else { GSON.fromJsonObject>(value).getOrNull() } } fun getHeaderMap(): Map<*, *>? { return when (val value = headers) { is Map<*, *> -> value is String -> GSON.fromJsonObject>(value).getOrNull() else -> null } } fun setBody(value: String?) { body = when { value.isNullOrBlank() -> null value.isJsonObject() -> GSON.fromJsonObject>(value).getOrNull() value.isJsonArray() -> GSON.fromJsonArray>(value).getOrNull() else -> value } } fun getBody(): String? { return body?.let { it as? String ?: GSON.toJson(it) } } fun setWebJs(value: String?) { webJs = if (value.isNullOrBlank()) null else value } fun getWebJs(): String? { return webJs } fun setJs(value: String?) { js = if (value.isNullOrBlank()) null else value } fun getJs(): String? { return js } fun setServerID(value: String?) { serverID = if (value.isNullOrBlank()) null else value.toLong() } fun getServerID(): Long? { return serverID } fun setWebViewDelayTime(value: String?) { webViewDelayTime = if (value.isNullOrBlank()) null else value.toLong() } fun getWebViewDelayTime(): Long? { return webViewDelayTime } } data class ConcurrentRecord( /** * 是否按频率 */ val isConcurrent: Boolean, /** * 开始访问时间 */ var time: Long, /** * 正在访问的个数 */ var frequency: Int ) } ================================================ FILE: app/src/main/java/io/legado/app/model/analyzeRule/CustomUrl.kt ================================================ package io.legado.app.model.analyzeRule import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonObject @Suppress("unused") class CustomUrl(url: String) { private val mUrl: String private val attribute = hashMapOf() init { val urlMatcher = AnalyzeUrl.paramPattern.matcher(url) mUrl = if (urlMatcher.find()) { val attr = url.substring(urlMatcher.end()) GSON.fromJsonObject>(attr).getOrNull()?.let { attribute.putAll(it) } url.substring(0, urlMatcher.start()) } else { url } } fun putAttribute(key: String, value: Any?): CustomUrl { if (value == null) { attribute.remove(key) } else { attribute[key] = value } return this } fun getUrl(): String { return mUrl } fun getAttr(): Map { return attribute } override fun toString(): String { if (attribute.isEmpty()) { return mUrl } return mUrl + "," + GSON.toJson(attribute) } } ================================================ FILE: app/src/main/java/io/legado/app/model/analyzeRule/QueryTTF.java ================================================ package io.legado.app.model.analyzeRule; import androidx.annotation.Keep; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.LinkedList; @Keep @SuppressWarnings({"FieldCanBeLocal", "unused"}) public class QueryTTF { /** * 文件头 * * @url Microsoft opentype 字体文档 */ private static class Header { /** * uint32 字体版本 0x00010000 (ttf) */ public long sfntVersion; /** * uint16 Number of tables. */ public int numTables; /** * uint16 */ public int searchRange; /** * uint16 */ public int entrySelector; /** * uint16 */ public int rangeShift; } /** * 数据表目录 */ private static class Directory { /** * uint32 (表标识符) */ public String tableTag; /** * uint32 (该表的校验和) */ public int checkSum; /** * uint32 (TTF文件 Bytes 数据索引 0 开始的偏移地址) */ public int offset; /** * uint32 (该表的长度) */ public int length; } private static class NameLayout { public int format; public int count; public int stringOffset; public LinkedList records = new LinkedList<>(); } private static class NameRecord { public int platformID; // 平台标识符<0:Unicode, 1:Mac, 2:ISO, 3:Windows, 4:Custom> public int encodingID; // 编码标识符 public int languageID; // 语言标识符 public int nameID; // 名称标识符 public int length; // 名称字符串的长度 public int offset; // 名称字符串相对于stringOffset的字节偏移量 } /** * Font Header Table */ private static class HeadLayout { /** * uint16 */ public int majorVersion; /** * uint16 */ public int minorVersion; /** * uint16 */ public int fontRevision; /** * uint32 */ public int checkSumAdjustment; /** * uint32 */ public int magicNumber; /** * uint16 */ public int flags; /** * uint16 */ public int unitsPerEm; /** * long */ public long created; /** * long */ public long modified; /** * int16 */ public short xMin; /** * int16 */ public short yMin; /** * int16 */ public short xMax; /** * int16 */ public short yMax; /** * uint16 */ public int macStyle; /** * uint16 */ public int lowestRecPPEM; /** * int16 */ public short fontDirectionHint; /** * int16 *

0 表示短偏移 (Offset16),1 表示长偏移 (Offset32)。 */ public short indexToLocFormat; /** * int16 */ public short glyphDataFormat; } /** * Maximum Profile */ private static class MaxpLayout { /** * uint32 高16位表示整数,低16位表示小数 */ public int version; /** * uint16 字体中的字形数量 */ public int numGlyphs; /** * uint16 非复合字形中包含的最大点数。点是构成字形轮廓的基本单位。 */ public int maxPoints; /** * uint16 非复合字形中包含的最大轮廓数。轮廓是由一系列点连接形成的封闭曲线。 */ public int maxContours; /** * uint16 复合字形中包含的最大点数。复合字形是由多个简单字形组合而成的。 */ public int maxCompositePoints; /** * uint16 复合字形中包含的最大轮廓数。 */ public int maxCompositeContours; /** * uint16 */ public int maxZones; /** * uint16 */ public int maxTwilightPoints; /** * uint16 */ public int maxStorage; /** * uint16 */ public int maxFunctionDefs; /** * uint16 */ public int maxInstructionDefs; /** * uint16 */ public int maxStackElements; /** * uint16 */ public int maxSizeOfInstructions; /** * uint16 任何复合字形在“顶层”引用的最大组件数。 */ public int maxComponentElements; /** * uint16 递归的最大层数;简单组件为1。 */ public int maxComponentDepth; } /** * 字符到字形索引映射表 */ private static class CmapLayout { /** * uint16 */ public int version; /** * uint16 后面的编码表的数量 */ public int numTables; public LinkedList records = new LinkedList<>(); public HashMap tables = new HashMap<>(); } /** * Encoding records and encodings */ private static class CmapRecord { /** * uint16 Platform ID. *

0、Unicode *

1、Macintosh *

2、ISO *

3、Windows *

4、Custom */ public int platformID; /** * uint16 Platform-specific encoding ID. *

platform ID = 3 *

0、Symbol *

1、Unicode BMP *

2、ShiftJIS *

3、PRC *

4、Big5 *

5、Wansung *

6、Johab *

7、Reserved *

8、Reserved *

9、Reserved *

10、Unicode full repertoire */ public int encodingID; /** * uint32 从 cmap 表开头到子表的字节偏移量 */ public int offset; } private static class CmapFormat { /** * uint16 *

cmapFormat 子表的格式类型 */ public int format; /** * uint16 *

这个 Format 表的长度(以字节为单位) */ public int length; /** * uint16 *

仅 platformID=1 时有效 */ public int language; /** * uint16[256] *

仅 Format=2 *

将高字节映射到 subHeaders 的数组:值为 subHeader 索引x8 */ public int[] subHeaderKeys; /** * uint16[] *

仅 Format=2 *

subHeader 子标头的可变长度数组 *

其结构为 uint16[][4]{ {uint16,uint16,int16,uint16}, ... } */ public int[] subHeaders; /** * uint16 segCount x2 *

仅 Format=4 *

seg段计数乘以 2。这是因为每个段用两个字节表示,所以这个值是实际段数的两倍。 */ public int segCountX2; /** * uint16 *

仅 Format=4 *

小于或等于段数的最大二次幂,再乘以 2。这是为二分查找优化搜索过程。 */ public int searchRange; /** * uint16 *

仅 Format=4 *

等于 log2(searchRange/2),这是最大二次幂的对数。 */ public int entrySelector; /** * uint16 *

仅 Format=4 *

segCount * 2 - searchRange 用于调整搜索范围的偏移。 */ public int rangeShift; /** * uint16[segCount] *

仅 Format=4 *

每个段的结束字符码,最后一个是 0xFFFF,表示 Unicode 范围的结束。 */ public int[] endCode; /** * uint16 *

仅 Format=4 *

固定设置为 0,用于填充保留位以保持数据对齐。 */ public int reservedPad; /** * uint16[segCount] *

仅 Format=4 *

每个段的起始字符码。 */ public int[] startCode; /** * int16[segCount] *

仅 Format=4 *

用于计算字形索引的偏移值。该值被加到从 startCode 到 endCode 的所有字符码上,得到相应的字形索引。 */ public int[] idDelta; /** * uint16[segCount] *

仅 Format=4 *

偏移到 glyphIdArray 中的起始位置,如果没有额外的字形索引映射,则为 0。 */ public int[] idRangeOffsets; /** * uint16 *

仅 Format=6 *

子范围的第一个字符代码。这是连续字符代码范围的起始点。 */ public int firstCode; /** * uint16 *

仅 Format=6 *

子范围中字符代码的数量。这表示从 firstCode 开始,连续多少个字符代码被包含 */ public int entryCount; /** * 字形索引数组 *

Format=0 为 bye[256]数组 *

Format>0 为 uint16[] 数组 *

Format>12 为 uint32[] 数组 *

@url Microsoft cmap文档 */ public int[] glyphIdArray; } /** * 字形轮廓数据表 */ private static class GlyfLayout { /** * int16 非负值为简单字形的轮廓数,负值表示为复合字形 */ public short numberOfContours; /** * int16 Minimum x for coordinate data. */ public short xMin; /** * int16 Minimum y for coordinate data. */ public short yMin; /** * int16 Maximum x for coordinate data. */ public short xMax; /** * int16 Maximum y for coordinate data. */ public short yMax; /** * 简单字形数据 */ public GlyphTableBySimple glyphSimple; /** * 复合字形数据 */ public LinkedList glyphComponent; } /** * 简单字形数据表 */ private static class GlyphTableBySimple { /** * uint16[numberOfContours] */ int[] endPtsOfContours; /** * uint16 */ int instructionLength; /** * uint8[instructionLength] */ int[] instructions; /** * uint8[variable] *

bit0: 该点位于曲线上 *

bit1: < 1:xCoordinate为uint8 > *

bit2: < 1:yCoordinate为uint8 > *

bit3: < 1:下一个uint8为此条目之后插入的附加逻辑标志条目的数量 > *

bit4: < bit1=1时表示符号[1.正,0.负]; bit1=0时[1.x坐标重复一次,0.x坐标读为int16] > *

bit5: < bit2=1时表示符号[1.正,0.负]; bit2=0时[1.y坐标重复一次,0.y坐标读为int16] > *

bit6: 字形描述中的轮廓可能会重叠 *

bit7: 保留位,无意义 */ int[] flags; /** * uint8[] when(flags&0x02==0x02) * int16[] when(flags&0x12==0x00) */ int[] xCoordinates; /** * uint8[] when(flags&0x04==0x02) * int16[] when(flags&0x24==0x00) */ int[] yCoordinates; } /** * 复合字形数据表 */ private static class GlyphTableComponent { /** * uint16 *

bit0: < 1:argument是16bit,0:argument是8bit > *

bit1: < 1:argument是有符号值,0:argument是无符号值 > *

bit3: 该组件有一个缩放比例,否则比例为1.0 *

bit5: 表示在此字形之后还有字形 */ int flags; /** * uint16 */ int glyphIndex; /** * x-offset *

uint8 when flags&0x03==0 *

int8 when flags&0x03==1 *

uint16 when flags&0x03==2 *

int16 when flags&0x03==3 */ int argument1; /** * y-offset *

uint8 when flags&0x03==0 *

int8 when flags&0x03==1 *

uint16 when flags&0x03==2 *

int16 when flags&0x03==3 */ int argument2; /** * uint16 *

值类型为 F2DOT14 的组件缩放X比例值 */ float xScale; /** * uint16 *

值类型为 F2DOT14 的2x2变换矩阵01值 */ float scale01; /** * uint16 *

值类型为 F2DOT14 的2x2变换矩阵10值 */ float scale10; /** * uint16 *

值类型为 F2DOT14 的组件缩放Y比例值 */ float yScale; } private static class BufferReader { private final ByteBuffer byteBuffer; public BufferReader(byte[] buffer, int index) { this.byteBuffer = ByteBuffer.wrap(buffer); this.byteBuffer.order(ByteOrder.BIG_ENDIAN); // 设置为大端模式 this.byteBuffer.position(index); // 设置起始索引 } public void position(int index) { byteBuffer.position(index); // 设置起始索引 } public int position() { return byteBuffer.position(); } public long ReadUInt64() { return byteBuffer.getLong(); } public int ReadUInt32() { return byteBuffer.getInt(); } public int ReadInt32() { return byteBuffer.getInt(); } public int ReadUInt16() { return byteBuffer.getShort() & 0xFFFF; } public short ReadInt16() { return byteBuffer.getShort(); } public short ReadUInt8() { return (short) (byteBuffer.get() & 0xFF); } public byte ReadInt8() { return byteBuffer.get(); } public byte[] ReadByteArray(int len) { assert len >= 0; byte[] result = new byte[len]; byteBuffer.get(result); return result; } public int[] ReadUInt8Array(int len) { assert len >= 0; var result = new int[len]; for (int i = 0; i < len; ++i) result[i] = byteBuffer.get() & 0xFF; return result; } public int[] ReadInt16Array(int len) { assert len >= 0; var result = new int[len]; for (int i = 0; i < len; ++i) result[i] = byteBuffer.getShort(); return result; } public int[] ReadUInt16Array(int len) { assert len >= 0; var result = new int[len]; for (int i = 0; i < len; ++i) result[i] = byteBuffer.getShort() & 0xFFFF; return result; } public int[] ReadInt32Array(int len) { assert len >= 0; var result = new int[len]; for (int i = 0; i < len; ++i) result[i] = byteBuffer.getInt(); return result; } } private final Header fileHeader = new Header(); private final HashMap directorys = new HashMap<>(); private final NameLayout name = new NameLayout(); private final HeadLayout head = new HeadLayout(); private final MaxpLayout maxp = new MaxpLayout(); private final CmapLayout Cmap = new CmapLayout(); private final int[][] pps = new int[][]{{3, 10}, {0, 4}, {3, 1}, {1, 0}, {0, 3}, {0, 1}}; private void readNameTable(byte[] buffer) { var dataTable = directorys.get("name"); assert dataTable != null; var reader = new BufferReader(buffer, dataTable.offset); name.format = reader.ReadUInt16(); name.count = reader.ReadUInt16(); name.stringOffset = reader.ReadUInt16(); for (int i = 0; i < name.count; ++i) { NameRecord record = new NameRecord(); record.platformID = reader.ReadUInt16(); record.encodingID = reader.ReadUInt16(); record.languageID = reader.ReadUInt16(); record.nameID = reader.ReadUInt16(); record.length = reader.ReadUInt16(); record.offset = reader.ReadUInt16(); name.records.add(record); } } private void readHeadTable(byte[] buffer) { var dataTable = directorys.get("head"); assert dataTable != null; var reader = new BufferReader(buffer, dataTable.offset); head.majorVersion = reader.ReadUInt16(); head.minorVersion = reader.ReadUInt16(); head.fontRevision = reader.ReadUInt32(); head.checkSumAdjustment = reader.ReadUInt32(); head.magicNumber = reader.ReadUInt32(); head.flags = reader.ReadUInt16(); head.unitsPerEm = reader.ReadUInt16(); head.created = reader.ReadUInt64(); head.modified = reader.ReadUInt64(); head.xMin = reader.ReadInt16(); head.yMin = reader.ReadInt16(); head.xMax = reader.ReadInt16(); head.yMax = reader.ReadInt16(); head.macStyle = reader.ReadUInt16(); head.lowestRecPPEM = reader.ReadUInt16(); head.fontDirectionHint = reader.ReadInt16(); head.indexToLocFormat = reader.ReadInt16(); head.glyphDataFormat = reader.ReadInt16(); } /** * glyfId到glyphData的索引 *

根据定义,索引零指向“丢失的字符”。 *

loca.length = maxp.numGlyphs + 1; */ private int[] loca; private void readLocaTable(byte[] buffer) { var dataTable = directorys.get("loca"); assert dataTable != null; var reader = new BufferReader(buffer, dataTable.offset); if (head.indexToLocFormat == 0) { loca = reader.ReadUInt16Array(dataTable.length / 2); // 当loca表数据长度为Uint16时,需要翻倍 for (var i = 0; i < loca.length; i++) loca[i] *= 2; } else { loca = reader.ReadInt32Array(dataTable.length / 4); } } private void readCmapTable(byte[] buffer) { var dataTable = directorys.get("cmap"); assert dataTable != null; var reader = new BufferReader(buffer, dataTable.offset); Cmap.version = reader.ReadUInt16(); Cmap.numTables = reader.ReadUInt16(); for (int i = 0; i < Cmap.numTables; ++i) { CmapRecord record = new CmapRecord(); record.platformID = reader.ReadUInt16(); record.encodingID = reader.ReadUInt16(); record.offset = reader.ReadUInt32(); Cmap.records.add(record); } for (var formatTable : Cmap.records) { int fmtOffset = formatTable.offset; if (Cmap.tables.containsKey(fmtOffset)) continue; reader.position(dataTable.offset + fmtOffset); CmapFormat f = new CmapFormat(); f.format = reader.ReadUInt16(); f.length = reader.ReadUInt16(); f.language = reader.ReadUInt16(); switch (f.format) { case 0: { f.glyphIdArray = reader.ReadUInt8Array(f.length - 6); // 记录 unicode->glyphId 映射表 int unicodeInclusive = 0; int unicodeExclusive = f.glyphIdArray.length; for (; unicodeInclusive < unicodeExclusive; unicodeInclusive++) { if (f.glyphIdArray[unicodeInclusive] == 0) continue; // 排除轮廓索引为0的Unicode unicodeToGlyphId.put(unicodeInclusive, f.glyphIdArray[unicodeInclusive]); } break; } case 4: { f.segCountX2 = reader.ReadUInt16(); int segCount = f.segCountX2 / 2; f.searchRange = reader.ReadUInt16(); f.entrySelector = reader.ReadUInt16(); f.rangeShift = reader.ReadUInt16(); f.endCode = reader.ReadUInt16Array(segCount); f.reservedPad = reader.ReadUInt16(); f.startCode = reader.ReadUInt16Array(segCount); f.idDelta = reader.ReadInt16Array(segCount); f.idRangeOffsets = reader.ReadUInt16Array(segCount); // 一个包含字形索引的数组,其长度是任意的,取决于映射的复杂性和字体中的字符数量。 int glyphIdArrayLength = (f.length - 16 - (segCount * 8)) / 2; f.glyphIdArray = reader.ReadUInt16Array(glyphIdArrayLength); // 记录 unicode->glyphId 映射表 for (int segmentIndex = 0; segmentIndex < segCount; segmentIndex++) { int unicodeInclusive = f.startCode[segmentIndex]; int unicodeExclusive = f.endCode[segmentIndex]; int idDelta = f.idDelta[segmentIndex]; int idRangeOffset = f.idRangeOffsets[segmentIndex]; for (int unicode = unicodeInclusive; unicode <= unicodeExclusive; unicode++) { int glyphId = 0; if (idRangeOffset == 0) { glyphId = (unicode + idDelta) & 0xFFFF; } else { int gIndex = (idRangeOffset / 2) + unicode - unicodeInclusive + segmentIndex - segCount; if (gIndex < glyphIdArrayLength) glyphId = f.glyphIdArray[gIndex] + idDelta; } if (glyphId == 0) continue; // 排除轮廓索引为0的Unicode unicodeToGlyphId.put(unicode, glyphId); } } break; } case 6: { f.firstCode = reader.ReadUInt16(); f.entryCount = reader.ReadUInt16(); // 范围内字符代码的字形索引值数组。 f.glyphIdArray = reader.ReadUInt16Array(f.entryCount); // 记录 unicode->glyphId 映射表 int unicodeIndex = f.firstCode; int unicodeCount = f.entryCount; for (int gIndex = 0; gIndex < unicodeCount; gIndex++) { unicodeToGlyphId.put(unicodeIndex, f.glyphIdArray[gIndex]); unicodeIndex++; } break; } default: break; } Cmap.tables.put(fmtOffset, f); } } private void readMaxpTable(byte[] buffer) { var dataTable = directorys.get("maxp"); assert dataTable != null; var reader = new BufferReader(buffer, dataTable.offset); maxp.version = reader.ReadUInt32(); maxp.numGlyphs = reader.ReadUInt16(); maxp.maxPoints = reader.ReadUInt16(); maxp.maxContours = reader.ReadUInt16(); maxp.maxCompositePoints = reader.ReadUInt16(); maxp.maxCompositeContours = reader.ReadUInt16(); maxp.maxZones = reader.ReadUInt16(); maxp.maxTwilightPoints = reader.ReadUInt16(); maxp.maxStorage = reader.ReadUInt16(); maxp.maxFunctionDefs = reader.ReadUInt16(); maxp.maxInstructionDefs = reader.ReadUInt16(); maxp.maxStackElements = reader.ReadUInt16(); maxp.maxSizeOfInstructions = reader.ReadUInt16(); maxp.maxComponentElements = reader.ReadUInt16(); maxp.maxComponentDepth = reader.ReadUInt16(); } /** * 字形轮廓表 数组 */ private GlyfLayout[] glyfArray; private void readGlyfTable(byte[] buffer) { var dataTable = directorys.get("glyf"); assert dataTable != null; int glyfCount = maxp.numGlyphs; glyfArray = new GlyfLayout[glyfCount]; // 创建字形容器 var reader = new BufferReader(buffer, 0); for (int index = 0; index < glyfCount; index++) { if (loca[index] == loca[index + 1]) continue; // 当前loca与下一个loca相同,表示这个字形不存在 int offset = dataTable.offset + loca[index]; // 读GlyphHeaders var glyph = new GlyfLayout(); reader.position(offset); glyph.numberOfContours = reader.ReadInt16(); if (glyph.numberOfContours > maxp.maxContours) continue; // 如果字形轮廓数大于非复合字形中包含的最大轮廓数,则说明该字形无效。 glyph.xMin = reader.ReadInt16(); glyph.yMin = reader.ReadInt16(); glyph.xMax = reader.ReadInt16(); glyph.yMax = reader.ReadInt16(); // 轮廓数为0时,不需要解析轮廓数据 if (glyph.numberOfContours == 0) continue; // 读Glyph轮廓数据 if (glyph.numberOfContours > 0) { // 简单轮廓 glyph.glyphSimple = new GlyphTableBySimple(); glyph.glyphSimple.endPtsOfContours = reader.ReadUInt16Array(glyph.numberOfContours); glyph.glyphSimple.instructionLength = reader.ReadUInt16(); glyph.glyphSimple.instructions = reader.ReadUInt8Array(glyph.glyphSimple.instructionLength); int flagLength = glyph.glyphSimple.endPtsOfContours[glyph.glyphSimple.endPtsOfContours.length - 1] + 1; // 获取轮廓点描述标志 glyph.glyphSimple.flags = new int[flagLength]; for (int n = 0; n < flagLength; ++n) { var glyphSimpleFlag = reader.ReadUInt8(); glyph.glyphSimple.flags[n] = glyphSimpleFlag; if ((glyphSimpleFlag & 0x08) == 0x08) { for (int m = reader.ReadUInt8(); m > 0; --m) { glyph.glyphSimple.flags[++n] = glyphSimpleFlag; } } } // 获取轮廓点描述x轴相对值 glyph.glyphSimple.xCoordinates = new int[flagLength]; for (int n = 0; n < flagLength; ++n) { switch (glyph.glyphSimple.flags[n] & 0x12) { case 0x02: glyph.glyphSimple.xCoordinates[n] = -1 * reader.ReadUInt8(); break; case 0x12: glyph.glyphSimple.xCoordinates[n] = reader.ReadUInt8(); break; case 0x10: glyph.glyphSimple.xCoordinates[n] = 0; // 点位数据重复上一次数据,那么相对数据变化量就是0 break; case 0x00: glyph.glyphSimple.xCoordinates[n] = reader.ReadInt16(); break; } } // 获取轮廓点描述y轴相对值 glyph.glyphSimple.yCoordinates = new int[flagLength]; for (int n = 0; n < flagLength; ++n) { switch (glyph.glyphSimple.flags[n] & 0x24) { case 0x04: glyph.glyphSimple.yCoordinates[n] = -1 * reader.ReadUInt8(); break; case 0x24: glyph.glyphSimple.yCoordinates[n] = reader.ReadUInt8(); break; case 0x20: glyph.glyphSimple.yCoordinates[n] = 0; // 点位数据重复上一次数据,那么相对数据变化量就是0 break; case 0x00: glyph.glyphSimple.yCoordinates[n] = reader.ReadInt16(); break; } } } else { // 复合轮廓 glyph.glyphComponent = new LinkedList<>(); while (true) { var glyphTableComponent = new GlyphTableComponent(); glyphTableComponent.flags = reader.ReadUInt16(); glyphTableComponent.glyphIndex = reader.ReadUInt16(); switch (glyphTableComponent.flags & 0b11) { case 0b00: glyphTableComponent.argument1 = reader.ReadUInt8(); glyphTableComponent.argument2 = reader.ReadUInt8(); break; case 0b10: glyphTableComponent.argument1 = reader.ReadInt8(); glyphTableComponent.argument2 = reader.ReadInt8(); break; case 0b01: glyphTableComponent.argument1 = reader.ReadUInt16(); glyphTableComponent.argument2 = reader.ReadUInt16(); break; case 0b11: glyphTableComponent.argument1 = reader.ReadInt16(); glyphTableComponent.argument2 = reader.ReadInt16(); break; } switch (glyphTableComponent.flags & 0b11001000) { case 0b00001000: // 有单一比例 glyphTableComponent.yScale = glyphTableComponent.xScale = ((float) reader.ReadUInt16()) / 16384.0f; break; case 0b01000000: // 有X和Y的独立比例 glyphTableComponent.xScale = ((float) reader.ReadUInt16()) / 16384.0f; glyphTableComponent.yScale = ((float) reader.ReadUInt16()) / 16384.0f; break; case 0b10000000: // 有2x2变换矩阵 glyphTableComponent.xScale = ((float) reader.ReadUInt16()) / 16384.0f; glyphTableComponent.scale01 = ((float) reader.ReadUInt16()) / 16384.0f; glyphTableComponent.scale10 = ((float) reader.ReadUInt16()) / 16384.0f; glyphTableComponent.yScale = ((float) reader.ReadUInt16()) / 16384.0f; break; } glyph.glyphComponent.add(glyphTableComponent); if ((glyphTableComponent.flags & 0x20) == 0) break; } } glyfArray[index] = glyph; } } /** * 使用轮廓索引值获取轮廓数据 * * @param glyfId 轮廓索引 * @return 轮廓数据 */ public String getGlyfById(int glyfId) { var glyph = glyfArray[glyfId]; if (glyph == null) return null; // 过滤不存在的字体轮廓 String glyphString; if (glyph.numberOfContours >= 0) { // 简单字形 int dataCount = glyph.glyphSimple.flags.length; String[] coordinateArray = new String[dataCount]; for (int i = 0; i < dataCount; i++) { coordinateArray[i] = glyph.glyphSimple.xCoordinates[i] + "," + glyph.glyphSimple.yCoordinates[i]; } glyphString = String.join("|", coordinateArray); } else { // 复合字形 LinkedList glyphIdList = new LinkedList<>(); for (var g : glyph.glyphComponent) { glyphIdList.add("{" + "flags:" + g.flags + "," + "glyphIndex:" + g.glyphIndex + "," + "arg1:" + g.argument1 + "," + "arg2:" + g.argument2 + "," + "xScale:" + g.xScale + "," + "scale01:" + g.scale01 + "," + "scale10:" + g.scale10 + "," + "yScale:" + g.yScale + "}"); } glyphString = "[" + String.join(",", glyphIdList) + "]"; } return glyphString; } /** * 构造函数 * * @param buffer 传入TTF字体二进制数组 */ public QueryTTF(final byte[] buffer) { var fontReader = new BufferReader(buffer, 0); // Log.i("QueryTTF", "读文件头"); // 获取文件头 fileHeader.sfntVersion = fontReader.ReadUInt32(); fileHeader.numTables = fontReader.ReadUInt16(); fileHeader.searchRange = fontReader.ReadUInt16(); fileHeader.entrySelector = fontReader.ReadUInt16(); fileHeader.rangeShift = fontReader.ReadUInt16(); // 获取目录 for (int i = 0; i < fileHeader.numTables; ++i) { Directory d = new Directory(); d.tableTag = new String(fontReader.ReadByteArray(4), StandardCharsets.US_ASCII); d.checkSum = fontReader.ReadUInt32(); d.offset = fontReader.ReadUInt32(); d.length = fontReader.ReadUInt32(); directorys.put(d.tableTag, d); } // Log.i("QueryTTF", "解析表 name"); // 字体信息,包含版权、名称、作者等... readNameTable(buffer); // Log.i("QueryTTF", "解析表 head"); // 获取 head.indexToLocFormat readHeadTable(buffer); // Log.i("QueryTTF", "解析表 cmap"); // Unicode编码->轮廓索引 对照表 readCmapTable(buffer); // Log.i("QueryTTF", "解析表 loca"); // 轮廓数据偏移地址表 readLocaTable(buffer); // Log.i("QueryTTF", "解析表 maxp"); // 获取 maxp.numGlyphs 字体轮廓数量 readMaxpTable(buffer); // Log.i("QueryTTF", "解析表 glyf"); // 字体轮廓数据表,需要解析loca,maxp表后计算 readGlyfTable(buffer); // Log.i("QueryTTF", "建立Unicode&Glyph映射表"); int glyfArrayLength = glyfArray.length; for (var item : unicodeToGlyphId.entrySet()) { int key = item.getKey(); int val = item.getValue(); if (val >= glyfArrayLength) continue; String glyfString = getGlyfById(val); unicodeToGlyph.put(key, glyfString); if (glyfString == null) continue; // null 不能用作hashmap的key glyphToUnicode.put(glyfString, key); } // Log.i("QueryTTF", "字体处理完成"); } public final HashMap unicodeToGlyph = new HashMap<>(); public final HashMap glyphToUnicode = new HashMap<>(); public final HashMap unicodeToGlyphId = new HashMap<>(); /** * 使用 Unicode 值获查询廓索引 * * @param unicode 传入 Unicode 值 * @return 轮廓索引 */ public int getGlyfIdByUnicode(int unicode) { var result = unicodeToGlyphId.get(unicode); if (result == null) return 0; // 如果找不到Unicode对应的轮廓索引,就返回默认值0 return result; } /** * 使用 Unicode 值查询轮廓数据 * * @param unicode 传入 Unicode 值 * @return 轮廓数据 */ public String getGlyfByUnicode(int unicode) { return unicodeToGlyph.get(unicode); } /** * 使用轮廓数据反查 Unicode 值 * * @param glyph 传入轮廓数据 * @return Unicode */ public int getUnicodeByGlyf(String glyph) { var result = glyphToUnicode.get(glyph); if (result == null) return 0; // 如果轮廓数据找不到对应的Unicode,就返回默认值0 return result; } /** * Unicode 空白字符判断 * * @param unicode 字符的 Unicode 值 * @return true:是空白字符; false:非空白字符 */ public boolean isBlankUnicode(int unicode) { return switch (unicode) { case 0x0009, // 水平制表符 (Horizontal Tab) 0x0020, // 空格 (Space) 0x00A0, // 不中断空格 (No-Break Space) 0x2002, // En空格 (En Space) 0x2003, // Em空格 (Em Space) 0x2007, // 刚性空格 (Figure Space) 0x200A, // 发音修饰字母的连字符 (Hair Space) 0x200B, // 零宽空格 (Zero Width Space) 0x200C, // 零宽不连字 (Zero Width Non-Joiner) 0x200D, // 零宽连字 (Zero Width Joiner) 0x202F, // 狭窄不中断空格 (Narrow No-Break Space) 0x205F // 中等数学空格 (Medium Mathematical Space) -> true; default -> false; }; } } ================================================ FILE: app/src/main/java/io/legado/app/model/analyzeRule/RuleAnalyzer.kt ================================================ package io.legado.app.model.analyzeRule //通用的规则切分处理 class RuleAnalyzer(data: String, code: Boolean = false) { private var queue: String = data //被处理字符串 private var pos = 0 //当前处理到的位置 private var start = 0 //当前处理字段的开始 private var startX = 0 //当前规则的开始 private var rule = ArrayList() //分割出的规则列表 private var step: Int = 0 //分割字符的长度 var elementsType = "" //当前分割字符串 fun trim() { // 修剪当前规则之前的"@"或者空白符 if (queue[pos] == '@' || queue[pos] < '!') { //在while里重复设置start和startX会拖慢执行速度,所以先来个判断是否存在需要修剪的字段,最后再一次性设置start和startX pos++ while (queue[pos] == '@' || queue[pos] < '!') pos++ start = pos //开始点推移 startX = pos //规则起始点推移 } } //将pos重置为0,方便复用 fun reSetPos() { pos = 0 startX = 0 } /** * 从剩余字串中拉出一个字符串,直到但不包括匹配序列 * @param seq 查找的字符串 **区分大小写** * @return 是否找到相应字段。 */ private fun consumeTo(seq: String): Boolean { start = pos //将处理到的位置设置为规则起点 val offset = queue.indexOf(seq, pos) return if (offset != -1) { pos = offset true } else false } /** * 从剩余字串中拉出一个字符串,直到但不包括匹配序列(匹配参数列表中一项即为匹配),或剩余字串用完。 * @param seq 匹配字符串序列 * @return 成功返回true并设置间隔,失败则直接返回fasle */ private fun consumeToAny(vararg seq: String): Boolean { var pos = pos //声明新变量记录匹配位置,不更改类本身的位置 while (pos != queue.length) { for (s in seq) { if (queue.regionMatches(pos, s, 0, s.length)) { step = s.length //间隔数 this.pos = pos //匹配成功, 同步处理位置到类 return true //匹配就返回 true } } pos++ //逐个试探 } return false } /** * 从剩余字串中拉出一个字符串,直到但不包括匹配序列(匹配参数列表中一项即为匹配),或剩余字串用完。 * @param seq 匹配字符序列 * @return 返回匹配位置 */ private fun findToAny(vararg seq: Char): Int { var pos = pos //声明新变量记录匹配位置,不更改类本身的位置 while (pos != queue.length) { for (s in seq) if (queue[pos] == s) return pos //匹配则返回位置 pos++ //逐个试探 } return -1 } /** * 拉出一个非内嵌代码平衡组,存在转义文本 */ private fun chompCodeBalanced(open: Char, close: Char): Boolean { var pos = pos //声明临时变量记录匹配位置,匹配成功后才同步到类的pos var depth = 0 //嵌套深度 var otherDepth = 0 //其他对称符合嵌套深度 var inSingleQuote = false //单引号 var inDoubleQuote = false //双引号 do { if (pos == queue.length) break val c = queue[pos++] if (c != ESC) { //非转义字符 if (c == '\'' && !inDoubleQuote) inSingleQuote = !inSingleQuote //匹配具有语法功能的单引号 else if (c == '"' && !inSingleQuote) inDoubleQuote = !inDoubleQuote //匹配具有语法功能的双引号 if (inSingleQuote || inDoubleQuote) continue //语法单元未匹配结束,直接进入下个循环 if (c == '[') depth++ //开始嵌套一层 else if (c == ']') depth-- //闭合一层嵌套 else if (depth == 0) { //处于默认嵌套中的非默认字符不需要平衡,仅depth为0时默认嵌套全部闭合,此字符才进行嵌套 if (c == open) otherDepth++ else if (c == close) otherDepth-- } } else pos++ } while (depth > 0 || otherDepth > 0) //拉出一个平衡字串 return if (depth > 0 || otherDepth > 0) false else { this.pos = pos //同步位置 true } } /** * 拉出一个规则平衡组,经过仔细测试xpath和jsoup中,引号内转义字符无效。 */ private fun chompRuleBalanced(open: Char, close: Char): Boolean { var pos = pos //声明临时变量记录匹配位置,匹配成功后才同步到类的pos var depth = 0 //嵌套深度 var inSingleQuote = false //单引号 var inDoubleQuote = false //双引号 do { if (pos == queue.length) break val c = queue[pos++] if (c == '\'' && !inDoubleQuote) inSingleQuote = !inSingleQuote //匹配具有语法功能的单引号 else if (c == '"' && !inSingleQuote) inDoubleQuote = !inDoubleQuote //匹配具有语法功能的双引号 if (inSingleQuote || inDoubleQuote) continue //语法单元未匹配结束,直接进入下个循环 else if (c == '\\') { //不在引号中的转义字符才将下个字符转义 pos++ continue } if (c == open) depth++ //开始嵌套一层 else if (c == close) depth-- //闭合一层嵌套 } while (depth > 0) //拉出一个平衡字串 return if (depth > 0) false else { this.pos = pos //同步位置 true } } /** * 不用正则,不到最后不切片也不用中间变量存储,只在序列中标记当前查找字段的开头结尾,到返回时才切片,高效快速准确切割规则 * 解决jsonPath自带的"&&"和"||"与阅读的规则冲突,以及规则正则或字符串中包含"&&"、"||"、"%%"、"@"导致的冲突 */ tailrec fun splitRule(vararg split: String): ArrayList { //首段匹配,elementsType为空 if (split.size == 1) { elementsType = split[0] //设置分割字串 return if (!consumeTo(elementsType)) { rule += queue.substring(startX) rule } else { step = elementsType.length //设置分隔符长度 splitRule() } //递归匹配 } else if (!consumeToAny(* split)) { //未找到分隔符 rule += queue.substring(startX) return rule } val end = pos //记录分隔位置 pos = start //重回开始,启动另一种查找 do { val st = findToAny('[', '(') //查找筛选器位置 if (st == -1) { rule = arrayListOf(queue.substring(startX, end)) //压入分隔的首段规则到数组 elementsType = queue.substring(end, end + step) //设置组合类型 pos = end + step //跳过分隔符 while (consumeTo(elementsType)) { //循环切分规则压入数组 rule += queue.substring(start, pos) pos += step //跳过分隔符 } rule += queue.substring(pos) //将剩余字段压入数组末尾 return rule } if (st > end) { //先匹配到st1pos,表明分隔字串不在选择器中,将选择器前分隔字串分隔的字段依次压入数组 rule = arrayListOf(queue.substring(startX, end)) //压入分隔的首段规则到数组 elementsType = queue.substring(end, end + step) //设置组合类型 pos = end + step //跳过分隔符 while (consumeTo(elementsType) && pos < st) { //循环切分规则压入数组 rule += queue.substring(start, pos) pos += step //跳过分隔符 } return if (pos > st) { startX = start splitRule() //首段已匹配,但当前段匹配未完成,调用二段匹配 } else { //执行到此,证明后面再无分隔字符 rule += queue.substring(pos) //将剩余字段压入数组末尾 rule } } pos = st //位置推移到筛选器处 val next = if (queue[pos] == '[') ']' else ')' //平衡组末尾字符 if (!chompBalanced(queue[pos], next)) throw Error( queue.substring(0, start) + "后未平衡" ) //拉出一个筛选器,不平衡则报错 } while (end > pos) start = pos //设置开始查找筛选器位置的起始位置 return splitRule(* split) //递归调用首段匹配 } @JvmName("splitRuleNext") private tailrec fun splitRule(): ArrayList { //二段匹配被调用,elementsType非空(已在首段赋值),直接按elementsType查找,比首段采用的方式更快 val end = pos //记录分隔位置 pos = start //重回开始,启动另一种查找 do { val st = findToAny('[', '(') //查找筛选器位置 if (st == -1) { rule += arrayOf(queue.substring(startX, end)) //压入分隔的首段规则到数组 pos = end + step //跳过分隔符 while (consumeTo(elementsType)) { //循环切分规则压入数组 rule += queue.substring(start, pos) pos += step //跳过分隔符 } rule += queue.substring(pos) //将剩余字段压入数组末尾 return rule } if (st > end) { //先匹配到st1pos,表明分隔字串不在选择器中,将选择器前分隔字串分隔的字段依次压入数组 rule += arrayListOf(queue.substring(startX, end)) //压入分隔的首段规则到数组 pos = end + step //跳过分隔符 while (consumeTo(elementsType) && pos < st) { //循环切分规则压入数组 rule += queue.substring(start, pos) pos += step //跳过分隔符 } return if (pos > st) { startX = start splitRule() //首段已匹配,但当前段匹配未完成,调用二段匹配 } else { //执行到此,证明后面再无分隔字符 rule += queue.substring(pos) //将剩余字段压入数组末尾 rule } } pos = st //位置推移到筛选器处 val next = if (queue[pos] == '[') ']' else ')' //平衡组末尾字符 if (!chompBalanced(queue[pos], next)) throw Error( queue.substring(0, start) + "后未平衡" ) //拉出一个筛选器,不平衡则报错 } while (end > pos) start = pos //设置开始查找筛选器位置的起始位置 return if (!consumeTo(elementsType)) { rule += queue.substring(startX) rule } else splitRule() //递归匹配 } /** * 替换内嵌规则 * @param inner 起始标志,如{$. * @param startStep 不属于规则部分的前置字符长度,如{$.中{不属于规则的组成部分,故startStep为1 * @param endStep 不属于规则部分的后置字符长度 * @param fr 查找到内嵌规则时,用于解析的函数 * * */ fun innerRule( inner: String, startStep: Int = 1, endStep: Int = 1, fr: (String) -> String? ): String { val st = StringBuilder() while (consumeTo(inner)) { //拉取成功返回true,ruleAnalyzes里的字符序列索引变量pos后移相应位置,否则返回false,且isEmpty为true val posPre = pos //记录consumeTo匹配位置 if (chompCodeBalanced('{', '}')) { val frv = fr(queue.substring(posPre + startStep, pos - endStep)) if (!frv.isNullOrEmpty()) { st.append(queue.substring(startX, posPre) + frv) //压入内嵌规则前的内容,及内嵌规则解析得到的字符串 startX = pos //记录下次规则起点 continue //获取内容成功,继续选择下个内嵌规则 } } pos += inner.length //拉出字段不平衡,inner只是个普通字串,跳到此inner后继续匹配 } return if (startX == 0) "" else st.apply { append(queue.substring(startX)) }.toString() } /** * 替换内嵌规则 * @param fr 查找到内嵌规则时,用于解析的函数 * * */ fun innerRule( startStr: String, endStr: String, fr: (String) -> String? ): String { val st = StringBuilder() while (consumeTo(startStr)) { //拉取成功返回true,ruleAnalyzes里的字符序列索引变量pos后移相应位置,否则返回false,且isEmpty为true pos += startStr.length //跳过开始字符串 val posPre = pos //记录consumeTo匹配位置 if (consumeTo(endStr)) { val frv = fr(queue.substring(posPre, pos)) st.append( queue.substring( startX, posPre - startStr.length ) + frv ) //压入内嵌规则前的内容,及内嵌规则解析得到的字符串 pos += endStr.length //跳过结束字符串 startX = pos //记录下次规则起点 } } return if (startX == 0) queue else st.apply { append(queue.substring(startX)) }.toString() } //设置平衡组函数,json或JavaScript时设置成chompCodeBalanced,否则为chompRuleBalanced val chompBalanced = if (code) ::chompCodeBalanced else ::chompRuleBalanced companion object { /** * 转义字符 */ private const val ESC = '\\' } } ================================================ FILE: app/src/main/java/io/legado/app/model/analyzeRule/RuleData.kt ================================================ package io.legado.app.model.analyzeRule import io.legado.app.utils.GSON class RuleData : RuleDataInterface { override val variableMap by lazy { hashMapOf() } override fun putBigVariable(key: String, value: String?) { if (value == null) { variableMap.remove(key) } else { variableMap[key] = value } } override fun getBigVariable(key: String): String? { return null } fun getVariable(): String? { if (variableMap.isEmpty()) { return null } return GSON.toJson(variableMap) } } ================================================ FILE: app/src/main/java/io/legado/app/model/analyzeRule/RuleDataInterface.kt ================================================ package io.legado.app.model.analyzeRule interface RuleDataInterface { val variableMap: HashMap fun putVariable(key: String, value: String?): Boolean { val keyExist = variableMap.contains(key) return when { value == null -> { variableMap.remove(key) putBigVariable(key, null) keyExist } value.length < 10000 -> { putBigVariable(key, null) variableMap[key] = value true } else -> { variableMap.remove(key) putBigVariable(key, value) keyExist } } } fun putBigVariable(key: String, value: String?) fun getVariable(key: String): String { return variableMap[key] ?: getBigVariable(key) ?: "" } fun getBigVariable(key: String): String? } ================================================ FILE: app/src/main/java/io/legado/app/model/localBook/BaseLocalBookParse.kt ================================================ package io.legado.app.model.localBook import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import java.io.InputStream /** *companion object interface *see EpubFile.kt */ interface BaseLocalBookParse { fun upBookInfo(book: Book) fun getChapterList(book: Book): ArrayList fun getContent(book: Book, chapter: BookChapter): String? fun getImage(book: Book, href: String): InputStream? } ================================================ FILE: app/src/main/java/io/legado/app/model/localBook/EpubFile.kt ================================================ package io.legado.app.model.localBook import android.graphics.Bitmap import android.graphics.BitmapFactory import android.os.ParcelFileDescriptor import android.text.TextUtils import io.legado.app.constant.AppLog import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.help.book.BookHelp import io.legado.app.utils.FileUtils import io.legado.app.utils.HtmlFormatter import io.legado.app.utils.encodeURI import io.legado.app.utils.isXml import io.legado.app.utils.printOnDebug import me.ag2s.epublib.domain.EpubBook import me.ag2s.epublib.domain.Resource import me.ag2s.epublib.domain.TOCReference import me.ag2s.epublib.epub.EpubReader import me.ag2s.epublib.util.zip.AndroidZipFile import org.jsoup.Jsoup import org.jsoup.nodes.Element import org.jsoup.parser.Parser import org.jsoup.select.Elements import java.io.File import java.io.FileOutputStream import java.io.IOException import java.io.InputStream import java.net.URI import java.net.URLDecoder import java.nio.charset.Charset class EpubFile(var book: Book) { companion object : BaseLocalBookParse { private var eFile: EpubFile? = null @Synchronized private fun getEFile(book: Book): EpubFile { if (eFile == null || eFile?.book?.bookUrl != book.bookUrl) { eFile = EpubFile(book) //对于Epub文件默认不启用替换 //io.legado.app.data.entities.Book getUseReplaceRule return eFile!! } eFile?.book = book return eFile!! } @Synchronized override fun getChapterList(book: Book): ArrayList { return getEFile(book).getChapterList() } @Synchronized override fun getContent(book: Book, chapter: BookChapter): String? { return getEFile(book).getContent(chapter) } @Synchronized override fun getImage( book: Book, href: String ): InputStream? { return getEFile(book).getImage(href) } @Synchronized override fun upBookInfo(book: Book) { return getEFile(book).upBookInfo() } fun clear() { eFile = null } } private var mCharset: Charset = Charset.defaultCharset() /** *持有引用,避免被回收 */ private var fileDescriptor: ParcelFileDescriptor? = null private var epubBook: EpubBook? = null get() { if (field == null || fileDescriptor == null) { field = readEpub() } return field } private var epubBookContents: List? = null get() { if (field == null || fileDescriptor == null) { field = epubBook?.contents } return field } init { upBookCover(true) } /** * 重写epub文件解析代码,直接读出压缩包文件生成Resources给epublib,这样的好处是可以逐一修改某些文件的格式错误 */ private fun readEpub(): EpubBook? { return kotlin.runCatching { //ContentScheme拷贝到私有文件夹采用懒加载防止OOM //val zipFile = BookHelp.getEpubFile(book) BookHelp.getBookPFD(book)?.let { fileDescriptor = it val zipFile = AndroidZipFile(it, book.originName) EpubReader().readEpubLazy(zipFile, "utf-8") } }.onFailure { AppLog.put("读取Epub文件失败\n${it.localizedMessage}", it) it.printOnDebug() }.getOrThrow() } private fun getContent(chapter: BookChapter): String? { /*获取当前章节文本*/ val contents = epubBookContents ?: return null val nextChapterFirstResourceHref = chapter.getVariable("nextUrl").substringBeforeLast("#") val currentChapterFirstResourceHref = chapter.url.substringBeforeLast("#") val isLastChapter = nextChapterFirstResourceHref.isBlank() val startFragmentId = chapter.startFragmentId val endFragmentId = chapter.endFragmentId val elements = Elements() var findChapterFirstSource = false val includeNextChapterResource = !endFragmentId.isNullOrBlank() /*一些书籍依靠href索引的resource会包含多个章节,需要依靠fragmentId来截取到当前章节的内容*/ /*注:这里较大增加了内容加载的时间,所以首次获取内容后可存储到本地cache,减少重复加载*/ for (res in contents) { if (!findChapterFirstSource) { if (currentChapterFirstResourceHref != res.href) continue findChapterFirstSource = true // 第一个xhtml文件 elements.add( getBody(res, startFragmentId, endFragmentId) ) // 不是最后章节 且 已经遍历到下一章节的内容时停止 if (!isLastChapter && res.href == nextChapterFirstResourceHref) break continue } if (nextChapterFirstResourceHref != res.href) { // 其余部分 elements.add(getBody(res, null, null)) } else { // 下一章节的第一个xhtml if (includeNextChapterResource) { //有Fragment 则添加到上一章节 elements.add(getBody(res, null, endFragmentId)) } break } } //title标签中的内容不需要显示在正文中,去除 elements.select("title").remove() elements.select("[style*=display:none]").remove() elements.select("img[src=\"cover.jpeg\"]").forEachIndexed { i, it -> if (i > 0) it.remove() } elements.select("img").forEach { if (it.attributesSize() <= 1) { return@forEach } val src = it.attr("src") it.clearAttributes() it.attr("src", src) } val tag = Book.rubyTag if (book.getDelTag(tag)) { elements.select("rp, rt").remove() } val html = elements.outerHtml() return HtmlFormatter.formatKeepImg(html) } private fun getBody(res: Resource, startFragmentId: String?, endFragmentId: String?): Element { /** * * ...titlepage.xhtml * 大多数epub文件的封面页都会带有cover,可以一定程度上解决封面读取问题 */ if (res.href.contains("titlepage.xhtml") || res.href.contains("cover") ) { return Jsoup.parseBodyFragment("") } // Jsoup可能会修复不规范的xhtml文件 解析处理后再获取 var bodyElement = Jsoup.parse(String(res.data, mCharset)).body() bodyElement.children().run { select("script").remove() select("style").remove() } // 获取body对应的文本 var bodyString = bodyElement.outerHtml() val originBodyString = bodyString /** * 某些xhtml文件 章节标题和内容不在一个节点或者不是兄弟节点 *

* 目录2 *
*

....

* 先找到FragmentId对应的Element 然后直接截取之间的html */ if (!startFragmentId.isNullOrBlank()) { bodyElement.getElementById(startFragmentId)?.outerHtml()?.let { val tagStart = it.substringBefore("\n") bodyString = tagStart + bodyString.substringAfter(tagStart) } } if (!endFragmentId.isNullOrBlank() && endFragmentId != startFragmentId) { bodyElement.getElementById(endFragmentId)?.outerHtml()?.let { val tagStart = it.substringBefore("\n") bodyString = bodyString.substringBefore(tagStart) } } //截取过再重新解析 if (bodyString != originBodyString) { bodyElement = Jsoup.parse(bodyString).body() } /*选择去除正文中的H标签,部分书籍标题与阅读标题重复待优化*/ val tag = Book.hTag if (book.getDelTag(tag)) { bodyElement.run { select("h1, h2, h3, h4, h5, h6").remove() //getElementsMatchingOwnText(chapter.title)?.remove() } } bodyElement.select("image").forEach { it.tagName("img", Parser.NamespaceHtml) it.attr("src", it.attr("xlink:href")) } bodyElement.select("img").forEach { val src = it.attr("src").trim().encodeURI() val href = res.href.encodeURI() val resolvedHref = URLDecoder.decode(URI(href).resolve(src).toString(), "UTF-8") it.attr("src", resolvedHref) } return bodyElement } private fun getImage(href: String): InputStream? { if (href == "cover.jpeg") return epubBook?.coverImage?.inputStream val abHref = URLDecoder.decode(href, "UTF-8") return epubBook?.resources?.getByHref(abHref)?.inputStream } private fun upBookCover(fastCheck: Boolean = false) { try { epubBook?.let { if (book.coverUrl.isNullOrEmpty()) { book.coverUrl = LocalBook.getCoverPath(book) } if (fastCheck && File(book.coverUrl!!).exists()) { return } /*部分书籍DRM处理后,封面获取异常,待优化*/ it.coverImage?.inputStream?.use { input -> val cover = BitmapFactory.decodeStream(input) val out = FileOutputStream(FileUtils.createFileIfNotExist(book.coverUrl!!)) cover.compress(Bitmap.CompressFormat.JPEG, 90, out) out.flush() out.close() } ?: AppLog.putDebug("Epub: 封面获取为空. path: ${book.bookUrl}") } } catch (e: Exception) { AppLog.put("加载书籍封面失败\n${e.localizedMessage}", e) e.printOnDebug() } } private fun upBookInfo() { if (epubBook == null) { eFile = null book.intro = "书籍导入异常" } else { upBookCover() val metadata = epubBook!!.metadata book.name = metadata.firstTitle if (book.name.isEmpty()) { book.name = book.originName.replace(".epub", "") } if (metadata.authors.isNotEmpty()) { val author = metadata.authors[0].toString().replace("^, |, $".toRegex(), "") book.author = author } if (metadata.descriptions.isNotEmpty()) { val desc = metadata.descriptions[0] book.intro = if (desc.isXml()) { Jsoup.parse(metadata.descriptions[0]).text() } else { desc } } } } private fun getChapterList(): ArrayList { val chapterList = ArrayList() epubBook?.let { eBook -> val refs = eBook.tableOfContents.tocReferences if (refs == null || refs.isEmpty()) { AppLog.putDebug("Epub: NCX file parse error, check the file: ${book.bookUrl}") val spineReferences = eBook.spine.spineReferences var i = 0 val size = spineReferences.size while (i < size) { val resource = spineReferences[i].resource var title = resource.title if (TextUtils.isEmpty(title)) { try { val doc = Jsoup.parse(String(resource.data, mCharset)) val elements = doc.getElementsByTag("title") if (elements.isNotEmpty()) { title = elements[0].text() } } catch (e: IOException) { e.printStackTrace() } } val chapter = BookChapter() chapter.index = i chapter.bookUrl = book.bookUrl chapter.url = resource.href if (i == 0 && title.isEmpty()) { chapter.title = "封面" } else { chapter.title = title } chapterList.lastOrNull()?.putVariable("nextUrl", chapter.url) chapterList.add(chapter) i++ } } else { parseFirstPage(chapterList, refs) parseMenu(chapterList, refs, 0) for (i in chapterList.indices) { chapterList[i].index = i } } } return chapterList } /*获取书籍起始页内容。部分书籍第一章之前存在封面,引言,扉页等内容*/ /*tile获取不同书籍风格杂乱,格式化处理待优化*/ private var durIndex = 0 private fun parseFirstPage( chapterList: ArrayList, refs: List? ) { val contents = epubBook?.contents if (epubBook == null || contents == null || refs == null) return val firstRef = refs.firstOrNull { it.resource != null } ?: return var i = 0 durIndex = 0 while (i < contents.size) { val content = contents[i] if (!content.mediaType.toString().contains("htm")) { i++ continue } /** * 检索到第一章href停止 * completeHref可能有fragment(#id) 必须去除 * fix https://github.com/gedoor/legado/issues/1932 */ if (firstRef.completeHref.substringBeforeLast("#") == content.href) break val chapter = BookChapter() var title = content.title if (TextUtils.isEmpty(title)) { val elements = Jsoup.parse( String(epubBook!!.resources.getByHref(content.href).data, mCharset) ).getElementsByTag("title") title = if (elements.isNotEmpty() && elements[0].text().isNotBlank()) elements[0].text() else "--卷首--" } chapter.bookUrl = book.bookUrl chapter.title = title chapter.url = content.href chapter.startFragmentId = if (content.href.substringAfter("#") == content.href) null else content.href.substringAfter("#") chapterList.lastOrNull()?.endFragmentId = chapter.startFragmentId chapterList.lastOrNull()?.putVariable("nextUrl", chapter.url) chapterList.add(chapter) durIndex++ i++ } } private fun parseMenu( chapterList: ArrayList, refs: List?, level: Int ) { refs?.forEach { ref -> if (ref.resource != null) { val chapter = BookChapter() chapter.bookUrl = book.bookUrl chapter.title = ref.title chapter.url = ref.completeHref chapter.startFragmentId = ref.fragmentId chapterList.lastOrNull()?.endFragmentId = chapter.startFragmentId chapterList.lastOrNull()?.putVariable("nextUrl", chapter.url) chapterList.add(chapter) durIndex++ } if (ref.children != null && ref.children.isNotEmpty()) { chapterList.lastOrNull()?.isVolume = true parseMenu(chapterList, ref.children, level + 1) } } } protected fun finalize() { fileDescriptor?.close() } } ================================================ FILE: app/src/main/java/io/legado/app/model/localBook/LocalBook.kt ================================================ package io.legado.app.model.localBook import android.net.Uri import android.util.Base64 import androidx.documentfile.provider.DocumentFile import com.script.ScriptBindings import com.script.rhino.RhinoScriptEngine import io.legado.app.R import io.legado.app.constant.AppLog import io.legado.app.constant.AppPattern 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.EmptyFileException import io.legado.app.exception.NoBooksDirException import io.legado.app.exception.NoStackTraceException import io.legado.app.exception.TocEmptyException import io.legado.app.help.AppWebDav import io.legado.app.help.book.BookHelp import io.legado.app.help.book.ContentProcessor import io.legado.app.help.book.addType import io.legado.app.help.book.archiveName import io.legado.app.help.book.getArchiveUri import io.legado.app.help.book.getLocalUri import io.legado.app.help.book.getRemoteUrl import io.legado.app.help.book.isArchive import io.legado.app.help.book.isEpub import io.legado.app.help.book.isMobi import io.legado.app.help.book.isPdf import io.legado.app.help.book.isUmd import io.legado.app.help.book.removeLocalUriCache import io.legado.app.help.book.simulatedTotalChapterNum import io.legado.app.help.config.AppConfig import io.legado.app.lib.webdav.WebDav import io.legado.app.lib.webdav.WebDavException import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.utils.ArchiveUtils import io.legado.app.utils.FileDoc import io.legado.app.utils.FileUtils import io.legado.app.utils.GSON import io.legado.app.utils.MD5Utils import io.legado.app.utils.externalFiles import io.legado.app.utils.fromJsonObject import io.legado.app.utils.getFile import io.legado.app.utils.inputStream import io.legado.app.utils.isAbsUrl import io.legado.app.utils.isContentScheme import io.legado.app.utils.isDataUrl import io.legado.app.utils.printOnDebug import kotlinx.coroutines.runBlocking import org.apache.commons.text.StringEscapeUtils import splitties.init.appCtx import java.io.ByteArrayInputStream import java.io.File import java.io.FileInputStream import java.io.FileNotFoundException import java.io.FileOutputStream import java.io.InputStream import java.util.regex.Pattern import kotlin.coroutines.coroutineContext /** * 书籍文件导入 目录正文解析 * 支持在线文件(txt epub umd 压缩文件 本地文件 */ object LocalBook { private val nameAuthorPatterns = arrayOf( Pattern.compile("(.*?)《([^《》]+)》.*?作者:(.*)"), Pattern.compile("(.*?)《([^《》]+)》(.*)"), Pattern.compile("(^)(.+) 作者:(.+)$"), Pattern.compile("(^)(.+) by (.+)$") ) @Throws(FileNotFoundException::class, SecurityException::class) fun getBookInputStream(book: Book): InputStream { val uri = book.getLocalUri() val inputStream = uri.inputStream(appCtx).getOrNull() ?: let { book.removeLocalUriCache() val localArchiveUri = book.getArchiveUri() val webDavUrl = book.getRemoteUrl() if (localArchiveUri != null) { // 重新导入对应的压缩包 importArchiveFile(localArchiveUri, book.originName) { it.contains(book.originName) }.firstOrNull()?.let { getBookInputStream(it) } } else if (webDavUrl != null && downloadRemoteBook(book)) { // 下载远程链接 getBookInputStream(book) } else { null } } if (inputStream != null) return inputStream book.removeLocalUriCache() throw FileNotFoundException("${uri.path} 文件不存在") } fun getLastModified(book: Book): Result { return kotlin.runCatching { val uri = Uri.parse(book.bookUrl) if (uri.isContentScheme()) { return@runCatching DocumentFile.fromSingleUri(appCtx, uri)!!.lastModified() } val file = File(uri.path!!) if (file.exists()) { return@runCatching file.lastModified() } throw FileNotFoundException("${uri.path} 文件不存在") } } @Throws(TocEmptyException::class) fun getChapterList(book: Book): ArrayList { val chapters = when { book.isEpub -> { EpubFile.getChapterList(book) } book.isUmd -> { UmdFile.getChapterList(book) } book.isPdf -> { PdfFile.getChapterList(book) } book.isMobi -> { MobiFile.getChapterList(book) } else -> { TextFile.getChapterList(book) } } if (chapters.isEmpty()) { throw TocEmptyException(appCtx.getString(R.string.chapter_list_empty)) } val list = ArrayList(LinkedHashSet(chapters)) list.forEachIndexed { index, bookChapter -> bookChapter.index = index if (bookChapter.title.isEmpty()) { bookChapter.title = "无标题章节" } } val replaceRules = ContentProcessor.get(book).getTitleReplaceRules() book.durChapterTitle = list.getOrElse(book.durChapterIndex) { list.last() } .getDisplayTitle(replaceRules, book.getUseReplaceRule()) book.latestChapterTitle = list.getOrElse(book.simulatedTotalChapterNum() - 1) { list.last() } .getDisplayTitle(replaceRules, book.getUseReplaceRule()) book.totalChapterNum = list.size book.latestChapterTime = System.currentTimeMillis() return list } fun getContent(book: Book, chapter: BookChapter): String? { var content = try { when { book.isEpub -> { EpubFile.getContent(book, chapter) } book.isUmd -> { UmdFile.getContent(book, chapter) } book.isPdf -> { PdfFile.getContent(book, chapter) } book.isMobi -> { MobiFile.getContent(book, chapter) } else -> { TextFile.getContent(book, chapter) } } } catch (e: Exception) { e.printOnDebug() AppLog.put("获取本地书籍内容失败\n${e.localizedMessage}", e) "获取本地书籍内容失败\n${e.localizedMessage}" } if (book.isEpub) { content ?: return null if (content.indexOf('&') > -1) { content = content.replace("<img", "< img", true) return StringEscapeUtils.unescapeHtml4(content) } } if (content.isNullOrEmpty()) { return null } return content } fun getCoverPath(book: Book): String { return getCoverPath(book.bookUrl) } private fun getCoverPath(bookUrl: String): String { return FileUtils.getPath( appCtx.externalFiles, "covers", "${MD5Utils.md5Encode16(bookUrl)}.jpg" ) } /** * 下载在线的文件并自动导入到阅读(txt umd epub) */ suspend fun importFileOnLine( str: String, fileName: String, source: BaseSource? = null, ): Book { return importFile(saveBookFile(str, fileName, source)) } /** * 导入本地文件 */ fun importFile(uri: Uri): Book { val bookUrl: String //updateTime变量不要修改,否则会导致读取不到缓存 val (fileName, _, _, updateTime, _) = FileDoc.fromUri(uri, false).apply { if (size == 0L) throw EmptyFileException("Unexpected empty File") bookUrl = toString() } var book = appDb.bookDao.getBook(bookUrl) if (book == null) { val nameAuthor = analyzeNameAuthor(fileName) book = Book( type = BookType.text or BookType.local, bookUrl = bookUrl, name = nameAuthor.first, author = nameAuthor.second, originName = fileName, latestChapterTime = updateTime, order = appDb.bookDao.minOrder - 1 ) upBookInfo(book) appDb.bookDao.insert(book) } else { deleteBook(book, false) upBookInfo(book) // 触发 isLocalModified book.latestChapterTime = 0 //已有书籍说明是更新,删除原有目录 appDb.bookChapterDao.delByBook(bookUrl) } return book } fun upBookInfo(book: Book) { when { book.isEpub -> EpubFile.upBookInfo(book) book.isUmd -> UmdFile.upBookInfo(book) book.isPdf -> PdfFile.upBookInfo(book) book.isMobi -> MobiFile.upBookInfo(book) } } /* 导入压缩包内的书籍 */ fun importArchiveFile( archiveFileUri: Uri, saveFileName: String? = null, filter: ((String) -> Boolean)? = null ): List { val archiveFileDoc = FileDoc.fromUri(archiveFileUri, false) val files = ArchiveUtils.deCompress(archiveFileDoc, filter = filter) if (files.isEmpty()) { throw NoStackTraceException(appCtx.getString(R.string.unsupport_archivefile_entry)) } return files.map { saveBookFile(FileInputStream(it), saveFileName ?: it.name).let { uri -> importFile(uri).apply { //附加压缩包名称 以便解压文件被删后再解压 origin = "${BookType.localTag}::${archiveFileDoc.name}" addType(BookType.archive) save() } } } } /* 批量导入 支持自动导入压缩包的支持书籍 */ fun importFiles(uri: Uri): List { val books = mutableListOf() val fileDoc = FileDoc.fromUri(uri, false) if (ArchiveUtils.isArchive(fileDoc.name)) { books.addAll( importArchiveFile(uri) { it.matches(AppPattern.bookFileRegex) } ) } else { books.add(importFile(uri)) } return books } fun importFiles(uris: List) { var errorCount = 0 uris.forEach { uri -> val fileDoc = FileDoc.fromUri(uri, false) kotlin.runCatching { if (ArchiveUtils.isArchive(fileDoc.name)) { importArchiveFile(uri) { it.matches(AppPattern.bookFileRegex) } } else { importFile(uri) } }.onFailure { AppLog.put("ImportFile Error:\nFile $fileDoc\n${it.localizedMessage}", it) errorCount += 1 } } if (errorCount == uris.size) { throw NoStackTraceException("ImportFiles Error:\nAll input files occur error") } } /** * 从文件分析书籍必要信息(书名 作者等) */ private fun analyzeNameAuthor(fileName: String): Pair { val tempFileName = fileName.substringBeforeLast(".") var name = "" var author = "" if (!AppConfig.bookImportFileName.isNullOrBlank()) { try { //在用户脚本后添加捕获author、name的代码,只要脚本中author、name有值就会被捕获 val js = AppConfig.bookImportFileName + "\nJSON.stringify({author:author,name:name})" //在脚本中定义如何分解文件名成书名、作者名 val jsonStr = RhinoScriptEngine.run { val bindings = ScriptBindings() bindings["src"] = tempFileName eval(js, bindings) }.toString() val bookMess = GSON.fromJsonObject>(jsonStr) .getOrThrow() name = bookMess["name"] ?: "" author = bookMess["author"]?.takeIf { it.length != tempFileName.length } ?: "" } catch (e: Exception) { AppLog.put("执行导入文件名规则出错\n${e.localizedMessage}", e) } } if (name.isBlank()) { for (pattern in nameAuthorPatterns) { pattern.matcher(tempFileName).takeIf { it.find() }?.run { name = group(2)!! val group1 = group(1) ?: "" val group3 = group(3) ?: "" author = BookHelp.formatBookAuthor(group1 + group3) return Pair(name, author) } } name = BookHelp.formatBookName(tempFileName) author = BookHelp.formatBookAuthor(tempFileName.replace(name, "")) .takeIf { it.length != tempFileName.length } ?: "" } return Pair(name, author) } fun deleteBook(book: Book, deleteOriginal: Boolean) { kotlin.runCatching { BookHelp.clearCache(book) if (!book.coverUrl.isNullOrEmpty()) { FileUtils.delete(book.coverUrl!!) } if (deleteOriginal) { if (book.bookUrl.isContentScheme()) { val uri = Uri.parse(book.bookUrl) DocumentFile.fromSingleUri(appCtx, uri)?.delete() } else { FileUtils.delete(book.bookUrl) } } } } /** * 下载在线的文件 */ suspend fun saveBookFile( str: String, fileName: String, source: BaseSource? = null, ): Uri { AppConfig.defaultBookTreeUri ?: throw NoBooksDirException() val inputStream = when { str.isAbsUrl() -> AnalyzeUrl( str, source = source, callTimeout = 0, coroutineContext = coroutineContext ).getInputStreamAwait() str.isDataUrl() -> ByteArrayInputStream( Base64.decode( str.substringAfter("base64,"), Base64.DEFAULT ) ) else -> throw NoStackTraceException("在线导入书籍支持http/https/DataURL") } return saveBookFile(inputStream, fileName) } @Throws(SecurityException::class) fun saveBookFile( inputStream: InputStream, fileName: String ): Uri { inputStream.use { val defaultBookTreeUri = AppConfig.defaultBookTreeUri if (defaultBookTreeUri.isNullOrBlank()) throw NoBooksDirException() val treeUri = Uri.parse(defaultBookTreeUri) return if (treeUri.isContentScheme()) { val treeDoc = DocumentFile.fromTreeUri(appCtx, treeUri) var doc = treeDoc!!.findFile(fileName) if (doc == null) { doc = treeDoc.createFile(FileUtils.getMimeType(fileName), fileName) ?: throw SecurityException("请重新设置书籍保存位置\nPermission Denial") } appCtx.contentResolver.openOutputStream(doc.uri)!!.use { oStream -> it.copyTo(oStream) } doc.uri } else { try { val treeFile = File(treeUri.path!!) val file = treeFile.getFile(fileName) FileOutputStream(file).use { oStream -> it.copyTo(oStream) } Uri.fromFile(file) } catch (e: FileNotFoundException) { throw SecurityException("请重新设置书籍保存位置\nPermission Denial\n$e").apply { addSuppressed(e) } } } } } fun isOnBookShelf( fileName: String ): Boolean { return appDb.bookDao.hasFile(fileName) == true } //文件类书源 合并在线书籍信息 在线 > 本地 fun mergeBook(localBook: Book, onLineBook: Book?): Book { onLineBook ?: return localBook localBook.name = onLineBook.name.ifBlank { localBook.name } localBook.author = onLineBook.author.ifBlank { localBook.author } localBook.coverUrl = onLineBook.coverUrl localBook.intro = if (onLineBook.intro.isNullOrBlank()) localBook.intro else onLineBook.intro localBook.save() return localBook } //下载book对应的远程文件 并更新Book private fun downloadRemoteBook(localBook: Book): Boolean { val webDavUrl = localBook.getRemoteUrl() if (webDavUrl.isNullOrBlank()) throw NoStackTraceException("Book file is not webDav File") try { AppConfig.defaultBookTreeUri ?: throw NoBooksDirException() // 兼容旧版链接 val webdav: WebDav = kotlin.runCatching { WebDav.fromPath(webDavUrl) }.getOrElse { AppWebDav.authorization?.let { WebDav(webDavUrl, it) } ?: throw WebDavException("Unexpected defaultBookWebDav") } val inputStream = runBlocking { webdav.downloadInputStream() } inputStream.use { if (localBook.isArchive) { // 压缩包 val archiveUri = saveBookFile(it, localBook.archiveName) val newBook = importArchiveFile(archiveUri, localBook.originName) { name -> name.contains(localBook.originName) }.first() localBook.origin = newBook.origin localBook.bookUrl = newBook.bookUrl } else { // txt epub pdf umd val fileUri = saveBookFile(it, localBook.originName) localBook.bookUrl = FileDoc.fromUri(fileUri, false).toString() localBook.save() } } return true } catch (e: Exception) { e.printOnDebug() AppLog.put("自动下载webDav书籍失败", e) return false } } } ================================================ FILE: app/src/main/java/io/legado/app/model/localBook/MobiFile.kt ================================================ package io.legado.app.model.localBook import android.graphics.Bitmap import android.graphics.BitmapFactory import android.os.ParcelFileDescriptor import io.legado.app.constant.AppLog import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.help.book.BookHelp import io.legado.app.lib.mobi.KF6Book import io.legado.app.lib.mobi.KF8Book import io.legado.app.lib.mobi.MobiBook import io.legado.app.lib.mobi.MobiReader import io.legado.app.lib.mobi.entities.TOC import io.legado.app.utils.FileUtils import io.legado.app.utils.HtmlFormatter import io.legado.app.utils.printOnDebug import org.jsoup.Jsoup import java.io.File import java.io.FileOutputStream import java.io.InputStream class MobiFile(var book: Book) { companion object : BaseLocalBookParse { private var mFile: MobiFile? = null private val xmlDeclarationRegex = "<\\?xml[^>]*>".toRegex() private val doctypeDeclarationRegex = "]*>".toRegex() @Synchronized private fun getMFile(book: Book): MobiFile { if (mFile == null || mFile?.book?.bookUrl != book.bookUrl) { mFile = MobiFile(book) return mFile!! } mFile?.book = book return mFile!! } @Synchronized override fun getChapterList(book: Book): ArrayList { return getMFile(book).getChapterList() } @Synchronized override fun getContent(book: Book, chapter: BookChapter): String? { return getMFile(book).getContent(chapter) } @Synchronized override fun getImage(book: Book, href: String): InputStream? { return getMFile(book).getImage(href) } @Synchronized override fun upBookInfo(book: Book) { return getMFile(book).upBookInfo() } fun clear() { mFile = null } } private var fileDescriptor: ParcelFileDescriptor? = null private var mobiBook: MobiBook? = null get() { if (field == null || fileDescriptor == null) { field = readMobi() } return field } init { upBookCover(true) } private fun readMobi(): MobiBook? { return kotlin.runCatching { BookHelp.getBookPFD(book)?.let { fileDescriptor = it MobiReader().readMobi(it) } }.onFailure { AppLog.put("读取Mobi文件失败\n${it.localizedMessage}", it) it.printOnDebug() }.getOrThrow() } private fun getChapterList(): ArrayList { return when (val book = mobiBook) { is KF8Book -> getChapterListKF8(book) is KF6Book -> getChapterListKF6(book) else -> error("impossible condition") } } private fun getChapterListKF6(kF6Book: KF6Book): ArrayList { val chapterList = arrayListOf() val toc = kF6Book.toc if (kF6Book.sectionIdMap[0] == null) { val section = kF6Book.sections.firstOrNull() if (section != null) { val chapter = BookChapter() val content = kF6Book.getSectionText(section) val soup = Jsoup.parse(content) val title = soup.getElementsByTag("title").first()?.text() ?: "卷首" chapter.bookUrl = book.bookUrl chapter.title = title chapter.url = "0:" + section.href chapterList.add(chapter) } } fun append(ref: TOC) { val chapter = BookChapter() chapter.bookUrl = book.bookUrl chapter.title = ref.label chapter.url = "${chapterList.size}:${ref.href}" chapter.isVolume = ref.subitems != null val lastChapter = chapterList.lastOrNull() if (lastChapter != null && lastChapter.isVolume && lastChapter.url.substringAfter(":") == chapter.url.substringAfter(":") ) { lastChapter.url = "skip:" + lastChapter.url } lastChapter?.putVariable("nextUrl", chapter.url) chapterList.add(chapter) ref.subitems?.forEach(::append) } toc?.forEach(::append) return chapterList } private fun getChapterListKF8(kf8Book: KF8Book): ArrayList { val chapterList = arrayListOf() val toc = kf8Book.toc if (kf8Book.sectionIdMap[0] == null) { val section = kf8Book.sections.firstOrNull { it.href.isNotEmpty() } if (section != null) { val chapter = BookChapter() val content = kf8Book.getSectionText(section) val soup = Jsoup.parse(content) val title = soup.getElementsByTag("title").first()?.text() ?: "卷首" chapter.bookUrl = book.bookUrl chapter.title = title chapter.url = "0:" + section.href chapterList.add(chapter) } } fun append(ref: TOC) { val chapter = BookChapter() chapter.bookUrl = book.bookUrl chapter.title = ref.label chapter.url = "${chapterList.size}:${ref.href}" chapter.isVolume = ref.subitems != null val lastChapter = chapterList.lastOrNull() if (lastChapter != null && lastChapter.isVolume && lastChapter.url.substringAfter(":") == chapter.url.substringAfter(":") ) { lastChapter.url = "skip:" + lastChapter.url } lastChapter?.putVariable("nextUrl", chapter.url) chapterList.add(chapter) ref.subitems?.forEach(::append) } toc?.forEach(::append) return chapterList } private fun getContent(chapter: BookChapter): String? { return when (val book = mobiBook) { is KF8Book -> getContentKF8(book, chapter) is KF6Book -> getContentKF6(book, chapter) else -> error("impossible condition") } } private fun getContentKF6(kf6Book: KF6Book, chapter: BookChapter): String? { if (chapter.isVolume && chapter.url.startsWith("skip:")) return "" var section = kf6Book.getSectionByHref(chapter.url) ?: return null val nextSectionHref = chapter.getVariable("nextUrl") val sb = StringBuilder() sb.append(kf6Book.getSectionText(section)) while (true) { section = section.next ?: break if (section.href == nextSectionHref) { break } if (kf6Book.sectionIdMap[section.index] != null) { break } sb.append(kf6Book.getSectionText(section)) } val soup = Jsoup.parse(sb.toString()) soup.select("title").remove() soup.select("[style*=display:none]").remove() soup.select("img[recindex]").forEach { val recindex = it.attr("recindex") it.clearAttributes() it.attr("src", "recindex:$recindex") } return format(soup.outerHtml()) } private fun getContentKF8(kf8Book: KF8Book, chapter: BookChapter): String? { if (chapter.isVolume && chapter.url.startsWith("skip:")) return "" var section = kf8Book.getSectionByHref(chapter.url) ?: return null val nextSectionHref = chapter.getVariable("nextUrl") val nextPos = kf8Book.parsePosURI(nextSectionHref) val sb = StringBuilder() sb.append(kf8Book.getTextByHref(chapter.url, nextSectionHref)) while (true) { if (nextPos != null && section.frags.any { it.index == nextPos.fid }) { break } section = section.next ?: break if (!section.linear) { continue } if (section.href == nextSectionHref) { break } if (kf8Book.sectionIdMap[section.index] != null) { break } sb.append(kf8Book.getSectionText(section)) } val soup = Jsoup.parse(sb.toString()) soup.select("title").remove() soup.select("[style*=display:none]").remove() return format(soup.outerHtml()) } private fun format(html: String): String { return HtmlFormatter.formatKeepImg(html) .replace(xmlDeclarationRegex, "") .replace(doctypeDeclarationRegex, "") } private fun getImage(href: String): InputStream? { return when (val book = mobiBook) { is KF8Book -> getImageKF8(book, href) is KF6Book -> getImageKF6(book, href) else -> error("impossible condition") } } private fun getImageKF6(kf6Book: KF6Book, href: String): InputStream? { return kf6Book.getResourceByHref(href)?.inputStream() } private fun getImageKF8(kf8Book: KF8Book, href: String): InputStream? { return kf8Book.getResourceByHref(href)?.inputStream() } private fun upBookCover(fastCheck: Boolean = false) { try { mobiBook?.let { if (book.coverUrl.isNullOrEmpty()) { book.coverUrl = LocalBook.getCoverPath(book) } if (fastCheck && File(book.coverUrl!!).exists()) { return } it.getCover()?.let { bytes -> val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) val file = FileUtils.createFileIfNotExist(book.coverUrl!!) FileOutputStream(file).use { out -> bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out) out.flush() } } } } catch (e: Exception) { AppLog.put("加载书籍封面失败\n${e.localizedMessage}", e) e.printOnDebug() } } private fun upBookInfo() { if (mobiBook == null) { mFile = null book.intro = "书籍导入异常" } else { upBookCover() val metadata = mobiBook!!.metadata book.name = metadata.title if (book.name.isEmpty()) { book.name = book.originName.replace("(?i)\\.(mobi|azw3)$".toRegex(), "") } if (metadata.author.isNotEmpty()) { book.author = metadata.author.first() } if (metadata.description.isNotBlank()) { book.intro = HtmlFormatter.format(metadata.description) } } } } ================================================ FILE: app/src/main/java/io/legado/app/model/localBook/PdfFile.kt ================================================ package io.legado.app.model.localBook import android.graphics.Bitmap import android.graphics.Color import android.graphics.pdf.PdfRenderer import android.os.ParcelFileDescriptor import androidx.core.graphics.createBitmap import io.legado.app.constant.AppLog import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.help.book.getLocalUri import io.legado.app.utils.BitmapUtils import io.legado.app.utils.FileUtils import io.legado.app.utils.SystemUtils import io.legado.app.utils.isContentScheme import io.legado.app.utils.printOnDebug import splitties.init.appCtx import java.io.File import java.io.FileOutputStream import java.io.InputStream import kotlin.math.ceil class PdfFile(var book: Book) { companion object : BaseLocalBookParse { private var pFile: PdfFile? = null /** * pdf分页尺寸 */ const val PAGE_SIZE = 10 @Synchronized private fun getPFile(book: Book): PdfFile { if (pFile == null || pFile?.book?.bookUrl != book.bookUrl) { pFile = PdfFile(book) return pFile!! } pFile?.book = book return pFile!! } @Synchronized override fun upBookInfo(book: Book) { getPFile(book).upBookInfo() } @Synchronized override fun getChapterList(book: Book): ArrayList { return getPFile(book).getChapterList() } @Synchronized override fun getContent(book: Book, chapter: BookChapter): String? { return getPFile(book).getContent(chapter) } @Synchronized override fun getImage(book: Book, href: String): InputStream? { return getPFile(book).getImage(href) } } /** *持有引用,避免被回收 */ private var fileDescriptor: ParcelFileDescriptor? = null private var pdfRenderer: PdfRenderer? = null get() { if (field != null && fileDescriptor != null) { return field } field = readPdf() return field } init { upBookCover(true) } /** * 读取PDF文件 * * @return */ private fun readPdf(): PdfRenderer? { val uri = book.getLocalUri() if (uri.isContentScheme()) { fileDescriptor = appCtx.contentResolver.openFileDescriptor(uri, "r")?.also { pdfRenderer = PdfRenderer(it) } } else { fileDescriptor = ParcelFileDescriptor.open(File(uri.path!!), ParcelFileDescriptor.MODE_READ_ONLY) ?.also { pdfRenderer = PdfRenderer(it) } } return pdfRenderer } /** * 关闭pdf文件 * */ private fun closePdf() { pdfRenderer?.close() fileDescriptor?.close() } /** * 渲染PDF页面 * 根据index打开pdf页面,并渲染到Bitmap * * @param renderer * @param index * @return */ private fun openPdfPage(renderer: PdfRenderer, index: Int): Bitmap? { if (index >= renderer.pageCount) { return null } return renderer.openPage(index).use { page -> createBitmap( SystemUtils.screenWidthPx, (SystemUtils.screenWidthPx.toDouble() * page.height / page.width).toInt() ).apply { this.eraseColor(Color.WHITE) page.render(this, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) } } } private fun getContent(chapter: BookChapter): String? = if (pdfRenderer == null) { null } else { pdfRenderer?.let { renderer -> buildString { val start = chapter.index * PAGE_SIZE val end = ((chapter.index + 1) * PAGE_SIZE).coerceAtMost(renderer.pageCount) (start until end).forEach { append("") .append('\n') } } } } private fun getImage(href: String): InputStream? { if (pdfRenderer == null) { return null } return try { val index = href.toInt() val bitmap = openPdfPage(pdfRenderer!!, index) if (bitmap != null) { BitmapUtils.toInputStream(bitmap) } else { null } } catch (_: Exception) { return null } } private fun getChapterList(): ArrayList { val chapterList = ArrayList() pdfRenderer?.let { renderer -> if (renderer.pageCount > 0) { val chapterCount = ceil((renderer.pageCount.toDouble() / PAGE_SIZE)).toInt() (0 until chapterCount).forEach { val chapter = BookChapter() chapter.index = it chapter.bookUrl = book.bookUrl chapter.title = "分段_${it}" chapter.url = "pdf_${it}" chapterList.add(chapter) } } } return chapterList } private fun upBookCover(fastCheck: Boolean = false) { try { pdfRenderer?.let { renderer -> if (book.coverUrl.isNullOrEmpty()) { book.coverUrl = LocalBook.getCoverPath(book) } if (fastCheck && File(book.coverUrl!!).exists()) { return } FileOutputStream(FileUtils.createFileIfNotExist(book.coverUrl!!)).use { out -> openPdfPage(renderer, 0)?.compress(Bitmap.CompressFormat.JPEG, 90, out) out.flush() } } } catch (e: Exception) { AppLog.put("加载书籍封面失败\n${e.localizedMessage}", e) e.printOnDebug() } } private fun upBookInfo() { if (pdfRenderer == null) { pFile = null book.intro = "书籍导入异常" } else { upBookCover() if (book.name.isEmpty()) { book.name = book.originName.replace(".pdf", "") } } } protected fun finalize() { closePdf() } } ================================================ FILE: app/src/main/java/io/legado/app/model/localBook/README.md ================================================ # 书籍文件导入解析 * BaseLocalBookParse.kt 本地书籍解析接口 * LocalBook.kt 导入解析总入口 * TextFile.kt 解析txt * EpubFile.kt 解析epub * PdfFile.kt 解析pdf 纯图片形式 * UmdFile.kt 解析umd ================================================ FILE: app/src/main/java/io/legado/app/model/localBook/TextFile.kt ================================================ package io.legado.app.model.localBook import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.TxtTocRule import io.legado.app.exception.EmptyFileException import io.legado.app.help.DefaultData import io.legado.app.help.book.isLocalModified import io.legado.app.utils.EncodingDetect import io.legado.app.utils.MD5Utils import io.legado.app.utils.StringUtils import io.legado.app.utils.Utf8BomUtils import java.io.FileNotFoundException import java.nio.charset.Charset import java.util.regex.Matcher import java.util.regex.Pattern import java.util.regex.PatternSyntaxException import kotlin.math.min class TextFile(private var book: Book) { @Suppress("ConstPropertyName") companion object { private val padRegex = "^[\\n\\s]+".toRegex() private const val txtBufferSize = 8 * 1024 * 1024 private var textFile: TextFile? = null @Synchronized private fun getTextFile(book: Book): TextFile { if (textFile == null || textFile?.book?.bookUrl != book.bookUrl || book.isLocalModified()) { textFile = TextFile(book) return textFile!! } textFile?.book = book return textFile!! } @Throws(FileNotFoundException::class) fun getChapterList(book: Book): ArrayList { return getTextFile(book).getChapterList() } @Synchronized @Throws(FileNotFoundException::class) fun getContent(book: Book, bookChapter: BookChapter): String { return getTextFile(book).getContent(bookChapter) } fun clear() { textFile = null } } private val blank: Byte = 0x0a //默认从文件中获取数据的长度 private val bufferSize = 512000 //没有标题的时候,每个章节的最大长度 private val maxLengthWithNoToc = 10 * 1024 //使用正则划分目录,每个章节的最大允许长度 private val maxLengthWithToc = 102400 private var charset: Charset = book.fileCharset() private var txtBuffer: ByteArray? = null private var bufferStart = -1L private var bufferEnd = -1L /** * 获取目录 */ @Throws(FileNotFoundException::class, SecurityException::class, EmptyFileException::class) fun getChapterList(): ArrayList { val modified = book.isLocalModified() if (book.charset == null || book.tocUrl.isBlank() || modified) { LocalBook.getBookInputStream(book).use { bis -> val buffer = ByteArray(bufferSize) val length = bis.read(buffer) if (length == -1) throw EmptyFileException("Unexpected Empty Txt File") if (book.charset.isNullOrBlank() || modified) { book.charset = EncodingDetect.getEncode(buffer.copyOf(length)) } charset = book.fileCharset() if (book.tocUrl.isBlank() || modified) { val blockContent = String(buffer, 0, length, charset) book.tocUrl = getTocRule(blockContent)?.pattern() ?: "" } } } val (toc, wordCount) = analyze(book.tocUrl.toPattern(Pattern.MULTILINE)) book.wordCount = StringUtils.wordCountFormat(wordCount) toc.forEachIndexed { index, bookChapter -> bookChapter.index = index bookChapter.bookUrl = book.bookUrl bookChapter.url = MD5Utils.md5Encode16(book.originName + index + bookChapter.title) } return toc } fun getContent(chapter: BookChapter): String { val start = chapter.start!! val end = chapter.end!! if (txtBuffer == null || start > bufferEnd || end < bufferStart) { LocalBook.getBookInputStream(book).use { bis -> bufferStart = txtBufferSize * (start / txtBufferSize) txtBuffer = ByteArray(min(txtBufferSize, bis.available() - bufferStart.toInt())) bufferEnd = bufferStart + txtBuffer!!.size bis.skip(bufferStart) bis.read(txtBuffer) } } val count = (end - start).toInt() val buffer = ByteArray(count) @Suppress("ConvertTwoComparisonsToRangeCheck") if (start < bufferEnd && end > bufferEnd || start < bufferStart && end > bufferStart) { /** 章节内容在缓冲区交界处 */ LocalBook.getBookInputStream(book).use { bis -> bis.skip(start) bis.read(buffer) } } else { /** 章节内容在缓冲区内 */ txtBuffer!!.copyInto( buffer, 0, (start - bufferStart).toInt(), (end - bufferStart).toInt() ) } return String(buffer, charset) .substringAfter(chapter.title) .replace(padRegex, "  ") } /** * 按规则解析目录 */ private fun analyze(pattern: Pattern?): Pair, Int> { if (pattern == null || pattern.pattern().isNullOrEmpty()) { return analyze() } val toc = arrayListOf() var bookWordCount = 0 LocalBook.getBookInputStream(book).use { bis -> var blockContent: String //加载章节 var curOffset: Long = 0 //读取的长度 var length: Int var lastChapterWordCount = 0 val buffer = ByteArray(bufferSize) var bufferStart = 3 bis.read(buffer, 0, 3) if (Utf8BomUtils.hasBom(buffer)) { bufferStart = 0 curOffset = 3 } //获取文件中的数据到buffer,直到没有数据为止 while (bis.read( buffer, bufferStart, bufferSize - bufferStart ).also { length = it } > 0 ) { var end = bufferStart + length if (end == bufferSize) { for (i in bufferStart + length - 1 downTo 0) { if (buffer[i] == blank) { end = i break } } } //将数据转换成String, 不能超过length blockContent = String(buffer, 0, end, charset) buffer.copyInto(buffer, 0, end, bufferStart + length) bufferStart = bufferStart + length - end length = end //当前Block下使过的String的指针 var seekPos = 0 //进行正则匹配 val matcher: Matcher = pattern.matcher(blockContent) //如果存在相应章节 while (matcher.find()) { //获取匹配到的字符在字符串中的起始位置 val chapterStart = matcher.start() //获取章节内容 val chapterContent = blockContent.substring(seekPos, chapterStart) val chapterLength = chapterContent.toByteArray(charset).size.toLong() val lastStart = toc.lastOrNull()?.start ?: curOffset if (book.getSplitLongChapter() && curOffset + chapterLength - lastStart > maxLengthWithToc) { toc.lastOrNull()?.let { it.end = it.start it.tag = null } //章节字数太多进行拆分 val lastTitle = toc.lastOrNull()?.title val lastTitleLength = lastTitle?.toByteArray(charset)?.size ?: 0 val (chapters, wordCount) = analyze( lastStart + lastTitleLength, curOffset + chapterLength ) lastTitle?.let { chapters.forEachIndexed { index, bookChapter -> bookChapter.title = "$lastTitle(${index + 1})" } } toc.addAll(chapters) bookWordCount += wordCount //创建当前章节 val curChapter = BookChapter() curChapter.title = matcher.group() curChapter.start = curOffset + chapterLength curChapter.end = curChapter.start toc.add(curChapter) lastChapterWordCount = 0 } else if (seekPos == 0 && chapterStart != 0) { /** * 如果 seekPos == 0 && chapterStart != 0 表示当前block处前面有一段内容 * 第一种情况一定是序章 第二种情况是上一个章节的内容 */ if (toc.isEmpty()) { //如果当前没有章节,那么就是序章 //加入简介 if (chapterContent.isNotBlank()) { val qyChapter = BookChapter() qyChapter.title = "前言" qyChapter.start = curOffset qyChapter.end = curOffset + chapterLength qyChapter.wordCount = StringUtils.wordCountFormat(chapterContent.length) toc.add(qyChapter) book.intro = if (chapterContent.length <= 500) { chapterContent } else { chapterContent.substring(0, 500) } } //创建当前章节 val curChapter = BookChapter() curChapter.title = matcher.group() curChapter.start = curOffset + chapterLength curChapter.end = curChapter.start toc.add(curChapter) } else { //否则就block分割之后,上一个章节的剩余内容 //获取上一章节 val lastChapter = toc.last() lastChapter.isVolume = chapterContent.substringAfter(lastChapter.title).isBlank() //将当前段落添加上一章去 lastChapter.end = lastChapter.end!! + chapterLength lastChapterWordCount += chapterContent.length lastChapter.wordCount = StringUtils.wordCountFormat(lastChapterWordCount) //创建当前章节 val curChapter = BookChapter() curChapter.title = matcher.group() curChapter.start = lastChapter.end curChapter.end = curChapter.start toc.add(curChapter) } bookWordCount += chapterContent.length lastChapterWordCount = 0 } else { if (toc.isNotEmpty()) { //获取章节内容 //获取上一章节 val lastChapter = toc.last() lastChapter.isVolume = chapterContent.substringAfter(lastChapter.title).isBlank() lastChapter.end = lastChapter.start!! + chapterLength lastChapter.wordCount = StringUtils.wordCountFormat(chapterContent.length) //创建当前章节 val curChapter = BookChapter() curChapter.title = matcher.group() curChapter.start = lastChapter.end curChapter.end = curChapter.start toc.add(curChapter) } else { //如果章节不存在则创建章节 val curChapter = BookChapter() curChapter.title = matcher.group() curChapter.start = curOffset curChapter.end = curOffset curChapter.wordCount = StringUtils.wordCountFormat(chapterContent.length) toc.add(curChapter) } bookWordCount += chapterContent.length lastChapterWordCount = 0 } //设置指针偏移 seekPos += chapterContent.length } val wordCount = blockContent.length - seekPos bookWordCount += wordCount lastChapterWordCount += wordCount //block的偏移点 curOffset += length.toLong() //设置上一章的结尾 toc.lastOrNull()?.let { it.end = curOffset it.wordCount = StringUtils.wordCountFormat(lastChapterWordCount) } } toc.lastOrNull()?.let { chapter -> //章节字数太多进行拆分 if (book.getSplitLongChapter() && chapter.end!! - chapter.start!! > maxLengthWithToc) { val end = chapter.end!! chapter.end = chapter.start chapter.tag = null val lastTitle = chapter.title val lastTitleLength = lastTitle.toByteArray(charset).size val (chapters, _) = analyze( chapter.start!! + lastTitleLength, end ) chapters.forEachIndexed { index, bookChapter -> bookChapter.title = "$lastTitle(${index + 1})" } toc.addAll(chapters) } } } System.gc() System.runFinalization() return toc to bookWordCount } /** * 无规则拆分目录 */ private fun analyze( fileStart: Long = 0L, fileEnd: Long = Long.MAX_VALUE ): Pair, Int> { val toc = arrayListOf() var bookWordCount = 0 LocalBook.getBookInputStream(book).use { bis -> //block的个数 var blockPos = 0 //加载章节 var curOffset: Long = 0 var chapterPos = 0 //读取的长度 var length = 0 var lastChapterWordCount = 0 val buffer = ByteArray(bufferSize) var bufferStart = 3 if (fileStart == 0L) { bis.read(buffer, 0, 3) if (Utf8BomUtils.hasBom(buffer)) { bufferStart = 0 curOffset = 3 } } else { bis.skip(fileStart) curOffset = fileStart bufferStart = 0 } //获取文件中的数据到buffer,直到没有数据为止 while (fileEnd - curOffset - bufferStart > 0 && bis.read( buffer, bufferStart, min( (bufferSize - bufferStart).toLong(), fileEnd - curOffset - bufferStart ).toInt() ).also { length = it } > 0 ) { blockPos++ //章节在buffer的偏移量 var chapterOffset = 0 //当前剩余可分配的长度 length += bufferStart var strLength = length //分章的位置 chapterPos = 0 while (strLength > 0) { chapterPos++ //是否长度超过一章 if (strLength > maxLengthWithNoToc) { //在buffer中一章的终止点 var end = length //寻找换行符作为终止点 for (i in chapterOffset + maxLengthWithNoToc until length) { if (buffer[i] == blank) { end = i break } } val content = String(buffer, chapterOffset, end - chapterOffset, charset) bookWordCount += content.length lastChapterWordCount = content.length val chapter = BookChapter() chapter.title = "第${blockPos}章($chapterPos)" chapter.start = toc.lastOrNull()?.end ?: curOffset chapter.end = chapter.start!! + end - chapterOffset chapter.wordCount = StringUtils.wordCountFormat(content.length) toc.add(chapter) //减去已经被分配的长度 strLength -= (end - chapterOffset) //设置偏移的位置 chapterOffset = end } else { buffer.copyInto(buffer, 0, length - strLength, length) length -= strLength bufferStart = strLength strLength = 0 } } //block的偏移点 curOffset += length.toLong() } //设置结尾章节 val content = String(buffer, 0, bufferStart, charset) bookWordCount += content.length if (bufferStart > 100 || toc.isEmpty()) { val chapter = BookChapter() chapter.title = "第${blockPos}章(${chapterPos})" chapter.start = toc.lastOrNull()?.end ?: curOffset chapter.end = chapter.start!! + bufferStart chapter.wordCount = StringUtils.wordCountFormat(content.length) toc.add(chapter) } else { val wordCount = lastChapterWordCount + content.length toc.lastOrNull()?.let { it.end = it.end!! + bufferStart it.wordCount = StringUtils.wordCountFormat(wordCount) } } } return toc to bookWordCount } /** * 获取合适的目录规则 */ private fun getTocRule(content: String): Pattern? { val rules = getTocRules().reversed() var maxNum = 1 var tocPattern: Pattern? = null for (tocRule in rules) { val pattern = try { tocRule.rule.toPattern(Pattern.MULTILINE) } catch (e: PatternSyntaxException) { AppLog.put("TXT目录规则正则语法错误:${tocRule.name}\n$e", e) continue } val matcher = pattern.matcher(content) var start = 0 var num = 0 while (matcher.find()) { if (start == 0 || matcher.start() - start > 1000) { num++ start = matcher.end() } } if (num >= maxNum) { maxNum = num tocPattern = pattern } } return tocPattern } /** * 获取启用的目录规则 */ private fun getTocRules(): List { var rules = appDb.txtTocRuleDao.enabled if (appDb.txtTocRuleDao.count == 0) { rules = DefaultData.txtTocRules.apply { appDb.txtTocRuleDao.insert(*this.toTypedArray()) }.filter { it.enable } } return rules } } ================================================ FILE: app/src/main/java/io/legado/app/model/localBook/UmdFile.kt ================================================ package io.legado.app.model.localBook import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.utils.DebugLog import io.legado.app.utils.FileUtils import io.legado.app.utils.printOnDebug import me.ag2s.umdlib.domain.UmdBook import me.ag2s.umdlib.umd.UmdReader import java.io.File import java.io.InputStream class UmdFile(var book: Book) { companion object : BaseLocalBookParse { private var uFile: UmdFile? = null @Synchronized private fun getUFile(book: Book): UmdFile { if (uFile == null || uFile?.book?.bookUrl != book.bookUrl) { uFile = UmdFile(book) return uFile!! } uFile?.book = book return uFile!! } @Synchronized override fun getChapterList(book: Book): ArrayList { return getUFile(book).getChapterList() } @Synchronized override fun getContent(book: Book, chapter: BookChapter): String? { return getUFile(book).getContent(chapter) } @Synchronized override fun getImage( book: Book, href: String ): InputStream? { return getUFile(book).getImage(href) } @Synchronized override fun upBookInfo(book: Book) { return getUFile(book).upBookInfo() } } private var umdBook: UmdBook? = null get() { if (field != null) { return field } field = readUmd() return field } init { upBookCover(true) } private fun readUmd(): UmdBook? { val input = LocalBook.getBookInputStream(book) return UmdReader().read(input) } private fun upBookCover(fastCheck: Boolean = false) { try { umdBook?.let { if (book.coverUrl.isNullOrEmpty()) { book.coverUrl = LocalBook.getCoverPath(book) } if (fastCheck && File(book.coverUrl!!).exists()) { return } FileUtils.writeBytes(book.coverUrl!!, it.cover.coverData) } } catch (e: Exception) { e.printOnDebug() } } private fun upBookInfo() { if (umdBook == null) { uFile = null book.intro = "书籍导入异常" } else { upBookCover() val hd = umdBook!!.header book.name = hd.title book.author = hd.author book.kind = hd.bookType } } private fun getContent(chapter: BookChapter): String? { return umdBook?.chapters?.getContentString(chapter.index) } private fun getChapterList(): ArrayList { val chapterList = ArrayList() umdBook?.chapters?.titles?.forEachIndexed { index, _ -> val title = umdBook!!.chapters.getTitle(index) val chapter = BookChapter() chapter.title = title chapter.index = index chapter.bookUrl = book.bookUrl chapter.url = index.toString() DebugLog.d(javaClass.name, chapter.url) chapterList.add(chapter) } return chapterList } private fun getImage(@Suppress("UNUSED_PARAMETER") href: String): InputStream? { return null } } ================================================ FILE: app/src/main/java/io/legado/app/model/remote/RemoteBook.kt ================================================ package io.legado.app.model.remote import androidx.annotation.Keep import io.legado.app.lib.webdav.WebDavFile import io.legado.app.model.localBook.LocalBook @Keep data class RemoteBook( val filename: String, val path: String, val size: Long, val lastModify: Long, var contentType: String = "folder", var isOnBookShelf: Boolean = false ) { val isDir get() = contentType == "folder" constructor(webDavFile: WebDavFile) : this( webDavFile.displayName, webDavFile.path, webDavFile.size, webDavFile.lastModify ) { if (!webDavFile.isDir) { contentType = webDavFile.displayName.substringAfterLast(".") isOnBookShelf = LocalBook.isOnBookShelf(webDavFile.displayName) } } } ================================================ FILE: app/src/main/java/io/legado/app/model/remote/RemoteBookManager.kt ================================================ package io.legado.app.model.remote import android.net.Uri import io.legado.app.data.entities.Book abstract class RemoteBookManager { /** * 获取书籍列表 */ @Throws(Exception::class) abstract suspend fun getRemoteBookList(path: String): MutableList /** * 根据书籍地址获取书籍信息 */ @Throws(Exception::class) abstract suspend fun getRemoteBook(path: String): RemoteBook? /** * @return Uri:下载到本地的路径 */ @Throws(Exception::class) abstract suspend fun downloadRemoteBook(remoteBook: RemoteBook): Uri /** * 上传书籍 */ @Throws(Exception::class) abstract suspend fun upload(book: Book) /** * 删除书籍 */ @Throws(Exception::class) abstract suspend fun delete(remoteBookUrl: String) } ================================================ FILE: app/src/main/java/io/legado/app/model/remote/RemoteBookWebDav.kt ================================================ package io.legado.app.model.remote import android.net.Uri import io.legado.app.constant.AppPattern.archiveFileRegex import io.legado.app.constant.AppPattern.bookFileRegex import io.legado.app.constant.BookType import io.legado.app.data.entities.Book import io.legado.app.exception.NoStackTraceException import io.legado.app.help.book.update import io.legado.app.help.config.AppConfig import io.legado.app.lib.webdav.Authorization import io.legado.app.lib.webdav.WebDav import io.legado.app.lib.webdav.WebDavFile import io.legado.app.model.analyzeRule.CustomUrl import io.legado.app.model.localBook.LocalBook import io.legado.app.utils.NetworkUtils import io.legado.app.utils.isContentScheme import kotlinx.coroutines.runBlocking class RemoteBookWebDav( val rootBookUrl: String, val authorization: Authorization, val serverID: Long? = null ) : RemoteBookManager() { init { runBlocking { WebDav(rootBookUrl, authorization).makeAsDir() } } @Throws(Exception::class) override suspend fun getRemoteBookList(path: String): MutableList { if (!NetworkUtils.isAvailable()) throw NoStackTraceException("网络不可用") val remoteBooks = mutableListOf() //读取文件列表 val remoteWebDavFileList: List = WebDav(path, authorization).listFiles() //转化远程文件信息到本地对象 remoteWebDavFileList.forEach { webDavFile -> if (webDavFile.isDir || bookFileRegex.matches(webDavFile.displayName) || archiveFileRegex.matches(webDavFile.displayName) ) { //扩展名符合阅读的格式则认为是书籍 remoteBooks.add(RemoteBook(webDavFile)) } } return remoteBooks } override suspend fun getRemoteBook(path: String): RemoteBook? { if (!NetworkUtils.isAvailable()) throw NoStackTraceException("网络不可用") val webDavFile = WebDav(path, authorization).getWebDavFile() ?: return null return RemoteBook(webDavFile) } override suspend fun downloadRemoteBook(remoteBook: RemoteBook): Uri { AppConfig.defaultBookTreeUri ?: throw NoStackTraceException("没有设置书籍保存位置!") if (!NetworkUtils.isAvailable()) throw NoStackTraceException("网络不可用") val webdav = WebDav(remoteBook.path, authorization) return webdav.downloadInputStream().let { inputStream -> LocalBook.saveBookFile(inputStream, remoteBook.filename) } } override suspend fun upload(book: Book) { if (!NetworkUtils.isAvailable()) throw NoStackTraceException("网络不可用") val localBookUri = Uri.parse(book.bookUrl) val putUrl = "$rootBookUrl${book.originName}" val webDav = WebDav(putUrl, authorization) if (localBookUri.isContentScheme()) { webDav.upload(localBookUri) } else { webDav.upload(localBookUri.path!!) } book.origin = BookType.webDavTag + CustomUrl(putUrl) .putAttribute("serverID", serverID) .toString() book.update() } override suspend fun delete(remoteBookUrl: String) { if (!NetworkUtils.isAvailable()) throw NoStackTraceException("网络不可用") WebDav(remoteBookUrl, authorization).delete() } } ================================================ FILE: app/src/main/java/io/legado/app/model/rss/Rss.kt ================================================ package io.legado.app.model.rss import io.legado.app.data.entities.RssArticle import io.legado.app.data.entities.RssSource import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.http.StrResponse import io.legado.app.model.Debug import io.legado.app.model.analyzeRule.AnalyzeRule import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.model.analyzeRule.RuleData import io.legado.app.utils.NetworkUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlin.coroutines.CoroutineContext import kotlin.coroutines.coroutineContext @Suppress("MemberVisibilityCanBePrivate") object Rss { fun getArticles( scope: CoroutineScope, sortName: String, sortUrl: String, rssSource: RssSource, page: Int, context: CoroutineContext = Dispatchers.IO ): Coroutine, String?>> { return Coroutine.async(scope, context) { getArticlesAwait(sortName, sortUrl, rssSource, page) } } suspend fun getArticlesAwait( sortName: String, sortUrl: String, rssSource: RssSource, page: Int, ): Pair, String?> { val ruleData = RuleData() val analyzeUrl = AnalyzeUrl( sortUrl, page = page, source = rssSource, ruleData = ruleData, coroutineContext = coroutineContext, hasLoginHeader = false ) val res = analyzeUrl.getStrResponseAwait() checkRedirect(rssSource, res) return RssParserByRule.parseXML(sortName, sortUrl, res.url, res.body, rssSource, ruleData) } fun getContent( scope: CoroutineScope, rssArticle: RssArticle, ruleContent: String, rssSource: RssSource, context: CoroutineContext = Dispatchers.IO ): Coroutine { return Coroutine.async(scope, context) { getContentAwait(rssArticle, ruleContent, rssSource) } } suspend fun getContentAwait( rssArticle: RssArticle, ruleContent: String, rssSource: RssSource, ): String { val analyzeUrl = AnalyzeUrl( rssArticle.link, baseUrl = rssArticle.origin, source = rssSource, ruleData = rssArticle, coroutineContext = coroutineContext, hasLoginHeader = false ) val res = analyzeUrl.getStrResponseAwait() checkRedirect(rssSource, res) Debug.log(rssSource.sourceUrl, "≡获取成功:${rssSource.sourceUrl}") Debug.log(rssSource.sourceUrl, res.body ?: "", state = 20) val analyzeRule = AnalyzeRule(rssArticle, rssSource) analyzeRule.setContent(res.body) .setBaseUrl(NetworkUtils.getAbsoluteURL(rssArticle.origin, rssArticle.link)) .setCoroutineContext(coroutineContext) .setRedirectUrl(res.url) return analyzeRule.getString(ruleContent) } /** * 检测重定向 */ private fun checkRedirect(rssSource: RssSource, response: StrResponse) { response.raw.priorResponse?.let { if (it.isRedirect) { Debug.log(rssSource.sourceUrl, "≡检测到重定向(${it.code})") Debug.log(rssSource.sourceUrl, "┌重定向后地址") Debug.log(rssSource.sourceUrl, "└${response.url}") } } } } ================================================ FILE: app/src/main/java/io/legado/app/model/rss/RssParserByRule.kt ================================================ package io.legado.app.model.rss import androidx.annotation.Keep import io.legado.app.R import io.legado.app.data.entities.RssArticle import io.legado.app.data.entities.RssSource import io.legado.app.exception.NoStackTraceException import io.legado.app.model.Debug import io.legado.app.model.analyzeRule.AnalyzeRule import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setRuleData import io.legado.app.model.analyzeRule.RuleData import io.legado.app.utils.NetworkUtils import splitties.init.appCtx import java.util.Locale import kotlin.coroutines.coroutineContext @Keep object RssParserByRule { @Throws(Exception::class) suspend fun parseXML( sortName: String, sortUrl: String, redirectUrl: String, body: String?, rssSource: RssSource, ruleData: RuleData ): Pair, String?> { val sourceUrl = rssSource.sourceUrl var nextUrl: String? = null if (body.isNullOrBlank()) { throw NoStackTraceException( appCtx.getString(R.string.error_get_web_content, rssSource.sourceUrl) ) } Debug.log(sourceUrl, "≡获取成功:$sourceUrl") Debug.log(sourceUrl, body, state = 10) var ruleArticles = rssSource.ruleArticles if (ruleArticles.isNullOrBlank()) { Debug.log(sourceUrl, "⇒列表规则为空, 使用默认规则解析") return RssParserDefault.parseXML(sortName, body, sourceUrl) } else { val articleList = mutableListOf() val analyzeRule = AnalyzeRule(ruleData, rssSource) analyzeRule.setCoroutineContext(coroutineContext) analyzeRule.setContent(body).setBaseUrl(sortUrl) analyzeRule.setRedirectUrl(redirectUrl) var reverse = false if (ruleArticles.startsWith("-")) { reverse = true ruleArticles = ruleArticles.substring(1) } Debug.log(sourceUrl, "┌获取列表") val collections = analyzeRule.getElements(ruleArticles) Debug.log(sourceUrl, "└列表大小:${collections.size}") if (!rssSource.ruleNextPage.isNullOrEmpty()) { Debug.log(sourceUrl, "┌获取下一页链接") if (rssSource.ruleNextPage!!.uppercase(Locale.getDefault()) == "PAGE") { nextUrl = sortUrl } else { nextUrl = analyzeRule.getString(rssSource.ruleNextPage) if (nextUrl.isNotEmpty()) { nextUrl = NetworkUtils.getAbsoluteURL(sortUrl, nextUrl) } } Debug.log(sourceUrl, "└$nextUrl") } val ruleTitle = analyzeRule.splitSourceRule(rssSource.ruleTitle) val rulePubDate = analyzeRule.splitSourceRule(rssSource.rulePubDate) val ruleDescription = analyzeRule.splitSourceRule(rssSource.ruleDescription) val ruleImage = analyzeRule.splitSourceRule(rssSource.ruleImage) val ruleLink = analyzeRule.splitSourceRule(rssSource.ruleLink) val variable = ruleData.getVariable() for ((index, item) in collections.withIndex()) { getItem( sourceUrl, item, analyzeRule, variable, index == 0, ruleTitle, rulePubDate, ruleDescription, ruleImage, ruleLink )?.let { it.sort = sortName it.origin = sourceUrl articleList.add(it) } } if (reverse) { articleList.reverse() } return Pair(articleList, nextUrl) } } private fun getItem( sourceUrl: String, item: Any, analyzeRule: AnalyzeRule, variable: String?, log: Boolean, ruleTitle: List, rulePubDate: List, ruleDescription: List, ruleImage: List, ruleLink: List ): RssArticle? { val rssArticle = RssArticle(variable = variable) analyzeRule.setRuleData(rssArticle) analyzeRule.setContent(item) Debug.log(sourceUrl, "┌获取标题", log) rssArticle.title = analyzeRule.getString(ruleTitle) Debug.log(sourceUrl, "└${rssArticle.title}", log) Debug.log(sourceUrl, "┌获取时间", log) rssArticle.pubDate = analyzeRule.getString(rulePubDate) Debug.log(sourceUrl, "└${rssArticle.pubDate}", log) Debug.log(sourceUrl, "┌获取描述", log) if (ruleDescription.isEmpty()) { rssArticle.description = null Debug.log(sourceUrl, "└描述规则为空,将会解析内容页", log) } else { rssArticle.description = analyzeRule.getString(ruleDescription) Debug.log(sourceUrl, "└${rssArticle.description}", log) } Debug.log(sourceUrl, "┌获取图片url", log) rssArticle.image = analyzeRule.getString(ruleImage, isUrl = true) Debug.log(sourceUrl, "└${rssArticle.image}", log) Debug.log(sourceUrl, "┌获取文章链接", log) rssArticle.link = NetworkUtils.getAbsoluteURL(sourceUrl, analyzeRule.getString(ruleLink)) Debug.log(sourceUrl, "└${rssArticle.link}", log) if (rssArticle.title.isBlank()) { return null } return rssArticle } } ================================================ FILE: app/src/main/java/io/legado/app/model/rss/RssParserDefault.kt ================================================ package io.legado.app.model.rss import io.legado.app.data.entities.RssArticle import io.legado.app.model.Debug import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParserException import org.xmlpull.v1.XmlPullParserFactory import java.io.IOException import java.io.StringReader @Suppress("unused") object RssParserDefault { @Throws(XmlPullParserException::class, IOException::class) fun parseXML( sortName: String, xml: String, sourceUrl: String ): Pair, String?> { val articleList = mutableListOf() var currentArticle = RssArticle() val factory = XmlPullParserFactory.newInstance() factory.isNamespaceAware = false val xmlPullParser = factory.newPullParser() xmlPullParser.setInput(StringReader(xml)) // A flag just to be sure of the correct parsing var insideItem = false var eventType = xmlPullParser.eventType // Start parsing the xml loop@ while (eventType != XmlPullParser.END_DOCUMENT) { // Start parsing the item if (eventType == XmlPullParser.START_TAG) { when { xmlPullParser.name.equals(RSS_ITEM, true) -> insideItem = true xmlPullParser.name.equals(RSS_ITEM_TITLE, true) -> if (insideItem) currentArticle.title = xmlPullParser.nextText().trim() xmlPullParser.name.equals(RSS_ITEM_LINK, true) -> if (insideItem) currentArticle.link = xmlPullParser.nextText().trim() xmlPullParser.name.equals(RSS_ITEM_THUMBNAIL, true) -> if (insideItem) currentArticle.image = xmlPullParser.getAttributeValue(null, RSS_ITEM_URL) xmlPullParser.name.equals(RSS_ITEM_ENCLOSURE, true) -> if (insideItem) { val type = xmlPullParser.getAttributeValue(null, RSS_ITEM_TYPE) if (type != null && type.contains("image/")) { currentArticle.image = xmlPullParser.getAttributeValue(null, RSS_ITEM_URL) } } xmlPullParser.name .equals(RSS_ITEM_DESCRIPTION, true) -> if (insideItem) { val description = xmlPullParser.nextText() currentArticle.description = description.trim() if (currentArticle.image == null) { currentArticle.image = getImageUrl(description) } } xmlPullParser.name.equals(RSS_ITEM_CONTENT, true) -> if (insideItem) { val content = xmlPullParser.nextText().trim() currentArticle.content = content if (currentArticle.image == null) { currentArticle.image = getImageUrl(content) } } xmlPullParser.name .equals(RSS_ITEM_PUB_DATE, true) -> if (insideItem) { val nextTokenType = xmlPullParser.next() if (nextTokenType == XmlPullParser.TEXT) { currentArticle.pubDate = xmlPullParser.text.trim() } // Skip to be able to find date inside 'tag' tag continue@loop } xmlPullParser.name.equals(RSS_ITEM_TIME, true) -> if (insideItem) currentArticle.pubDate = xmlPullParser.nextText() } } else if (eventType == XmlPullParser.END_TAG && xmlPullParser.name.equals("item", true) ) { // The item is correctly parsed insideItem = false currentArticle.origin = sourceUrl currentArticle.sort = sortName articleList.add(currentArticle) currentArticle = RssArticle() } eventType = xmlPullParser.next() } articleList.firstOrNull()?.let { Debug.log(sourceUrl, "┌获取标题") Debug.log(sourceUrl, "└${it.title}") Debug.log(sourceUrl, "┌获取时间") Debug.log(sourceUrl, "└${it.pubDate}") Debug.log(sourceUrl, "┌获取描述") Debug.log(sourceUrl, "└${it.description}") Debug.log(sourceUrl, "┌获取图片url") Debug.log(sourceUrl, "└${it.image}") Debug.log(sourceUrl, "┌获取文章链接") Debug.log(sourceUrl, "└${it.link}") } return Pair(articleList, null) } /** * Finds the first img tag and get the src as featured image * * @param input The content in which to search for the tag * @return The url, if there is one */ private fun getImageUrl(input: String): String? { var url: String? = null val patternImg = "(]*>)".toPattern() val matcherImg = patternImg.matcher(input) if (matcherImg.find()) { val imgTag = matcherImg.group(1) val patternLink = "src\\s*=\\s*\"([^\"]+)\"".toPattern() val matcherLink = patternLink.matcher(imgTag!!) if (matcherLink.find()) { url = matcherLink.group(1)!!.trim() } } return url } private const val RSS_ITEM = "item" private const val RSS_ITEM_TITLE = "title" private const val RSS_ITEM_LINK = "link" private const val RSS_ITEM_CATEGORY = "category" private const val RSS_ITEM_THUMBNAIL = "media:thumbnail" private const val RSS_ITEM_ENCLOSURE = "enclosure" private const val RSS_ITEM_DESCRIPTION = "description" private const val RSS_ITEM_CONTENT = "content:encoded" private const val RSS_ITEM_PUB_DATE = "pubDate" private const val RSS_ITEM_TIME = "time" private const val RSS_ITEM_URL = "url" private const val RSS_ITEM_TYPE = "type" } ================================================ FILE: app/src/main/java/io/legado/app/model/webBook/BookChapterList.kt ================================================ package io.legado.app.model.webBook import android.text.TextUtils import com.script.ScriptBindings import com.script.rhino.RhinoScriptEngine import io.legado.app.R import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.rule.TocRule import io.legado.app.exception.NoStackTraceException import io.legado.app.exception.TocEmptyException import io.legado.app.help.book.ContentProcessor import io.legado.app.help.book.simulatedTotalChapterNum import io.legado.app.help.config.AppConfig import io.legado.app.model.Debug 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 import io.legado.app.utils.isTrue import io.legado.app.utils.mapAsync import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.flow import org.mozilla.javascript.Context import splitties.init.appCtx import kotlin.coroutines.coroutineContext /** * 获取目录 */ object BookChapterList { suspend fun analyzeChapterList( bookSource: BookSource, book: Book, baseUrl: String, redirectUrl: String, body: String? ): List { body ?: throw NoStackTraceException( appCtx.getString(R.string.error_get_web_content, baseUrl) ) val chapterList = ArrayList() Debug.log(bookSource.bookSourceUrl, "≡获取成功:${baseUrl}") Debug.log(bookSource.bookSourceUrl, body, state = 30) val tocRule = bookSource.getTocRule() val nextUrlList = arrayListOf(redirectUrl) var reverse = false var listRule = tocRule.chapterList ?: "" if (listRule.startsWith("-")) { reverse = true listRule = listRule.substring(1) } if (listRule.startsWith("+")) { listRule = listRule.substring(1) } var chapterData = analyzeChapterList( book, baseUrl, redirectUrl, body, tocRule, listRule, bookSource, log = true ) chapterList.addAll(chapterData.first) when (chapterData.second.size) { 0 -> Unit 1 -> { var nextUrl = chapterData.second[0] while (nextUrl.isNotEmpty() && !nextUrlList.contains(nextUrl)) { nextUrlList.add(nextUrl) val analyzeUrl = AnalyzeUrl( mUrl = nextUrl, source = bookSource, ruleData = book, coroutineContext = coroutineContext ) val res = analyzeUrl.getStrResponseAwait() //控制并发访问 res.body?.let { nextBody -> chapterData = analyzeChapterList( book, nextUrl, nextUrl, nextBody, tocRule, listRule, bookSource ) nextUrl = chapterData.second.firstOrNull() ?: "" chapterList.addAll(chapterData.first) } } Debug.log(bookSource.bookSourceUrl, "◇目录总页数:${nextUrlList.size}") } else -> { Debug.log( bookSource.bookSourceUrl, "◇并发解析目录,总页数:${chapterData.second.size}" ) flow { for (urlStr in chapterData.second) { emit(urlStr) } }.mapAsync(AppConfig.threadCount) { urlStr -> val analyzeUrl = AnalyzeUrl( mUrl = urlStr, source = bookSource, ruleData = book, coroutineContext = coroutineContext ) val res = analyzeUrl.getStrResponseAwait() //控制并发访问 analyzeChapterList( book, urlStr, res.url, res.body!!, tocRule, listRule, bookSource, false ).first }.collect { chapterList.addAll(it) } } } if (chapterList.isEmpty()) { throw TocEmptyException(appCtx.getString(R.string.chapter_list_empty)) } if (!reverse) { chapterList.reverse() } coroutineContext.ensureActive() //去重 val lh = LinkedHashSet(chapterList) val list = ArrayList(lh) if (!book.getReverseToc()) { list.reverse() } Debug.log(book.origin, "◇目录总数:${list.size}") coroutineContext.ensureActive() list.forEachIndexed { index, bookChapter -> bookChapter.index = index } val formatJs = tocRule.formatJs if (!formatJs.isNullOrBlank()) { Context.enter().use { val bindings = ScriptBindings() bindings["gInt"] = 0 list.forEachIndexed { index, bookChapter -> bindings["index"] = index + 1 bindings["chapter"] = bookChapter bindings["title"] = bookChapter.title RhinoScriptEngine.runCatching { eval(formatJs, bindings)?.toString()?.let { bookChapter.title = it } }.onFailure { Debug.log(book.origin, "格式化标题出错, ${it.localizedMessage}") } } } } val replaceRules = ContentProcessor.get(book).getTitleReplaceRules() book.durChapterTitle = list.getOrElse(book.durChapterIndex) { list.last() } .getDisplayTitle(replaceRules, book.getUseReplaceRule()) if (book.totalChapterNum < list.size) { book.lastCheckCount = list.size - book.totalChapterNum book.latestChapterTime = System.currentTimeMillis() } book.lastCheckTime = System.currentTimeMillis() book.totalChapterNum = list.size book.latestChapterTitle = list.getOrElse(book.simulatedTotalChapterNum() - 1) { list.last() } .getDisplayTitle(replaceRules, book.getUseReplaceRule()) coroutineContext.ensureActive() getWordCount(list, book) return list } private suspend fun analyzeChapterList( book: Book, baseUrl: String, redirectUrl: String, body: String, tocRule: TocRule, listRule: String, bookSource: BookSource, getNextUrl: Boolean = true, log: Boolean = false ): Pair, List> { val analyzeRule = AnalyzeRule(book, bookSource) analyzeRule.setContent(body).setBaseUrl(baseUrl) analyzeRule.setRedirectUrl(redirectUrl) analyzeRule.setCoroutineContext(coroutineContext) //获取目录列表 val chapterList = arrayListOf() Debug.log(bookSource.bookSourceUrl, "┌获取目录列表", log) val elements = analyzeRule.getElements(listRule) Debug.log(bookSource.bookSourceUrl, "└列表大小:${elements.size}", log) //获取下一页链接 val nextUrlList = arrayListOf() val nextTocRule = tocRule.nextTocUrl if (getNextUrl && !nextTocRule.isNullOrEmpty()) { Debug.log(bookSource.bookSourceUrl, "┌获取目录下一页列表", log) analyzeRule.getStringList(nextTocRule, isUrl = true)?.let { for (item in it) { if (item != redirectUrl) { nextUrlList.add(item) } } } Debug.log( bookSource.bookSourceUrl, "└" + TextUtils.join(",\n", nextUrlList), log ) } coroutineContext.ensureActive() if (elements.isNotEmpty()) { Debug.log(bookSource.bookSourceUrl, "┌解析目录列表", log) val nameRule = analyzeRule.splitSourceRule(tocRule.chapterName) val urlRule = analyzeRule.splitSourceRule(tocRule.chapterUrl) val vipRule = analyzeRule.splitSourceRule(tocRule.isVip) val payRule = analyzeRule.splitSourceRule(tocRule.isPay) val upTimeRule = analyzeRule.splitSourceRule(tocRule.updateTime) val isVolumeRule = analyzeRule.splitSourceRule(tocRule.isVolume) elements.forEachIndexed { index, item -> coroutineContext.ensureActive() analyzeRule.setContent(item) val bookChapter = BookChapter(bookUrl = book.bookUrl, baseUrl = redirectUrl) analyzeRule.setChapter(bookChapter) bookChapter.title = analyzeRule.getString(nameRule) bookChapter.url = analyzeRule.getString(urlRule) bookChapter.tag = analyzeRule.getString(upTimeRule) val isVolume = analyzeRule.getString(isVolumeRule) bookChapter.isVolume = false if (isVolume.isTrue()) { bookChapter.isVolume = true } if (bookChapter.url.isEmpty()) { if (bookChapter.isVolume) { bookChapter.url = bookChapter.title + index Debug.log( bookSource.bookSourceUrl, "⇒一级目录${index}未获取到url,使用标题替代" ) } else { bookChapter.url = baseUrl Debug.log( bookSource.bookSourceUrl, "⇒目录${index}未获取到url,使用baseUrl替代" ) } } if (bookChapter.title.isNotEmpty()) { val isVip = analyzeRule.getString(vipRule) val isPay = analyzeRule.getString(payRule) if (isVip.isTrue()) { bookChapter.isVip = true } if (isPay.isTrue()) { bookChapter.isPay = true } chapterList.add(bookChapter) } } Debug.log(bookSource.bookSourceUrl, "└目录列表解析完成", log) if (chapterList.isEmpty()) { Debug.log(bookSource.bookSourceUrl, "◇章节列表为空", log) } else { Debug.log(bookSource.bookSourceUrl, "≡首章信息", log) Debug.log(bookSource.bookSourceUrl, "◇章节名称:${chapterList[0].title}", log) Debug.log(bookSource.bookSourceUrl, "◇章节链接:${chapterList[0].url}", log) Debug.log(bookSource.bookSourceUrl, "◇章节信息:${chapterList[0].tag}", log) Debug.log(bookSource.bookSourceUrl, "◇是否VIP:${chapterList[0].isVip}", log) Debug.log(bookSource.bookSourceUrl, "◇是否购买:${chapterList[0].isPay}", log) } } return Pair(chapterList, nextUrlList) } private fun getWordCount(list: ArrayList, book: Book) { if (!AppConfig.tocCountWords) { return } val chapterList = appDb.bookChapterDao.getChapterList(book.bookUrl) if (chapterList.isNotEmpty()) { val map = chapterList.associateBy({ it.getFileName() }, { it.wordCount }) for (bookChapter in list) { val wordCount = map[bookChapter.getFileName()] if (wordCount != null) { bookChapter.wordCount = wordCount } } } } } ================================================ FILE: app/src/main/java/io/legado/app/model/webBook/BookContent.kt ================================================ package io.legado.app.model.webBook import io.legado.app.R import io.legado.app.constant.AppPattern import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.rule.ContentRule import io.legado.app.exception.ContentEmptyException import io.legado.app.exception.NoStackTraceException import io.legado.app.help.book.BookHelp import io.legado.app.help.config.AppConfig import io.legado.app.model.Debug 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.AnalyzeRule.Companion.setNextChapterUrl import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.utils.HtmlFormatter import io.legado.app.utils.NetworkUtils import io.legado.app.utils.mapAsync import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.flow import org.apache.commons.text.StringEscapeUtils import splitties.init.appCtx import kotlin.coroutines.coroutineContext /** * 获取正文 */ object BookContent { @Throws(Exception::class) suspend fun analyzeContent( bookSource: BookSource, book: Book, bookChapter: BookChapter, baseUrl: String, redirectUrl: String, body: String?, nextChapterUrl: String?, needSave: Boolean = true ): String { body ?: throw NoStackTraceException( appCtx.getString(R.string.error_get_web_content, baseUrl) ) Debug.log(bookSource.bookSourceUrl, "≡获取成功:${baseUrl}") Debug.log(bookSource.bookSourceUrl, body, state = 40) val mNextChapterUrl = if (nextChapterUrl.isNullOrEmpty()) { appDb.bookChapterDao.getChapter(book.bookUrl, bookChapter.index + 1)?.url ?: appDb.bookChapterDao.getChapter(book.bookUrl, 0)?.url } else { nextChapterUrl } val contentList = arrayListOf() val nextUrlList = arrayListOf(redirectUrl) val contentRule = bookSource.getContentRule() val analyzeRule = AnalyzeRule(book, bookSource) analyzeRule.setContent(body, baseUrl) analyzeRule.setRedirectUrl(redirectUrl) analyzeRule.setCoroutineContext(coroutineContext) analyzeRule.setChapter(bookChapter) analyzeRule.setNextChapterUrl(mNextChapterUrl) coroutineContext.ensureActive() val titleRule = contentRule.title if (!titleRule.isNullOrBlank()) { val title = analyzeRule.runCatching { getString(titleRule) }.onFailure { Debug.log(bookSource.bookSourceUrl, "获取标题出错, ${it.localizedMessage}") }.getOrNull() if (!title.isNullOrBlank()) { bookChapter.title = title bookChapter.titleMD5 = null appDb.bookChapterDao.update(bookChapter) } } var contentData = analyzeContent( book, baseUrl, redirectUrl, body, contentRule, bookChapter, bookSource, mNextChapterUrl ) contentList.add(contentData.first) if (contentData.second.size == 1) { var nextUrl = contentData.second[0] while (nextUrl.isNotEmpty() && !nextUrlList.contains(nextUrl)) { if (!mNextChapterUrl.isNullOrEmpty() && NetworkUtils.getAbsoluteURL(redirectUrl, nextUrl) == NetworkUtils.getAbsoluteURL(redirectUrl, mNextChapterUrl) ) break nextUrlList.add(nextUrl) coroutineContext.ensureActive() val analyzeUrl = AnalyzeUrl( mUrl = nextUrl, source = bookSource, ruleData = book, coroutineContext = coroutineContext ) val res = analyzeUrl.getStrResponseAwait() //控制并发访问 res.body?.let { nextBody -> contentData = analyzeContent( book, nextUrl, res.url, nextBody, contentRule, bookChapter, bookSource, mNextChapterUrl, printLog = false ) nextUrl = if (contentData.second.isNotEmpty()) contentData.second[0] else "" contentList.add(contentData.first) Debug.log(bookSource.bookSourceUrl, "第${contentList.size}页完成") } } Debug.log(bookSource.bookSourceUrl, "◇本章总页数:${nextUrlList.size}") } else if (contentData.second.size > 1) { Debug.log(bookSource.bookSourceUrl, "◇并发解析正文,总页数:${contentData.second.size}") flow { for (urlStr in contentData.second) { emit(urlStr) } }.mapAsync(AppConfig.threadCount) { urlStr -> val analyzeUrl = AnalyzeUrl( mUrl = urlStr, source = bookSource, ruleData = book, coroutineContext = coroutineContext ) val res = analyzeUrl.getStrResponseAwait() //控制并发访问 analyzeContent( book, urlStr, res.url, res.body!!, contentRule, bookChapter, bookSource, mNextChapterUrl, getNextPageUrl = false, printLog = false ).first }.collect { coroutineContext.ensureActive() contentList.add(it) } } var contentStr = contentList.joinToString("\n") //全文替换 val replaceRegex = contentRule.replaceRegex if (!replaceRegex.isNullOrEmpty()) { contentStr = contentStr.split(AppPattern.LFRegex).joinToString("\n") { it.trim() } contentStr = analyzeRule.getString(replaceRegex, contentStr) contentStr = contentStr.split(AppPattern.LFRegex).joinToString("\n") { "  $it" } } Debug.log(bookSource.bookSourceUrl, "┌获取章节名称") Debug.log(bookSource.bookSourceUrl, "└${bookChapter.title}") Debug.log(bookSource.bookSourceUrl, "┌获取正文内容") Debug.log(bookSource.bookSourceUrl, "└\n$contentStr") if (!bookChapter.isVolume && contentStr.isBlank()) { throw ContentEmptyException("内容为空") } if (needSave) { BookHelp.saveContent(bookSource, book, bookChapter, contentStr) } return contentStr } @Throws(Exception::class) private suspend fun analyzeContent( book: Book, baseUrl: String, redirectUrl: String, body: String, contentRule: ContentRule, chapter: BookChapter, bookSource: BookSource, nextChapterUrl: String?, getNextPageUrl: Boolean = true, printLog: Boolean = true ): Pair> { val analyzeRule = AnalyzeRule(book, bookSource) analyzeRule.setContent(body, baseUrl) analyzeRule.setCoroutineContext(coroutineContext) val rUrl = analyzeRule.setRedirectUrl(redirectUrl) analyzeRule.setNextChapterUrl(nextChapterUrl) val nextUrlList = arrayListOf() analyzeRule.setChapter(chapter) //获取正文 var content = analyzeRule.getString(contentRule.content, unescape = false) content = HtmlFormatter.formatKeepImg(content, rUrl) if (content.indexOf('&') > -1) { content = StringEscapeUtils.unescapeHtml4(content) } //获取下一页链接 if (getNextPageUrl) { val nextUrlRule = contentRule.nextContentUrl if (!nextUrlRule.isNullOrEmpty()) { Debug.log(bookSource.bookSourceUrl, "┌获取正文下一页链接", printLog) analyzeRule.getStringList(nextUrlRule, isUrl = true)?.let { nextUrlList.addAll(it) } Debug.log(bookSource.bookSourceUrl, "└" + nextUrlList.joinToString(","), printLog) } } return Pair(content, nextUrlList) } } ================================================ FILE: app/src/main/java/io/legado/app/model/webBook/BookInfo.kt ================================================ package io.legado.app.model.webBook import android.text.TextUtils import io.legado.app.R import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookSource import io.legado.app.exception.NoStackTraceException import io.legado.app.help.book.BookHelp import io.legado.app.help.book.isWebFile import io.legado.app.model.Debug import io.legado.app.model.analyzeRule.AnalyzeRule import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext import io.legado.app.utils.DebugLog import io.legado.app.utils.HtmlFormatter import io.legado.app.utils.NetworkUtils import io.legado.app.utils.StringUtils.wordCountFormat import kotlinx.coroutines.ensureActive import splitties.init.appCtx import kotlin.coroutines.coroutineContext /** * 获取详情 */ object BookInfo { @Throws(Exception::class) suspend fun analyzeBookInfo( bookSource: BookSource, book: Book, baseUrl: String, redirectUrl: String, body: String?, canReName: Boolean, ) { body ?: throw NoStackTraceException( appCtx.getString(R.string.error_get_web_content, baseUrl) ) Debug.log(bookSource.bookSourceUrl, "≡获取成功:${baseUrl}") Debug.log(bookSource.bookSourceUrl, body, state = 20) val analyzeRule = AnalyzeRule(book, bookSource) analyzeRule.setContent(body).setBaseUrl(baseUrl) analyzeRule.setRedirectUrl(redirectUrl) analyzeRule.setCoroutineContext(coroutineContext) analyzeBookInfo(book, body, analyzeRule, bookSource, baseUrl, redirectUrl, canReName) } suspend fun analyzeBookInfo( book: Book, body: String, analyzeRule: AnalyzeRule, bookSource: BookSource, baseUrl: String, redirectUrl: String, canReName: Boolean, ) { val infoRule = bookSource.getBookInfoRule() infoRule.init?.let { if (it.isNotBlank()) { coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "≡执行详情页初始化规则") analyzeRule.setContent(analyzeRule.getElement(it)) } } val mCanReName = canReName && !infoRule.canReName.isNullOrBlank() coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "┌获取书名") BookHelp.formatBookName(analyzeRule.getString(infoRule.name)).let { if (it.isNotEmpty() && (mCanReName || book.name.isEmpty())) { book.name = it } Debug.log(bookSource.bookSourceUrl, "└${it}") } coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "┌获取作者") BookHelp.formatBookAuthor(analyzeRule.getString(infoRule.author)).let { if (it.isNotEmpty() && (mCanReName || book.author.isEmpty())) { book.author = it } Debug.log(bookSource.bookSourceUrl, "└${it}") } coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "┌获取分类") try { analyzeRule.getStringList(infoRule.kind) ?.joinToString(",") ?.let { if (it.isNotEmpty()) book.kind = it Debug.log(bookSource.bookSourceUrl, "└${it}") } ?: Debug.log(bookSource.bookSourceUrl, "└") } catch (e: Exception) { coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}") DebugLog.e("获取分类出错", e) } coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "┌获取字数") try { wordCountFormat(analyzeRule.getString(infoRule.wordCount)).let { if (it.isNotEmpty()) book.wordCount = it Debug.log(bookSource.bookSourceUrl, "└${it}") } } catch (e: Exception) { coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}") DebugLog.e("获取字数出错", e) } coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "┌获取最新章节") try { analyzeRule.getString(infoRule.lastChapter).let { if (it.isNotEmpty()) book.latestChapterTitle = it Debug.log(bookSource.bookSourceUrl, "└${it}") } } catch (e: Exception) { coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}") DebugLog.e("获取最新章节出错", e) } coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "┌获取简介") try { HtmlFormatter.format(analyzeRule.getString(infoRule.intro)).let { if (it.isNotEmpty()) book.intro = it Debug.log(bookSource.bookSourceUrl, "└${it}") } } catch (e: Exception) { coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}") DebugLog.e("获取简介出错", e) } coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "┌获取封面链接") try { analyzeRule.getString(infoRule.coverUrl).let { if (it.isNotEmpty()) { book.coverUrl = NetworkUtils.getAbsoluteURL(redirectUrl, it) } Debug.log(bookSource.bookSourceUrl, "└${it}") } } catch (e: Exception) { coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}") DebugLog.e("获取封面出错", e) } coroutineContext.ensureActive() if (!book.isWebFile) { Debug.log(bookSource.bookSourceUrl, "┌获取目录链接") book.tocUrl = analyzeRule.getString(infoRule.tocUrl, isUrl = true) if (book.tocUrl.isEmpty()) book.tocUrl = baseUrl if (book.tocUrl == baseUrl) { book.tocHtml = body } Debug.log(bookSource.bookSourceUrl, "└${book.tocUrl}") } else { Debug.log(bookSource.bookSourceUrl, "┌获取文件下载链接") book.downloadUrls = analyzeRule.getStringList(infoRule.downloadUrls, isUrl = true) if (book.downloadUrls.isNullOrEmpty()) { Debug.log(bookSource.bookSourceUrl, "└") throw NoStackTraceException("下载链接为空") } else { Debug.log( bookSource.bookSourceUrl, "└" + TextUtils.join(",\n", book.downloadUrls!!) ) } } } } ================================================ FILE: app/src/main/java/io/legado/app/model/webBook/BookList.kt ================================================ package io.legado.app.model.webBook import io.legado.app.R import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.SearchBook import io.legado.app.data.entities.rule.BookListRule import io.legado.app.data.entities.rule.ExploreKind import io.legado.app.exception.NoStackTraceException import io.legado.app.help.book.BookHelp import io.legado.app.help.source.exploreKindsJson import io.legado.app.help.source.getBookType import io.legado.app.model.Debug import io.legado.app.model.analyzeRule.AnalyzeRule import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setRuleData import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.model.analyzeRule.RuleData import io.legado.app.utils.GSON import io.legado.app.utils.GSONStrict import io.legado.app.utils.HtmlFormatter import io.legado.app.utils.NetworkUtils import io.legado.app.utils.StringUtils.wordCountFormat import io.legado.app.utils.fromJsonArray import kotlinx.coroutines.ensureActive import splitties.init.appCtx import kotlin.coroutines.coroutineContext /** * 获取书籍列表 */ object BookList { @Throws(Exception::class) suspend fun analyzeBookList( bookSource: BookSource, ruleData: RuleData, analyzeUrl: AnalyzeUrl, baseUrl: String, body: String?, isSearch: Boolean = true, isRedirect: Boolean = false, filter: ((name: String, author: String) -> Boolean)? = null, shouldBreak: ((size: Int) -> Boolean)? = null ): ArrayList { body ?: throw NoStackTraceException( appCtx.getString( R.string.error_get_web_content, analyzeUrl.ruleUrl ) ) val bookList = ArrayList() Debug.log(bookSource.bookSourceUrl, "≡获取成功:${analyzeUrl.ruleUrl}") Debug.log(bookSource.bookSourceUrl, body, state = 10) val analyzeRule = AnalyzeRule(ruleData, bookSource) analyzeRule.setContent(body).setBaseUrl(baseUrl) analyzeRule.setRedirectUrl(baseUrl) analyzeRule.setCoroutineContext(coroutineContext) if (!isSearch) { checkExploreJson(bookSource) } if (isSearch) bookSource.bookUrlPattern?.let { coroutineContext.ensureActive() if (baseUrl.matches(it.toRegex())) { Debug.log(bookSource.bookSourceUrl, "≡链接为详情页") getInfoItem( bookSource, analyzeRule, analyzeUrl, body, baseUrl, ruleData.getVariable(), isRedirect, filter )?.let { searchBook -> searchBook.infoHtml = body bookList.add(searchBook) } return bookList } } val collections: List var reverse = false val bookListRule: BookListRule = when { isSearch -> bookSource.getSearchRule() bookSource.getExploreRule().bookList.isNullOrBlank() -> bookSource.getSearchRule() else -> bookSource.getExploreRule() } var ruleList: String = bookListRule.bookList ?: "" if (ruleList.startsWith("-")) { reverse = true ruleList = ruleList.substring(1) } if (ruleList.startsWith("+")) { ruleList = ruleList.substring(1) } Debug.log(bookSource.bookSourceUrl, "┌获取书籍列表") collections = analyzeRule.getElements(ruleList) coroutineContext.ensureActive() if (collections.isEmpty() && bookSource.bookUrlPattern.isNullOrEmpty()) { Debug.log(bookSource.bookSourceUrl, "└列表为空,按详情页解析") getInfoItem( bookSource, analyzeRule, analyzeUrl, body, baseUrl, ruleData.getVariable(), isRedirect, filter )?.let { searchBook -> searchBook.infoHtml = body bookList.add(searchBook) } } else { val ruleName = analyzeRule.splitSourceRule(bookListRule.name) val ruleBookUrl = analyzeRule.splitSourceRule(bookListRule.bookUrl) val ruleAuthor = analyzeRule.splitSourceRule(bookListRule.author) val ruleCoverUrl = analyzeRule.splitSourceRule(bookListRule.coverUrl) val ruleIntro = analyzeRule.splitSourceRule(bookListRule.intro) val ruleKind = analyzeRule.splitSourceRule(bookListRule.kind) val ruleLastChapter = analyzeRule.splitSourceRule(bookListRule.lastChapter) val ruleWordCount = analyzeRule.splitSourceRule(bookListRule.wordCount) Debug.log(bookSource.bookSourceUrl, "└列表大小:${collections.size}") for ((index, item) in collections.withIndex()) { getSearchItem( bookSource, analyzeRule, item, baseUrl, ruleData.getVariable(), index == 0, filter, ruleName = ruleName, ruleBookUrl = ruleBookUrl, ruleAuthor = ruleAuthor, ruleCoverUrl = ruleCoverUrl, ruleIntro = ruleIntro, ruleKind = ruleKind, ruleLastChapter = ruleLastChapter, ruleWordCount = ruleWordCount )?.let { searchBook -> if (baseUrl == searchBook.bookUrl) { searchBook.infoHtml = body } bookList.add(searchBook) } if (shouldBreak?.invoke(bookList.size) == true) { break } } val lh = LinkedHashSet(bookList) bookList.clear() bookList.addAll(lh) if (reverse) { bookList.reverse() } } Debug.log(bookSource.bookSourceUrl, "◇书籍总数:${bookList.size}") return bookList } @Throws(Exception::class) private suspend fun getInfoItem( bookSource: BookSource, analyzeRule: AnalyzeRule, analyzeUrl: AnalyzeUrl, body: String, baseUrl: String, variable: String?, isRedirect: Boolean, filter: ((name: String, author: String) -> Boolean)? ): SearchBook? { val book = Book(variable = variable) book.bookUrl = if (isRedirect) { baseUrl } else { NetworkUtils.getAbsoluteURL(analyzeUrl.url, analyzeUrl.ruleUrl) } book.origin = bookSource.bookSourceUrl book.originName = bookSource.bookSourceName book.originOrder = bookSource.customOrder book.type = bookSource.getBookType() analyzeRule.setRuleData(book) BookInfo.analyzeBookInfo( book, body, analyzeRule, bookSource, baseUrl, baseUrl, false ) if (filter?.invoke(book.name, book.author) == false) { return null } if (book.name.isNotBlank()) { return book.toSearchBook() } return null } @Throws(Exception::class) private suspend fun getSearchItem( bookSource: BookSource, analyzeRule: AnalyzeRule, item: Any, baseUrl: String, variable: String?, log: Boolean, filter: ((name: String, author: String) -> Boolean)?, ruleName: List, ruleBookUrl: List, ruleAuthor: List, ruleKind: List, ruleCoverUrl: List, ruleWordCount: List, ruleIntro: List, ruleLastChapter: List ): SearchBook? { val searchBook = SearchBook(variable = variable) searchBook.type = bookSource.getBookType() searchBook.origin = bookSource.bookSourceUrl searchBook.originName = bookSource.bookSourceName searchBook.originOrder = bookSource.customOrder analyzeRule.setRuleData(searchBook) analyzeRule.setContent(item) coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "┌获取书名", log) searchBook.name = BookHelp.formatBookName(analyzeRule.getString(ruleName)) Debug.log(bookSource.bookSourceUrl, "└${searchBook.name}", log) if (searchBook.name.isNotEmpty()) { coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "┌获取作者", log) searchBook.author = BookHelp.formatBookAuthor(analyzeRule.getString(ruleAuthor)) Debug.log(bookSource.bookSourceUrl, "└${searchBook.author}", log) if (filter?.invoke(searchBook.name, searchBook.author) == false) { return null } coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "┌获取分类", log) try { searchBook.kind = analyzeRule.getStringList(ruleKind)?.joinToString(",") Debug.log(bookSource.bookSourceUrl, "└${searchBook.kind ?: ""}", log) } catch (e: Exception) { coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}", log) } coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "┌获取字数", log) try { searchBook.wordCount = wordCountFormat(analyzeRule.getString(ruleWordCount)) Debug.log(bookSource.bookSourceUrl, "└${searchBook.wordCount}", log) } catch (e: Exception) { coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}", log) } coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "┌获取最新章节", log) try { searchBook.latestChapterTitle = analyzeRule.getString(ruleLastChapter) Debug.log(bookSource.bookSourceUrl, "└${searchBook.latestChapterTitle}", log) } catch (e: Exception) { coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}", log) } coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "┌获取简介", log) try { searchBook.intro = HtmlFormatter.format(analyzeRule.getString(ruleIntro)) Debug.log(bookSource.bookSourceUrl, "└${searchBook.intro}", log) } catch (e: Exception) { coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}", log) } coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "┌获取封面链接", log) try { analyzeRule.getString(ruleCoverUrl).let { if (it.isNotEmpty()) { searchBook.coverUrl = NetworkUtils.getAbsoluteURL(baseUrl, it) } } Debug.log(bookSource.bookSourceUrl, "└${searchBook.coverUrl ?: ""}", log) } catch (e: Exception) { coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}", log) } coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "┌获取详情页链接", log) searchBook.bookUrl = analyzeRule.getString(ruleBookUrl, isUrl = true) if (searchBook.bookUrl.isEmpty()) { searchBook.bookUrl = baseUrl } Debug.log(bookSource.bookSourceUrl, "└${searchBook.bookUrl}", log) return searchBook } return null } private fun checkExploreJson(bookSource: BookSource) { if (Debug.callback == null) { return } val json = bookSource.exploreKindsJson() if (json.isEmpty()) { return } val kinds = GSONStrict.fromJsonArray(json).getOrNull() if (kinds != null) { return } GSON.fromJsonArray(json).getOrNull()?.let { Debug.log("≡发现地址规则 JSON 格式不规范,请改为规范格式") } } } ================================================ FILE: app/src/main/java/io/legado/app/model/webBook/SearchModel.kt ================================================ package io.legado.app.model.webBook import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog import io.legado.app.constant.PreferKey import io.legado.app.data.appDb import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.SearchBook import io.legado.app.exception.NoStackTraceException import io.legado.app.help.config.AppConfig import io.legado.app.ui.book.search.SearchScope import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.mapParallelSafe import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExecutorCoroutineDispatcher import kotlinx.coroutines.Job import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import splitties.init.appCtx import java.util.concurrent.Executors import kotlin.coroutines.coroutineContext import kotlin.math.min class SearchModel(private val scope: CoroutineScope, private val callBack: CallBack) { val threadCount = AppConfig.threadCount private var searchPool: ExecutorCoroutineDispatcher? = null private var mSearchId = 0L private var searchPage = 1 private var searchKey: String = "" private var bookSourceParts = emptyList() private var searchBooks = arrayListOf() private var searchJob: Job? = null private var workingState = MutableStateFlow(true) private fun initSearchPool() { searchPool?.close() searchPool = Executors .newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher() } fun search(searchId: Long, key: String) { if (searchId != mSearchId) { if (key.isEmpty()) { return } searchKey = key if (mSearchId != 0L) { close() } searchBooks.clear() bookSourceParts = callBack.getSearchScope().getBookSourceParts() if (bookSourceParts.isEmpty()) { callBack.onSearchCancel(NoStackTraceException("启用书源为空")) return } mSearchId = searchId searchPage = 1 initSearchPool() } else { searchPage++ } startSearch() } private fun startSearch() { val precision = appCtx.getPrefBoolean(PreferKey.precisionSearch) var hasMore = false searchJob = scope.launch(searchPool!!) { flow { for (bs in bookSourceParts) { bs.getBookSource()?.let { emit(it) } workingState.first { it } } }.onStart { callBack.onSearchStart() }.mapParallelSafe(threadCount) { withTimeout(30000L) { WebBook.searchBookAwait( it, searchKey, searchPage, filter = { name, author -> !precision || name.contains(searchKey) || author.contains(searchKey) }) } }.onEach { items -> for (book in items) { book.releaseHtmlData() } hasMore = hasMore || items.isNotEmpty() appDb.searchBookDao.insert(*items.toTypedArray()) mergeItems(items, precision) currentCoroutineContext().ensureActive() callBack.onSearchSuccess(searchBooks) }.onCompletion { if (it == null) callBack.onSearchFinish(searchBooks.isEmpty(), hasMore) }.catch { AppLog.put("书源搜索出错\n${it.localizedMessage}", it) }.collect() } } private suspend fun mergeItems(newDataS: List, precision: Boolean) { if (newDataS.isNotEmpty()) { val copyData = ArrayList(searchBooks) val equalData = arrayListOf() val containsData = arrayListOf() val otherData = arrayListOf() copyData.forEach { coroutineContext.ensureActive() if (it.name == searchKey || it.author == searchKey) { equalData.add(it) } else if (it.name.contains(searchKey) || it.author.contains(searchKey)) { containsData.add(it) } else { otherData.add(it) } } newDataS.forEach { nBook -> coroutineContext.ensureActive() if (nBook.name == searchKey || nBook.author == searchKey) { var hasSame = false equalData.forEach { pBook -> coroutineContext.ensureActive() if (pBook.name == nBook.name && pBook.author == nBook.author) { pBook.addOrigin(nBook.origin) hasSame = true } } if (!hasSame) { equalData.add(nBook) } } else if (nBook.name.contains(searchKey) || nBook.author.contains(searchKey)) { var hasSame = false containsData.forEach { pBook -> coroutineContext.ensureActive() if (pBook.name == nBook.name && pBook.author == nBook.author) { pBook.addOrigin(nBook.origin) hasSame = true } } if (!hasSame) { containsData.add(nBook) } } else if (!precision) { var hasSame = false otherData.forEach { pBook -> coroutineContext.ensureActive() if (pBook.name == nBook.name && pBook.author == nBook.author) { pBook.addOrigin(nBook.origin) hasSame = true } } if (!hasSame) { otherData.add(nBook) } } } coroutineContext.ensureActive() equalData.sortByDescending { it.origins.size } equalData.addAll(containsData.sortedByDescending { it.origins.size }) if (!precision) { equalData.addAll(otherData) } coroutineContext.ensureActive() searchBooks = equalData } } fun pause() { workingState.value = false } fun resume() { workingState.value = true } fun cancelSearch() { close() callBack.onSearchCancel() } fun close() { searchJob?.cancel() searchPool?.close() searchPool = null mSearchId = 0L } interface CallBack { fun getSearchScope(): SearchScope fun onSearchStart() fun onSearchSuccess(searchBooks: List) fun onSearchFinish(isEmpty: Boolean, hasMore: Boolean) fun onSearchCancel(exception: Throwable? = null) } } ================================================ FILE: app/src/main/java/io/legado/app/model/webBook/WebBook.kt ================================================ package io.legado.app.model.webBook import io.legado.app.constant.AppLog import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.SearchBook import io.legado.app.exception.NoStackTraceException import io.legado.app.help.book.addType import io.legado.app.help.book.removeAllBookType import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.http.StrResponse import io.legado.app.help.source.getBookType import io.legado.app.model.Debug import io.legado.app.model.analyzeRule.AnalyzeRule import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.model.analyzeRule.RuleData import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ensureActive import kotlinx.coroutines.sync.Semaphore import kotlin.coroutines.CoroutineContext import kotlin.coroutines.coroutineContext @Suppress("MemberVisibilityCanBePrivate") object WebBook { /** * 搜索 */ fun searchBook( scope: CoroutineScope, bookSource: BookSource, key: String, page: Int? = 1, context: CoroutineContext = Dispatchers.IO, start: CoroutineStart = CoroutineStart.DEFAULT, executeContext: CoroutineContext = Dispatchers.Main, ): Coroutine> { return Coroutine.async(scope, context, start = start, executeContext = executeContext) { searchBookAwait(bookSource, key, page) } } suspend fun searchBookAwait( bookSource: BookSource, key: String, page: Int? = 1, filter: ((name: String, author: String) -> Boolean)? = null, shouldBreak: ((size: Int) -> Boolean)? = null ): ArrayList { val searchUrl = bookSource.searchUrl if (searchUrl.isNullOrBlank()) { throw NoStackTraceException("搜索url不能为空") } val ruleData = RuleData() val analyzeUrl = AnalyzeUrl( mUrl = searchUrl, key = key, page = page, baseUrl = bookSource.bookSourceUrl, source = bookSource, ruleData = ruleData, coroutineContext = coroutineContext ) var res = analyzeUrl.getStrResponseAwait() //检测书源是否已登录 bookSource.loginCheckJs?.let { checkJs -> if (checkJs.isNotBlank()) { res = analyzeUrl.evalJS(checkJs, res) as StrResponse } } checkRedirect(bookSource, res) return BookList.analyzeBookList( bookSource = bookSource, ruleData = ruleData, analyzeUrl = analyzeUrl, baseUrl = res.url, body = res.body, isSearch = true, isRedirect = res.raw.priorResponse?.isRedirect == true, filter = filter, shouldBreak = shouldBreak ) } /** * 发现 */ fun exploreBook( scope: CoroutineScope, bookSource: BookSource, url: String, page: Int? = 1, context: CoroutineContext = Dispatchers.IO, ): Coroutine> { return Coroutine.async(scope, context) { exploreBookAwait(bookSource, url, page) } } suspend fun exploreBookAwait( bookSource: BookSource, url: String, page: Int? = 1, ): ArrayList { val ruleData = RuleData() val analyzeUrl = AnalyzeUrl( mUrl = url, page = page, baseUrl = bookSource.bookSourceUrl, source = bookSource, ruleData = ruleData, coroutineContext = coroutineContext ) var res = analyzeUrl.getStrResponseAwait() //检测书源是否已登录 bookSource.loginCheckJs?.let { checkJs -> if (checkJs.isNotBlank()) { res = analyzeUrl.evalJS(checkJs, result = res) as StrResponse } } checkRedirect(bookSource, res) return BookList.analyzeBookList( bookSource = bookSource, ruleData = ruleData, analyzeUrl = analyzeUrl, baseUrl = res.url, body = res.body, isSearch = false ) } /** * 书籍信息 */ fun getBookInfo( scope: CoroutineScope, bookSource: BookSource, book: Book, context: CoroutineContext = Dispatchers.IO, canReName: Boolean = true, ): Coroutine { return Coroutine.async(scope, context) { getBookInfoAwait(bookSource, book, canReName) } } suspend fun getBookInfoAwait( bookSource: BookSource, book: Book, canReName: Boolean = true, ): Book { book.removeAllBookType() book.addType(bookSource.getBookType()) if (!book.infoHtml.isNullOrEmpty()) { BookInfo.analyzeBookInfo( bookSource = bookSource, book = book, baseUrl = book.bookUrl, redirectUrl = book.bookUrl, body = book.infoHtml, canReName = canReName ) } else { val analyzeUrl = AnalyzeUrl( mUrl = book.bookUrl, baseUrl = bookSource.bookSourceUrl, source = bookSource, ruleData = book, coroutineContext = coroutineContext ) var res = analyzeUrl.getStrResponseAwait() //检测书源是否已登录 bookSource.loginCheckJs?.let { checkJs -> if (checkJs.isNotBlank()) { res = analyzeUrl.evalJS(checkJs, result = res) as StrResponse } } checkRedirect(bookSource, res) BookInfo.analyzeBookInfo( bookSource = bookSource, book = book, baseUrl = book.bookUrl, redirectUrl = res.url, body = res.body, canReName = canReName ) } return book } /** * 目录 */ fun getChapterList( scope: CoroutineScope, bookSource: BookSource, book: Book, runPerJs: Boolean = false, context: CoroutineContext = Dispatchers.IO ): Coroutine> { return Coroutine.async(scope, context) { getChapterListAwait(bookSource, book, runPerJs).getOrThrow() } } suspend fun runPreUpdateJs(bookSource: BookSource, book: Book): Result { return kotlin.runCatching { val preUpdateJs = bookSource.ruleToc?.preUpdateJs if (!preUpdateJs.isNullOrBlank()) { AnalyzeRule(book, bookSource, true) .setCoroutineContext(coroutineContext) .evalJS(preUpdateJs) } }.onFailure { coroutineContext.ensureActive() AppLog.put("执行preUpdateJs规则失败 书源:${bookSource.bookSourceName}", it) } } suspend fun getChapterListAwait( bookSource: BookSource, book: Book, runPerJs: Boolean = false ): Result> { book.removeAllBookType() book.addType(bookSource.getBookType()) return kotlin.runCatching { if (runPerJs) { runPreUpdateJs(bookSource, book).getOrThrow() } if (book.bookUrl == book.tocUrl && !book.tocHtml.isNullOrEmpty()) { BookChapterList.analyzeChapterList( bookSource = bookSource, book = book, baseUrl = book.tocUrl, redirectUrl = book.tocUrl, body = book.tocHtml ) } else { val analyzeUrl = AnalyzeUrl( mUrl = book.tocUrl, baseUrl = book.bookUrl, source = bookSource, ruleData = book, coroutineContext = coroutineContext ) var res = analyzeUrl.getStrResponseAwait() //检测书源是否已登录 bookSource.loginCheckJs?.let { checkJs -> if (checkJs.isNotBlank()) { res = analyzeUrl.evalJS(checkJs, result = res) as StrResponse } } checkRedirect(bookSource, res) BookChapterList.analyzeChapterList( bookSource = bookSource, book = book, baseUrl = book.tocUrl, redirectUrl = res.url, body = res.body ) } }.onFailure { coroutineContext.ensureActive() } } /** * 章节内容 */ fun getContent( scope: CoroutineScope, bookSource: BookSource, book: Book, bookChapter: BookChapter, nextChapterUrl: String? = null, needSave: Boolean = true, context: CoroutineContext = Dispatchers.IO, start: CoroutineStart = CoroutineStart.DEFAULT, executeContext: CoroutineContext = Dispatchers.Main, semaphore: Semaphore? = null, ): Coroutine { return Coroutine.async( scope, context, start = start, executeContext = executeContext, semaphore = semaphore ) { getContentAwait(bookSource, book, bookChapter, nextChapterUrl, needSave) } } suspend fun getContentAwait( bookSource: BookSource, book: Book, bookChapter: BookChapter, nextChapterUrl: String? = null, needSave: Boolean = true ): String { if (bookSource.getContentRule().content.isNullOrEmpty()) { Debug.log(bookSource.bookSourceUrl, "⇒正文规则为空,使用章节链接:${bookChapter.url}") return bookChapter.url } if (bookChapter.isVolume && bookChapter.url.startsWith(bookChapter.title)) { Debug.log(bookSource.bookSourceUrl, "⇒一级目录正文不解析规则") return bookChapter.tag ?: "" } return if (bookChapter.url == book.bookUrl && !book.tocHtml.isNullOrEmpty()) { BookContent.analyzeContent( bookSource = bookSource, book = book, bookChapter = bookChapter, baseUrl = bookChapter.getAbsoluteURL(), redirectUrl = bookChapter.getAbsoluteURL(), body = book.tocHtml, nextChapterUrl = nextChapterUrl, needSave = needSave ) } else { val analyzeUrl = AnalyzeUrl( mUrl = bookChapter.getAbsoluteURL(), baseUrl = book.tocUrl, source = bookSource, ruleData = book, chapter = bookChapter, coroutineContext = coroutineContext ) var res = analyzeUrl.getStrResponseAwait( jsStr = bookSource.getContentRule().webJs, sourceRegex = bookSource.getContentRule().sourceRegex ) //检测书源是否已登录 bookSource.loginCheckJs?.let { checkJs -> if (checkJs.isNotBlank()) { res = analyzeUrl.evalJS(checkJs, result = res) as StrResponse } } checkRedirect(bookSource, res) BookContent.analyzeContent( bookSource = bookSource, book = book, bookChapter = bookChapter, baseUrl = bookChapter.getAbsoluteURL(), redirectUrl = res.url, body = res.body, nextChapterUrl = nextChapterUrl, needSave = needSave ) } } /** * 精准搜索 */ fun preciseSearch( scope: CoroutineScope, bookSourceParts: List, name: String, author: String, context: CoroutineContext = Dispatchers.IO, semaphore: Semaphore? = null, ): Coroutine> { return Coroutine.async(scope, context, semaphore = semaphore) { for (s in bookSourceParts) { val source = s.getBookSource() ?: continue val book = preciseSearchAwait(source, name, author).getOrNull() if (book != null) { return@async Pair(book, source) } } throw NoStackTraceException("没有搜索到<$name>$author") } } suspend fun preciseSearchAwait( bookSource: BookSource, name: String, author: String, ): Result { return kotlin.runCatching { coroutineContext.ensureActive() searchBookAwait( bookSource, name, filter = { fName, fAuthor -> fName == name && fAuthor == author }, shouldBreak = { it > 0 } ).firstOrNull()?.let { searchBook -> coroutineContext.ensureActive() return@runCatching searchBook.toBook() } throw NoStackTraceException("未搜索到 $name($author) 书籍") }.onFailure { coroutineContext.ensureActive() } } /** * 检测重定向 */ private fun checkRedirect(bookSource: BookSource, response: StrResponse) { response.raw.priorResponse?.let { if (it.isRedirect) { Debug.log(bookSource.bookSourceUrl, "≡检测到重定向(${it.code})") Debug.log(bookSource.bookSourceUrl, "┌重定向后地址") Debug.log(bookSource.bookSourceUrl, "└${response.url}") } } } } ================================================ FILE: app/src/main/java/io/legado/app/receiver/MediaButtonReceiver.kt ================================================ package io.legado.app.receiver import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.view.KeyEvent import io.legado.app.constant.EventBus import io.legado.app.data.appDb import io.legado.app.help.LifecycleHelp import io.legado.app.help.config.AppConfig import io.legado.app.model.AudioPlay import io.legado.app.model.ReadAloud import io.legado.app.model.ReadBook import io.legado.app.service.AudioPlayService import io.legado.app.service.BaseReadAloudService import io.legado.app.ui.book.audio.AudioPlayActivity import io.legado.app.ui.book.read.ReadBookActivity import io.legado.app.utils.LogUtils import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.postEvent /** * Created by GKF on 2018/1/6. * 监听耳机键 */ class MediaButtonReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (handleIntent(context, intent) && isOrderedBroadcast) { abortBroadcast() } } companion object { private const val TAG = "MediaButtonReceiver" fun handleIntent(context: Context, intent: Intent): Boolean { val intentAction = intent.action if (Intent.ACTION_MEDIA_BUTTON == intentAction) { @Suppress("DEPRECATION") val keyEvent = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) ?: return false val keycode: Int = keyEvent.keyCode val action: Int = keyEvent.action if (action == KeyEvent.ACTION_DOWN) { LogUtils.d(TAG, "Receive mediaButton event, keycode:$keycode") when (keycode) { KeyEvent.KEYCODE_MEDIA_PREVIOUS -> { if (context.getPrefBoolean("mediaButtonPerNext", false)) { ReadBook.moveToPrevChapter(true) } else { ReadAloud.prevParagraph(context) } } KeyEvent.KEYCODE_MEDIA_NEXT -> { if (context.getPrefBoolean("mediaButtonPerNext", false)) { ReadBook.moveToNextChapter(true) } else { ReadAloud.nextParagraph(context) } } else -> readAloud(context) } } } return true } fun readAloud(context: Context, isMediaKey: Boolean = true) { when { BaseReadAloudService.isRun -> { if (BaseReadAloudService.isPlay()) { ReadAloud.pause(context) AudioPlay.pause(context) } else { ReadAloud.resume(context) AudioPlay.resume(context) } } AudioPlayService.isRun -> { if (AudioPlayService.pause) { AudioPlay.resume(context) } else { AudioPlay.pause(context) } } isMediaKey && !AppConfig.readAloudByMediaButton -> { // break } LifecycleHelp.isExistActivity(ReadBookActivity::class.java) -> postEvent(EventBus.MEDIA_BUTTON, true) LifecycleHelp.isExistActivity(AudioPlayActivity::class.java) -> postEvent(EventBus.MEDIA_BUTTON, true) else -> if (AppConfig.mediaButtonOnExit || LifecycleHelp.activitySize() > 0 || !isMediaKey) { ReadAloud.upReadAloudClass() if (ReadBook.book != null) { ReadBook.readAloud() } else { appDb.bookDao.lastReadBook?.let { ReadBook.resetData(it) ReadBook.clearTextChapter() ReadBook.loadContent(false) { ReadBook.readAloud() } } } } } } } } ================================================ FILE: app/src/main/java/io/legado/app/receiver/NetworkChangedListener.kt ================================================ package io.legado.app.receiver import android.annotation.SuppressLint import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.net.ConnectivityManager import android.net.Network import android.os.Build import splitties.systemservices.connectivityManager /** * 监测网络变化 */ @SuppressLint("ObsoleteSdkInt") class NetworkChangedListener(private val context: Context) { var onNetworkChanged: (() -> Unit)? = null private val receiver: NetworkChangedReceiver? by lazy { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { NetworkChangedReceiver() } return@lazy null } private val networkCallback: ConnectivityManager.NetworkCallback? by lazy { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { return@lazy object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { onNetworkChanged?.invoke() } } } return@lazy null } @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") fun register() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { networkCallback?.let { connectivityManager.registerDefaultNetworkCallback(it) } } else { receiver?.let { context.registerReceiver(it, it.filter) } } } fun unRegister() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { networkCallback?.let { connectivityManager.unregisterNetworkCallback(it) } } else { receiver?.let { context.unregisterReceiver(it) } } } inner class NetworkChangedReceiver : BroadcastReceiver() { val filter = IntentFilter().apply { @Suppress("DEPRECATION") addAction(ConnectivityManager.CONNECTIVITY_ACTION) } override fun onReceive(context: Context, intent: Intent) { onNetworkChanged?.invoke() } } } ================================================ FILE: app/src/main/java/io/legado/app/receiver/SharedReceiverActivity.kt ================================================ package io.legado.app.receiver import android.annotation.SuppressLint import android.content.Intent import android.os.Build import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import io.legado.app.ui.book.search.SearchActivity import io.legado.app.ui.main.MainActivity import io.legado.app.utils.startActivity import splitties.init.appCtx class SharedReceiverActivity : AppCompatActivity() { private val receivingType = "text/plain" override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) initIntent() finish() } @SuppressLint("ObsoleteSdkInt") private fun initIntent() { when { intent.action == Intent.ACTION_SEND && intent.type == receivingType -> { intent.getStringExtra(Intent.EXTRA_TEXT)?.let { dispose(it) } } Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && intent.action == Intent.ACTION_PROCESS_TEXT && intent.type == receivingType -> { intent.getStringExtra(Intent.EXTRA_PROCESS_TEXT)?.let { dispose(it) } } intent.getStringExtra("action") == "readAloud" -> { MediaButtonReceiver.readAloud(appCtx, false) } } } private fun dispose(text: String) { if (text.isBlank()) { return } val urls = text.split("\\s".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() val result = StringBuilder() for (url in urls) { if (url.matches("http.+".toRegex())) result.append("\n").append(url.trim { it <= ' ' }) } if (result.length > 1) { startActivity() } else { SearchActivity.start(this, text) } } } ================================================ FILE: app/src/main/java/io/legado/app/receiver/TimeBatteryReceiver.kt ================================================ package io.legado.app.receiver import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.BatteryManager import io.legado.app.constant.EventBus import io.legado.app.utils.postEvent class TimeBatteryReceiver : BroadcastReceiver() { val filter = IntentFilter().apply { addAction(Intent.ACTION_TIME_TICK) addAction(Intent.ACTION_BATTERY_CHANGED) } override fun onReceive(context: Context, intent: Intent) { when (intent.action) { Intent.ACTION_TIME_TICK -> { postEvent(EventBus.TIME_CHANGED, "") } Intent.ACTION_BATTERY_CHANGED -> { val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) postEvent(EventBus.BATTERY_CHANGED, level) } } } } ================================================ FILE: app/src/main/java/io/legado/app/service/AudioPlayService.kt ================================================ package io.legado.app.service import android.annotation.SuppressLint import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.graphics.Bitmap import android.graphics.BitmapFactory import android.media.AudioManager import android.net.wifi.WifiManager.WIFI_MODE_FULL_HIGH_PERF import android.os.Build import android.os.Bundle import android.os.PowerManager import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat import androidx.core.app.NotificationCompat import androidx.lifecycle.lifecycleScope import androidx.media.AudioFocusRequestCompat import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer import io.legado.app.R import io.legado.app.base.BaseService import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog import io.legado.app.constant.EventBus import io.legado.app.constant.IntentAction import io.legado.app.constant.NotificationId import io.legado.app.constant.Status import io.legado.app.help.MediaHelp import io.legado.app.help.config.AppConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.exoplayer.ExoPlayerHelper import io.legado.app.help.glide.ImageLoader import io.legado.app.model.AudioPlay import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.model.analyzeRule.AnalyzeUrl.Companion.getMediaItem import io.legado.app.receiver.MediaButtonReceiver import io.legado.app.ui.book.audio.AudioPlayActivity import io.legado.app.utils.activityPendingIntent import io.legado.app.utils.broadcastPendingIntent import io.legado.app.utils.postEvent import io.legado.app.utils.printOnDebug import io.legado.app.utils.servicePendingIntent import io.legado.app.utils.toastOnUi import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import splitties.init.appCtx import splitties.systemservices.audioManager import splitties.systemservices.notificationManager import splitties.systemservices.powerManager import splitties.systemservices.wifiManager /** * 音频播放服务 */ class AudioPlayService : BaseService(), AudioManager.OnAudioFocusChangeListener, Player.Listener { companion object { @JvmStatic var isRun = false private set @JvmStatic var pause = true private set @JvmStatic var timeMinute: Int = 0 var url: String = "" private set private const val MEDIA_SESSION_ACTIONS = (PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_SEEK_TO) private const val APP_ACTION_STOP = "Stop" private const val APP_ACTION_TIMER = "Timer" } private val useWakeLock = AppConfig.audioPlayUseWakeLock private val wakeLock by lazy { powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "legado:AudioPlayService") .apply { this.setReferenceCounted(false) } } private val wifiLock by lazy { @Suppress("DEPRECATION") wifiManager?.createWifiLock(WIFI_MODE_FULL_HIGH_PERF, "legado:AudioPlayService")?.apply { setReferenceCounted(false) } } private val mFocusRequest: AudioFocusRequestCompat by lazy { MediaHelp.buildAudioFocusRequestCompat(this) } private val exoPlayer: ExoPlayer by lazy { ExoPlayerHelper.createHttpExoPlayer(this) } private var mediaSessionCompat: MediaSessionCompat? = null private var broadcastReceiver: BroadcastReceiver? = null private var needResumeOnAudioFocusGain = false private var position = AudioPlay.book?.durChapterPos ?: 0 private var dsJob: Job? = null private var upNotificationJob: Coroutine<*>? = null private var upPlayProgressJob: Job? = null private var playSpeed: Float = 1f private var cover: Bitmap = BitmapFactory.decodeResource(appCtx.resources, R.drawable.icon_read_book) override fun onCreate() { super.onCreate() isRun = true exoPlayer.addListener(this) AudioPlay.registerService(this) initMediaSession() initBroadcastReceiver() upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING) doDs() execute { ImageLoader .loadBitmap(this@AudioPlayService, AudioPlay.book?.getDisplayCover()) .submit() .get() }.onSuccess { if (it.width > 16 && it.height > 16) { cover = it upMediaMetadata() upAudioPlayNotification() } } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { intent?.action?.let { action -> when (action) { IntentAction.play -> { exoPlayer.stop() upPlayProgressJob?.cancel() pause = false position = AudioPlay.book?.durChapterPos ?: 0 url = AudioPlay.durPlayUrl play() } IntentAction.playNew -> { exoPlayer.stop() upPlayProgressJob?.cancel() pause = false position = 0 url = AudioPlay.durPlayUrl play() } IntentAction.stopPlay -> { exoPlayer.stop() upPlayProgressJob?.cancel() AudioPlay.status = Status.STOP postEvent(EventBus.AUDIO_STATE, Status.STOP) } IntentAction.pause -> pause() IntentAction.resume -> resume() IntentAction.prev -> AudioPlay.prev() IntentAction.next -> AudioPlay.next() IntentAction.adjustSpeed -> upSpeed(intent.getFloatExtra("adjust", 1f)) IntentAction.addTimer -> addTimer() IntentAction.setTimer -> setTimer(intent.getIntExtra("minute", 0)) IntentAction.adjustProgress -> { adjustProgress(intent.getIntExtra("position", position)) } IntentAction.stop -> stopSelf() } } return super.onStartCommand(intent, flags, startId) } override fun onDestroy() { super.onDestroy() if (useWakeLock) { wakeLock.release() wifiLock?.release() } isRun = false abandonFocus() exoPlayer.release() mediaSessionCompat?.release() unregisterReceiver(broadcastReceiver) upMediaSessionPlaybackState(PlaybackStateCompat.STATE_STOPPED) AudioPlay.status = Status.STOP postEvent(EventBus.AUDIO_STATE, Status.STOP) AudioPlay.unregisterService() upNotificationJob?.invokeOnCompletion { notificationManager.cancel(NotificationId.AudioPlayService) } } /** * 播放音频 */ @SuppressLint("WakelockTimeout") private fun play() { if (useWakeLock) { wakeLock.acquire() wifiLock?.acquire() } upAudioPlayNotification() if (!requestFocus()) { return } execute(context = Main) { AudioPlay.status = Status.STOP postEvent(EventBus.AUDIO_STATE, Status.STOP) upPlayProgressJob?.cancel() val analyzeUrl = AnalyzeUrl( url, source = AudioPlay.bookSource, ruleData = AudioPlay.book, chapter = AudioPlay.durChapter, coroutineContext = coroutineContext ) exoPlayer.setMediaItem(analyzeUrl.getMediaItem()) exoPlayer.playWhenReady = true exoPlayer.seekTo(position.toLong()) exoPlayer.prepare() }.onError { AppLog.put("播放出错\n${it.localizedMessage}", it) toastOnUi("$url ${it.localizedMessage}") stopSelf() } } /** * 暂停播放 */ private fun pause(abandonFocus: Boolean = true) { if (useWakeLock) { wakeLock.release() wifiLock?.release() } try { pause = true if (abandonFocus) { abandonFocus() } upPlayProgressJob?.cancel() position = exoPlayer.currentPosition.toInt() if (exoPlayer.isPlaying) exoPlayer.pause() upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PAUSED) AudioPlay.status = Status.PAUSE postEvent(EventBus.AUDIO_STATE, Status.PAUSE) upAudioPlayNotification() } catch (e: Exception) { e.printOnDebug() } } /** * 恢复播放 */ @SuppressLint("WakelockTimeout") private fun resume() { if (useWakeLock) { wakeLock.acquire() wifiLock?.acquire() } try { pause = false if (url.isEmpty()) { AudioPlay.loadOrUpPlayUrl() return } if (!exoPlayer.isPlaying) { exoPlayer.play() } upPlayProgress() upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING) AudioPlay.status = Status.PLAY postEvent(EventBus.AUDIO_STATE, Status.PLAY) upAudioPlayNotification() } catch (e: Exception) { e.printOnDebug() stopSelf() } } /** * 调节进度 */ private fun adjustProgress(position: Int) { this.position = position exoPlayer.seekTo(position.toLong()) } /** * 调节速度 */ @SuppressLint(value = ["ObsoleteSdkInt"]) private fun upSpeed(adjust: Float) { kotlin.runCatching { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { playSpeed += adjust exoPlayer.setPlaybackSpeed(playSpeed) postEvent(EventBus.AUDIO_SPEED, playSpeed) } } } /** * 播放状态监控 */ override fun onPlaybackStateChanged(playbackState: Int) { super.onPlaybackStateChanged(playbackState) when (playbackState) { Player.STATE_IDLE -> { // 空闲 } Player.STATE_BUFFERING -> { // 缓冲中 } Player.STATE_READY -> { // 准备好 AudioPlay.upLoading(false) if (exoPlayer.playWhenReady) { AudioPlay.status = Status.PLAY postEvent(EventBus.AUDIO_STATE, Status.PLAY) } else { AudioPlay.status = Status.PAUSE postEvent(EventBus.AUDIO_STATE, Status.PAUSE) } postEvent(EventBus.AUDIO_SIZE, exoPlayer.duration.toInt()) upMediaMetadata() upPlayProgress() AudioPlay.saveDurChapter(exoPlayer.duration) } Player.STATE_ENDED -> { // 结束 upPlayProgressJob?.cancel() AudioPlay.playPositionChanged(exoPlayer.duration.toInt()) AudioPlay.next() } } upAudioPlayNotification() } private fun upMediaMetadata() { val metadata = MediaMetadataCompat.Builder() .putBitmap(MediaMetadataCompat.METADATA_KEY_ART, cover) .putText(MediaMetadataCompat.METADATA_KEY_TITLE, AudioPlay.durChapter?.title ?: "null") .putText(MediaMetadataCompat.METADATA_KEY_ARTIST, AudioPlay.book?.name ?: "null") .putText(MediaMetadataCompat.METADATA_KEY_ALBUM, AudioPlay.book?.author ?: "null") .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, exoPlayer.duration) .build() mediaSessionCompat?.setMetadata(metadata) } /** * 播放错误事件 */ override fun onPlayerError(error: PlaybackException) { super.onPlayerError(error) AudioPlay.status = Status.STOP postEvent(EventBus.AUDIO_STATE, Status.STOP) AudioPlay.upLoading(false) val errorMsg = "音频播放出错\n${error.errorCodeName} ${error.errorCode}" AppLog.put(errorMsg, error) toastOnUi(errorMsg) } private fun setTimer(minute: Int) { timeMinute = minute doDs() } private fun addTimer() { if (timeMinute == 180) { timeMinute = 0 } else { timeMinute += 10 if (timeMinute > 180) timeMinute = 180 } doDs() } /** * 定时 */ private fun doDs() { postEvent(EventBus.AUDIO_DS, timeMinute) upAudioPlayNotification() dsJob?.cancel() dsJob = lifecycleScope.launch { while (isActive) { delay(60000) if (!pause) { if (timeMinute >= 0) { timeMinute-- } if (timeMinute == 0) { AudioPlay.stop() postEvent(EventBus.AUDIO_DS, timeMinute) break } } postEvent(EventBus.AUDIO_DS, timeMinute) upAudioPlayNotification() } } } /** * 每隔1秒发送播放进度 */ private fun upPlayProgress() { upPlayProgressJob?.cancel() upPlayProgressJob = lifecycleScope.launch { while (isActive) { //更新buffer位置 AudioPlay.playPositionChanged(exoPlayer.currentPosition.toInt()) postEvent(EventBus.AUDIO_BUFFER_PROGRESS, exoPlayer.bufferedPosition.toInt()) postEvent(EventBus.AUDIO_PROGRESS, AudioPlay.durChapterPos) postEvent(EventBus.AUDIO_SIZE, exoPlayer.duration.toInt()) upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING) delay(1000) } } } /** * 更新媒体状态 */ private fun upMediaSessionPlaybackState(state: Int) { mediaSessionCompat?.setPlaybackState( PlaybackStateCompat.Builder() .setActions(MEDIA_SESSION_ACTIONS) .setState(state, exoPlayer.currentPosition, 1f) .setBufferedPosition(exoPlayer.bufferedPosition) .addCustomAction( APP_ACTION_STOP, getString(R.string.stop), R.drawable.ic_stop_black_24dp ) .addCustomAction( APP_ACTION_TIMER, getString(R.string.set_timer), R.drawable.ic_time_add_24dp ) .build() ) } /** * 初始化MediaSession, 注册多媒体按钮 */ @SuppressLint("UnspecifiedImmutableFlag") private fun initMediaSession() { mediaSessionCompat = MediaSessionCompat(this, "readAloud") mediaSessionCompat?.setCallback(object : MediaSessionCompat.Callback() { override fun onSeekTo(pos: Long) { position = pos.toInt() exoPlayer.seekTo(pos) } override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { return MediaButtonReceiver.handleIntent(this@AudioPlayService, mediaButtonEvent) } override fun onPlay() = resume() override fun onPause() = pause() override fun onCustomAction(action: String?, extras: Bundle?) { action ?: return when (action) { APP_ACTION_STOP -> stopSelf() APP_ACTION_TIMER -> addTimer() } } }) mediaSessionCompat?.setMediaButtonReceiver( broadcastPendingIntent(Intent.ACTION_MEDIA_BUTTON) ) mediaSessionCompat?.isActive = true } /** * 断开耳机监听 */ private fun initBroadcastReceiver() { broadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (AudioManager.ACTION_AUDIO_BECOMING_NOISY == intent.action) { pause() } } } val intentFilter = IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY) registerReceiver(broadcastReceiver, intentFilter) } /** * 音频焦点变化 */ override fun onAudioFocusChange(focusChange: Int) { if (AppConfig.ignoreAudioFocus) { AppLog.put("忽略音频焦点处理(有声)") return } when (focusChange) { AudioManager.AUDIOFOCUS_GAIN -> { if (needResumeOnAudioFocusGain) { AppLog.put("音频焦点获得,继续播放") resume() } else { AppLog.put("音频焦点获得") } } AudioManager.AUDIOFOCUS_LOSS -> { AppLog.put("音频焦点丢失,暂停播放") pause() } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { AppLog.put("音频焦点暂时丢失并会很快再次获得,暂停播放") if (!pause) { needResumeOnAudioFocusGain = true pause(false) } } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { // 短暂丢失焦点,这种情况是被其他应用申请了短暂的焦点希望其他声音能压低音量(或者关闭声音)凸显这个声音(比如短信提示音), AppLog.put("音频焦点短暂丢失,不做处理") } } } private fun createNotification(): NotificationCompat.Builder { var nTitle: String = when { pause -> getString(R.string.audio_pause) timeMinute in 1..60 -> getString( R.string.playing_timer, timeMinute ) else -> getString(R.string.audio_play_t) } nTitle += ": ${AudioPlay.book?.name}" var nSubtitle = AudioPlay.durChapter?.title if (nSubtitle.isNullOrEmpty()) { nSubtitle = getString(R.string.audio_play_s) } val builder = NotificationCompat .Builder(this@AudioPlayService, AppConst.channelIdReadAloud) .setSmallIcon(R.drawable.ic_volume_up) .setSubText(getString(R.string.audio)) .setOngoing(true) .setOnlyAlertOnce(true) .setContentTitle(nTitle) .setContentText(nSubtitle) .setContentIntent( activityPendingIntent("activity") ) builder.setLargeIcon(cover) if (pause) { builder.addAction( R.drawable.ic_play_24dp, getString(R.string.resume), servicePendingIntent(IntentAction.resume) ) } else { builder.addAction( R.drawable.ic_pause_24dp, getString(R.string.pause), servicePendingIntent(IntentAction.pause) ) } builder.addAction( R.drawable.ic_stop_black_24dp, getString(R.string.stop), servicePendingIntent(IntentAction.stop) ) builder.addAction( R.drawable.ic_time_add_24dp, getString(R.string.set_timer), servicePendingIntent(IntentAction.addTimer) ) builder.setStyle( androidx.media.app.NotificationCompat.MediaStyle() .setShowActionsInCompactView(0, 1, 2) .setMediaSession(mediaSessionCompat?.sessionToken) ) builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) return builder } private fun upAudioPlayNotification() { upNotificationJob = execute { try { val notification = createNotification() notificationManager.notify(NotificationId.AudioPlayService, notification.build()) } catch (e: Exception) { AppLog.put("创建音频播放通知出错,${e.localizedMessage}", e, true) } } } /** * 更新通知 */ override fun startForegroundNotification() { execute { try { val notification = createNotification() startForeground(NotificationId.AudioPlayService, notification.build()) } catch (e: Exception) { AppLog.put("创建音频播放通知出错,${e.localizedMessage}", e, true) //创建通知出错不结束服务就会崩溃,服务必须绑定通知 stopSelf() } } } /** * 请求音频焦点 * @return 音频焦点 */ private fun requestFocus(): Boolean { if (AppConfig.ignoreAudioFocus) { return true } return MediaHelp.requestFocus(mFocusRequest) } /** * 放弃音频焦点 */ private fun abandonFocus() { @Suppress("DEPRECATION") audioManager.abandonAudioFocus(this) } } ================================================ FILE: app/src/main/java/io/legado/app/service/BaseReadAloudService.kt ================================================ @file:Suppress("DEPRECATION") package io.legado.app.service import android.annotation.SuppressLint import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.graphics.Bitmap import android.graphics.BitmapFactory import android.media.AudioManager import android.net.wifi.WifiManager import android.os.Bundle import android.os.PowerManager import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat import android.telephony.PhoneStateListener import android.telephony.TelephonyManager import androidx.annotation.CallSuper import androidx.core.app.NotificationCompat import androidx.lifecycle.lifecycleScope import androidx.media.AudioFocusRequestCompat import androidx.media.AudioManagerCompat import io.legado.app.R import io.legado.app.base.BaseService import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog import io.legado.app.constant.AppPattern import io.legado.app.constant.EventBus import io.legado.app.constant.IntentAction import io.legado.app.constant.NotificationId import io.legado.app.constant.PreferKey import io.legado.app.constant.Status import io.legado.app.help.MediaHelp import io.legado.app.help.config.AppConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.glide.ImageLoader import io.legado.app.lib.permission.Permissions import io.legado.app.lib.permission.PermissionsCompat import io.legado.app.model.ReadAloud import io.legado.app.model.ReadBook import io.legado.app.receiver.MediaButtonReceiver import io.legado.app.ui.book.read.ReadBookActivity import io.legado.app.ui.book.read.page.entities.TextChapter import io.legado.app.utils.LogUtils import io.legado.app.utils.activityPendingIntent import io.legado.app.utils.broadcastPendingIntent import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.observeEvent import io.legado.app.utils.observeSharedPreferences import io.legado.app.utils.postEvent import io.legado.app.utils.toastOnUi import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import splitties.init.appCtx import splitties.systemservices.audioManager import splitties.systemservices.notificationManager import splitties.systemservices.powerManager import splitties.systemservices.telephonyManager import splitties.systemservices.wifiManager /** * 朗读服务 */ abstract class BaseReadAloudService : BaseService(), AudioManager.OnAudioFocusChangeListener { companion object { @JvmStatic var isRun = false private set @JvmStatic var pause = true private set @JvmStatic var timeMinute: Int = 0 private set fun isPlay(): Boolean { return isRun && !pause } private const val TAG = "BaseReadAloudService" } private val useWakeLock = appCtx.getPrefBoolean(PreferKey.readAloudWakeLock, false) private val wakeLock by lazy { powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "legado:ReadAloudService") .apply { this.setReferenceCounted(false) } } private val wifiLock by lazy { @Suppress("DEPRECATION") wifiManager?.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "legado:AudioPlayService") ?.apply { setReferenceCounted(false) } } private val mFocusRequest: AudioFocusRequestCompat by lazy { MediaHelp.buildAudioFocusRequestCompat(this) } private val mediaSessionCompat: MediaSessionCompat by lazy { MediaSessionCompat(this, "readAloud") } private val phoneStateListener by lazy { ReadAloudPhoneStateListener() } internal var contentList = emptyList() internal var nowSpeak: Int = 0 internal var readAloudNumber: Int = 0 internal var textChapter: TextChapter? = null internal var pageIndex = 0 private var needResumeOnAudioFocusGain = false private var needResumeOnCallStateIdle = false private var registeredPhoneStateListener = false private var dsJob: Job? = null private var upNotificationJob: Coroutine<*>? = null private var cover: Bitmap = BitmapFactory.decodeResource(appCtx.resources, R.drawable.icon_read_book) var pageChanged = false private var toLast = false var paragraphStartPos = 0 var readAloudByPage = false private set private val broadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (AudioManager.ACTION_AUDIO_BECOMING_NOISY == intent.action) { pauseReadAloud() } } } @SuppressLint("WakelockTimeout") override fun onCreate() { super.onCreate() isRun = true pause = false observeLiveBus() initMediaSession() initBroadcastReceiver() initPhoneStateListener() upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING) setTimer(AppConfig.ttsTimer) if (AppConfig.ttsTimer > 0) { toastOnUi("朗读定时 ${AppConfig.ttsTimer} 分钟") } execute { ImageLoader .loadBitmap(this@BaseReadAloudService, ReadBook.book?.getDisplayCover()) .submit() .get() }.onSuccess { if (it.width > 16 && it.height > 16) { cover = it upReadAloudNotification() } } } fun observeLiveBus() { observeEvent(EventBus.READ_ALOUD_PLAY) { val play = it.getBoolean("play") val pageIndex = it.getInt("pageIndex") val startPos = it.getInt("startPos") newReadAloud(play, pageIndex, startPos) } observeSharedPreferences { _, key -> when (key) { PreferKey.ignoreAudioFocus, PreferKey.pauseReadAloudWhilePhoneCalls -> { initPhoneStateListener() } } } } override fun onDestroy() { super.onDestroy() if (useWakeLock) { wakeLock.release() wifiLock?.release() } isRun = false pause = true abandonFocus() unregisterReceiver(broadcastReceiver) postEvent(EventBus.ALOUD_STATE, Status.STOP) notificationManager.cancel(NotificationId.ReadAloudService) upMediaSessionPlaybackState(PlaybackStateCompat.STATE_STOPPED) mediaSessionCompat.release() ReadBook.uploadProgress() unregisterPhoneStateListener(phoneStateListener) upNotificationJob?.invokeOnCompletion { notificationManager.cancel(NotificationId.ReadAloudService) } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { IntentAction.play -> newReadAloud( intent.getBooleanExtra("play", true), intent.getIntExtra("pageIndex", ReadBook.durPageIndex), intent.getIntExtra("startPos", 0) ) IntentAction.pause -> pauseReadAloud() IntentAction.resume -> resumeReadAloud() IntentAction.upTtsSpeechRate -> upSpeechRate(true) IntentAction.prevParagraph -> prevP() IntentAction.nextParagraph -> nextP() IntentAction.prev -> prevChapter() IntentAction.next -> nextChapter() IntentAction.addTimer -> addTimer() IntentAction.setTimer -> setTimer(intent.getIntExtra("minute", 0)) IntentAction.stop -> stopSelf() } return super.onStartCommand(intent, flags, startId) } private fun newReadAloud(play: Boolean, pageIndex: Int, startPos: Int) { execute(executeContext = IO) { this@BaseReadAloudService.pageIndex = pageIndex textChapter = ReadBook.curTextChapter val textChapter = textChapter ?: return@execute if (!textChapter.isCompleted) { return@execute } readAloudNumber = textChapter.getReadLength(pageIndex) + startPos readAloudByPage = getPrefBoolean(PreferKey.readAloudByPage) contentList = textChapter.getNeedReadAloud(0, readAloudByPage, 0) .split("\n") .filter { it.isNotEmpty() } var pos = startPos val page = textChapter.getPage(pageIndex)!! if (pos > 0) { for (paragraph in page.paragraphs) { val tmp = pos - paragraph.length - 1 if (tmp < 0) break pos = tmp } } nowSpeak = textChapter.getParagraphNum(readAloudNumber + 1, readAloudByPage) - 1 if (!readAloudByPage && startPos == 0 && !toLast) { pos = page.chapterPosition - textChapter.paragraphs[nowSpeak].chapterPosition } if (toLast) { toLast = false readAloudNumber = textChapter.getLastParagraphPosition() nowSpeak = contentList.lastIndex if (page.paragraphs.size == 1) { pos = page.chapterPosition - textChapter.paragraphs[nowSpeak].chapterPosition } } paragraphStartPos = pos launch(Main) { if (play) play() else pageChanged = true } }.onError { AppLog.put("启动朗读出错\n${it.localizedMessage}", it, true) } } @SuppressLint("WakelockTimeout") open fun play() { if (useWakeLock) { wakeLock.acquire() wifiLock?.acquire() } isRun = true pause = false needResumeOnAudioFocusGain = false needResumeOnCallStateIdle = false upReadAloudNotification() upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING) postEvent(EventBus.ALOUD_STATE, Status.PLAY) } abstract fun playStop() @CallSuper open fun pauseReadAloud(abandonFocus: Boolean = true) { if (useWakeLock) { wakeLock.release() wifiLock?.release() } pause = true if (abandonFocus) { abandonFocus() } upReadAloudNotification() upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PAUSED) postEvent(EventBus.ALOUD_STATE, Status.PAUSE) ReadBook.uploadProgress() doDs() } @SuppressLint("WakelockTimeout") @CallSuper open fun resumeReadAloud() { resumeReadAloudInternal() } private fun resumeReadAloudInternal() { pause = false needResumeOnAudioFocusGain = false needResumeOnCallStateIdle = false upReadAloudNotification() upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING) postEvent(EventBus.ALOUD_STATE, Status.PLAY) } abstract fun upSpeechRate(reset: Boolean = false) fun upTtsProgress(progress: Int) { postEvent(EventBus.TTS_PROGRESS, progress) } private fun prevP() { if (nowSpeak > 0) { playStop() do { nowSpeak-- readAloudNumber -= contentList[nowSpeak].length + 1 + paragraphStartPos paragraphStartPos = 0 } while (contentList[nowSpeak].matches(AppPattern.notReadAloudRegex)) textChapter?.let { if (readAloudByPage) { val paragraphs = it.getParagraphs(true) if (!paragraphs[nowSpeak].isParagraphEnd) readAloudNumber++ } if (readAloudNumber < it.getReadLength(pageIndex)) { pageIndex-- ReadBook.moveToPrevPage() } } upTtsProgress(readAloudNumber + 1) play() } else { toLast = true ReadBook.moveToPrevChapter(true) } } private fun nextP() { if (nowSpeak < contentList.size - 1) { playStop() readAloudNumber += contentList[nowSpeak].length.plus(1) - paragraphStartPos paragraphStartPos = 0 nowSpeak++ textChapter?.let { if (readAloudByPage) { val paragraphs = it.getParagraphs(true) if (!paragraphs[nowSpeak].isParagraphEnd) readAloudNumber-- } if (pageIndex + 1 < it.pageSize && readAloudNumber >= it.getReadLength(pageIndex + 1) ) { pageIndex++ ReadBook.moveToNextPage() } } upTtsProgress(readAloudNumber + 1) play() } else { nextChapter() } } private fun setTimer(minute: Int) { timeMinute = minute doDs() } private fun addTimer() { if (timeMinute == 180) { timeMinute = 0 } else { timeMinute += 10 if (timeMinute > 180) timeMinute = 180 } doDs() } /** * 定时 */ @Synchronized private fun doDs() { postEvent(EventBus.READ_ALOUD_DS, timeMinute) upReadAloudNotification() dsJob?.cancel() dsJob = lifecycleScope.launch { while (isActive) { delay(60000) if (!pause) { if (timeMinute >= 0) { timeMinute-- } if (timeMinute == 0) { ReadAloud.stop(this@BaseReadAloudService) postEvent(EventBus.READ_ALOUD_DS, timeMinute) break } } postEvent(EventBus.READ_ALOUD_DS, timeMinute) upReadAloudNotification() } } } /** * 请求音频焦点 * @return 音频焦点 */ fun requestFocus(): Boolean { if (AppConfig.ignoreAudioFocus) { return true } val requestFocus = MediaHelp.requestFocus(mFocusRequest) if (!requestFocus) { pauseReadAloud(false) toastOnUi("未获取到音频焦点") } return requestFocus } /** * 放弃音频焦点 */ private fun abandonFocus() { AudioManagerCompat.abandonAudioFocusRequest(audioManager, mFocusRequest) } /** * 更新媒体状态 */ private fun upMediaSessionPlaybackState(state: Int) { mediaSessionCompat.setPlaybackState( PlaybackStateCompat.Builder() .setActions(MediaHelp.MEDIA_SESSION_ACTIONS) .setState(state, nowSpeak.toLong(), 1f) // 为系统媒体控件添加定时按钮 .addCustomAction( PlaybackStateCompat.CustomAction.Builder( "ACTION_ADD_TIMER", getString(R.string.set_timer), R.drawable.ic_time_add_24dp ).build() ) .build() ) } /** * 初始化MediaSession, 注册多媒体按钮 */ @SuppressLint("UnspecifiedImmutableFlag") private fun initMediaSession() { if (getPrefBoolean("systemMediaControlCompatibilityChange")) { mediaSessionCompat.setCallback(object : MediaSessionCompat.Callback() { override fun onPlay() { resumeReadAloud() } override fun onPause() { pauseReadAloud() } override fun onSkipToNext() { if (getPrefBoolean("mediaButtonPerNext", false)) { nextChapter() } else { nextP() } } override fun onSkipToPrevious() { if (getPrefBoolean("mediaButtonPerNext", false)) { prevChapter() } else { prevP() } } override fun onStop() { stopSelf() } override fun onCustomAction(action: String, extras: Bundle?) { if (action == "ACTION_ADD_TIMER") addTimer() } override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { return MediaButtonReceiver.handleIntent( this@BaseReadAloudService, mediaButtonEvent ) } }) } else { mediaSessionCompat.setCallback(object : MediaSessionCompat.Callback() { override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { return MediaButtonReceiver.handleIntent( this@BaseReadAloudService, mediaButtonEvent ) } }) } mediaSessionCompat.setMediaButtonReceiver( broadcastPendingIntent(Intent.ACTION_MEDIA_BUTTON) ) mediaSessionCompat.isActive = true } /** * 注册多媒体按钮监听 */ private fun initBroadcastReceiver() { val intentFilter = IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY) registerReceiver(broadcastReceiver, intentFilter) } /** * 音频焦点变化 */ override fun onAudioFocusChange(focusChange: Int) { if (AppConfig.ignoreAudioFocus) { AppLog.put("忽略音频焦点处理(TTS)") return } when (focusChange) { AudioManager.AUDIOFOCUS_GAIN -> { if (needResumeOnAudioFocusGain) { AppLog.put("音频焦点获得,继续朗读") resumeReadAloud() } else { AppLog.put("音频焦点获得") } } AudioManager.AUDIOFOCUS_LOSS -> { AppLog.put("音频焦点丢失,暂停朗读") pauseReadAloud() } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { AppLog.put("音频焦点暂时丢失并会很快再次获得,暂停朗读") if (!pause) { needResumeOnAudioFocusGain = true pauseReadAloud(false) } } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { // 短暂丢失焦点,这种情况是被其他应用申请了短暂的焦点希望其他声音能压低音量(或者关闭声音)凸显这个声音(比如短信提示音), AppLog.put("音频焦点短暂丢失,不做处理") } } } private fun upReadAloudNotification() { upNotificationJob = execute { try { val notification = createNotification() notificationManager.notify(NotificationId.ReadAloudService, notification.build()) } catch (e: Exception) { AppLog.put("创建朗读通知出错,${e.localizedMessage}", e, true) } } } private fun choiceMediaStyle(): androidx.media.app.NotificationCompat.MediaStyle { val mediaStyle = androidx.media.app.NotificationCompat.MediaStyle() .setShowActionsInCompactView(1, 2, 4) if (getPrefBoolean("systemMediaControlCompatibilityChange")) { //fix #4090 android 14 can not show play control in lock screen mediaStyle.setMediaSession(mediaSessionCompat.sessionToken) } return mediaStyle } private fun createNotification(): NotificationCompat.Builder { var nTitle: String = when { pause -> getString(R.string.read_aloud_pause) timeMinute > 0 -> getString( R.string.read_aloud_timer, timeMinute ) else -> getString(R.string.read_aloud_t) } nTitle += ": ${ReadBook.book?.name}" var nSubtitle = ReadBook.curTextChapter?.title if (nSubtitle.isNullOrBlank()) nSubtitle = getString(R.string.read_aloud_s) val builder = NotificationCompat .Builder(this, AppConst.channelIdReadAloud) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) .setCategory(NotificationCompat.CATEGORY_TRANSPORT) .setSmallIcon(R.drawable.ic_volume_up) .setSubText(getString(R.string.read_aloud)) .setOngoing(true) .setOnlyAlertOnce(true) .setContentTitle(nTitle) .setContentText(nSubtitle) .setContentIntent( activityPendingIntent("activity") ) .setVibrate(null) .setSound(null) .setLights(0, 0, 0) builder.setLargeIcon(cover) // 按钮定义:上一章、播放、停止、下一章、定时 builder.addAction( R.drawable.ic_skip_previous, getString(R.string.previous_chapter), aloudServicePendingIntent(IntentAction.prev) ) if (pause) { builder.addAction( R.drawable.ic_play_24dp, getString(R.string.resume), aloudServicePendingIntent(IntentAction.resume) ) } else { builder.addAction( R.drawable.ic_pause_24dp, getString(R.string.pause), aloudServicePendingIntent(IntentAction.pause) ) } builder.addAction( R.drawable.ic_stop_black_24dp, getString(R.string.stop), aloudServicePendingIntent(IntentAction.stop) ) builder.addAction( R.drawable.ic_skip_next, getString(R.string.next_chapter), aloudServicePendingIntent(IntentAction.next) ) builder.addAction( R.drawable.ic_time_add_24dp, getString(R.string.set_timer), aloudServicePendingIntent(IntentAction.addTimer) ) builder.setStyle(choiceMediaStyle()) return builder } /** * 更新通知 */ override fun startForegroundNotification() { execute { try { val notification = createNotification() startForeground(NotificationId.ReadAloudService, notification.build()) } catch (e: Exception) { AppLog.put("创建朗读通知出错,${e.localizedMessage}", e, true) //创建通知出错不结束服务就会崩溃,服务必须绑定通知 stopSelf() } } } abstract fun aloudServicePendingIntent(actionStr: String): PendingIntent? open fun prevChapter() { toLast = false resumeReadAloudInternal() ReadBook.moveToPrevChapter(true, toLast = false) } open fun nextChapter() { ReadBook.upReadTime() AppLog.putDebug("${ReadBook.curTextChapter?.chapter?.title} 朗读结束跳转下一章并朗读") resumeReadAloudInternal() if (!ReadBook.moveToNextChapter(true)) { stopSelf() } } private fun initPhoneStateListener() { val needRegister = AppConfig.ignoreAudioFocus && AppConfig.pauseReadAloudWhilePhoneCalls if (needRegister && registeredPhoneStateListener) { return } if (needRegister) { registerPhoneStateListener(phoneStateListener) } else { unregisterPhoneStateListener(phoneStateListener) } } private fun unregisterPhoneStateListener(l: PhoneStateListener) { if (registeredPhoneStateListener) { withReadPhoneStatePermission { telephonyManager.listen(l, PhoneStateListener.LISTEN_NONE) registeredPhoneStateListener = false } } } private fun registerPhoneStateListener(l: PhoneStateListener) { withReadPhoneStatePermission { telephonyManager.listen(l, PhoneStateListener.LISTEN_CALL_STATE) registeredPhoneStateListener = true } } private fun withReadPhoneStatePermission(block: () -> Unit) { try { block.invoke() } catch (_: SecurityException) { PermissionsCompat.Builder() .addPermissions(Permissions.READ_PHONE_STATE) .rationale(R.string.read_aloud_read_phone_state_permission_rationale) .onGranted { try { block.invoke() } catch (_: SecurityException) { LogUtils.d(TAG, "Grant read phone state permission fail.") } } .request() } } @Suppress("OVERRIDE_DEPRECATION") inner class ReadAloudPhoneStateListener : PhoneStateListener() { override fun onCallStateChanged(state: Int, phoneNumber: String?) { super.onCallStateChanged(state, phoneNumber) when (state) { TelephonyManager.CALL_STATE_IDLE -> { if (needResumeOnCallStateIdle) { AppLog.put("来电结束,继续朗读") resumeReadAloud() } else { AppLog.put("来电结束") } } TelephonyManager.CALL_STATE_RINGING -> { if (!pause) { AppLog.put("来电响铃,暂停朗读") needResumeOnCallStateIdle = true pauseReadAloud() } else { AppLog.put("来电响铃") } } TelephonyManager.CALL_STATE_OFFHOOK -> { AppLog.put("来电接听,不做处理") } } } } } ================================================ FILE: app/src/main/java/io/legado/app/service/CacheBookService.kt ================================================ package io.legado.app.service import android.content.Intent import androidx.core.app.NotificationCompat import androidx.lifecycle.lifecycleScope import io.legado.app.R import io.legado.app.base.BaseService import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog import io.legado.app.constant.EventBus import io.legado.app.constant.IntentAction import io.legado.app.constant.NotificationId import io.legado.app.data.appDb import io.legado.app.help.book.update import io.legado.app.help.config.AppConfig import io.legado.app.model.CacheBook import io.legado.app.model.webBook.WebBook import io.legado.app.ui.book.cache.CacheActivity import io.legado.app.utils.activityPendingIntent import io.legado.app.utils.postEvent import io.legado.app.utils.servicePendingIntent import kotlinx.coroutines.Job import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import splitties.init.appCtx import splitties.systemservices.notificationManager import java.util.concurrent.Executors import kotlin.math.min /** * 缓存书籍服务 */ class CacheBookService : BaseService() { companion object { var isRun = false private set } private val threadCount = AppConfig.threadCount private var cachePool = Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher() private var downloadJob: Job? = null private var notificationContent = appCtx.getString(R.string.service_starting) private var mutex = Mutex() private val notificationBuilder by lazy { val builder = NotificationCompat.Builder(this, AppConst.channelIdDownload) .setSmallIcon(R.drawable.ic_download) .setOngoing(true) .setOnlyAlertOnce(true) .setContentTitle(getString(R.string.offline_cache)) .setContentIntent(activityPendingIntent("cacheActivity")) builder.addAction( R.drawable.ic_stop_black_24dp, getString(R.string.cancel), servicePendingIntent(IntentAction.stop) ) builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) } override fun onCreate() { super.onCreate() isRun = true lifecycleScope.launch { while (isActive) { delay(1000) notificationContent = CacheBook.downloadSummary upCacheBookNotification() postEvent(EventBus.UP_DOWNLOAD, "") } } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { intent?.action?.let { action -> when (action) { IntentAction.start -> addDownloadData( intent.getStringExtra("bookUrl"), intent.getIntExtra("start", 0), intent.getIntExtra("end", 0) ) IntentAction.remove -> removeDownload(intent.getStringExtra("bookUrl")) IntentAction.stop -> stopSelf() } } return super.onStartCommand(intent, flags, startId) } override fun onDestroy() { isRun = false cachePool.close() CacheBook.close() super.onDestroy() postEvent(EventBus.UP_DOWNLOAD, "") } private fun addDownloadData(bookUrl: String?, start: Int, end: Int) { bookUrl ?: return execute { val cacheBook = CacheBook.getOrCreate(bookUrl) ?: return@execute val chapterCount = appDb.bookChapterDao.getChapterCount(bookUrl) val book = cacheBook.book if (chapterCount == 0) { cacheBook.setLoading() mutex.withLock { val name = book.name if (book.tocUrl.isEmpty()) { kotlin.runCatching { WebBook.getBookInfoAwait(cacheBook.bookSource, book) }.onFailure { removeDownload(bookUrl) val msg = "《$name》目录为空且加载详情页失败\n${it.localizedMessage}" AppLog.put(msg, it, true) return@execute } } WebBook.getChapterListAwait(cacheBook.bookSource, book).onFailure { if (book.totalChapterNum > 0) { book.totalChapterNum = 0 book.update() } removeDownload(bookUrl) val msg = "《$name》目录为空且加载目录失败\n${it.localizedMessage}" AppLog.put(msg, it, true) return@execute }.getOrNull()?.let { toc -> appDb.bookChapterDao.insert(*toc.toTypedArray()) } book.update() } } val end2 = if (end < 0) { book.lastChapterIndex } else { min(end, book.lastChapterIndex) } cacheBook.addDownload(start, end2) notificationContent = CacheBook.downloadSummary upCacheBookNotification() }.onFinally { if (downloadJob == null) { download() } } } private fun removeDownload(bookUrl: String?) { CacheBook.cacheBookMap[bookUrl]?.stop() postEvent(EventBus.UP_DOWNLOAD, "") if (downloadJob == null && CacheBook.isRun) { download() return } if (CacheBook.cacheBookMap.isEmpty()) { stopSelf() } } private fun download() { downloadJob?.cancel() downloadJob = lifecycleScope.launch(cachePool) { CacheBook.startProcessJob(cachePool) stopSelf() } } private fun upCacheBookNotification() { notificationBuilder.setContentText(notificationContent) val notification = notificationBuilder.build() notificationManager.notify(NotificationId.CacheBookService, notification) } /** * 更新通知 */ override fun startForegroundNotification() { notificationBuilder.setContentText(notificationContent) val notification = notificationBuilder.build() startForeground(NotificationId.CacheBookService, notification) } } ================================================ FILE: app/src/main/java/io/legado/app/service/CheckSourceService.kt ================================================ package io.legado.app.service import android.content.Intent import androidx.core.app.NotificationCompat import androidx.lifecycle.lifecycleScope import com.script.ScriptException import io.legado.app.R import io.legado.app.base.BaseService import io.legado.app.constant.AppConst import io.legado.app.constant.BookSourceType import io.legado.app.constant.EventBus import io.legado.app.constant.IntentAction import io.legado.app.constant.NotificationId import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookSource import io.legado.app.exception.ContentEmptyException import io.legado.app.exception.NoStackTraceException import io.legado.app.exception.TocEmptyException import io.legado.app.help.IntentData import io.legado.app.help.config.AppConfig import io.legado.app.help.source.exploreKinds import io.legado.app.model.CheckSource import io.legado.app.model.Debug import io.legado.app.model.webBook.WebBook import io.legado.app.ui.book.source.manage.BookSourceActivity import io.legado.app.utils.activityPendingIntent import io.legado.app.utils.onEachParallel import io.legado.app.utils.postEvent import io.legado.app.utils.servicePendingIntent import io.legado.app.utils.toastOnUi import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import org.mozilla.javascript.WrappedException import splitties.init.appCtx import splitties.systemservices.notificationManager import java.util.concurrent.Executors import kotlin.coroutines.coroutineContext import kotlin.math.min /** * 校验书源 */ class CheckSourceService : BaseService() { private var threadCount = AppConfig.threadCount private var searchCoroutine = Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher() private var notificationMsg = appCtx.getString(R.string.service_starting) private var checkJob: Job? = null private var originSize = 0 private var finishCount = 0 private val notificationBuilder by lazy { NotificationCompat.Builder(this, AppConst.channelIdReadAloud) .setSmallIcon(R.drawable.ic_network_check) .setOngoing(true) .setOnlyAlertOnce(true) .setContentTitle(getString(R.string.check_book_source)) .setContentIntent( activityPendingIntent("activity") ) .addAction( R.drawable.ic_stop_black_24dp, getString(R.string.cancel), servicePendingIntent(IntentAction.stop) ) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { IntentAction.start -> IntentData.get>("checkSourceSelectedIds")?.let { check(it) } IntentAction.resume -> upNotification() IntentAction.stop -> stopSelf() } return super.onStartCommand(intent, flags, startId) } override fun onDestroy() { super.onDestroy() Debug.finishChecking() searchCoroutine.close() postEvent(EventBus.CHECK_SOURCE_DONE, 0) notificationManager.cancel(NotificationId.CheckSourceService) } private fun check(ids: List) { if (checkJob?.isActive == true) { toastOnUi("已有书源在校验,等完成后再试") return } checkJob = lifecycleScope.launch(searchCoroutine) { flow { for (origin in ids) { appDb.bookSourceDao.getBookSource(origin)?.let { emit(it) } } }.onStart { originSize = ids.size finishCount = 0 notificationMsg = getString(R.string.progress_show, "", 0, originSize) upNotification() }.onEachParallel(threadCount) { checkSource(it) }.onEach { finishCount++ notificationMsg = getString( R.string.progress_show, it.bookSourceName, finishCount, originSize ) upNotification() appDb.bookSourceDao.update(it) }.onCompletion { stopSelf() }.collect() } } private suspend fun checkSource(source: BookSource) { kotlin.runCatching { withTimeout(CheckSource.timeout) { doCheckSource(source) } }.onSuccess { Debug.updateFinalMessage(source.bookSourceUrl, "校验成功") }.onFailure { coroutineContext.ensureActive() when (it) { is TimeoutCancellationException -> source.addGroup("校验超时") is ScriptException, is WrappedException -> source.addGroup("js失效") !is NoStackTraceException -> source.addGroup("网站失效") } source.addErrorComment(it) Debug.updateFinalMessage(source.bookSourceUrl, "校验失败:${it.localizedMessage}") } source.respondTime = Debug.getRespondTime(source.bookSourceUrl) } private suspend fun doCheckSource(source: BookSource) { Debug.startChecking(source) source.removeInvalidGroups() source.removeErrorComment() //校验搜索书籍 if (CheckSource.checkSearch) { val searchWord = source.getCheckKeyword(CheckSource.keyword) if (!source.searchUrl.isNullOrBlank()) { source.removeGroup("搜索链接规则为空") val searchBooks = WebBook.searchBookAwait(source, searchWord) if (searchBooks.isEmpty()) { source.addGroup("搜索失效") } else { source.removeGroup("搜索失效") checkBook(searchBooks.first().toBook(), source) } } else { source.addGroup("搜索链接规则为空") } } //校验发现书籍 if (CheckSource.checkDiscovery && !source.exploreUrl.isNullOrBlank()) { val url = source.exploreKinds().firstOrNull { !it.url.isNullOrBlank() }?.url if (url.isNullOrBlank()) { source.addGroup("发现规则为空") } else { source.removeGroup("发现规则为空") val exploreBooks = WebBook.exploreBookAwait(source, url) if (exploreBooks.isEmpty()) { source.addGroup("发现失效") } else { source.removeGroup("发现失效") checkBook(exploreBooks.first().toBook(), source, false) } } } val finalCheckMessage = source.getInvalidGroupNames() if (finalCheckMessage.isNotBlank()) { throw NoStackTraceException(finalCheckMessage) } } /** *校验书源的详情目录正文 */ private suspend fun checkBook(book: Book, source: BookSource, isSearchBook: Boolean = true) { kotlin.runCatching { if (!CheckSource.checkInfo) { return } //校验详情 if (book.tocUrl.isBlank()) { WebBook.getBookInfoAwait(source, book) } if (!CheckSource.checkCategory || source.bookSourceType == BookSourceType.file) { return } //校验目录 val toc = WebBook.getChapterListAwait(source, book).getOrThrow().asSequence() .filter { !(it.isVolume && it.url.startsWith(it.title)) } .take(2) .toList() val nextChapterUrl = toc.getOrNull(1)?.url ?: toc.first().url if (!CheckSource.checkContent) { return } //校验正文 WebBook.getContentAwait( bookSource = source, book = book, bookChapter = toc.first(), nextChapterUrl = nextChapterUrl, needSave = false ) }.onFailure { val bookType = if (isSearchBook) "搜索" else "发现" when (it) { is ContentEmptyException -> source.addGroup("${bookType}正文失效") is TocEmptyException -> source.addGroup("${bookType}目录失效") else -> throw it } }.onSuccess { val bookType = if (isSearchBook) "搜索" else "发现" source.removeGroup("${bookType}目录失效") source.removeGroup("${bookType}正文失效") } } private fun upNotification() { notificationBuilder.setContentText(notificationMsg) notificationBuilder.setProgress(originSize, finishCount, false) postEvent(EventBus.CHECK_SOURCE, notificationMsg) notificationManager.notify(NotificationId.CheckSourceService, notificationBuilder.build()) } /** * 更新通知 */ override fun startForegroundNotification() { notificationBuilder.setContentText(notificationMsg) notificationBuilder.setProgress(originSize, finishCount, false) postEvent(EventBus.CHECK_SOURCE, notificationMsg) startForeground(NotificationId.CheckSourceService, notificationBuilder.build()) } } ================================================ FILE: app/src/main/java/io/legado/app/service/DownloadService.kt ================================================ package io.legado.app.service import android.annotation.SuppressLint import android.app.DownloadManager import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.net.Uri import android.os.Environment import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import io.legado.app.R import io.legado.app.base.BaseService import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog import io.legado.app.constant.IntentAction import io.legado.app.constant.NotificationId import io.legado.app.utils.IntentType import io.legado.app.utils.openFileUri import io.legado.app.utils.servicePendingIntent import io.legado.app.utils.toastOnUi import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import splitties.init.appCtx import splitties.systemservices.downloadManager import splitties.systemservices.notificationManager /** * 下载文件 */ class DownloadService : BaseService() { private val groupKey = "${appCtx.packageName}.download" private val downloads = hashMapOf() private val completeDownloads = hashSetOf() private var upStateJob: Job? = null private val downloadReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { queryState() } } @SuppressLint("UnspecifiedRegisterReceiverFlag") override fun onCreate() { super.onCreate() ContextCompat.registerReceiver( this, downloadReceiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), ContextCompat.RECEIVER_EXPORTED ) } override fun onDestroy() { super.onDestroy() unregisterReceiver(downloadReceiver) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { IntentAction.start -> startDownload( intent.getStringExtra("url"), intent.getStringExtra("fileName") ) IntentAction.play -> { val id = intent.getLongExtra("downloadId", 0) if (completeDownloads.contains(id)) { openDownload(id, downloads[id]?.fileName) } else { toastOnUi("未完成,下载的文件夹Download") } } IntentAction.stop -> { val downloadId = intent.getLongExtra("downloadId", 0) removeDownload(downloadId) } } return super.onStartCommand(intent, flags, startId) } /** * 开始下载 */ @Synchronized private fun startDownload(url: String?, fileName: String?) { if (url == null || fileName == null) { if (downloads.isEmpty()) { stopSelf() } return } if (downloads.values.any { it.url == url }) { toastOnUi("已在下载列表") return } kotlin.runCatching { // 指定下载地址 val request = DownloadManager.Request(Uri.parse(url)) // 设置通知 request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN) // 设置下载文件保存的路径和文件名 request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName) // 添加一个下载任务 val downloadId = downloadManager.enqueue(request) downloads[downloadId] = DownloadInfo(url, fileName, NotificationId.Download + downloads.size) queryState() if (upStateJob == null) { checkDownloadState() } }.onFailure { it.printStackTrace() val msg = when (it) { is SecurityException -> "下载出错,没有存储权限" else -> "下载出错,${it.localizedMessage}" } toastOnUi(msg) AppLog.put(msg, it) } } /** * 取消下载 */ @Synchronized private fun removeDownload(downloadId: Long) { if (!completeDownloads.contains(downloadId)) { downloadManager.remove(downloadId) } downloads.remove(downloadId) completeDownloads.remove(downloadId) notificationManager.cancel(downloadId.toInt()) } /** * 下载成功 */ @Synchronized private fun successDownload(downloadId: Long) { if (!completeDownloads.contains(downloadId)) { completeDownloads.add(downloadId) val fileName = downloads[downloadId]?.fileName openDownload(downloadId, fileName) } } private fun checkDownloadState() { upStateJob?.cancel() upStateJob = lifecycleScope.launch { while (isActive) { queryState() delay(1000) } } } /** * 查询下载进度 */ @Synchronized private fun queryState() { if (downloads.isEmpty()) { stopSelf() return } val ids = downloads.keys val query = DownloadManager.Query() query.setFilterById(*ids.toLongArray()) downloadManager.query(query).use { cursor -> if (cursor.moveToFirst()) { val idIndex = cursor.getColumnIndex(DownloadManager.COLUMN_ID) val progressIndex = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR) val fileSizeIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES) val statusIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS) do { val id = cursor.getLong(idIndex) val progress = cursor.getInt(progressIndex) val max = cursor.getInt(fileSizeIndex) val status = when (cursor.getInt(statusIndex)) { DownloadManager.STATUS_PAUSED -> getString(R.string.pause) DownloadManager.STATUS_PENDING -> getString(R.string.wait_download) DownloadManager.STATUS_RUNNING -> getString(R.string.downloading) DownloadManager.STATUS_SUCCESSFUL -> { successDownload(id) getString(R.string.download_success) } DownloadManager.STATUS_FAILED -> getString(R.string.download_error) else -> getString(R.string.unknown_state) } downloads[id]?.let { downloadInfo -> upDownloadNotification( id, downloadInfo.notificationId, "${downloadInfo.fileName} $status", max, progress, downloadInfo.startTime ) } } while (cursor.moveToNext()) } } } /** * 打开下载文件 */ private fun openDownload(downloadId: Long, fileName: String?) { kotlin.runCatching { downloadManager.getUriForDownloadedFile(downloadId)?.let { uri -> val type = IntentType.from(fileName) openFileUri(uri, type) } }.onFailure { AppLog.put("打开下载文件${fileName}出错", it) } } override fun startForegroundNotification() { val notification = NotificationCompat.Builder(this, AppConst.channelIdDownload) .setSmallIcon(R.drawable.ic_download) .setSubText(getString(R.string.action_download)) .setGroup(groupKey) .setGroupSummary(true) .setOngoing(true) .build() startForeground(NotificationId.DownloadService, notification) } /** * 更新通知 */ private fun upDownloadNotification( downloadId: Long, notificationId: Int, content: String, max: Int, progress: Int, startTime: Long ) { val notificationBuilder = NotificationCompat.Builder(this, AppConst.channelIdDownload) .setSmallIcon(R.drawable.ic_download) .setSubText(getString(R.string.action_download)) .setContentTitle(content) .setOnlyAlertOnce(true) .setContentIntent( servicePendingIntent(IntentAction.play, downloadId.toInt()) { putExtra("downloadId", downloadId) } ) .setDeleteIntent( servicePendingIntent(IntentAction.stop, downloadId.toInt()) { putExtra("downloadId", downloadId) } ) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setGroup(groupKey) .setWhen(startTime) if (progress < max) { notificationBuilder.setProgress(max, progress, false) } notificationManager.notify(notificationId, notificationBuilder.build()) } private data class DownloadInfo( val url: String, val fileName: String, val notificationId: Int, val startTime: Long = System.currentTimeMillis() ) } ================================================ FILE: app/src/main/java/io/legado/app/service/ExportBookService.kt ================================================ package io.legado.app.service import android.annotation.SuppressLint import android.content.Intent import androidx.core.app.NotificationCompat import androidx.lifecycle.lifecycleScope import com.bumptech.glide.Glide import io.legado.app.R import io.legado.app.base.BaseService import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog import io.legado.app.constant.AppPattern import io.legado.app.constant.EventBus import io.legado.app.constant.IntentAction import io.legado.app.constant.NotificationId import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.exception.NoStackTraceException import io.legado.app.help.AppWebDav import io.legado.app.help.book.BookHelp import io.legado.app.help.book.ContentProcessor import io.legado.app.help.book.getExportFileName import io.legado.app.help.book.isLocalModified import io.legado.app.help.config.AppConfig import io.legado.app.model.ReadBook import io.legado.app.model.localBook.LocalBook import io.legado.app.ui.book.cache.CacheActivity import io.legado.app.utils.FileDoc import io.legado.app.utils.FileUtils import io.legado.app.utils.HtmlFormatter import io.legado.app.utils.MD5Utils import io.legado.app.utils.NetworkUtils import io.legado.app.utils.activityPendingIntent import io.legado.app.utils.cnCompare import io.legado.app.utils.createFileIfNotExist import io.legado.app.utils.delete import io.legado.app.utils.find import io.legado.app.utils.list import io.legado.app.utils.mapAsync import io.legado.app.utils.mapAsyncIndexed import io.legado.app.utils.openOutputStream import io.legado.app.utils.postEvent import io.legado.app.utils.servicePendingIntent import io.legado.app.utils.toastOnUi import io.legado.app.utils.writeFile import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.collectIndexed import kotlinx.coroutines.flow.flow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import me.ag2s.epublib.domain.Author import me.ag2s.epublib.domain.Date import me.ag2s.epublib.domain.EpubBook import me.ag2s.epublib.domain.FileResourceProvider import me.ag2s.epublib.domain.LazyResource import me.ag2s.epublib.domain.LazyResourceProvider import me.ag2s.epublib.domain.Metadata import me.ag2s.epublib.domain.Resource import me.ag2s.epublib.domain.TOCReference import me.ag2s.epublib.epub.EpubWriter import me.ag2s.epublib.epub.EpubWriterProcessor import me.ag2s.epublib.util.ResourceUtil import splitties.init.appCtx import splitties.systemservices.notificationManager import java.nio.charset.Charset import java.util.concurrent.ConcurrentHashMap import kotlin.coroutines.coroutineContext import kotlin.math.min /** * 导出书籍服务 */ class ExportBookService : BaseService() { companion object { val exportProgress = ConcurrentHashMap() val exportMsg = ConcurrentHashMap() } data class ExportConfig( val path: String, val type: String, val epubSize: Int = 1, val epubScope: String? = null ) private val groupKey = "${appCtx.packageName}.exportBook" private val waitExportBooks = linkedMapOf() private var exportJob: Job? = null private var notificationContentText = appCtx.getString(R.string.service_starting) override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { IntentAction.start -> kotlin.runCatching { val bookUrl = intent.getStringExtra("bookUrl")!! if (!exportProgress.contains(bookUrl)) { val exportConfig = ExportConfig( path = intent.getStringExtra("exportPath")!!, type = intent.getStringExtra("exportType")!!, epubSize = intent.getIntExtra("epubSize", 1), epubScope = intent.getStringExtra("epubScope") ) waitExportBooks[bookUrl] = exportConfig exportMsg[bookUrl] = getString(R.string.export_wait) postEvent(EventBus.EXPORT_BOOK, bookUrl) export() } }.onFailure { toastOnUi(it.localizedMessage) } IntentAction.stop -> { notificationManager.cancel(NotificationId.ExportBook) stopSelf() } } return super.onStartCommand(intent, flags, startId) } override fun onDestroy() { super.onDestroy() exportProgress.clear() exportMsg.clear() waitExportBooks.keys.forEach { postEvent(EventBus.EXPORT_BOOK, it) } } @SuppressLint("MissingPermission") override fun startForegroundNotification() { val notification = NotificationCompat.Builder(this, AppConst.channelIdDownload) .setSmallIcon(R.drawable.ic_export) .setSubText(getString(R.string.export_book)) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setGroup(groupKey) .setGroupSummary(true) startForeground(NotificationId.ExportBookService, notification.build()) } private fun upExportNotification(finish: Boolean = false) { val notification = NotificationCompat.Builder(this, AppConst.channelIdDownload) .setSmallIcon(R.drawable.ic_export) .setSubText(getString(R.string.export_book)) .setContentIntent(activityPendingIntent("cacheActivity")) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setContentText(notificationContentText) .setDeleteIntent(servicePendingIntent(IntentAction.stop)) .setGroup(groupKey) .setOnlyAlertOnce(true) if (!finish) { notification.setOngoing(true) notification.addAction( R.drawable.ic_stop_black_24dp, getString(R.string.cancel), servicePendingIntent(IntentAction.stop) ) } notificationManager.notify(NotificationId.ExportBook, notification.build()) } private fun export() { if (exportJob?.isActive == true) { return } exportJob = lifecycleScope.launch(IO) { while (isActive) { val (bookUrl, exportConfig) = waitExportBooks.entries.firstOrNull() ?: let { notificationContentText = "导出完成" upExportNotification(true) stopSelf() return@launch } exportProgress[bookUrl] = 0 waitExportBooks.remove(bookUrl) val book = appDb.bookDao.getBook(bookUrl) try { book ?: throw NoStackTraceException("获取${bookUrl}书籍出错") refreshChapterList(book) notificationContentText = getString( R.string.export_book_notification_content, book.name, waitExportBooks.size ) upExportNotification() if (exportConfig.type == "epub") { if (exportConfig.epubScope.isNullOrBlank()) { exportEpub(exportConfig.path, book) } else { CustomExporter( exportConfig.epubScope, exportConfig.epubSize ).export(exportConfig.path, book) } } else { exportTxt(exportConfig.path, book) } exportMsg[book.bookUrl] = getString(R.string.export_success) } catch (e: Throwable) { ensureActive() exportMsg[bookUrl] = e.localizedMessage ?: "ERROR" AppLog.put("导出书籍<${book?.name ?: bookUrl}>出错", e) } finally { exportProgress.remove(bookUrl) postEvent(EventBus.EXPORT_BOOK, bookUrl) } } } } private fun refreshChapterList(book: Book) { if (!book.isLocalModified()) { return } kotlin.runCatching { LocalBook.getChapterList(book) }.onSuccess { appDb.bookChapterDao.delByBook(book.bookUrl) appDb.bookChapterDao.insert(*it.toTypedArray()) appDb.bookDao.update(book) ReadBook.onChapterListUpdated(book) } } private data class SrcData( val chapterTitle: String, val index: Int, val src: String ) private suspend fun exportTxt(path: String, book: Book) { exportMsg.remove(book.bookUrl) postEvent(EventBus.EXPORT_BOOK, book.bookUrl) val fileDoc = FileDoc.fromDir(path) exportTxt(fileDoc, book) } private suspend fun exportTxt(fileDoc: FileDoc, book: Book) { val filename = book.getExportFileName("txt") fileDoc.find(filename)?.delete() val bookDoc = fileDoc.createFileIfNotExist(filename) val charset = Charset.forName(AppConfig.exportCharset) bookDoc.openOutputStream().getOrThrow().bufferedWriter(charset).use { bw -> getAllContents(book) { text, srcList -> bw.write(text) srcList?.forEach { val vFile = BookHelp.getImage(book, it.src) if (vFile.exists()) { fileDoc.createFileIfNotExist( "${it.index}-${MD5Utils.md5Encode16(it.src)}.jpg", subDirs = arrayOf( "${book.name}_${book.author}", "images", it.chapterTitle ) ).writeFile(vFile) } } } } if (AppConfig.exportToWebDav) { // 导出到webdav AppWebDav.exportWebDav(bookDoc.uri, filename) } } private suspend fun getAllContents( book: Book, append: (text: String, srcList: ArrayList?) -> Unit ) = coroutineScope { val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule() val contentProcessor = ContentProcessor.get(book.name, book.origin) val qy = "${book.name}\n${ getString(R.string.author_show, book.getRealAuthor()) }\n${ getString( R.string.intro_show, "\n" + HtmlFormatter.format(book.getDisplayIntro()) ) }" append(qy, null) val threads = if (AppConfig.parallelExportBook) { AppConst.MAX_THREAD } else { 1 } flow { appDb.bookChapterDao.getChapterList(book.bookUrl).forEach { chapter -> emit(chapter) } }.mapAsync(threads) { chapter -> getExportData(book, chapter, contentProcessor, useReplace) }.collectIndexed { index, result -> postEvent(EventBus.EXPORT_BOOK, book.bookUrl) exportProgress[book.bookUrl] = index append.invoke(result.first, result.second) } } private fun getExportData( book: Book, chapter: BookChapter, contentProcessor: ContentProcessor, useReplace: Boolean ): Pair?> { val content = BookHelp.getContent(book, chapter) val content1 = contentProcessor .getContent( book, // 不导出vip标识 chapter.apply { isVip = false }, content ?: if (chapter.isVolume) "" else "null", includeTitle = !AppConfig.exportNoChapterName, useReplace = useReplace, chineseConvert = false, reSegment = false ).toString() if (AppConfig.exportPictureFile) { //txt导出图片文件 val srcList = arrayListOf() content?.split("\n")?.forEachIndexed { index, text -> val matcher = AppPattern.imgPattern.matcher(text) while (matcher.find()) { matcher.group(1)?.let { val src = NetworkUtils.getAbsoluteURL(chapter.url, it) srcList.add(SrcData(chapter.title, index, src)) } } } return Pair("\n\n$content1", srcList) } else { return Pair("\n\n$content1", null) } } /** * 导出Epub */ private suspend fun exportEpub(path: String, book: Book) { exportMsg.remove(book.bookUrl) postEvent(EventBus.EXPORT_BOOK, book.bookUrl) val fileDoc = FileDoc.fromDir(path) exportEpub(fileDoc, book) } private suspend fun exportEpub(fileDoc: FileDoc, book: Book) { val filename = book.getExportFileName("epub") fileDoc.find(filename)?.delete() val epubBook = EpubBook() epubBook.version = "2.0" //set metadata setEpubMetadata(book, epubBook) //set cover setCover(book, epubBook) //set css val contentModel = setAssets(fileDoc, book, epubBook) //设置正文 setEpubContent(contentModel, book, epubBook) val bookDoc = fileDoc.createFileIfNotExist(filename) bookDoc.openOutputStream().getOrThrow().buffered().use { bookOs -> EpubWriter().write(epubBook, bookOs) } if (AppConfig.exportToWebDav) { // 导出到webdav AppWebDav.exportWebDav(bookDoc.uri, filename) } } private fun setAssets(doc: FileDoc, book: Book, epubBook: EpubBook): String { val customPath = doc.find("Asset") val contentModel = if (customPath == null) {//使用内置模板 setAssets(book, epubBook) } else {//外部模板 setAssetsExternal(customPath, book, epubBook) } return contentModel } private fun setAssetsExternal(doc: FileDoc, book: Book, epubBook: EpubBook): String { var contentModel = "" doc.list()!!.forEach { folder -> if (folder.isDir && folder.name == "Text") { folder.list()!!.sortedWith { o1, o2 -> o1.name.cnCompare(o2.name) }.forEach loop@{ file -> if (file.isDir) { return@loop } when { //正文模板 file.name.equals("chapter.html", true) || file.name.equals("chapter.xhtml", true) -> { contentModel = file.readText() } //封面等其他模板 file.name.endsWith("html", true) -> { epubBook.addSection( FileUtils.getNameExcludeExtension(file.name), ResourceUtil.createPublicResource( book.name, book.getRealAuthor(), book.getDisplayIntro(), book.kind, book.wordCount, file.readText(), "${folder.name}/${file.name}" ) ) } //其他格式文件当做资源文件 else -> { epubBook.resources.add( Resource( file.readBytes(), "${folder.name}/${file.name}" ) ) } } } } else if (folder.isDir) { //资源文件 folder.list()!!.forEach loop2@{ if (it.isDir) { return@loop2 } epubBook.resources.add( Resource( it.readBytes(), "${folder.name}/${it.name}" ) ) } } else {//Asset下面的资源文件 epubBook.resources.add( Resource( folder.readBytes(), folder.name ) ) } } return contentModel } private fun setAssets(book: Book, epubBook: EpubBook): String { epubBook.resources.add( Resource( appCtx.assets.open("epub/fonts.css").readBytes(), "Styles/fonts.css" ) ) epubBook.resources.add( Resource( appCtx.assets.open("epub/main.css").readBytes(), "Styles/main.css" ) ) epubBook.resources.add( Resource( appCtx.assets.open("epub/logo.png").readBytes(), "Images/logo.png" ) ) epubBook.addSection( getString(R.string.img_cover), ResourceUtil.createPublicResource( book.name, book.getRealAuthor(), book.getDisplayIntro(), book.kind, book.wordCount, String(appCtx.assets.open("epub/cover.html").readBytes()), "Text/cover.html" ) ) epubBook.addSection( getString(R.string.book_intro), ResourceUtil.createPublicResource( book.name, book.getRealAuthor(), book.getDisplayIntro(), book.kind, book.wordCount, String(appCtx.assets.open("epub/intro.html").readBytes()), "Text/intro.html" ) ) return String(appCtx.assets.open("epub/chapter.html").readBytes()) } private fun setCover(book: Book, epubBook: EpubBook) { kotlin.runCatching { val file = Glide.with(this) .asFile() .load(book.getDisplayCover()) .submit() .get() val provider = LazyResourceProvider { _ -> file.inputStream() } epubBook.coverImage = LazyResource(provider, "Images/cover.jpg") }.onFailure { AppLog.put("获取书籍封面出错\n${it.localizedMessage}", it) } } private suspend fun setEpubContent( contentModel: String, book: Book, epubBook: EpubBook ) = coroutineScope { //正文 val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule() val contentProcessor = ContentProcessor.get(book.name, book.origin) val threads = if (AppConfig.parallelExportBook) { AppConst.MAX_THREAD } else { 1 } var parentSection: TOCReference? = null flow { appDb.bookChapterDao.getChapterList(book.bookUrl).forEach { chapter -> emit(chapter) } }.mapAsyncIndexed(threads) { index, chapter -> val content = BookHelp.getContent(book, chapter) val (contentFix, resources) = fixPic( book, content ?: if (chapter.isVolume) "" else "null", chapter ) // 不导出vip标识 chapter.isVip = false val content1 = contentProcessor .getContent( book, chapter, contentFix, includeTitle = false, useReplace = useReplace, chineseConvert = false, reSegment = false ).toString() val title = chapter.run { // 不导出vip标识 isVip = false getDisplayTitle( contentProcessor.getTitleReplaceRules(), useReplace = useReplace ) } val chapterResource = ResourceUtil.createChapterResource( title.replace("\uD83D\uDD12", ""), content1, contentModel, "Text/chapter_${index}.html" ) ExportChapter(title, chapterResource, resources, chapter) }.collectIndexed { index, exportChapter -> postEvent(EventBus.EXPORT_BOOK, book.bookUrl) exportProgress[book.bookUrl] = index val (title, chapterResource, resources, chapter) = exportChapter epubBook.resources.addAll(resources) if (chapter.isVolume) { parentSection = epubBook.addSection(title, chapterResource) } else if (parentSection == null) { epubBook.addSection(title, chapterResource) } else { epubBook.addSection(parentSection, title, chapterResource) } } } data class ExportChapter( val title: String, val chapterResource: Resource, val resources: ArrayList, val chapter: BookChapter ) private fun fixPic( book: Book, content: String, chapter: BookChapter ): Pair> { val data = StringBuilder("") val resources = arrayListOf() content.split("\n").forEach { text -> var text1 = text val matcher = AppPattern.imgPattern.matcher(text) while (matcher.find()) { matcher.group(1)?.let { val src = NetworkUtils.getAbsoluteURL(chapter.url, it) val originalHref = "${MD5Utils.md5Encode16(src)}.${BookHelp.getImageSuffix(src)}" val href = "Images/${MD5Utils.md5Encode16(src)}.${BookHelp.getImageSuffix(src)}" val vFile = BookHelp.getImage(book, src) val fp = FileResourceProvider(vFile.parent) if (vFile.exists()) { val img = LazyResource(fp, href, originalHref) resources.add(img) } text1 = text1.replace(src, "../${href}") } } data.append(text1).append("\n") } return data.toString() to resources } private fun setEpubMetadata(book: Book, epubBook: EpubBook) { val metadata = Metadata() metadata.titles.add(book.name)//书籍的名称 metadata.authors.add(Author(book.getRealAuthor()))//书籍的作者 metadata.language = "zh"//数据的语言 metadata.dates.add(Date())//数据的创建日期 metadata.publishers.add("Legado")//数据的创建者 metadata.descriptions.add(book.getDisplayIntro())//书籍的简介 //metadata.subjects.add("")//书籍的主题,在静读天下里面有使用这个分类书籍 epubBook.metadata = metadata } //////end of EPUB //////start of custom exporter /** * 自定义Exporter * @param scope 导出范围 * @param size epub 文件包含最大章节数 */ inner class CustomExporter(scopeStr: String, private val size: Int) { private var scope = parseScope(scopeStr) /** * 导出Epub * @param path 导出的路径 * @param book 书籍 */ suspend fun export( path: String, book: Book ) { exportProgress[book.bookUrl] = 0 exportMsg.remove(book.bookUrl) postEvent(EventBus.EXPORT_BOOK, book.bookUrl) val currentTimeMillis = System.currentTimeMillis() val count = appDb.bookChapterDao.getChapterCount(book.bookUrl) scope = scope.filter { it < count }.toHashSet() val fileDoc = FileDoc.fromDir(path) val (contentModel, epubList) = createEpubs(book, fileDoc) var progressBar = 0.0 epubList.forEachIndexed { index, ep -> val (filename, epubBook) = ep //设置正文 setEpubContent( contentModel, book, epubBook, index ) { _, _ -> // 将章节写入内存时更新进度条 postEvent(EventBus.EXPORT_BOOK, book.bookUrl) progressBar += book.totalChapterNum.toDouble() / scope.size / 2 exportProgress[book.bookUrl] = progressBar.toInt() } save2Drive(filename, epubBook, fileDoc) { total, _ -> //写入硬盘时更新进度条 progressBar += book.totalChapterNum.toDouble() / epubList.size / total / 2 postEvent(EventBus.EXPORT_BOOK, book.bookUrl) exportProgress[book.bookUrl] = progressBar.toInt() } } val elapsed = System.currentTimeMillis() - currentTimeMillis AppLog.put("分割导出书籍 ${book.name} 一共耗时 $elapsed") } /** * 设置epub正文 * * @param contentModel 正文模板 * @param book 书籍 * @param epubBook 分割后的epub * @param epubBookIndex 分割后的epub序号 */ private suspend fun setEpubContent( contentModel: String, book: Book, epubBook: EpubBook, epubBookIndex: Int, updateProgress: (chapterList: MutableList, index: Int) -> Unit ) { //正文 val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule() val contentProcessor = ContentProcessor.get(book.name, book.origin) var chapterList: MutableList = ArrayList() appDb.bookChapterDao.getChapterList(book.bookUrl).forEachIndexed { index, chapter -> if (scope.contains(index)) { chapterList.add(chapter) } if (scope.size == chapterList.size) { return@forEachIndexed } } // val totalChapterNum = book.totalChapterNum / scope.size if (chapterList.isEmpty()) { throw RuntimeException("书籍<${book.name}>(${epubBookIndex + 1})未找到章节信息") } chapterList = chapterList.subList( epubBookIndex * size, min(scope.size, (epubBookIndex + 1) * size) ) chapterList.forEachIndexed { index, chapter -> coroutineContext.ensureActive() updateProgress(chapterList, index) BookHelp.getContent(book, chapter).let { content -> val (contentFix, resources) = fixPic( book, content ?: if (chapter.isVolume) "" else "null", chapter ) epubBook.resources.addAll(resources) val content1 = contentProcessor .getContent( book, chapter, contentFix, includeTitle = false, useReplace = useReplace, chineseConvert = false, reSegment = false ).toString() val title = chapter.run { // 不导出vip标识 isVip = false getDisplayTitle( contentProcessor.getTitleReplaceRules(), useReplace = useReplace ) } epubBook.addSection( title, ResourceUtil.createChapterResource( title.replace("\uD83D\uDD12", ""), content1, contentModel, "Text/chapter_${index}.html" ) ) } } } /** * 创建多个epub 对象 * * 分割epub时,一个书籍需要创建多个epub对象 * @param book 书籍 * @param fileDoc 导出文件夹文档 * * @return <内容模板字符串, > */ private fun createEpubs( book: Book, fileDoc: FileDoc ): Pair>> { val paresNumOfEpub = paresNumOfEpub(scope.size, size) val result: MutableList> = ArrayList(paresNumOfEpub) var contentModel = "" for (i in 1..paresNumOfEpub) { val filename = book.getExportFileName("epub", i) fileDoc.find(filename)?.delete() val epubBook = EpubBook() epubBook.version = "2.0" //set metadata setEpubMetadata(book, epubBook) //set cover setCover(book, epubBook) //set css contentModel = setAssets(fileDoc, book, epubBook) // add epubBook result.add(Pair(filename, epubBook)) } return Pair(contentModel, result) } /** * 保存文件到 设备 */ private suspend fun save2Drive( filename: String, epubBook: EpubBook, fileDoc: FileDoc, callback: (total: Int, progress: Int) -> Unit ) { val bookDoc = fileDoc.createFileIfNotExist(filename) bookDoc.openOutputStream().getOrThrow().buffered().use { bookOs -> EpubWriter() .setCallback(object : EpubWriterProcessor.Callback { override fun onProgressing(total: Int, progress: Int) { callback(total, progress) } }) .write(epubBook, bookOs) } if (AppConfig.exportToWebDav) { // 导出到webdav AppWebDav.exportWebDav(bookDoc.uri, filename) } } /** * 解析 分割epub后的数量 * * @param total 章节总数 * @param size 每个epub文件包含多少章节 */ private fun paresNumOfEpub(total: Int, size: Int): Int { val i = total % size var result = total / size if (i > 0) { result++ } return result } /** * 解析范围字符串 * * @param scope 范围字符串 * @return 范围 * * @since 2023/5/22 * @author Discut */ private fun parseScope(scope: String): Set { val split = scope.split(",") val result = linkedSetOf() for (s in split) { val v = s.split("-") if (v.size != 2) { result.add(s.toInt() - 1) continue } val left = v[0].toInt() val right = v[1].toInt() if (left > right) { AppLog.put("Error expression : $s; left > right") continue } for (i in left..right) result.add(i - 1) } return result } } } ================================================ FILE: app/src/main/java/io/legado/app/service/HttpReadAloudService.kt ================================================ package io.legado.app.service import android.annotation.SuppressLint import android.app.PendingIntent import android.net.Uri import androidx.core.net.toUri import androidx.lifecycle.lifecycleScope import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.Timeline import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.datasource.DataSource import androidx.media3.datasource.cache.CacheDataSink import androidx.media3.datasource.cache.CacheDataSource import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor import androidx.media3.datasource.cache.SimpleCache import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.offline.DefaultDownloaderFactory import androidx.media3.exoplayer.offline.DownloadRequest import androidx.media3.exoplayer.offline.Downloader import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy import com.script.ScriptException import io.legado.app.R import io.legado.app.constant.AppLog import io.legado.app.constant.AppPattern import io.legado.app.data.entities.HttpTTS import io.legado.app.exception.NoStackTraceException import io.legado.app.help.config.AppConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.exoplayer.InputStreamDataSource import io.legado.app.help.http.okHttpClient import io.legado.app.model.ReadAloud import io.legado.app.model.ReadBook import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.ui.book.read.page.entities.TextChapter import io.legado.app.utils.FileUtils import io.legado.app.utils.MD5Utils import io.legado.app.utils.printOnDebug import io.legado.app.utils.servicePendingIntent import io.legado.app.utils.toastOnUi import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.Response import org.mozilla.javascript.WrappedException import splitties.init.appCtx import java.io.File import java.io.InputStream import java.net.ConnectException import java.net.SocketTimeoutException /** * 在线朗读 */ @SuppressLint("UnsafeOptInUsageError") class HttpReadAloudService : BaseReadAloudService(), Player.Listener { private val exoPlayer: ExoPlayer by lazy { ExoPlayer.Builder(this).build() } private val ttsFolderPath: String by lazy { cacheDir.absolutePath + File.separator + "httpTTS" + File.separator } private val cache by lazy { SimpleCache( File(cacheDir, "httpTTS_cache"), LeastRecentlyUsedCacheEvictor(128 * 1024 * 1024), StandaloneDatabaseProvider(appCtx) ) } private val cacheDataSinkFactory by lazy { CacheDataSink.Factory() .setCache(cache) } private val loadErrorHandlingPolicy by lazy { CustomLoadErrorHandlingPolicy() } private var speechRate: Int = AppConfig.speechRatePlay + 5 private var downloadTask: Coroutine<*>? = null private var playIndexJob: Job? = null private var downloadErrorNo: Int = 0 private var playErrorNo = 0 private val downloadTaskActiveLock = Mutex() override fun onCreate() { super.onCreate() exoPlayer.addListener(this) } override fun onDestroy() { super.onDestroy() downloadTask?.cancel() exoPlayer.release() cache.release() Coroutine.async { removeCacheFile() } } override fun play() { pageChanged = false exoPlayer.stop() if (!requestFocus()) return if (contentList.isEmpty()) { AppLog.putDebug("朗读列表为空") ReadBook.readAloud() } else { super.play() if (AppConfig.streamReadAloudAudio) { downloadAndPlayAudiosStream() } else { downloadAndPlayAudios() } } } override fun playStop() { exoPlayer.stop() playIndexJob?.cancel() } private fun updateNextPos() { readAloudNumber += contentList[nowSpeak].length + 1 - paragraphStartPos paragraphStartPos = 0 if (nowSpeak < contentList.lastIndex) { nowSpeak++ } else { nextChapter() } } private fun downloadAndPlayAudios() { exoPlayer.clearMediaItems() downloadTask?.cancel() downloadTask = execute { downloadTaskActiveLock.withLock { ensureActive() val httpTts = ReadAloud.httpTTS ?: throw NoStackTraceException("tts is null") contentList.forEachIndexed { index, content -> ensureActive() if (index < nowSpeak) return@forEachIndexed var text = content if (paragraphStartPos > 0 && index == nowSpeak) { text = text.substring(paragraphStartPos) } val fileName = md5SpeakFileName(text) val speakText = text.replace(AppPattern.notReadAloudRegex, "") if (speakText.isEmpty()) { AppLog.put("阅读段落内容为空,使用无声音频代替。\n朗读文本:$text") createSilentSound(fileName) } else if (!hasSpeakFile(fileName)) { runCatching { val inputStream = getSpeakStream(httpTts, speakText) if (inputStream != null) { createSpeakFile(fileName, inputStream) } else { createSilentSound(fileName) } }.onFailure { when (it) { is CancellationException -> Unit else -> pauseReadAloud() } return@execute } } val file = getSpeakFileAsMd5(fileName) val mediaItem = MediaItem.fromUri(Uri.fromFile(file)) launch(Main) { exoPlayer.addMediaItem(mediaItem) } } preDownloadAudios(httpTts) } }.onError { AppLog.put("朗读下载出错\n${it.localizedMessage}", it, true) } } private suspend fun preDownloadAudios(httpTts: HttpTTS) { val textChapter = ReadBook.nextTextChapter ?: return val contentList = textChapter.getNeedReadAloud(0, readAloudByPage, 0, 1) .splitToSequence("\n") .filter { it.isNotEmpty() } .take(10) .toList() contentList.forEach { content -> currentCoroutineContext().ensureActive() val fileName = md5SpeakFileName(content, textChapter) val speakText = content.replace(AppPattern.notReadAloudRegex, "") if (speakText.isEmpty()) { createSilentSound(fileName) } else if (!hasSpeakFile(fileName)) { runCatching { val inputStream = getSpeakStream(httpTts, speakText) if (inputStream != null) { createSpeakFile(fileName, inputStream) } else { createSilentSound(fileName) } } } } } private fun downloadAndPlayAudiosStream() { exoPlayer.clearMediaItems() downloadTask?.cancel() downloadTask = execute { downloadTaskActiveLock.withLock { ensureActive() val httpTts = ReadAloud.httpTTS ?: throw NoStackTraceException("tts is null") val downloaderChannel = Channel() launch { for (downloader in downloaderChannel) { downloader.download(null) } } contentList.forEachIndexed { index, content -> ensureActive() if (index < nowSpeak) return@forEachIndexed var text = content if (paragraphStartPos > 0 && index == nowSpeak) { text = text.substring(paragraphStartPos) } val speakText = text.replace(AppPattern.notReadAloudRegex, "") if (speakText.isEmpty()) { AppLog.put("阅读段落内容为空,使用无声音频代替。\n朗读文本:$speakText") } val fileName = md5SpeakFileName(text) val dataSourceFactory = createDataSourceFactory(httpTts, speakText) val downloader = createDownloader(dataSourceFactory, fileName) downloaderChannel.send(downloader) val mediaSource = createMediaSource(dataSourceFactory, fileName) launch(Main) { exoPlayer.addMediaSource(mediaSource) } } preDownloadAudiosStream(httpTts, downloaderChannel) } }.onError { AppLog.put("朗读下载出错\n${it.localizedMessage}", it, true) } } private suspend fun preDownloadAudiosStream( httpTts: HttpTTS, downloaderChannel: Channel ) { val textChapter = ReadBook.nextTextChapter ?: return val contentList = textChapter.getNeedReadAloud(0, readAloudByPage, 0, 1) .splitToSequence("\n") .filter { it.isNotEmpty() } .take(10) .toList() contentList.forEach { content -> currentCoroutineContext().ensureActive() val fileName = md5SpeakFileName(content, textChapter) val speakText = content.replace(AppPattern.notReadAloudRegex, "") val dataSourceFactory = createDataSourceFactory(httpTts, speakText) val downloader = createDownloader(dataSourceFactory, fileName) downloaderChannel.send(downloader) } } private fun createDataSourceFactory( httpTts: HttpTTS, speakText: String ): CacheDataSource.Factory { val upstreamFactory = DataSource.Factory { InputStreamDataSource { if (speakText.isEmpty()) { null } else { kotlin.runCatching { runBlocking(lifecycleScope.coroutineContext[Job]!!) { getSpeakStream(httpTts, speakText) } }.onFailure { when (it) { is InterruptedException, is CancellationException -> Unit else -> pauseReadAloud() } }.getOrThrow() } ?: resources.openRawResource(R.raw.silent_sound) } } val factory = CacheDataSource.Factory() .setCache(cache) .setUpstreamDataSourceFactory(upstreamFactory) .setCacheWriteDataSinkFactory(cacheDataSinkFactory) return factory } private fun createDownloader(factory: CacheDataSource.Factory, fileName: String): Downloader { val uri = fileName.toUri() val request = DownloadRequest.Builder(fileName, uri).build() return DefaultDownloaderFactory(factory, okHttpClient.dispatcher.executorService) .createDownloader(request) } private fun createMediaSource(factory: DataSource.Factory, fileName: String): MediaSource { return DefaultMediaSourceFactory(this) .setDataSourceFactory(factory) .setLoadErrorHandlingPolicy(loadErrorHandlingPolicy) .createMediaSource(MediaItem.fromUri(fileName)) } private suspend fun getSpeakStream( httpTts: HttpTTS, speakText: String ): InputStream? { while (true) { try { val analyzeUrl = AnalyzeUrl( httpTts.url, speakText = speakText, speakSpeed = speechRate, source = httpTts, readTimeout = 300 * 1000L, coroutineContext = currentCoroutineContext() ) var response = analyzeUrl.getResponseAwait() currentCoroutineContext().ensureActive() val checkJs = httpTts.loginCheckJs if (checkJs?.isNotBlank() == true) { response = analyzeUrl.evalJS(checkJs, response) as Response } response.headers["Content-Type"]?.let { contentType -> val contentType = contentType.substringBefore(";") val ct = httpTts.contentType if (contentType == "application/json" || contentType.startsWith("text/")) { throw NoStackTraceException(response.body.string()) } else if (ct?.isNotBlank() == true) { if (!contentType.matches(ct.toRegex())) { throw NoStackTraceException( "TTS服务器返回错误:" + response.body.string() ) } } } currentCoroutineContext().ensureActive() response.body.byteStream().let { stream -> downloadErrorNo = 0 return stream } } catch (e: Exception) { when (e) { is CancellationException -> throw e is ScriptException, is WrappedException -> { AppLog.put("js错误\n${e.localizedMessage}", e, true) e.printOnDebug() throw e } is SocketTimeoutException, is ConnectException -> { downloadErrorNo++ if (downloadErrorNo > 5) { val msg = "tts超时或连接错误超过5次\n${e.localizedMessage}" AppLog.put(msg, e, true) throw e } } else -> { downloadErrorNo++ val msg = "tts下载错误\n${e.localizedMessage}" AppLog.put(msg, e) e.printOnDebug() if (downloadErrorNo > 5) { val msg1 = "TTS服务器连续5次错误,已暂停阅读。" AppLog.put(msg1, e, true) throw e } else { AppLog.put("TTS下载音频出错,使用无声音频代替。\n朗读文本:$speakText") break } } } } } return null } private fun md5SpeakFileName(content: String, textChapter: TextChapter? = this.textChapter): String { return MD5Utils.md5Encode16(textChapter?.title ?: "") + "_" + MD5Utils.md5Encode16("${ReadAloud.httpTTS?.url}-|-$speechRate-|-$content") } private fun createSilentSound(fileName: String) { val file = createSpeakFile(fileName) file.writeBytes(resources.openRawResource(R.raw.silent_sound).readBytes()) } private fun hasSpeakFile(name: String): Boolean { return FileUtils.exist("${ttsFolderPath}$name.mp3") } private fun getSpeakFileAsMd5(name: String): File { return File("${ttsFolderPath}$name.mp3") } private fun createSpeakFile(name: String): File { return FileUtils.createFileIfNotExist("${ttsFolderPath}$name.mp3") } private fun createSpeakFile(name: String, inputStream: InputStream) { FileUtils.createFileIfNotExist("${ttsFolderPath}$name.mp3").outputStream().use { out -> inputStream.use { it.copyTo(out) } } } /** * 移除缓存文件 */ private fun removeCacheFile() { val titleMd5 = MD5Utils.md5Encode16(textChapter?.title ?: "") FileUtils.listDirsAndFiles(ttsFolderPath)?.forEach { val isSilentSound = it.length() == 2160L if ((!it.name.startsWith(titleMd5) && System.currentTimeMillis() - it.lastModified() > 600000) || isSilentSound ) { FileUtils.delete(it.absolutePath) } } } override fun pauseReadAloud(abandonFocus: Boolean) { super.pauseReadAloud(abandonFocus) kotlin.runCatching { playIndexJob?.cancel() exoPlayer.pause() } } override fun resumeReadAloud() { super.resumeReadAloud() kotlin.runCatching { if (pageChanged) { play() } else { exoPlayer.play() upPlayPos() } } } private fun upPlayPos() { playIndexJob?.cancel() val textChapter = textChapter ?: return playIndexJob = lifecycleScope.launch { upTtsProgress(readAloudNumber + 1) if (exoPlayer.duration <= 0) { return@launch } val speakTextLength = contentList[nowSpeak].length if (speakTextLength <= 0) { return@launch } val sleep = exoPlayer.duration / speakTextLength val start = speakTextLength * exoPlayer.currentPosition / exoPlayer.duration for (i in start..contentList[nowSpeak].length) { if (pageIndex + 1 < textChapter.pageSize && readAloudNumber + i > textChapter.getReadLength(pageIndex + 1) ) { pageIndex++ ReadBook.moveToNextPage() upTtsProgress(readAloudNumber + i.toInt()) } delay(sleep) } } } /** * 更新朗读速度 */ override fun upSpeechRate(reset: Boolean) { downloadTask?.cancel() exoPlayer.stop() speechRate = AppConfig.speechRatePlay + 5 if (AppConfig.streamReadAloudAudio) { downloadAndPlayAudiosStream() } else { downloadAndPlayAudios() } } override fun onPlaybackStateChanged(playbackState: Int) { super.onPlaybackStateChanged(playbackState) when (playbackState) { Player.STATE_IDLE -> { // 空闲 } Player.STATE_BUFFERING -> { // 缓冲中 } Player.STATE_READY -> { // 准备好 if (pause) return exoPlayer.play() upPlayPos() } Player.STATE_ENDED -> { // 结束 playErrorNo = 0 updateNextPos() exoPlayer.stop() exoPlayer.clearMediaItems() } } } override fun onTimelineChanged(timeline: Timeline, reason: Int) { when (reason) { Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED -> { if (!timeline.isEmpty && exoPlayer.playbackState == Player.STATE_IDLE) { exoPlayer.prepare() } } else -> {} } } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED) return if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) { playErrorNo = 0 } updateNextPos() upPlayPos() } override fun onPlayerError(error: PlaybackException) { super.onPlayerError(error) AppLog.put("朗读错误\n${contentList[nowSpeak]}", error) deleteCurrentSpeakFile() playErrorNo++ if (playErrorNo >= 5) { toastOnUi("朗读连续5次错误, 最后一次错误代码(${error.localizedMessage})") AppLog.put("朗读连续5次错误, 最后一次错误代码(${error.localizedMessage})", error) pauseReadAloud() } else { if (exoPlayer.hasNextMediaItem()) { exoPlayer.seekToNextMediaItem() exoPlayer.prepare() } else { exoPlayer.clearMediaItems() updateNextPos() } } } private fun deleteCurrentSpeakFile() { if (AppConfig.streamReadAloudAudio) { return } val mediaItem = exoPlayer.currentMediaItem ?: return val filePath = mediaItem.localConfiguration!!.uri.path!! File(filePath).delete() } override fun aloudServicePendingIntent(actionStr: String): PendingIntent? { return servicePendingIntent(actionStr) } class CustomLoadErrorHandlingPolicy : DefaultLoadErrorHandlingPolicy(0) { override fun getRetryDelayMsFor(loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo): Long { return C.TIME_UNSET } } } ================================================ FILE: app/src/main/java/io/legado/app/service/README.md ================================================ # android服务 * AudioPlayService 音频播放服务 * CheckSourceService 书源检测服务 * DownloadService 缓存服务 * HttpReadAloudService 在线朗读服务 * TTSReadAloudService tts朗读服务 * WebService web服务 ================================================ FILE: app/src/main/java/io/legado/app/service/TTSReadAloudService.kt ================================================ package io.legado.app.service import android.app.PendingIntent import android.speech.tts.TextToSpeech import android.speech.tts.UtteranceProgressListener import io.legado.app.R import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog import io.legado.app.constant.AppPattern import io.legado.app.exception.NoStackTraceException import io.legado.app.help.MediaHelp import io.legado.app.help.config.AppConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.lib.dialogs.SelectItem import io.legado.app.model.ReadAloud import io.legado.app.model.ReadBook import io.legado.app.utils.GSON import io.legado.app.utils.LogUtils import io.legado.app.utils.fromJsonObject import io.legado.app.utils.servicePendingIntent import io.legado.app.utils.toastOnUi import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive /** * 本地朗读 */ class TTSReadAloudService : BaseReadAloudService(), TextToSpeech.OnInitListener { private var textToSpeech: TextToSpeech? = null private var ttsInitFinish = false private val ttsUtteranceListener = TTSUtteranceListener() private var speakJob: Coroutine<*>? = null private val TAG = "TTSReadAloudService" override fun onCreate() { super.onCreate() kotlin.runCatching { initTts() }.onFailure { AppLog.put("${getString(R.string.tts_init_failed)}\n$it", it, true) } } override fun onDestroy() { super.onDestroy() clearTTS() } @Synchronized private fun initTts() { ttsInitFinish = false val engine = GSON.fromJsonObject>(ReadAloud.ttsEngine).getOrNull()?.value LogUtils.d(TAG, "initTts engine:$engine") textToSpeech = if (engine.isNullOrBlank()) { TextToSpeech(this, this) } else { TextToSpeech(this, this, engine) } upSpeechRate() } @Synchronized fun clearTTS() { textToSpeech?.runCatching { stop() shutdown() } textToSpeech = null ttsInitFinish = false } override fun onInit(status: Int) { if (status == TextToSpeech.SUCCESS) { textToSpeech?.let { it.setOnUtteranceProgressListener(ttsUtteranceListener) ttsInitFinish = true play() } } else { toastOnUi(R.string.tts_init_failed) } } @Synchronized override fun play() { if (!ttsInitFinish) return if (!requestFocus()) return if (contentList.isEmpty()) { AppLog.putDebug("朗读列表为空") ReadBook.readAloud() return } super.play() MediaHelp.playSilentSound(this@TTSReadAloudService) speakJob?.cancel() speakJob = execute { LogUtils.d(TAG, "朗读列表大小 ${contentList.size}") LogUtils.d(TAG, "朗读页数 ${textChapter?.pageSize}") val tts = textToSpeech ?: throw NoStackTraceException("tts is null") val contentList = contentList var isAddedText = false for (i in nowSpeak until contentList.size) { ensureActive() var text = contentList[i] if (paragraphStartPos > 0 && i == nowSpeak) { text = text.substring(paragraphStartPos) } if (text.matches(AppPattern.notReadAloudRegex)) { continue } if (!isAddedText) { val result = tts.runCatching { speak(text, TextToSpeech.QUEUE_FLUSH, null, AppConst.APP_TAG + i) }.getOrElse { AppLog.put("tts出错\n${it.localizedMessage}", it, true) TextToSpeech.ERROR } if (result == TextToSpeech.ERROR) { AppLog.put("tts出错 尝试重新初始化") clearTTS() initTts() return@execute } } else { val result = tts.runCatching { speak(text, TextToSpeech.QUEUE_ADD, null, AppConst.APP_TAG + i) }.getOrElse { AppLog.put("tts出错\n${it.localizedMessage}", it, true) TextToSpeech.ERROR } if (result == TextToSpeech.ERROR) { AppLog.put("tts朗读出错:$text") } } isAddedText = true } LogUtils.d(TAG, "朗读内容添加完成") if (!isAddedText) { playStop() delay(1000) nextChapter() } }.onError { AppLog.put("tts朗读出错\n${it.localizedMessage}", it, true) } } override fun playStop() { textToSpeech?.runCatching { stop() } } /** * 更新朗读速度 */ override fun upSpeechRate(reset: Boolean) { if (AppConfig.ttsFlowSys) { if (reset) { clearTTS() initTts() } } else { val speechRate = (AppConfig.ttsSpeechRate + 5) / 10f textToSpeech?.setSpeechRate(speechRate) } } /** * 暂停朗读 */ override fun pauseReadAloud(abandonFocus: Boolean) { super.pauseReadAloud(abandonFocus) speakJob?.cancel() textToSpeech?.runCatching { stop() } } /** * 恢复朗读 */ override fun resumeReadAloud() { super.resumeReadAloud() play() } /** * 朗读监听 */ private inner class TTSUtteranceListener : UtteranceProgressListener() { private val TAG = "TTSUtteranceListener" override fun onStart(s: String) { LogUtils.d(TAG, "onStart nowSpeak:$nowSpeak pageIndex:$pageIndex utteranceId:$s") textChapter?.let { if (contentList[nowSpeak].matches(AppPattern.notReadAloudRegex)) { nextParagraph() } if (pageIndex + 1 < it.pageSize && readAloudNumber + 1 > it.getReadLength(pageIndex + 1) ) { pageIndex++ ReadBook.moveToNextPage() } upTtsProgress(readAloudNumber + 1) } } override fun onDone(s: String) { LogUtils.d(TAG, "onDone utteranceId:$s") nextParagraph() } override fun onRangeStart(utteranceId: String?, start: Int, end: Int, frame: Int) { super.onRangeStart(utteranceId, start, end, frame) val msg = "onRangeStart nowSpeak:$nowSpeak pageIndex:$pageIndex utteranceId:$utteranceId start:$start end:$end frame:$frame" LogUtils.d(TAG, msg) textChapter?.let { if (pageIndex + 1 < it.pageSize && readAloudNumber + start > it.getReadLength(pageIndex + 1) ) { pageIndex++ ReadBook.moveToNextPage() upTtsProgress(readAloudNumber + start) } } } override fun onError(utteranceId: String?, errorCode: Int) { LogUtils.d( TAG, "onError nowSpeak:$nowSpeak pageIndex:$pageIndex utteranceId:$utteranceId errorCode:$errorCode" ) nextParagraph() } private fun nextParagraph() { //跳过全标点段落 do { readAloudNumber += contentList[nowSpeak].length + 1 - paragraphStartPos paragraphStartPos = 0 nowSpeak++ if (nowSpeak >= contentList.size) { nextChapter() return } } while (contentList[nowSpeak].matches(AppPattern.notReadAloudRegex)) } @Deprecated("Deprecated in Java") override fun onError(s: String) { LogUtils.d(TAG, "onError nowSpeak:$nowSpeak pageIndex:$pageIndex s:$s") nextParagraph() } } override fun aloudServicePendingIntent(actionStr: String): PendingIntent? { return servicePendingIntent(actionStr) } } ================================================ FILE: app/src/main/java/io/legado/app/service/WebService.kt ================================================ package io.legado.app.service import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.net.wifi.WifiManager import android.os.Build import android.os.PowerManager import androidx.core.app.NotificationCompat import io.legado.app.R import io.legado.app.base.BaseService import io.legado.app.constant.AppConst import io.legado.app.constant.EventBus import io.legado.app.constant.IntentAction import io.legado.app.constant.NotificationId import io.legado.app.constant.PreferKey import io.legado.app.receiver.NetworkChangedListener import io.legado.app.utils.NetworkUtils import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.getPrefInt import io.legado.app.utils.postEvent import io.legado.app.utils.printOnDebug import io.legado.app.utils.sendToClip import io.legado.app.utils.servicePendingIntent import io.legado.app.utils.startForegroundServiceCompat import io.legado.app.utils.startService import io.legado.app.utils.stopService import io.legado.app.utils.toastOnUi import io.legado.app.web.HttpServer import io.legado.app.web.WebSocketServer import splitties.init.appCtx import splitties.systemservices.powerManager import splitties.systemservices.wifiManager import java.io.IOException class WebService : BaseService() { companion object { var isRun = false var hostAddress = "" fun start(context: Context) { context.startService() } fun startForeground(context: Context) { val intent = Intent(context, WebService::class.java) context.startForegroundServiceCompat(intent) } fun stop(context: Context) { context.stopService() } fun serve() { appCtx.startService { action = "serve" } } } private val useWakeLock = appCtx.getPrefBoolean(PreferKey.webServiceWakeLock, false) private val wakeLock: PowerManager.WakeLock by lazy { powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "legado:WebService") .apply { setReferenceCounted(false) } } private val wifiLock by lazy { @Suppress("DEPRECATION") wifiManager?.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "legado:WebService") ?.apply { setReferenceCounted(false) } } private var httpServer: HttpServer? = null private var webSocketServer: WebSocketServer? = null private var notificationList = mutableListOf(appCtx.getString(R.string.service_starting)) private val networkChangedListener by lazy { NetworkChangedListener(this) } @SuppressLint("WakelockTimeout") override fun onCreate() { super.onCreate() if (useWakeLock) { wakeLock.acquire() wifiLock?.acquire() } isRun = true upTile(true) networkChangedListener.register() networkChangedListener.onNetworkChanged = { val addressList = NetworkUtils.getLocalIPAddress() notificationList.clear() if (addressList.any()) { notificationList.addAll(addressList.map { address -> getString( R.string.http_ip, address.hostAddress, getPort() ) }) hostAddress = notificationList.first() } else { hostAddress = getString(R.string.network_connection_unavailable) notificationList.add(hostAddress) } startForegroundNotification() postEvent(EventBus.WEB_SERVICE, hostAddress) } } @SuppressLint("WakelockTimeout") override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { IntentAction.stop -> stopSelf() "copyHostAddress" -> sendToClip(hostAddress) "serve" -> if (useWakeLock) { wakeLock.acquire() wifiLock?.acquire() } else -> upWebServer() } return super.onStartCommand(intent, flags, startId) } override fun onDestroy() { super.onDestroy() if (useWakeLock) { wakeLock.release() wifiLock?.release() } networkChangedListener.unRegister() isRun = false if (httpServer?.isAlive == true) { httpServer?.stop() } if (webSocketServer?.isAlive == true) { webSocketServer?.stop() } postEvent(EventBus.WEB_SERVICE, "") upTile(false) } private fun upWebServer() { if (httpServer?.isAlive == true) { httpServer?.stop() } if (webSocketServer?.isAlive == true) { webSocketServer?.stop() } val addressList = NetworkUtils.getLocalIPAddress() if (addressList.any()) { val port = getPort() httpServer = HttpServer(port) webSocketServer = WebSocketServer(port + 1) try { httpServer?.start() webSocketServer?.start(1000 * 30) // 通信超时设置 notificationList.clear() notificationList.addAll(addressList.map { address -> getString( R.string.http_ip, address.hostAddress, getPort() ) }) hostAddress = notificationList.first() isRun = true postEvent(EventBus.WEB_SERVICE, hostAddress) startForegroundNotification() } catch (e: IOException) { toastOnUi(e.localizedMessage ?: "") e.printOnDebug() stopSelf() } } else { toastOnUi("web service cant start, no ip address") stopSelf() } } private fun getPort(): Int { var port = getPrefInt(PreferKey.webPort, 1122) if (port > 65530 || port < 1024) { port = 1122 } return port } /** * 更新通知 */ override fun startForegroundNotification() { val builder = NotificationCompat.Builder(this, AppConst.channelIdWeb) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setSmallIcon(R.drawable.ic_web_service_noti) .setOngoing(true) .setContentTitle(getString(R.string.web_service)) .setContentText(notificationList.joinToString("\n")) .setContentIntent( servicePendingIntent("copyHostAddress") ) builder.addAction( R.drawable.ic_stop_black_24dp, getString(R.string.cancel), servicePendingIntent(IntentAction.stop) ) val notification = builder.build() startForeground(NotificationId.WebService, notification) } @SuppressLint("ObsoleteSdkInt") private fun upTile(active: Boolean) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { kotlin.runCatching { startService { action = if (active) { IntentAction.start } else { IntentAction.stop } } } } } } ================================================ FILE: app/src/main/java/io/legado/app/service/WebTileService.kt ================================================ package io.legado.app.service import android.app.Dialog import android.app.ForegroundServiceStartNotAllowedException import android.content.Intent import android.os.Build import android.service.quicksettings.Tile import android.service.quicksettings.TileService import android.view.WindowManager.BadTokenException import androidx.annotation.RequiresApi import io.legado.app.R import io.legado.app.constant.IntentAction import io.legado.app.utils.printOnDebug /** * web服务快捷开关 */ @RequiresApi(Build.VERSION_CODES.N) class WebTileService : TileService() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { try { when (intent?.action) { IntentAction.start -> qsTile?.run { state = Tile.STATE_ACTIVE updateTile() } IntentAction.stop -> qsTile?.run { state = Tile.STATE_INACTIVE updateTile() } } } catch (e: Exception) { e.printOnDebug() } return super.onStartCommand(intent, flags, startId) } override fun onStartListening() { super.onStartListening() qsTile?.run { state = if (WebService.isRun) { Tile.STATE_ACTIVE } else { Tile.STATE_INACTIVE } updateTile() } } override fun onClick() { super.onClick() if (WebService.isRun) { WebService.stop(this) } else { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { val dialog = Dialog(this, R.style.AppTheme_Transparent) dialog.setOnShowListener { try { WebService.startForeground(this) } catch (e: ForegroundServiceStartNotAllowedException) { e.printStackTrace() } dialog.dismiss() } try { showDialog(dialog) } catch (e: BadTokenException) { e.printStackTrace() } } else { WebService.start(this) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/README.md ================================================ # 放置与界面有关的类 * about 关于界面 * association 导入书源界面 * book\audio 音频播放界面 * book\arrange 书架整理界面 * book\info 书籍信息查看 * book\read 书籍阅读界面 * book\search 搜索书籍界面 * book\source 书源界面 * book\changeCover 封面换源界面 * book\changeSource 换源界面 * book\toc 目录界面 * book\download 下载界面 * book\explore 发现界面 * book\local 书籍导入界面 * document 文件选择界面 * config 配置界面 * main 主界面 * qrCode 二维码扫描界面 * replaceRule 替换净化界面 * rss\article 订阅条目界面 * rss\read 订阅阅读界面 * rss\source 订阅源界面 * welcome 欢迎界面 * widget 自定义插件 ================================================ FILE: app/src/main/java/io/legado/app/ui/about/AboutActivity.kt ================================================ package io.legado.app.ui.about import android.os.Bundle import android.text.Spannable import android.text.SpannableString import android.text.style.ForegroundColorSpan import android.view.Menu import android.view.MenuItem import io.legado.app.R import io.legado.app.base.BaseActivity import io.legado.app.databinding.ActivityAboutBinding import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.filletBackground import io.legado.app.utils.openUrl import io.legado.app.utils.share import io.legado.app.utils.viewbindingdelegate.viewBinding class AboutActivity : BaseActivity() { override val binding by viewBinding(ActivityAboutBinding::inflate) override fun onActivityCreated(savedInstanceState: Bundle?) { binding.llAbout.background = filletBackground val fTag = "aboutFragment" var aboutFragment = supportFragmentManager.findFragmentByTag(fTag) if (aboutFragment == null) aboutFragment = AboutFragment() supportFragmentManager.beginTransaction() .replace(R.id.fl_fragment, aboutFragment, fTag) .commit() binding.tvAppSummary.post { kotlin.runCatching { val span = ForegroundColorSpan(accentColor) val spannableString = SpannableString(binding.tvAppSummary.text) val gzh = getString(R.string.legado_gzh) val start = spannableString.indexOf(gzh) spannableString.setSpan( span, start, start + gzh.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE ) binding.tvAppSummary.text = spannableString } } } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.about, menu) return super.onCompatCreateOptionsMenu(menu) } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_scoring -> openUrl("market://details?id=$packageName") R.id.menu_share_it -> share( getString(R.string.app_share_description), getString(R.string.app_name) ) } return super.onCompatOptionsItemSelected(item) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/about/AboutFragment.kt ================================================ package io.legado.app.ui.about import android.content.Intent import android.net.Uri import android.os.Bundle import android.view.View import androidx.annotation.StringRes import androidx.lifecycle.lifecycleScope import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import io.legado.app.R import io.legado.app.constant.AppConst.appInfo import io.legado.app.constant.AppLog import io.legado.app.help.CrashHandler import io.legado.app.help.config.AppConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.update.AppUpdate import io.legado.app.ui.widget.dialog.TextDialog import io.legado.app.ui.widget.dialog.WaitDialog import io.legado.app.utils.FileDoc import io.legado.app.utils.compress.ZipUtils import io.legado.app.utils.createFileIfNotExist import io.legado.app.utils.createFolderIfNotExist import io.legado.app.utils.delete import io.legado.app.utils.externalCache import io.legado.app.utils.find import io.legado.app.utils.list import io.legado.app.utils.openInputStream import io.legado.app.utils.openOutputStream import io.legado.app.utils.openUrl import io.legado.app.utils.sendMail import io.legado.app.utils.sendToClip import io.legado.app.utils.showDialogFragment import io.legado.app.utils.toastOnUi import kotlinx.coroutines.delay import splitties.init.appCtx import java.io.File class AboutFragment : PreferenceFragmentCompat() { private val waitDialog by lazy { WaitDialog(requireContext()) } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.about) findPreference("update_log")?.summary = "${getString(R.string.version)} ${appInfo.versionName}" } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) listView.overScrollMode = View.OVER_SCROLL_NEVER } override fun onPreferenceTreeClick(preference: Preference): Boolean { when (preference.key) { "contributors" -> openUrl(R.string.contributors_url) "update_log" -> showMdFile(getString(R.string.update_log), "updateLog.md") "check_update" -> checkUpdate() "mail" -> requireContext().sendMail(getString(R.string.email)) "license" -> showMdFile(getString(R.string.license), "LICENSE.md") "disclaimer" -> showMdFile(getString(R.string.disclaimer), "disclaimer.md") "privacyPolicy" -> showMdFile(getString(R.string.privacy_policy), "privacyPolicy.md") "gzGzh" -> requireContext().sendToClip(getString(R.string.legado_gzh)) "crashLog" -> showDialogFragment() "saveLog" -> saveLog() "createHeapDump" -> createHeapDump() } return super.onPreferenceTreeClick(preference) } @Suppress("SameParameterValue") private fun openUrl(@StringRes addressID: Int) { requireContext().openUrl(getString(addressID)) } /** * 显示md文件 */ private fun showMdFile(title: String, fileName: String) { val mdText = String(requireContext().assets.open(fileName).readBytes()) showDialogFragment(TextDialog(title, mdText, TextDialog.Mode.MD)) } /** * 检测更新 */ private fun checkUpdate() { waitDialog.show() AppUpdate.gitHubUpdate?.run { check(lifecycleScope) .onSuccess { showDialogFragment( UpdateDialog(it) ) }.onError { appCtx.toastOnUi("${getString(R.string.check_update)}\n${it.localizedMessage}") }.onFinally { waitDialog.dismiss() } } } /** * 加入qq群 */ private fun joinQQGroup(key: String): Boolean { val intent = Intent() intent.data = Uri.parse("mqqopensdkapi://bizAgent/qm/qr?url=http%3A%2F%2Fqm.qq.com%2Fcgi-bin%2Fqm%2Fqr%3Ffrom%3Dapp%26p%3Dandroid%26k%3D$key") // 此Flag可根据具体产品需要自定义,如设置,则在加群界面按返回,返回手Q主界面,不设置,按返回会返回到呼起产品界面 // intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) kotlin.runCatching { startActivity(intent) return true }.onFailure { toastOnUi("添加失败,请手动添加") } return false } private fun saveLog() { Coroutine.async { val backupPath = AppConfig.backupPath ?: let { appCtx.toastOnUi("未设置备份目录") return@async } if (!AppConfig.recordLog) { appCtx.toastOnUi("未开启日志记录,请去其他设置里打开记录日志") delay(3000) } val doc = FileDoc.fromUri(Uri.parse(backupPath), true) copyLogs(doc) copyHeapDump(doc) appCtx.toastOnUi("已保存至备份目录") }.onError { AppLog.put("保存日志出错\n${it.localizedMessage}", it, true) } } private fun createHeapDump() { Coroutine.async { val backupPath = AppConfig.backupPath ?: let { appCtx.toastOnUi("未设置备份目录") return@async } if (!AppConfig.recordHeapDump) { appCtx.toastOnUi("未开启堆转储记录,请去其他设置里打开记录堆转储") delay(3000) } appCtx.toastOnUi("开始创建堆转储") System.gc() CrashHandler.doHeapDump(true) val doc = FileDoc.fromUri(Uri.parse(backupPath), true) if (!copyHeapDump(doc)) { appCtx.toastOnUi("未找到堆转储文件") } else { appCtx.toastOnUi("已保存至备份目录") } }.onError { AppLog.put("保存堆转储失败\n${it.localizedMessage}", it) } } private fun copyLogs(doc: FileDoc) { val cacheDir = appCtx.externalCache val logFiles = File(cacheDir, "logs") val crashFiles = File(cacheDir, "crash") val logcatFile = File(cacheDir, "logcat.txt") dumpLogcat(logcatFile) val zipFile = File(cacheDir, "logs.zip") ZipUtils.zipFiles(arrayListOf(logFiles, crashFiles, logcatFile), zipFile) doc.find("logs.zip")?.delete() zipFile.inputStream().use { input -> doc.createFileIfNotExist("logs.zip").openOutputStream().getOrNull() ?.use { input.copyTo(it) } } zipFile.delete() } private fun copyHeapDump(doc: FileDoc): Boolean { val heapFile = FileDoc.fromFile(File(appCtx.externalCache, "heapDump")).list() ?.firstOrNull() ?: return false doc.find("heapDump")?.delete() val heapDumpDoc = doc.createFolderIfNotExist("heapDump") heapFile.openInputStream().getOrNull()?.use { input -> heapDumpDoc.createFileIfNotExist(heapFile.name).openOutputStream().getOrNull() ?.use { input.copyTo(it) } } return true } private fun dumpLogcat(file: File) { try { val process = Runtime.getRuntime().exec("logcat -d") file.outputStream().use { process.inputStream.copyTo(it) } } catch (e: Exception) { AppLog.put("保存Logcat失败\n$e", e) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/about/AppLogDialog.kt ================================================ package io.legado.app.ui.about import android.content.Context import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.constant.AppLog import io.legado.app.databinding.DialogRecyclerViewBinding import io.legado.app.databinding.ItemAppLogBinding import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.widget.dialog.TextDialog import io.legado.app.utils.LogUtils import io.legado.app.utils.setLayout import io.legado.app.utils.showDialogFragment import io.legado.app.utils.viewbindingdelegate.viewBinding import splitties.views.onClick import java.util.* class AppLogDialog : BaseDialogFragment(R.layout.dialog_recycler_view), Toolbar.OnMenuItemClickListener { private val binding by viewBinding(DialogRecyclerViewBinding::bind) private val adapter by lazy { LogAdapter(requireContext()) } override fun onStart() { super.onStart() setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.run { toolBar.setBackgroundColor(primaryColor) toolBar.setTitle(R.string.log) toolBar.inflateMenu(R.menu.app_log) toolBar.setOnMenuItemClickListener(this@AppLogDialog) recyclerView.layoutManager = LinearLayoutManager(requireContext()) recyclerView.adapter = adapter } adapter.setItems(AppLog.logs) } override fun onMenuItemClick(item: MenuItem?): Boolean { when (item?.itemId) { R.id.menu_clear -> { AppLog.clear() adapter.clearItems() } } return true } inner class LogAdapter(context: Context) : RecyclerAdapter, ItemAppLogBinding>(context) { override fun getViewBinding(parent: ViewGroup): ItemAppLogBinding { return ItemAppLogBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemAppLogBinding, item: Triple, payloads: MutableList ) { binding.textTime.text = LogUtils.logTimeFormat.format(Date(item.first)) binding.textMessage.text = item.second } override fun registerListener(holder: ItemViewHolder, binding: ItemAppLogBinding) { binding.root.onClick { getItem(holder.layoutPosition)?.let { item -> item.third?.let { showDialogFragment(TextDialog("Log", it.stackTraceToString())) } } } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/about/CrashLogsDialog.kt ================================================ package io.legado.app.ui.about import android.app.Application import android.net.Uri import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.fragment.app.viewModels import androidx.lifecycle.MutableLiveData import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.BaseViewModel import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.databinding.DialogRecyclerViewBinding import io.legado.app.databinding.Item1lineTextBinding import io.legado.app.help.config.AppConfig import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.widget.dialog.TextDialog import io.legado.app.utils.FileDoc import io.legado.app.utils.FileUtils import io.legado.app.utils.delete import io.legado.app.utils.find import io.legado.app.utils.getFile import io.legado.app.utils.list import io.legado.app.utils.setLayout import io.legado.app.utils.showDialogFragment import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.isActive import java.io.FileFilter class CrashLogsDialog : BaseDialogFragment(R.layout.dialog_recycler_view), Toolbar.OnMenuItemClickListener { private val binding by viewBinding(DialogRecyclerViewBinding::bind) private val viewModel by viewModels() private val adapter by lazy { LogAdapter() } override fun onStart() { super.onStart() setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) binding.toolBar.setTitle(R.string.crash_log) binding.toolBar.inflateMenu(R.menu.crash_log) binding.toolBar.setOnMenuItemClickListener(this) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.adapter = adapter viewModel.logLiveData.observe(viewLifecycleOwner) { adapter.setItems(it) } viewModel.initData() } override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_clear -> viewModel.clearCrashLog() } return true } private fun showLogFile(fileDoc: FileDoc) { viewModel.readFile(fileDoc) { if (lifecycleScope.isActive) { showDialogFragment(TextDialog(fileDoc.name, it)) } } } inner class LogAdapter : RecyclerAdapter(requireContext()) { override fun getViewBinding(parent: ViewGroup): Item1lineTextBinding { return Item1lineTextBinding.inflate(inflater, parent, false) } override fun registerListener(holder: ItemViewHolder, binding: Item1lineTextBinding) { binding.root.setOnClickListener { getItemByLayoutPosition(holder.layoutPosition)?.let { item -> showLogFile(item) } } } override fun convert( holder: ItemViewHolder, binding: Item1lineTextBinding, item: FileDoc, payloads: MutableList ) { binding.textView.text = item.name } } class CrashViewModel(application: Application) : BaseViewModel(application) { val logLiveData = MutableLiveData>() fun initData() { execute { val list = arrayListOf() context.externalCacheDir ?.getFile("crash") ?.listFiles(FileFilter { it.isFile }) ?.forEach { list.add(FileDoc.fromFile(it)) } val backupPath = AppConfig.backupPath if (!backupPath.isNullOrEmpty()) { val uri = Uri.parse(backupPath) FileDoc.fromUri(uri, true) .find("crash") ?.list { !it.isDir }?.let { list.addAll(it) } } return@execute list.sortedByDescending { it.name }.distinctBy { it.name } }.onSuccess { logLiveData.postValue(it) } } fun readFile(fileDoc: FileDoc, success: (String) -> Unit) { execute { String(fileDoc.readBytes()) }.onSuccess { success.invoke(it) }.onError { context.toastOnUi(it.localizedMessage) } } fun clearCrashLog() { execute { context.externalCacheDir ?.getFile("crash") ?.let { FileUtils.delete(it, false) } val backupPath = AppConfig.backupPath if (!backupPath.isNullOrEmpty()) { val uri = Uri.parse(backupPath) FileDoc.fromUri(uri, true) .find("crash") ?.delete() } }.onError { context.toastOnUi(it.localizedMessage) }.onFinally { initData() } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/about/ReadRecordActivity.kt ================================================ package io.legado.app.ui.about import android.content.Context import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.ViewGroup import androidx.appcompat.widget.SearchView import androidx.lifecycle.lifecycleScope import io.legado.app.R import io.legado.app.base.BaseActivity import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.data.appDb import io.legado.app.data.entities.ReadRecordShow import io.legado.app.databinding.ActivityReadRecordBinding import io.legado.app.databinding.ItemReadRecordBinding import io.legado.app.help.config.AppConfig import io.legado.app.help.config.LocalConfig import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.primaryTextColor import io.legado.app.ui.book.search.SearchActivity import io.legado.app.utils.applyNavigationBarPadding import io.legado.app.utils.applyTint import io.legado.app.utils.cnCompare import io.legado.app.utils.getInt import io.legado.app.utils.putInt import io.legado.app.utils.startActivityForBook import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.text.SimpleDateFormat import java.util.Locale class ReadRecordActivity : BaseActivity() { private val adapter by lazy { RecordAdapter(this) } private var sortMode get() = LocalConfig.getInt("readRecordSort") set(value) { LocalConfig.putInt("readRecordSort", value) } private val searchView: SearchView by lazy { binding.titleBar.findViewById(R.id.search_view) } override val binding by viewBinding(ActivityReadRecordBinding::inflate) override fun onActivityCreated(savedInstanceState: Bundle?) { initView() initAllTime() initData() } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.book_read_record, menu) return super.onCompatCreateOptionsMenu(menu) } override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { menu.findItem(R.id.menu_enable_record)?.isChecked = AppConfig.enableReadRecord when (sortMode) { 1 -> menu.findItem(R.id.menu_sort_read_long)?.isChecked = true 2 -> menu.findItem(R.id.menu_sort_read_time)?.isChecked = true else -> menu.findItem(R.id.menu_sort_name)?.isChecked = true } return super.onMenuOpened(featureId, menu) } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_sort_name -> { sortMode = 0 item.isChecked = true initData() } R.id.menu_sort_read_long -> { sortMode = 1 item.isChecked = true initData() } R.id.menu_sort_read_time -> { sortMode = 2 item.isChecked = true initData() } R.id.menu_enable_record -> { AppConfig.enableReadRecord = !item.isChecked } } return super.onCompatOptionsItemSelected(item) } private fun initView() { initSearchView() binding.tvBookName.setText(R.string.all_read_time) binding.tvRemove.setOnClickListener { alert(R.string.delete, R.string.sure_del) { yesButton { appDb.readRecordDao.clear() initData() } noButton() } } binding.recyclerView.adapter = adapter binding.recyclerView.applyNavigationBarPadding() } private fun initSearchView() { searchView.applyTint(primaryTextColor) searchView.isSubmitButtonEnabled = true searchView.queryHint = getString(R.string.search) searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean { searchView.clearFocus() return false } override fun onQueryTextChange(newText: String?): Boolean { initData(newText) return false } }) } private fun initAllTime() { lifecycleScope.launch { val allTime = withContext(IO) { appDb.readRecordDao.allTime } binding.tvReadingTime.text = formatDuring(allTime) } } private fun initData(searchKey: String? = null) { lifecycleScope.launch { val readRecords = withContext(IO) { appDb.readRecordDao.search(searchKey ?: "").let { records -> when (sortMode) { 1 -> records.sortedByDescending { it.readTime } 2 -> records.sortedByDescending { it.lastRead } else -> records.sortedWith { o1, o2 -> o1.bookName.cnCompare(o2.bookName) } } } } adapter.setItems(readRecords) } } inner class RecordAdapter(context: Context) : RecyclerAdapter(context) { private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) override fun getViewBinding(parent: ViewGroup): ItemReadRecordBinding { return ItemReadRecordBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemReadRecordBinding, item: ReadRecordShow, payloads: MutableList, ) { binding.apply { tvBookName.text = item.bookName tvReadingTime.text = formatDuring(item.readTime) if (item.lastRead > 0) { tvLastReadTime.text = dateFormat.format(item.lastRead) } else { tvLastReadTime.text = "" } } } override fun registerListener(holder: ItemViewHolder, binding: ItemReadRecordBinding) { binding.apply { root.setOnClickListener { val item = getItem(holder.layoutPosition) ?: return@setOnClickListener lifecycleScope.launch { val book = withContext(IO) { appDb.bookDao.findByName(item.bookName).firstOrNull() } if (book == null) { SearchActivity.start(this@ReadRecordActivity, item.bookName) } else { startActivityForBook(book) } } } tvRemove.setOnClickListener { getItem(holder.layoutPosition)?.let { item -> sureDelAlert(item) } } } } private fun sureDelAlert(item: ReadRecordShow) { alert(R.string.delete) { setMessage(getString(R.string.sure_del_any, item.bookName)) yesButton { appDb.readRecordDao.deleteByName(item.bookName) initData() } noButton() } } } fun formatDuring(mss: Long): String { val days = mss / (1000 * 60 * 60 * 24) val hours = mss % (1000 * 60 * 60 * 24) / (1000 * 60 * 60) val minutes = mss % (1000 * 60 * 60) / (1000 * 60) val seconds = mss % (1000 * 60) / 1000 val d = if (days > 0) "${days}天" else "" val h = if (hours > 0) "${hours}小时" else "" val m = if (minutes > 0) "${minutes}分钟" else "" val s = if (seconds > 0) "${seconds}秒" else "" var time = "$d$h$m$s" if (time.isBlank()) { time = "0秒" } return time } } ================================================ FILE: app/src/main/java/io/legado/app/ui/about/UpdateDialog.kt ================================================ package io.legado.app.ui.about import android.os.Bundle import android.view.View import android.view.ViewGroup import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.databinding.DialogUpdateBinding import io.legado.app.help.update.AppUpdate import io.legado.app.lib.theme.primaryColor import io.legado.app.model.Download import io.legado.app.utils.setLayout import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding import io.noties.markwon.Markwon import io.noties.markwon.ext.tables.TablePlugin import io.noties.markwon.html.HtmlPlugin import io.noties.markwon.image.glide.GlideImagesPlugin class UpdateDialog() : BaseDialogFragment(R.layout.dialog_update) { constructor(updateInfo: AppUpdate.UpdateInfo) : this() { arguments = Bundle().apply { putString("newVersion", updateInfo.tagName) putString("updateBody", updateInfo.updateLog) putString("url", updateInfo.downloadUrl) putString("name", updateInfo.fileName) } } val binding by viewBinding(DialogUpdateBinding::bind) override fun onStart() { super.onStart() setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) binding.toolBar.title = arguments?.getString("newVersion") val updateBody = arguments?.getString("updateBody") if (updateBody == null) { toastOnUi("没有数据") dismiss() return } binding.textView.post { Markwon.builder(requireContext()) .usePlugin(GlideImagesPlugin.create(requireContext())) .usePlugin(HtmlPlugin.create()) .usePlugin(TablePlugin.create(requireContext())) .build() .setMarkdown(binding.textView, updateBody) } binding.toolBar.inflateMenu(R.menu.app_update) binding.toolBar.setOnMenuItemClickListener { when (it.itemId) { R.id.menu_download -> { val url = arguments?.getString("url") val name = arguments?.getString("name") if (url != null && name != null) { Download.start(requireContext(), url, name) toastOnUi(R.string.download_start) } } } return@setOnMenuItemClickListener true } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/association/AddToBookshelfDialog.kt ================================================ package io.legado.app.ui.association import android.annotation.SuppressLint import android.app.Application import android.content.DialogInterface import android.os.Bundle import android.view.View import android.view.ViewGroup import androidx.fragment.app.viewModels import androidx.lifecycle.MutableLiveData import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookSource import io.legado.app.databinding.DialogAddToBookshelfBinding import io.legado.app.exception.NoStackTraceException import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.model.webBook.WebBook import io.legado.app.ui.book.info.BookInfoActivity import io.legado.app.utils.GSON import io.legado.app.utils.NetworkUtils import io.legado.app.utils.fromJsonObject import io.legado.app.utils.setLayout import io.legado.app.utils.startActivity import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding /** * 添加书籍链接到书架,需要对应网站书源 * ${origin}/${path}, {origin: bookSourceUrl} * 按以下顺序尝试匹配书源并添加网址 * - UrlOption中的指定的书源网址bookSourceUrl * - 在所有启用的书源中匹配orgin * - 在所有启用的书源中使用详情页正则匹配${origin}/${path}, {origin: bookSourceUrl} */ class AddToBookshelfDialog() : BaseDialogFragment(R.layout.dialog_add_to_bookshelf) { constructor(bookUrl: String, finishOnDismiss: Boolean = false) : this() { arguments = Bundle().apply { putString("bookUrl", bookUrl) putBoolean("finishOnDismiss", finishOnDismiss) } } val binding by viewBinding(DialogAddToBookshelfBinding::bind) val viewModel by viewModels() override fun onStart() { super.onStart() setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) if (arguments?.getBoolean("finishOnDismiss") == true) { activity?.finish() } } @SuppressLint("SetTextI18n") override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { val bookUrl = arguments?.getString("bookUrl") if (bookUrl.isNullOrBlank()) { toastOnUi("url不能为空") dismiss() return } viewModel.loadStateLiveData.observe(this) { if (it) { binding.rotateLoading.visible() } else { binding.rotateLoading.gone() } } viewModel.loadErrorLiveData.observe(this) { toastOnUi(it) dismiss() } viewModel.load(bookUrl) { viewModel.saveSearchBook(it) { startActivity { putExtra("name", it.name) putExtra("author", it.author) putExtra("bookUrl", it.bookUrl) } dismiss() } } binding.tvCancel.setOnClickListener { dismiss() } } class ViewModel(application: Application) : BaseViewModel(application) { val loadStateLiveData = MutableLiveData() val loadErrorLiveData = MutableLiveData() var book: Book? = null fun load(bookUrl: String, success: (book: Book) -> Unit) { execute { appDb.bookDao.getBook(bookUrl)?.let { throw NoStackTraceException("${it.name} 已在书架") } val baseUrl = NetworkUtils.getBaseUrl(bookUrl) ?: throw NoStackTraceException("书籍地址格式不对") val urlMatcher = AnalyzeUrl.paramPattern.matcher(bookUrl) if (urlMatcher.find()) { val origin = GSON.fromJsonObject( bookUrl.substring(urlMatcher.end()) ).getOrNull()?.getOrigin() origin?.let { val source = appDb.bookSourceDao.getBookSource(it) source?.let { getBookInfo(bookUrl, source)?.let { book -> return@execute book } } } } appDb.bookSourceDao.getBookSourceAddBook(baseUrl)?.let { source -> getBookInfo(bookUrl, source)?.let { book -> return@execute book } } appDb.bookSourceDao.hasBookUrlPattern.forEach { source -> try { val bs = source.getBookSource()!! if (bookUrl.matches(bs.bookUrlPattern!!.toRegex())) { getBookInfo(bookUrl, bs)?.let { book -> return@execute book } } } catch (_: Exception) { } } throw NoStackTraceException("未找到匹配书源") }.onError { AppLog.put("添加书籍 $bookUrl 出错", it) loadErrorLiveData.postValue(it.localizedMessage) }.onSuccess { book = it success.invoke(it) }.onStart { loadStateLiveData.postValue(true) }.onFinally { loadStateLiveData.postValue(false) } } private suspend fun getBookInfo(bookUrl: String, source: BookSource): Book? { return kotlin.runCatching { val book = Book( bookUrl = bookUrl, origin = source.bookSourceUrl, originName = source.bookSourceName ) WebBook.getBookInfoAwait(source, book) }.getOrNull() } fun saveSearchBook(book: Book, success: () -> Unit) { execute { val searchBook = book.toSearchBook() appDb.searchBookDao.insert(searchBook) searchBook }.onSuccess { success.invoke() } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/association/BaseAssociationViewModel.kt ================================================ package io.legado.app.ui.association import android.app.Application import android.net.Uri import androidx.lifecycle.MutableLiveData import io.legado.app.base.BaseViewModel import io.legado.app.utils.inputStream import io.legado.app.utils.jsonPath abstract class BaseAssociationViewModel(application: Application) : BaseViewModel(application) { val successLive = MutableLiveData>() val errorLive = MutableLiveData() fun importJson(uri: Uri) { val map = uri.inputStream(context).getOrThrow().use { jsonPath.parse(it).read>("$[0]") } ?: uri.inputStream(context).getOrThrow().use { jsonPath.parse(it).read("$") } when { map.containsKey("bookSourceUrl") -> successLive.postValue("bookSource" to uri.toString()) map.containsKey("sourceUrl") -> successLive.postValue("rssSource" to uri.toString()) map.containsKey("pattern") -> successLive.postValue("replaceRule" to uri.toString()) map.containsKey("themeName") -> successLive.postValue("theme" to uri.toString()) map.containsKey("showRule") -> successLive.postValue("dictRule" to uri.toString()) map.containsKey("name") && map.containsKey("rule") -> successLive.postValue("txtRule" to uri.toString()) map.containsKey("name") && map.containsKey("url") -> successLive.postValue("httpTts" to uri.toString()) else -> errorLive.postValue("格式不对") } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/association/FileAssociationActivity.kt ================================================ package io.legado.app.ui.association import android.net.Uri import android.os.Bundle import androidx.activity.viewModels import androidx.core.os.postDelayed import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.lifecycleScope import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.constant.AppLog import io.legado.app.databinding.ActivityTranslucenceBinding import io.legado.app.exception.InvalidBooksDirException import io.legado.app.help.config.AppConfig import io.legado.app.lib.dialogs.alert import io.legado.app.lib.permission.Permissions import io.legado.app.lib.permission.PermissionsCompat import io.legado.app.ui.file.HandleFileContract import io.legado.app.utils.FileUtils import io.legado.app.utils.buildMainHandler import io.legado.app.utils.canRead import io.legado.app.utils.checkWrite import io.legado.app.utils.getFile import io.legado.app.utils.isContentScheme import io.legado.app.utils.readUri import io.legado.app.utils.showDialogFragment import io.legado.app.utils.startActivity import io.legado.app.utils.startActivityForBook import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import splitties.init.appCtx import java.io.File import java.io.FileOutputStream class FileAssociationActivity : VMBaseActivity() { private val localBookTreeSelect = registerForActivityResult(HandleFileContract()) { intent.data?.let { uri -> it.uri?.let { treeUri -> AppConfig.defaultBookTreeUri = treeUri.toString() importBook(treeUri, uri) } ?: let { val storageHelp = String(assets.open("storageHelp.md").readBytes()) toastOnUi(storageHelp) importBook(null, uri) } } } override val binding by viewBinding(ActivityTranslucenceBinding::inflate) override val viewModel by viewModels() private val handler by lazy { buildMainHandler() } override fun onActivityCreated(savedInstanceState: Bundle?) { binding.rotateLoading.visible() viewModel.importBookLiveData.observe(this) { uri -> importBook(uri) } viewModel.onLineImportLive.observe(this) { startActivity { data = it } finish() } viewModel.successLive.observe(this) { when (it.first) { "bookSource" -> showDialogFragment(ImportBookSourceDialog(it.second, true)) "rssSource" -> showDialogFragment(ImportRssSourceDialog(it.second, true)) "replaceRule" -> showDialogFragment(ImportReplaceRuleDialog(it.second, true)) "httpTts" -> showDialogFragment(ImportHttpTtsDialog(it.second, true)) "theme" -> showDialogFragment(ImportThemeDialog(it.second, true)) "txtRule" -> showDialogFragment(ImportTxtTocRuleDialog(it.second, true)) "dictRule" -> showDialogFragment(ImportDictRuleDialog(it.second, true)) } } viewModel.errorLive.observe(this) { binding.rotateLoading.gone() toastOnUi(it) handler.postDelayed(2000) { finish() } } viewModel.openBookLiveData.observe(this) { binding.rotateLoading.gone() startActivityForBook(it) finish() } viewModel.notSupportedLiveData.observe(this) { data -> binding.rotateLoading.gone() alert( title = appCtx.getString(R.string.draw), message = appCtx.getString(R.string.file_not_supported, data.second) ) { yesButton { importBook(data.first) } noButton { finish() } onCancelled { finish() } } } intent.data?.let { data -> if (data.isContentScheme() && data.canRead()) { viewModel.dispatchIntent(data) } else { PermissionsCompat.Builder() .addPermissions(*Permissions.Group.STORAGE) .rationale(R.string.tip_perm_request_storage) .onGranted { viewModel.dispatchIntent(data) }.onDenied { toastOnUi("请求存储权限失败。") handler.postDelayed(2000) { finish() } }.request() } } ?: finish() } private fun importBook(uri: Uri) { if (uri.isContentScheme()) { val treeUriStr = AppConfig.defaultBookTreeUri if (treeUriStr.isNullOrEmpty()) { localBookTreeSelect.launch { title = getString(R.string.select_book_folder) mode = HandleFileContract.DIR_SYS } } else { importBook(Uri.parse(treeUriStr), uri) } } else { importBook(null, uri) } } private fun importBook(treeUri: Uri?, uri: Uri) { lifecycleScope.launch { runCatching { withContext(IO) { if (treeUri == null) { viewModel.importBook(uri) } else if (treeUri.isContentScheme()) { val treeDoc = DocumentFile.fromTreeUri(this@FileAssociationActivity, treeUri) if (!treeDoc!!.checkWrite()) { throw InvalidBooksDirException( "请重新设置书籍保存位置\nPermission Denial" ) } readUri(uri) { fileDoc, inputStream -> val name = fileDoc.name var doc = treeDoc.findFile(name) if (doc == null || fileDoc.lastModified > doc.lastModified()) { if (doc == null) { doc = treeDoc.createFile(FileUtils.getMimeType(name), name) ?: throw InvalidBooksDirException( "请重新设置书籍保存位置\nPermission Denial" ) } contentResolver.openOutputStream(doc.uri)!!.use { oStream -> inputStream.copyTo(oStream) oStream.flush() } } viewModel.importBook(doc.uri) } } else { val treeFile = File(treeUri.path ?: treeUri.toString()) if (!treeFile.checkWrite()) { throw InvalidBooksDirException( "请重新设置书籍保存位置\nPermission Denial" ) } readUri(uri) { fileDoc, inputStream -> val name = fileDoc.name val file = treeFile.getFile(name) if (!file.exists() || fileDoc.lastModified > file.lastModified()) { FileOutputStream(file).use { oStream -> inputStream.copyTo(oStream) oStream.flush() } } viewModel.importBook(Uri.fromFile(file)) } } } }.onFailure { when (it) { is InvalidBooksDirException -> localBookTreeSelect.launch { title = getString(R.string.select_book_folder) mode = HandleFileContract.DIR_SYS } else -> { val msg = "导入书籍失败\n${it.localizedMessage}" AppLog.put(msg, it) toastOnUi(msg) handler.postDelayed(2000) { finish() } } } } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/association/FileAssociationViewModel.kt ================================================ package io.legado.app.ui.association import android.app.Application import android.net.Uri import androidx.lifecycle.MutableLiveData import io.legado.app.constant.AppLog import io.legado.app.constant.AppPattern import io.legado.app.constant.AppPattern.bookFileRegex import io.legado.app.data.entities.Book import io.legado.app.model.localBook.LocalBook import io.legado.app.utils.* class FileAssociationViewModel(application: Application) : BaseAssociationViewModel(application) { val importBookLiveData = MutableLiveData() val onLineImportLive = MutableLiveData() val openBookLiveData = MutableLiveData() val notSupportedLiveData = MutableLiveData>() fun dispatchIntent(uri: Uri) { execute { //如果是普通的url,需要根据返回的内容判断是什么 if (uri.isContentScheme() || uri.isFileScheme()) { val fileDoc = FileDoc.fromUri(uri, false) val fileName = fileDoc.name if (fileName.matches(AppPattern.archiveFileRegex)) { ArchiveUtils.deCompress(fileDoc, ArchiveUtils.TEMP_PATH) { it.matches(bookFileRegex) }.forEach { dispatch(FileDoc.fromFile(it)) } } else { dispatch(fileDoc) } } else { onLineImportLive.postValue(uri) } }.onError { it.printOnDebug() val msg = "无法打开文件\n${it.localizedMessage}" errorLive.postValue(msg) AppLog.put(msg, it) } } private fun dispatch(fileDoc: FileDoc) { kotlin.runCatching { if (fileDoc.openInputStream().getOrNull().isJson()) { importJson(fileDoc.uri) return } }.onFailure { it.printOnDebug() AppLog.put("尝试导入为JSON文件失败\n${it.localizedMessage}", it) } if (fileDoc.name.matches(bookFileRegex)) { importBookLiveData.postValue(fileDoc.uri) return } notSupportedLiveData.postValue(Pair(fileDoc.uri, fileDoc.name)) } fun importBook(uri: Uri) { val book = LocalBook.importFile(uri) openBookLiveData.postValue(book) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/association/ImportBookSourceDialog.kt ================================================ package io.legado.app.ui.association import android.annotation.SuppressLint import android.content.Context import android.content.DialogInterface import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.fragment.app.viewModels import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.constant.PreferKey import io.legado.app.data.appDb import io.legado.app.data.entities.BookSource import io.legado.app.databinding.DialogCustomGroupBinding import io.legado.app.databinding.DialogRecyclerViewBinding import io.legado.app.databinding.ItemSourceImportBinding import io.legado.app.help.config.AppConfig import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.widget.dialog.CodeDialog import io.legado.app.ui.widget.dialog.WaitDialog import io.legado.app.utils.GSON import io.legado.app.utils.dpToPx import io.legado.app.utils.fromJsonObject import io.legado.app.utils.putPrefBoolean import io.legado.app.utils.setLayout import io.legado.app.utils.showDialogFragment import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible import splitties.views.onClick /** * 导入书源弹出窗口 */ class ImportBookSourceDialog() : BaseDialogFragment(R.layout.dialog_recycler_view), Toolbar.OnMenuItemClickListener, CodeDialog.Callback { constructor(source: String, finishOnDismiss: Boolean = false) : this() { arguments = Bundle().apply { putString("source", source) putBoolean("finishOnDismiss", finishOnDismiss) } } private val binding by viewBinding(DialogRecyclerViewBinding::bind) private val viewModel by viewModels() private val adapter by lazy { SourcesAdapter(requireContext()) } override fun onStart() { super.onStart() setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) if (arguments?.getBoolean("finishOnDismiss") == true) { activity?.finish() } } @SuppressLint("NotifyDataSetChanged") override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) binding.toolBar.setTitle(R.string.import_book_source) binding.rotateLoading.visible() initMenu() binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.adapter = adapter binding.tvCancel.visible() binding.tvCancel.setOnClickListener { dismissAllowingStateLoss() } binding.tvOk.visible() binding.tvOk.setOnClickListener { val waitDialog = WaitDialog(requireContext()) waitDialog.show() viewModel.importSelect { waitDialog.dismiss() dismissAllowingStateLoss() } } binding.tvFooterLeft.visible() binding.tvFooterLeft.setOnClickListener { val selectAll = viewModel.isSelectAll viewModel.selectStatus.forEachIndexed { index, b -> if (b != !selectAll) { viewModel.selectStatus[index] = !selectAll } } adapter.notifyDataSetChanged() upSelectText() } viewModel.errorLiveData.observe(this) { binding.rotateLoading.gone() binding.tvMsg.apply { text = it visible() } } viewModel.successLiveData.observe(this) { binding.rotateLoading.gone() if (it > 0) { adapter.setItems(viewModel.allSources) upSelectText() } else { binding.tvMsg.apply { setText(R.string.wrong_format) visible() } } } val source = arguments?.getString("source") if (source.isNullOrEmpty()) { dismiss() return } viewModel.importSource(source) } private fun upSelectText() { if (viewModel.isSelectAll) { binding.tvFooterLeft.text = getString( R.string.select_cancel_count, viewModel.selectCount, viewModel.allSources.size ) } else { binding.tvFooterLeft.text = getString( R.string.select_all_count, viewModel.selectCount, viewModel.allSources.size ) } } private fun initMenu() { binding.toolBar.setOnMenuItemClickListener(this) binding.toolBar.inflateMenu(R.menu.import_source) binding.toolBar.menu.findItem(R.id.menu_keep_original_name) ?.isChecked = AppConfig.importKeepName binding.toolBar.menu.findItem(R.id.menu_keep_group) ?.isChecked = AppConfig.importKeepGroup binding.toolBar.menu.findItem(R.id.menu_keep_enable) ?.isChecked = AppConfig.importKeepEnable } @SuppressLint("InflateParams", "NotifyDataSetChanged") override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_new_group -> alertCustomGroup(item) R.id.menu_select_new_source -> { val selectAllNew = viewModel.isSelectAllNew viewModel.newSourceStatus.forEachIndexed { index, b -> if (b) { viewModel.selectStatus[index] = !selectAllNew } } adapter.notifyDataSetChanged() upSelectText() } R.id.menu_select_update_source -> { val selectAllUpdate = viewModel.isSelectAllUpdate viewModel.updateSourceStatus.forEachIndexed { index, b -> if (b) { viewModel.selectStatus[index] = !selectAllUpdate } } adapter.notifyDataSetChanged() upSelectText() } R.id.menu_keep_original_name -> { item.isChecked = !item.isChecked putPrefBoolean(PreferKey.importKeepName, item.isChecked) } R.id.menu_keep_group -> { item.isChecked = !item.isChecked putPrefBoolean(PreferKey.importKeepGroup, item.isChecked) } R.id.menu_keep_enable -> { item.isChecked = !item.isChecked AppConfig.importKeepEnable = item.isChecked } } return false } private fun alertCustomGroup(item: MenuItem) { alert(R.string.diy_edit_source_group) { val alertBinding = DialogCustomGroupBinding.inflate(layoutInflater).apply { val groups = appDb.bookSourceDao.allGroups() textInputLayout.setHint(R.string.group_name) editView.setFilterValues(groups.toList()) editView.dropDownHeight = 180.dpToPx() } customView { alertBinding.root } okButton { viewModel.isAddGroup = alertBinding.swAddGroup.isChecked viewModel.groupName = alertBinding.editView.text?.toString() if (viewModel.groupName.isNullOrBlank()) { item.title = getString(R.string.diy_source_group) } else { val group = getString(R.string.diy_edit_source_group_title, viewModel.groupName) if (viewModel.isAddGroup) { item.title = "+$group" } else { item.title = group } } } cancelButton() } } override fun onCodeSave(code: String, requestId: String?) { requestId?.toInt()?.let { GSON.fromJsonObject(code).getOrNull()?.let { source -> viewModel.allSources[it] = source adapter.setItem(it, source) } } } inner class SourcesAdapter(context: Context) : RecyclerAdapter(context) { override fun getViewBinding(parent: ViewGroup): ItemSourceImportBinding { return ItemSourceImportBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemSourceImportBinding, item: BookSource, payloads: MutableList ) { binding.apply { cbSourceName.isChecked = viewModel.selectStatus[holder.layoutPosition] cbSourceName.text = item.bookSourceName val localSource = viewModel.checkSources[holder.layoutPosition] tvSourceState.text = when { localSource == null -> "新增" item.lastUpdateTime > localSource.lastUpdateTime -> "更新" else -> "已有" } } } override fun registerListener(holder: ItemViewHolder, binding: ItemSourceImportBinding) { binding.apply { cbSourceName.setOnUserCheckedChangeListener { isChecked -> viewModel.selectStatus[holder.layoutPosition] = isChecked upSelectText() } root.onClick { cbSourceName.isChecked = !cbSourceName.isChecked viewModel.selectStatus[holder.layoutPosition] = cbSourceName.isChecked upSelectText() } tvOpen.setOnClickListener { val source = viewModel.allSources[holder.layoutPosition] showDialogFragment( CodeDialog( GSON.toJson(source), disableEdit = false, requestId = holder.layoutPosition.toString() ) ) } } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/association/ImportBookSourceViewModel.kt ================================================ package io.legado.app.ui.association import android.app.Application import android.net.Uri import androidx.lifecycle.MutableLiveData import com.jayway.jsonpath.JsonPath import io.legado.app.R import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog import io.legado.app.constant.AppPattern import io.legado.app.data.appDb import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSourcePart import io.legado.app.exception.NoStackTraceException import io.legado.app.help.book.ContentProcessor import io.legado.app.help.config.AppConfig import io.legado.app.help.http.decompressed import io.legado.app.help.http.newCallResponseBody import io.legado.app.help.http.okHttpClient import io.legado.app.help.source.SourceHelp import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonArray import io.legado.app.utils.fromJsonObject import io.legado.app.utils.inputStream import io.legado.app.utils.isAbsUrl import io.legado.app.utils.isJsonArray import io.legado.app.utils.isJsonObject import io.legado.app.utils.isUri import io.legado.app.utils.splitNotBlank class ImportBookSourceViewModel(app: Application) : BaseViewModel(app) { var isAddGroup = false var groupName: String? = null val errorLiveData = MutableLiveData() val successLiveData = MutableLiveData() val allSources = arrayListOf() val checkSources = arrayListOf() val selectStatus = arrayListOf() val newSourceStatus = arrayListOf() val updateSourceStatus = arrayListOf() val isSelectAll: Boolean get() { selectStatus.forEach { if (!it) { return false } } return true } val isSelectAllNew: Boolean get() { newSourceStatus.forEachIndexed { index, b -> if (b && !selectStatus[index]) { return false } } return true } val isSelectAllUpdate: Boolean get() { updateSourceStatus.forEachIndexed { index, b -> if (b && !selectStatus[index]) { return false } } return true } val selectCount: Int get() { var count = 0 selectStatus.forEach { if (it) { count++ } } return count } fun importSelect(finally: () -> Unit) { execute { val group = groupName?.trim() val keepName = AppConfig.importKeepName val keepGroup = AppConfig.importKeepGroup val keepEnable = AppConfig.importKeepEnable val selectSource = arrayListOf() selectStatus.forEachIndexed { index, b -> if (b) { val source = allSources[index] checkSources[index]?.let { if (keepName) { source.bookSourceName = it.bookSourceName } if (keepGroup) { source.bookSourceGroup = it.bookSourceGroup } if (keepEnable) { source.enabled = it.enabled source.enabledExplore = it.enabledExplore } source.customOrder = it.customOrder } if (!group.isNullOrEmpty()) { if (isAddGroup) { val groups = linkedSetOf() source.bookSourceGroup?.splitNotBlank(AppPattern.splitGroupRegex)?.let { groups.addAll(it) } groups.add(group) source.bookSourceGroup = groups.joinToString(",") } else { source.bookSourceGroup = group } } selectSource.add(source) } } SourceHelp.insertBookSource(*selectSource.toTypedArray()) ContentProcessor.upReplaceRules() }.onFinally { finally.invoke() } } fun importSource(text: String) { execute { val mText = text.trim() when { mText.isJsonObject() -> { kotlin.runCatching { val json = JsonPath.parse(mText) json.read>("$.sourceUrls") }.onSuccess { listUrl -> listUrl.forEach { importSourceUrl(it) } }.onFailure { GSON.fromJsonObject(mText).getOrThrow().let { if (it.bookSourceUrl.isEmpty()) { throw NoStackTraceException("不是书源") } allSources.add(it) } } } mText.isJsonArray() -> GSON.fromJsonArray(mText).getOrThrow() .let { items -> val source = items.firstOrNull() ?: return@let if (source.bookSourceUrl.isEmpty()) { throw NoStackTraceException("不是书源") } allSources.addAll(items) } mText.isAbsUrl() -> { importSourceUrl(mText) } mText.isUri() -> { val uri = Uri.parse(mText) uri.inputStream(context).getOrThrow().use { inputS -> GSON.fromJsonArray(inputS).getOrThrow().let { val source = it.firstOrNull() ?: return@let if (source.bookSourceUrl.isEmpty()) { throw NoStackTraceException("不是书源") } allSources.addAll(it) } } } else -> throw NoStackTraceException(context.getString(R.string.wrong_format)) } }.onError { errorLiveData.postValue("ImportError:${it.localizedMessage}") AppLog.put("ImportError:${it.localizedMessage}", it) }.onSuccess { comparisonSource() } } private suspend fun importSourceUrl(url: String) { okHttpClient.newCallResponseBody { if (url.endsWith("#requestWithoutUA")) { url(url.substringBeforeLast("#requestWithoutUA")) header(AppConst.UA_NAME, "null") } else { url(url) } }.decompressed().byteStream().use { GSON.fromJsonArray(it).getOrThrow().let { list -> val source = list.firstOrNull() ?: return@let if (source.bookSourceUrl.isEmpty()) { throw NoStackTraceException("不是书源") } allSources.addAll(list) } } } private fun comparisonSource() { execute { allSources.forEach { val source = appDb.bookSourceDao.getBookSourcePart(it.bookSourceUrl) checkSources.add(source) selectStatus.add(source == null || source.lastUpdateTime < it.lastUpdateTime) newSourceStatus.add(source == null) updateSourceStatus.add(source != null && source.lastUpdateTime < it.lastUpdateTime) } successLiveData.postValue(allSources.size) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/association/ImportDictRuleDialog.kt ================================================ package io.legado.app.ui.association import android.annotation.SuppressLint import android.content.Context import android.content.DialogInterface import android.os.Bundle import android.view.View import android.view.ViewGroup import androidx.fragment.app.viewModels import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.data.entities.DictRule import io.legado.app.databinding.DialogRecyclerViewBinding import io.legado.app.databinding.ItemSourceImportBinding import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.widget.dialog.CodeDialog import io.legado.app.ui.widget.dialog.WaitDialog import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonObject import io.legado.app.utils.setLayout import io.legado.app.utils.showDialogFragment import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible import splitties.views.onClick class ImportDictRuleDialog() : BaseDialogFragment(R.layout.dialog_recycler_view), CodeDialog.Callback { constructor(source: String, finishOnDismiss: Boolean = false) : this() { arguments = Bundle().apply { putString("source", source) putBoolean("finishOnDismiss", finishOnDismiss) } } private val binding by viewBinding(DialogRecyclerViewBinding::bind) private val viewModel by viewModels() private val adapter by lazy { SourcesAdapter(requireContext()) } override fun onStart() { super.onStart() setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) if (arguments?.getBoolean("finishOnDismiss") == true) { activity?.finish() } } @SuppressLint("NotifyDataSetChanged") override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) binding.toolBar.setTitle(R.string.import_dict_rule) binding.rotateLoading.visible() binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.adapter = adapter binding.tvCancel.visible() binding.tvCancel.setOnClickListener { dismissAllowingStateLoss() } binding.tvOk.visible() binding.tvOk.setOnClickListener { val waitDialog = WaitDialog(requireContext()) waitDialog.show() viewModel.importSelect { waitDialog.dismiss() dismissAllowingStateLoss() } } binding.tvFooterLeft.visible() binding.tvFooterLeft.setOnClickListener { val selectAll = viewModel.isSelectAll viewModel.selectStatus.forEachIndexed { index, b -> if (b != !selectAll) { viewModel.selectStatus[index] = !selectAll } } adapter.notifyDataSetChanged() upSelectText() } viewModel.errorLiveData.observe(this) { binding.rotateLoading.gone() binding.tvMsg.apply { text = it visible() } } viewModel.successLiveData.observe(this) { binding.rotateLoading.gone() if (it > 0) { adapter.setItems(viewModel.allSources) upSelectText() } else { binding.tvMsg.apply { setText(R.string.wrong_format) visible() } } } val source = arguments?.getString("source") if (source.isNullOrEmpty()) { dismiss() return } viewModel.importSource(source) } private fun upSelectText() { if (viewModel.isSelectAll) { binding.tvFooterLeft.text = getString( R.string.select_cancel_count, viewModel.selectCount, viewModel.allSources.size ) } else { binding.tvFooterLeft.text = getString( R.string.select_all_count, viewModel.selectCount, viewModel.allSources.size ) } } inner class SourcesAdapter(context: Context) : RecyclerAdapter(context) { override fun getViewBinding(parent: ViewGroup): ItemSourceImportBinding { return ItemSourceImportBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemSourceImportBinding, item: DictRule, payloads: MutableList ) { binding.apply { cbSourceName.isChecked = viewModel.selectStatus[holder.layoutPosition] cbSourceName.text = item.name val localSource = viewModel.checkSources[holder.layoutPosition] tvSourceState.text = when (localSource) { null -> "新增" else -> "已有" } } } override fun registerListener(holder: ItemViewHolder, binding: ItemSourceImportBinding) { binding.apply { cbSourceName.setOnUserCheckedChangeListener { isChecked -> viewModel.selectStatus[holder.layoutPosition] = isChecked upSelectText() } root.onClick { cbSourceName.isChecked = !cbSourceName.isChecked viewModel.selectStatus[holder.layoutPosition] = cbSourceName.isChecked upSelectText() } tvOpen.setOnClickListener { val source = viewModel.allSources[holder.layoutPosition] showDialogFragment( CodeDialog( GSON.toJson(source), disableEdit = false, requestId = holder.layoutPosition.toString() ) ) } } } } override fun onCodeSave(code: String, requestId: String?) { requestId?.toInt()?.let { GSON.fromJsonObject(code).getOrNull()?.let { source -> viewModel.allSources[it] = source adapter.setItem(it, source) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/association/ImportDictRuleViewModel.kt ================================================ package io.legado.app.ui.association import android.app.Application import androidx.core.net.toUri import androidx.lifecycle.MutableLiveData import io.legado.app.R import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.DictRule import io.legado.app.exception.NoStackTraceException import io.legado.app.help.http.decompressed import io.legado.app.help.http.newCallResponseBody import io.legado.app.help.http.okHttpClient import io.legado.app.help.http.text import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonArray import io.legado.app.utils.fromJsonObject import io.legado.app.utils.isAbsUrl import io.legado.app.utils.isJsonArray import io.legado.app.utils.isJsonObject import io.legado.app.utils.isUri import io.legado.app.utils.readText import splitties.init.appCtx class ImportDictRuleViewModel(app: Application) : BaseViewModel(app) { val errorLiveData = MutableLiveData() val successLiveData = MutableLiveData() val allSources = arrayListOf() val checkSources = arrayListOf() val selectStatus = arrayListOf() val isSelectAll: Boolean get() { selectStatus.forEach { if (!it) { return false } } return true } val selectCount: Int get() { var count = 0 selectStatus.forEach { if (it) { count++ } } return count } fun importSelect(finally: () -> Unit) { execute { val selectSource = arrayListOf() selectStatus.forEachIndexed { index, b -> if (b) { selectSource.add(allSources[index]) } } appDb.dictRuleDao.insert(*selectSource.toTypedArray()) }.onFinally { finally.invoke() } } fun importSource(text: String) { execute { importSourceAwait(text.trim()) }.onError { errorLiveData.postValue("ImportError:${it.localizedMessage}") AppLog.put("ImportError:${it.localizedMessage}", it) }.onSuccess { comparisonSource() } } private suspend fun importSourceAwait(text: String) { when { text.isJsonObject() -> { GSON.fromJsonObject(text).getOrThrow().let { allSources.add(it) } } text.isJsonArray() -> GSON.fromJsonArray(text).getOrThrow().let { items -> allSources.addAll(items) } text.isAbsUrl() -> { importSourceUrl(text) } text.isUri() -> { importSourceAwait(text.toUri().readText(appCtx)) } else -> throw NoStackTraceException(context.getString(R.string.wrong_format)) } } private suspend fun importSourceUrl(url: String) { okHttpClient.newCallResponseBody { if (url.endsWith("#requestWithoutUA")) { url(url.substringBeforeLast("#requestWithoutUA")) header(AppConst.UA_NAME, "null") } else { url(url) } }.decompressed().text().let { importSourceAwait(it) } } private fun comparisonSource() { execute { allSources.forEach { val source = appDb.dictRuleDao.getByName(it.name) checkSources.add(source) selectStatus.add(source == null) } successLiveData.postValue(allSources.size) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/association/ImportHttpTtsDialog.kt ================================================ package io.legado.app.ui.association import android.annotation.SuppressLint import android.content.Context import android.content.DialogInterface import android.os.Bundle import android.view.View import android.view.ViewGroup import androidx.fragment.app.viewModels import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.data.entities.HttpTTS import io.legado.app.databinding.DialogRecyclerViewBinding import io.legado.app.databinding.ItemSourceImportBinding import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.widget.dialog.CodeDialog import io.legado.app.ui.widget.dialog.WaitDialog import io.legado.app.utils.GSON import io.legado.app.utils.setLayout import io.legado.app.utils.showDialogFragment import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible import splitties.views.onClick class ImportHttpTtsDialog() : BaseDialogFragment(R.layout.dialog_recycler_view), CodeDialog.Callback { constructor(source: String, finishOnDismiss: Boolean = false) : this() { arguments = Bundle().apply { putString("source", source) putBoolean("finishOnDismiss", finishOnDismiss) } } private val binding by viewBinding(DialogRecyclerViewBinding::bind) private val viewModel by viewModels() private val adapter by lazy { SourcesAdapter(requireContext()) } override fun onStart() { super.onStart() setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) if (arguments?.getBoolean("finishOnDismiss") == true) { activity?.finish() } } @SuppressLint("NotifyDataSetChanged") override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) binding.toolBar.setTitle(R.string.import_tts) binding.rotateLoading.visible() binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.adapter = adapter binding.tvCancel.visible() binding.tvCancel.setOnClickListener { dismissAllowingStateLoss() } binding.tvOk.visible() binding.tvOk.setOnClickListener { val waitDialog = WaitDialog(requireContext()) waitDialog.show() viewModel.importSelect { waitDialog.dismiss() dismissAllowingStateLoss() } } binding.tvFooterLeft.visible() binding.tvFooterLeft.setOnClickListener { val selectAll = viewModel.isSelectAll viewModel.selectStatus.forEachIndexed { index, b -> if (b != !selectAll) { viewModel.selectStatus[index] = !selectAll } } adapter.notifyDataSetChanged() upSelectText() } viewModel.errorLiveData.observe(this) { binding.rotateLoading.gone() binding.tvMsg.apply { text = it visible() } } viewModel.successLiveData.observe(this) { binding.rotateLoading.gone() if (it > 0) { adapter.setItems(viewModel.allSources) upSelectText() } else { binding.tvMsg.apply { setText(R.string.wrong_format) visible() } } } val source = arguments?.getString("source") if (source.isNullOrEmpty()) { dismiss() return } viewModel.importSource(source) } private fun upSelectText() { if (viewModel.isSelectAll) { binding.tvFooterLeft.text = getString( R.string.select_cancel_count, viewModel.selectCount, viewModel.allSources.size ) } else { binding.tvFooterLeft.text = getString( R.string.select_all_count, viewModel.selectCount, viewModel.allSources.size ) } } inner class SourcesAdapter(context: Context) : RecyclerAdapter(context) { override fun getViewBinding(parent: ViewGroup): ItemSourceImportBinding { return ItemSourceImportBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemSourceImportBinding, item: HttpTTS, payloads: MutableList ) { binding.apply { cbSourceName.isChecked = viewModel.selectStatus[holder.layoutPosition] cbSourceName.text = item.name val localSource = viewModel.checkSources[holder.layoutPosition] tvSourceState.text = when { localSource == null -> "新增" item.lastUpdateTime > localSource.lastUpdateTime -> "更新" else -> "已有" } } } override fun registerListener(holder: ItemViewHolder, binding: ItemSourceImportBinding) { binding.apply { cbSourceName.setOnUserCheckedChangeListener { isChecked -> viewModel.selectStatus[holder.layoutPosition] = isChecked upSelectText() } root.onClick { cbSourceName.isChecked = !cbSourceName.isChecked viewModel.selectStatus[holder.layoutPosition] = cbSourceName.isChecked upSelectText() } tvOpen.setOnClickListener { val source = viewModel.allSources[holder.layoutPosition] showDialogFragment( CodeDialog( GSON.toJson(source), disableEdit = false, requestId = holder.layoutPosition.toString() ) ) } } } } override fun onCodeSave(code: String, requestId: String?) { requestId?.toInt()?.let { HttpTTS.fromJson(code).getOrNull()?.let { source -> viewModel.allSources[it] = source adapter.setItem(it, source) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/association/ImportHttpTtsViewModel.kt ================================================ package io.legado.app.ui.association import android.app.Application import androidx.core.net.toUri import androidx.lifecycle.MutableLiveData import io.legado.app.R import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.HttpTTS import io.legado.app.exception.NoStackTraceException import io.legado.app.help.http.decompressed import io.legado.app.help.http.newCallResponseBody import io.legado.app.help.http.okHttpClient import io.legado.app.help.http.text import io.legado.app.utils.isAbsUrl import io.legado.app.utils.isJsonArray import io.legado.app.utils.isJsonObject import io.legado.app.utils.isUri import io.legado.app.utils.readText import splitties.init.appCtx class ImportHttpTtsViewModel(app: Application) : BaseViewModel(app) { val errorLiveData = MutableLiveData() val successLiveData = MutableLiveData() val allSources = arrayListOf() val checkSources = arrayListOf() val selectStatus = arrayListOf() val isSelectAll: Boolean get() { selectStatus.forEach { if (!it) { return false } } return true } val selectCount: Int get() { var count = 0 selectStatus.forEach { if (it) { count++ } } return count } fun importSelect(finally: () -> Unit) { execute { val selectSource = arrayListOf() selectStatus.forEachIndexed { index, b -> if (b) { selectSource.add(allSources[index]) } } appDb.httpTTSDao.insert(*selectSource.toTypedArray()) }.onFinally { finally.invoke() } } fun importSource(text: String) { execute { importSourceAwait(text.trim()) }.onError { errorLiveData.postValue("ImportError:${it.localizedMessage}") AppLog.put("ImportError:${it.localizedMessage}", it) }.onSuccess { comparisonSource() } } private suspend fun importSourceAwait(text: String) { when { text.isJsonObject() -> { HttpTTS.fromJson(text).getOrThrow().let { allSources.add(it) } } text.isJsonArray() -> HttpTTS.fromJsonArray(text).getOrThrow().let { items -> allSources.addAll(items) } text.isAbsUrl() -> { importSourceUrl(text) } text.isUri() -> { importSourceAwait(text.toUri().readText(appCtx)) } else -> throw NoStackTraceException(context.getString(R.string.wrong_format)) } } private suspend fun importSourceUrl(url: String) { okHttpClient.newCallResponseBody { if (url.endsWith("#requestWithoutUA")) { url(url.substringBeforeLast("#requestWithoutUA")) header(AppConst.UA_NAME, "null") } else { url(url) } }.decompressed().text().let { importSourceAwait(it) } } private fun comparisonSource() { execute { allSources.forEach { val source = appDb.httpTTSDao.get(it.id) checkSources.add(source) selectStatus.add(source == null || source.lastUpdateTime < it.lastUpdateTime) } successLiveData.postValue(allSources.size) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/association/ImportReplaceRuleDialog.kt ================================================ package io.legado.app.ui.association import android.annotation.SuppressLint import android.content.Context import android.content.DialogInterface import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.fragment.app.viewModels import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.constant.PreferKey import io.legado.app.data.appDb import io.legado.app.data.entities.ReplaceRule import io.legado.app.databinding.DialogCustomGroupBinding import io.legado.app.databinding.DialogRecyclerViewBinding import io.legado.app.databinding.ItemSourceImportBinding import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.widget.dialog.CodeDialog import io.legado.app.ui.widget.dialog.WaitDialog import io.legado.app.utils.GSON import io.legado.app.utils.dpToPx import io.legado.app.utils.fromJsonObject import io.legado.app.utils.putPrefBoolean import io.legado.app.utils.setLayout import io.legado.app.utils.showDialogFragment import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible import splitties.views.onClick class ImportReplaceRuleDialog() : BaseDialogFragment(R.layout.dialog_recycler_view), Toolbar.OnMenuItemClickListener, CodeDialog.Callback { constructor(source: String, finishOnDismiss: Boolean = false) : this() { arguments = Bundle().apply { putString("source", source) putBoolean("finishOnDismiss", finishOnDismiss) } } private val binding by viewBinding(DialogRecyclerViewBinding::bind) private val viewModel by viewModels() private val adapter by lazy { SourcesAdapter(requireContext()) } override fun onStart() { super.onStart() setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) if (arguments?.getBoolean("finishOnDismiss") == true) { activity?.finish() } } @SuppressLint("NotifyDataSetChanged") override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) binding.toolBar.setTitle(R.string.import_replace_rule) binding.rotateLoading.visible() initMenu() binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.adapter = adapter binding.tvCancel.visible() binding.tvCancel.setOnClickListener { dismissAllowingStateLoss() } binding.tvOk.visible() binding.tvOk.setOnClickListener { val waitDialog = WaitDialog(requireContext()) waitDialog.show() viewModel.importSelect { waitDialog.dismiss() dismissAllowingStateLoss() } } binding.tvFooterLeft.visible() binding.tvFooterLeft.setOnClickListener { val selectAll = viewModel.isSelectAll viewModel.selectStatus.forEachIndexed { index, b -> if (b != !selectAll) { viewModel.selectStatus[index] = !selectAll } } adapter.notifyDataSetChanged() upSelectText() } viewModel.errorLiveData.observe(this) { binding.rotateLoading.gone() binding.tvMsg.apply { text = it visible() } } viewModel.successLiveData.observe(this) { binding.rotateLoading.gone() if (it > 0) { adapter.setItems(viewModel.allRules) upSelectText() } else { binding.tvMsg.apply { setText(R.string.wrong_format) visible() } } } val source = arguments?.getString("source") if (source.isNullOrEmpty()) { dismiss() return } viewModel.import(source) } private fun initMenu() { binding.toolBar.setOnMenuItemClickListener(this) binding.toolBar.inflateMenu(R.menu.import_replace) } override fun onMenuItemClick(item: MenuItem?): Boolean { when (item?.itemId) { R.id.menu_new_group -> alertCustomGroup(item) R.id.menu_keep_original_name -> { item.isChecked = !item.isChecked putPrefBoolean(PreferKey.importKeepName, item.isChecked) } } return true } private fun alertCustomGroup(item: MenuItem) { alert(R.string.diy_edit_source_group) { val alertBinding = DialogCustomGroupBinding.inflate(layoutInflater).apply { val groups = appDb.replaceRuleDao.allGroups() textInputLayout.setHint(R.string.group_name) editView.setFilterValues(groups.toList()) editView.dropDownHeight = 180.dpToPx() } customView { alertBinding.root } okButton { viewModel.isAddGroup = alertBinding.swAddGroup.isChecked viewModel.groupName = alertBinding.editView.text?.toString() if (viewModel.groupName.isNullOrBlank()) { item.title = getString(R.string.diy_source_group) } else { val group = getString(R.string.diy_edit_source_group_title, viewModel.groupName) if (viewModel.isAddGroup) { item.title = "+$group" } else { item.title = group } } } noButton() } } private fun upSelectText() { if (viewModel.isSelectAll) { binding.tvFooterLeft.text = getString( R.string.select_cancel_count, viewModel.selectCount, viewModel.allRules.size ) } else { binding.tvFooterLeft.text = getString( R.string.select_all_count, viewModel.selectCount, viewModel.allRules.size ) } } override fun onCodeSave(code: String, requestId: String?) { requestId?.toInt()?.let { GSON.fromJsonObject(code).getOrNull()?.let { rule -> viewModel.allRules[it] = rule adapter.setItem(it, rule) } } } inner class SourcesAdapter(context: Context) : RecyclerAdapter(context) { override fun getViewBinding(parent: ViewGroup): ItemSourceImportBinding { return ItemSourceImportBinding.inflate(inflater, parent, false) } @SuppressLint("SetTextI18n") override fun convert( holder: ItemViewHolder, binding: ItemSourceImportBinding, item: ReplaceRule, payloads: MutableList ) { binding.run { cbSourceName.isChecked = viewModel.selectStatus[holder.layoutPosition] cbSourceName.text = if (item.group.isNullOrBlank()) { item.name } else { "${item.name}(${item.group})" } val localRule = viewModel.checkRules[holder.layoutPosition] tvSourceState.text = when { localRule == null -> "新增" item.pattern != localRule.pattern || item.replacement != localRule.replacement || item.isRegex != localRule.isRegex || item.scope != localRule.scope -> "更新" else -> "已有" } } } override fun registerListener(holder: ItemViewHolder, binding: ItemSourceImportBinding) { binding.run { cbSourceName.setOnUserCheckedChangeListener { isChecked -> viewModel.selectStatus[holder.layoutPosition] = isChecked upSelectText() } root.onClick { cbSourceName.isChecked = !cbSourceName.isChecked viewModel.selectStatus[holder.layoutPosition] = cbSourceName.isChecked upSelectText() } tvOpen.setOnClickListener { val source = viewModel.allRules[holder.layoutPosition] showDialogFragment( CodeDialog( GSON.toJson(source), disableEdit = false, requestId = holder.layoutPosition.toString() ) ) } } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/association/ImportReplaceRuleViewModel.kt ================================================ package io.legado.app.ui.association import android.app.Application import androidx.core.net.toUri import androidx.lifecycle.MutableLiveData import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog import io.legado.app.constant.AppPattern import io.legado.app.data.appDb import io.legado.app.data.entities.ReplaceRule 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 import io.legado.app.help.http.okHttpClient import io.legado.app.help.http.text import io.legado.app.utils.isAbsUrl import io.legado.app.utils.isJsonArray import io.legado.app.utils.isJsonObject import io.legado.app.utils.isUri import io.legado.app.utils.readText import io.legado.app.utils.splitNotBlank import splitties.init.appCtx class ImportReplaceRuleViewModel(app: Application) : BaseViewModel(app) { var isAddGroup = false var groupName: String? = null val errorLiveData = MutableLiveData() val successLiveData = MutableLiveData() val allRules = arrayListOf() val checkRules = arrayListOf() val selectStatus = arrayListOf() val isSelectAll: Boolean get() { selectStatus.forEach { if (!it) { return false } } return true } val selectCount: Int get() { var count = 0 selectStatus.forEach { if (it) { count++ } } return count } fun importSelect(finally: () -> Unit) { execute { val group = groupName?.trim() val selectRules = arrayListOf() selectStatus.forEachIndexed { index, b -> if (b) { val rule = allRules[index] if (!group.isNullOrEmpty()) { if (isAddGroup) { val groups = linkedSetOf() rule.group?.splitNotBlank(AppPattern.splitGroupRegex)?.let { groups.addAll(it) } groups.add(group) rule.group = groups.joinToString(",") } else { rule.group = group } } selectRules.add(rule) } } appDb.replaceRuleDao.insert(*selectRules.toTypedArray()) }.onFinally { finally.invoke() } } fun import(text: String) { execute { importAwait(text.trim()) }.onError { errorLiveData.postValue("ImportError:${it.localizedMessage}") AppLog.put("ImportError:${it.localizedMessage}", it) }.onSuccess { comparisonSource() } } private suspend fun importAwait(text: String) { when { text.isAbsUrl() -> importUrl(text) text.isJsonArray() -> { val rules = ReplaceAnalyzer.jsonToReplaceRules(text).getOrThrow() allRules.addAll(rules) } text.isJsonObject() -> { val rule = ReplaceAnalyzer.jsonToReplaceRule(text).getOrThrow() allRules.add(rule) } text.isUri() -> { importAwait(text.toUri().readText(appCtx)) } else -> throw NoStackTraceException("格式不对") } } private suspend fun importUrl(url: String) { okHttpClient.newCallResponseBody { if (url.endsWith("#requestWithoutUA")) { url(url.substringBeforeLast("#requestWithoutUA")) header(AppConst.UA_NAME, "null") } else { url(url) } }.decompressed().text("utf-8").let { importAwait(it) } } private fun comparisonSource() { execute { allRules.forEach { val rule = appDb.replaceRuleDao.findById(it.id) checkRules.add(rule) selectStatus.add(rule == null) } }.onSuccess { successLiveData.postValue(allRules.size) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/association/ImportRssSourceDialog.kt ================================================ package io.legado.app.ui.association import android.annotation.SuppressLint import android.content.Context import android.content.DialogInterface import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.fragment.app.viewModels import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.constant.PreferKey import io.legado.app.data.appDb import io.legado.app.data.entities.RssSource import io.legado.app.databinding.DialogCustomGroupBinding import io.legado.app.databinding.DialogRecyclerViewBinding import io.legado.app.databinding.ItemSourceImportBinding import io.legado.app.help.config.AppConfig import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.widget.dialog.CodeDialog import io.legado.app.ui.widget.dialog.WaitDialog import io.legado.app.utils.GSON import io.legado.app.utils.dpToPx import io.legado.app.utils.fromJsonObject import io.legado.app.utils.putPrefBoolean import io.legado.app.utils.setLayout import io.legado.app.utils.showDialogFragment import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible import splitties.views.onClick /** * 导入rss源弹出窗口 */ class ImportRssSourceDialog() : BaseDialogFragment(R.layout.dialog_recycler_view), Toolbar.OnMenuItemClickListener, CodeDialog.Callback { constructor(source: String, finishOnDismiss: Boolean = false) : this() { arguments = Bundle().apply { putString("source", source) putBoolean("finishOnDismiss", finishOnDismiss) } } private val binding by viewBinding(DialogRecyclerViewBinding::bind) private val viewModel by viewModels() private val adapter by lazy { SourcesAdapter(requireContext()) } override fun onStart() { super.onStart() setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) if (arguments?.getBoolean("finishOnDismiss") == true) { activity?.finish() } } @SuppressLint("NotifyDataSetChanged") override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) binding.toolBar.setTitle(R.string.import_rss_source) binding.rotateLoading.visible() initMenu() binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.adapter = adapter binding.tvCancel.visible() binding.tvCancel.setOnClickListener { dismissAllowingStateLoss() } binding.tvOk.visible() binding.tvOk.setOnClickListener { val waitDialog = WaitDialog(requireContext()) waitDialog.show() viewModel.importSelect { waitDialog.dismiss() dismissAllowingStateLoss() } } binding.tvFooterLeft.visible() binding.tvFooterLeft.setOnClickListener { val selectAll = viewModel.isSelectAll viewModel.selectStatus.forEachIndexed { index, b -> if (b != !selectAll) { viewModel.selectStatus[index] = !selectAll } } adapter.notifyDataSetChanged() upSelectText() } viewModel.errorLiveData.observe(this) { binding.rotateLoading.gone() binding.tvMsg.apply { text = it visible() } } viewModel.successLiveData.observe(this) { binding.rotateLoading.gone() if (it > 0) { adapter.setItems(viewModel.allSources) upSelectText() } else { binding.tvMsg.apply { setText(R.string.wrong_format) visible() } } } val source = arguments?.getString("source") if (source.isNullOrEmpty()) { dismiss() return } viewModel.importSource(source) } private fun upSelectText() { if (viewModel.isSelectAll) { binding.tvFooterLeft.text = getString( R.string.select_cancel_count, viewModel.selectCount, viewModel.allSources.size ) } else { binding.tvFooterLeft.text = getString( R.string.select_all_count, viewModel.selectCount, viewModel.allSources.size ) } } private fun initMenu() { binding.toolBar.setOnMenuItemClickListener(this) binding.toolBar.inflateMenu(R.menu.import_source) binding.toolBar.menu.findItem(R.id.menu_keep_original_name)?.isChecked = AppConfig.importKeepName binding.toolBar.menu.findItem(R.id.menu_keep_group)?.isChecked = AppConfig.importKeepGroup binding.toolBar.menu.findItem(R.id.menu_keep_enable)?.isChecked = AppConfig.importKeepEnable binding.toolBar.menu.findItem(R.id.menu_select_new_source)?.isVisible = false binding.toolBar.menu.findItem(R.id.menu_select_update_source)?.isVisible = false } @SuppressLint("InflateParams") override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_new_group -> alertCustomGroup(item) R.id.menu_keep_original_name -> { item.isChecked = !item.isChecked putPrefBoolean(PreferKey.importKeepName, item.isChecked) } R.id.menu_keep_group -> { item.isChecked = !item.isChecked putPrefBoolean(PreferKey.importKeepGroup, item.isChecked) } R.id.menu_keep_enable -> { item.isChecked = !item.isChecked AppConfig.importKeepEnable = item.isChecked } } return false } private fun alertCustomGroup(item: MenuItem) { alert(R.string.diy_edit_source_group) { val alertBinding = DialogCustomGroupBinding.inflate(layoutInflater).apply { val groups = appDb.rssSourceDao.allGroups() textInputLayout.setHint(R.string.group_name) editView.setFilterValues(groups.toList()) editView.dropDownHeight = 180.dpToPx() } customView { alertBinding.root } okButton { viewModel.isAddGroup = alertBinding.swAddGroup.isChecked viewModel.groupName = alertBinding.editView.text?.toString() if (viewModel.groupName.isNullOrBlank()) { item.title = getString(R.string.diy_source_group) } else { val group = getString(R.string.diy_edit_source_group_title, viewModel.groupName) if (viewModel.isAddGroup) { item.title = "+$group" } else { item.title = group } } } cancelButton() } } override fun onCodeSave(code: String, requestId: String?) { requestId?.toInt()?.let { GSON.fromJsonObject(code).getOrNull()?.let { source -> viewModel.allSources[it] = source adapter.setItem(it, source) } } } inner class SourcesAdapter(context: Context) : RecyclerAdapter(context) { override fun getViewBinding(parent: ViewGroup): ItemSourceImportBinding { return ItemSourceImportBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemSourceImportBinding, item: RssSource, payloads: MutableList ) { binding.apply { cbSourceName.isChecked = viewModel.selectStatus[holder.layoutPosition] cbSourceName.text = item.sourceName val localSource = viewModel.checkSources[holder.layoutPosition] tvSourceState.text = when { localSource == null -> "新增" item.lastUpdateTime > localSource.lastUpdateTime -> "更新" else -> "已有" } } } override fun registerListener(holder: ItemViewHolder, binding: ItemSourceImportBinding) { binding.apply { cbSourceName.setOnUserCheckedChangeListener { isChecked -> viewModel.selectStatus[holder.layoutPosition] = isChecked upSelectText() } root.onClick { cbSourceName.isChecked = !cbSourceName.isChecked viewModel.selectStatus[holder.layoutPosition] = cbSourceName.isChecked upSelectText() } tvOpen.setOnClickListener { val source = viewModel.allSources[holder.layoutPosition] showDialogFragment( CodeDialog( GSON.toJson(source), disableEdit = false, requestId = holder.layoutPosition.toString() ) ) } } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/association/ImportRssSourceViewModel.kt ================================================ package io.legado.app.ui.association import android.app.Application import androidx.core.net.toUri import androidx.lifecycle.MutableLiveData import com.jayway.jsonpath.JsonPath import io.legado.app.R import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog import io.legado.app.constant.AppPattern import io.legado.app.data.appDb import io.legado.app.data.entities.RssSource import io.legado.app.exception.NoStackTraceException import io.legado.app.help.config.AppConfig import io.legado.app.help.http.decompressed import io.legado.app.help.http.newCallResponseBody import io.legado.app.help.http.okHttpClient import io.legado.app.help.source.SourceHelp import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonArray import io.legado.app.utils.fromJsonObject import io.legado.app.utils.isAbsUrl import io.legado.app.utils.isJsonArray import io.legado.app.utils.isJsonObject import io.legado.app.utils.isUri import io.legado.app.utils.jsonPath import io.legado.app.utils.readText import io.legado.app.utils.splitNotBlank import splitties.init.appCtx class ImportRssSourceViewModel(app: Application) : BaseViewModel(app) { var isAddGroup = false var groupName: String? = null val errorLiveData = MutableLiveData() val successLiveData = MutableLiveData() val allSources = arrayListOf() val checkSources = arrayListOf() val selectStatus = arrayListOf() val isSelectAll: Boolean get() { selectStatus.forEach { if (!it) { return false } } return true } val selectCount: Int get() { var count = 0 selectStatus.forEach { if (it) { count++ } } return count } fun importSelect(finally: () -> Unit) { execute { val group = groupName?.trim() val keepName = AppConfig.importKeepName val keepGroup = AppConfig.importKeepGroup val keepEnable = AppConfig.importKeepEnable val selectSource = arrayListOf() selectStatus.forEachIndexed { index, b -> if (b) { val source = allSources[index] checkSources[index]?.let { if (keepName) { source.sourceName = it.sourceName } if (keepGroup) { source.sourceGroup = it.sourceGroup } if (keepEnable) { source.enabled = it.enabled } source.customOrder = it.customOrder } if (!group.isNullOrEmpty()) { if (isAddGroup) { val groups = linkedSetOf() source.sourceGroup?.splitNotBlank(AppPattern.splitGroupRegex)?.let { groups.addAll(it) } groups.add(group) source.sourceGroup = groups.joinToString(",") } else { source.sourceGroup = group } } selectSource.add(source) } } SourceHelp.insertRssSource(*selectSource.toTypedArray()) }.onFinally { finally.invoke() } } fun importSource(text: String) { execute { importSourceAwait(text) }.onError { errorLiveData.postValue("ImportError:${it.localizedMessage}") AppLog.put("ImportError:${it.localizedMessage}", it) }.onSuccess { comparisonSource() } } private suspend fun importSourceAwait(text: String) { val mText = text.trim() when { mText.isJsonObject() -> kotlin.runCatching { val json = JsonPath.parse(mText) val urls = json.read>("$.sourceUrls") if (!urls.isNullOrEmpty()) { urls.forEach { importSourceUrl(it) } } }.onFailure { GSON.fromJsonArray(mText).getOrThrow().let { val source = it.firstOrNull() ?: return@let if (source.sourceUrl.isEmpty()) { throw NoStackTraceException("不是订阅源") } allSources.addAll(it) } } mText.isJsonArray() -> { GSON.fromJsonArray(mText).getOrThrow().let { val source = it.firstOrNull() ?: return@let if (source.sourceUrl.isEmpty()) { throw NoStackTraceException("不是订阅源") } allSources.addAll(it) } } mText.isAbsUrl() -> { importSourceUrl(mText) } mText.isUri() -> { importSourceAwait(mText.toUri().readText(appCtx)) } else -> throw NoStackTraceException(context.getString(R.string.wrong_format)) } } private suspend fun importSourceUrl(url: String) { okHttpClient.newCallResponseBody { if (url.endsWith("#requestWithoutUA")) { url(url.substringBeforeLast("#requestWithoutUA")) header(AppConst.UA_NAME, "null") } else { url(url) } }.decompressed().byteStream().use { body -> val items: List> = jsonPath.parse(body).read("$") for (item in items) { if (!item.containsKey("sourceUrl")) { throw NoStackTraceException("不是订阅源") } val jsonItem = jsonPath.parse(item) GSON.fromJsonObject(jsonItem.jsonString()).getOrThrow().let { source -> allSources.add(source) } } } } private fun comparisonSource() { execute { allSources.forEach { val has = appDb.rssSourceDao.getByKey(it.sourceUrl) checkSources.add(has) selectStatus.add(has == null || has.lastUpdateTime < it.lastUpdateTime) } successLiveData.postValue(allSources.size) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/association/ImportThemeDialog.kt ================================================ package io.legado.app.ui.association import android.annotation.SuppressLint import android.content.Context import android.content.DialogInterface import android.os.Bundle import android.view.View import android.view.ViewGroup import androidx.fragment.app.viewModels import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.databinding.DialogRecyclerViewBinding import io.legado.app.databinding.ItemSourceImportBinding import io.legado.app.help.config.ThemeConfig import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.widget.dialog.CodeDialog import io.legado.app.ui.widget.dialog.WaitDialog import io.legado.app.utils.GSON import io.legado.app.utils.setLayout import io.legado.app.utils.showDialogFragment import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible import splitties.views.onClick class ImportThemeDialog() : BaseDialogFragment(R.layout.dialog_recycler_view) { constructor(source: String, finishOnDismiss: Boolean = false) : this() { arguments = Bundle().apply { putString("source", source) putBoolean("finishOnDismiss", finishOnDismiss) } } private val binding by viewBinding(DialogRecyclerViewBinding::bind) private val viewModel by viewModels() private val adapter by lazy { SourcesAdapter(requireContext()) } override fun onStart() { super.onStart() setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) if (arguments?.getBoolean("finishOnDismiss") == true) { activity?.finish() } } @SuppressLint("NotifyDataSetChanged") override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) binding.toolBar.setTitle(R.string.import_theme) binding.rotateLoading.visible() binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.adapter = adapter binding.tvCancel.visible() binding.tvCancel.setOnClickListener { dismissAllowingStateLoss() } binding.tvOk.visible() binding.tvOk.setOnClickListener { val waitDialog = WaitDialog(requireContext()) waitDialog.show() viewModel.importSelect { waitDialog.dismiss() dismissAllowingStateLoss() } } binding.tvFooterLeft.visible() binding.tvFooterLeft.setOnClickListener { val selectAll = viewModel.isSelectAll viewModel.selectStatus.forEachIndexed { index, b -> if (b != !selectAll) { viewModel.selectStatus[index] = !selectAll } } adapter.notifyDataSetChanged() upSelectText() } viewModel.errorLiveData.observe(this) { binding.rotateLoading.gone() binding.tvMsg.apply { text = it visible() } } viewModel.successLiveData.observe(this) { binding.rotateLoading.gone() if (it > 0) { adapter.setItems(viewModel.allSources) upSelectText() } else { binding.tvMsg.apply { setText(R.string.wrong_format) visible() } } } val source = arguments?.getString("source") if (source.isNullOrEmpty()) { dismiss() return } viewModel.importSource(source) } private fun upSelectText() { if (viewModel.isSelectAll) { binding.tvFooterLeft.text = getString( R.string.select_cancel_count, viewModel.selectCount, viewModel.allSources.size ) } else { binding.tvFooterLeft.text = getString( R.string.select_all_count, viewModel.selectCount, viewModel.allSources.size ) } } inner class SourcesAdapter(context: Context) : RecyclerAdapter(context) { override fun getViewBinding(parent: ViewGroup): ItemSourceImportBinding { return ItemSourceImportBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemSourceImportBinding, item: ThemeConfig.Config, payloads: MutableList ) { binding.apply { cbSourceName.isChecked = viewModel.selectStatus[holder.layoutPosition] cbSourceName.text = item.themeName val localSource = viewModel.checkSources[holder.layoutPosition] tvSourceState.text = when { localSource == null -> "新增" localSource != item -> "更新" else -> "已有" } } } override fun registerListener(holder: ItemViewHolder, binding: ItemSourceImportBinding) { binding.apply { cbSourceName.setOnUserCheckedChangeListener { isChecked -> viewModel.selectStatus[holder.layoutPosition] = isChecked upSelectText() } root.onClick { cbSourceName.isChecked = !cbSourceName.isChecked viewModel.selectStatus[holder.layoutPosition] = cbSourceName.isChecked upSelectText() } tvOpen.setOnClickListener { val source = viewModel.allSources[holder.layoutPosition] showDialogFragment( CodeDialog( GSON.toJson(source), disableEdit = false, requestId = holder.layoutPosition.toString() ) ) } } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/association/ImportThemeViewModel.kt ================================================ package io.legado.app.ui.association import android.app.Application import androidx.core.net.toUri import androidx.lifecycle.MutableLiveData import io.legado.app.R import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog import io.legado.app.exception.NoStackTraceException import io.legado.app.help.config.ThemeConfig import io.legado.app.help.http.decompressed import io.legado.app.help.http.newCallResponseBody import io.legado.app.help.http.okHttpClient import io.legado.app.help.http.text import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonArray import io.legado.app.utils.fromJsonObject import io.legado.app.utils.isAbsUrl import io.legado.app.utils.isJsonArray import io.legado.app.utils.isJsonObject import io.legado.app.utils.isUri import io.legado.app.utils.readText import splitties.init.appCtx class ImportThemeViewModel(app: Application) : BaseViewModel(app) { val errorLiveData = MutableLiveData() val successLiveData = MutableLiveData() val allSources = arrayListOf() val checkSources = arrayListOf() val selectStatus = arrayListOf() val isSelectAll: Boolean get() { selectStatus.forEach { if (!it) { return false } } return true } val selectCount: Int get() { var count = 0 selectStatus.forEach { if (it) { count++ } } return count } fun importSelect(finally: () -> Unit) { execute { selectStatus.forEachIndexed { index, b -> if (b) { ThemeConfig.addConfig(allSources[index]) } } }.onFinally { finally.invoke() } } fun importSource(text: String) { execute { importSourceAwait(text.trim()) }.onError { errorLiveData.postValue("ImportError:${it.localizedMessage}") AppLog.put("ImportError:${it.localizedMessage}", it) }.onSuccess { comparisonSource() } } private suspend fun importSourceAwait(text: String) { when { text.isJsonObject() -> { GSON.fromJsonObject(text).getOrThrow().let { allSources.add(it) } } text.isJsonArray() -> GSON.fromJsonArray(text).getOrThrow() .let { items -> allSources.addAll(items) } text.isAbsUrl() -> { importSourceUrl(text) } text.isUri() -> { importSourceAwait(text.toUri().readText(appCtx)) } else -> throw NoStackTraceException(context.getString(R.string.wrong_format)) } } private suspend fun importSourceUrl(url: String) { okHttpClient.newCallResponseBody { if (url.endsWith("#requestWithoutUA")) { url(url.substringBeforeLast("#requestWithoutUA")) header(AppConst.UA_NAME, "null") } else { url(url) } }.decompressed().text().let { importSourceAwait(it) } } private fun comparisonSource() { execute { allSources.forEach { config -> val source = ThemeConfig.configList.find { it.themeName == config.themeName } checkSources.add(source) selectStatus.add(source == null || source != config) } successLiveData.postValue(allSources.size) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/association/ImportTxtTocRuleDialog.kt ================================================ package io.legado.app.ui.association import android.annotation.SuppressLint import android.content.Context import android.content.DialogInterface import android.os.Bundle import android.view.View import android.view.ViewGroup import androidx.fragment.app.viewModels import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.data.entities.TxtTocRule import io.legado.app.databinding.DialogRecyclerViewBinding import io.legado.app.databinding.ItemSourceImportBinding import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.widget.dialog.CodeDialog import io.legado.app.ui.widget.dialog.WaitDialog import io.legado.app.utils.GSON import io.legado.app.utils.setLayout import io.legado.app.utils.showDialogFragment import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible import splitties.views.onClick class ImportTxtTocRuleDialog() : BaseDialogFragment(R.layout.dialog_recycler_view) { constructor(source: String, finishOnDismiss: Boolean = false) : this() { arguments = Bundle().apply { putString("source", source) putBoolean("finishOnDismiss", finishOnDismiss) } } private val binding by viewBinding(DialogRecyclerViewBinding::bind) private val viewModel by viewModels() private val adapter by lazy { SourcesAdapter(requireContext()) } override fun onStart() { super.onStart() setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) if (arguments?.getBoolean("finishOnDismiss") == true) { activity?.finish() } } @SuppressLint("NotifyDataSetChanged") override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) binding.toolBar.setTitle(R.string.import_txt_toc_rule) binding.rotateLoading.visible() binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.adapter = adapter binding.tvCancel.visible() binding.tvCancel.setOnClickListener { dismissAllowingStateLoss() } binding.tvOk.visible() binding.tvOk.setOnClickListener { val waitDialog = WaitDialog(requireContext()) waitDialog.show() viewModel.importSelect { waitDialog.dismiss() dismissAllowingStateLoss() } } binding.tvFooterLeft.visible() binding.tvFooterLeft.setOnClickListener { val selectAll = viewModel.isSelectAll viewModel.selectStatus.forEachIndexed { index, b -> if (b != !selectAll) { viewModel.selectStatus[index] = !selectAll } } adapter.notifyDataSetChanged() upSelectText() } viewModel.errorLiveData.observe(this) { binding.rotateLoading.gone() binding.tvMsg.apply { text = it visible() } } viewModel.successLiveData.observe(this) { binding.rotateLoading.gone() if (it > 0) { adapter.setItems(viewModel.allSources) upSelectText() } else { binding.tvMsg.apply { setText(R.string.wrong_format) visible() } } } val source = arguments?.getString("source") if (source.isNullOrEmpty()) { dismiss() return } viewModel.importSource(source) } private fun upSelectText() { if (viewModel.isSelectAll) { binding.tvFooterLeft.text = getString( R.string.select_cancel_count, viewModel.selectCount, viewModel.allSources.size ) } else { binding.tvFooterLeft.text = getString( R.string.select_all_count, viewModel.selectCount, viewModel.allSources.size ) } } inner class SourcesAdapter(context: Context) : RecyclerAdapter(context) { override fun getViewBinding(parent: ViewGroup): ItemSourceImportBinding { return ItemSourceImportBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemSourceImportBinding, item: TxtTocRule, payloads: MutableList ) { binding.apply { cbSourceName.isChecked = viewModel.selectStatus[holder.layoutPosition] cbSourceName.text = item.name val localSource = viewModel.checkSources[holder.layoutPosition] tvSourceState.text = when { localSource == null -> "新增" item != localSource -> "更新" else -> "已有" } } } override fun registerListener(holder: ItemViewHolder, binding: ItemSourceImportBinding) { binding.apply { cbSourceName.setOnUserCheckedChangeListener { isChecked -> viewModel.selectStatus[holder.layoutPosition] = isChecked upSelectText() } root.onClick { cbSourceName.isChecked = !cbSourceName.isChecked viewModel.selectStatus[holder.layoutPosition] = cbSourceName.isChecked upSelectText() } tvOpen.setOnClickListener { val source = viewModel.allSources[holder.layoutPosition] showDialogFragment( CodeDialog( GSON.toJson(source), disableEdit = false, requestId = holder.layoutPosition.toString() ) ) } } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/association/ImportTxtTocRuleViewModel.kt ================================================ package io.legado.app.ui.association import android.app.Application import androidx.core.net.toUri import androidx.lifecycle.MutableLiveData import io.legado.app.R import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.TxtTocRule import io.legado.app.exception.NoStackTraceException import io.legado.app.help.http.decompressed import io.legado.app.help.http.newCallResponseBody import io.legado.app.help.http.okHttpClient import io.legado.app.help.http.text import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonArray import io.legado.app.utils.fromJsonObject import io.legado.app.utils.isAbsUrl import io.legado.app.utils.isJsonArray import io.legado.app.utils.isJsonObject import io.legado.app.utils.isUri import io.legado.app.utils.readText import splitties.init.appCtx class ImportTxtTocRuleViewModel(app: Application) : BaseViewModel(app) { val errorLiveData = MutableLiveData() val successLiveData = MutableLiveData() val allSources = arrayListOf() val checkSources = arrayListOf() val selectStatus = arrayListOf() val isSelectAll: Boolean get() { selectStatus.forEach { if (!it) { return false } } return true } val selectCount: Int get() { var count = 0 selectStatus.forEach { if (it) { count++ } } return count } fun importSelect(finally: () -> Unit) { execute { val selectSource = arrayListOf() selectStatus.forEachIndexed { index, b -> if (b) { selectSource.add(allSources[index]) } } appDb.txtTocRuleDao.insert(*selectSource.toTypedArray()) }.onFinally { finally.invoke() } } fun importSource(text: String) { execute { importSourceAwait(text.trim()) }.onError { errorLiveData.postValue("ImportError:${it.localizedMessage}") AppLog.put("ImportError:${it.localizedMessage}", it) }.onSuccess { comparisonSource() } } private suspend fun importSourceAwait(text: String) { when { text.isJsonObject() -> { GSON.fromJsonObject(text).getOrThrow().let { allSources.add(it) } } text.isJsonArray() -> GSON.fromJsonArray(text).getOrThrow() .let { items -> allSources.addAll(items) } text.isAbsUrl() -> { importSourceUrl(text) } text.isUri() -> { importSourceAwait(text.toUri().readText(appCtx)) } else -> throw NoStackTraceException(context.getString(R.string.wrong_format)) } } private suspend fun importSourceUrl(url: String) { okHttpClient.newCallResponseBody { if (url.endsWith("#requestWithoutUA")) { url(url.substringBeforeLast("#requestWithoutUA")) header(AppConst.UA_NAME, "null") } else { url(url) } }.decompressed().text().let { importSourceAwait(it) } } private fun comparisonSource() { execute { allSources.forEach { val source = appDb.txtTocRuleDao.get(it.id) checkSources.add(source) selectStatus.add(source == null || it != source) } successLiveData.postValue(allSources.size) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/association/OnLineImportActivity.kt ================================================ package io.legado.app.ui.association import android.os.Bundle import androidx.activity.viewModels import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.databinding.ActivityTranslucenceBinding import io.legado.app.lib.dialogs.alert import io.legado.app.utils.showDialogFragment import io.legado.app.utils.viewbindingdelegate.viewBinding /** * 网络一键导入 * 格式: legado://import/{path}?src={url} */ class OnLineImportActivity : VMBaseActivity() { override val binding by viewBinding(ActivityTranslucenceBinding::inflate) override val viewModel by viewModels() override fun onActivityCreated(savedInstanceState: Bundle?) { viewModel.successLive.observe(this) { when (it.first) { "bookSource" -> showDialogFragment( ImportBookSourceDialog(it.second, true) ) "rssSource" -> showDialogFragment( ImportRssSourceDialog(it.second, true) ) "replaceRule" -> showDialogFragment( ImportReplaceRuleDialog(it.second, true) ) "httpTts" -> showDialogFragment( ImportHttpTtsDialog(it.second, true) ) "theme" -> showDialogFragment( ImportThemeDialog(it.second, true) ) "txtRule" -> showDialogFragment( ImportTxtTocRuleDialog(it.second, true) ) "dictRule" -> showDialogFragment( ImportDictRuleDialog(it.second, true) ) } } viewModel.errorLive.observe(this) { finallyDialog(getString(R.string.error), it) } intent.data?.let { val url = it.getQueryParameter("src") if (url.isNullOrEmpty()) { finish() return } when (it.path) { "/bookSource" -> showDialogFragment( ImportBookSourceDialog(url, true) ) "/rssSource" -> showDialogFragment( ImportRssSourceDialog(url, true) ) "/replaceRule" -> showDialogFragment( ImportReplaceRuleDialog(url, true) ) "/textTocRule" -> showDialogFragment( ImportTxtTocRuleDialog(url, true) ) "/httpTTS" -> showDialogFragment( ImportHttpTtsDialog(url, true) ) "/dictRule" -> showDialogFragment( ImportDictRuleDialog(url, true) ) "/theme" -> showDialogFragment( ImportThemeDialog(url, true) ) "/readConfig" -> viewModel.getBytes(url) { bytes -> viewModel.importReadConfig(bytes, this::finallyDialog) } "/addToBookshelf" -> showDialogFragment( AddToBookshelfDialog(url, true) ) "/importonline" -> when (it.host) { "booksource" -> showDialogFragment( ImportBookSourceDialog(url, true) ) "rsssource" -> showDialogFragment( ImportRssSourceDialog(url, true) ) "replace" -> showDialogFragment( ImportReplaceRuleDialog(url, true) ) else -> { viewModel.determineType(url, this::finallyDialog) } } else -> viewModel.determineType(url, this::finallyDialog) } } } private fun finallyDialog(title: String, msg: String) { alert(title, msg) { okButton() onDismiss { finish() } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/association/OnLineImportViewModel.kt ================================================ package io.legado.app.ui.association import android.app.Application import androidx.core.net.toUri import io.legado.app.R import io.legado.app.constant.AppConst import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.http.decompressed import io.legado.app.help.http.newCallResponseBody import io.legado.app.help.http.okHttpClient import io.legado.app.help.http.text import io.legado.app.utils.FileUtils import io.legado.app.utils.externalCache import okhttp3.MediaType.Companion.toMediaType import splitties.init.appCtx class OnLineImportViewModel(app: Application) : BaseAssociationViewModel(app) { fun getText(url: String, success: (text: String) -> Unit) { execute { okHttpClient.newCallResponseBody { if (url.endsWith("#requestWithoutUA")) { url(url.substringBeforeLast("#requestWithoutUA")) header(AppConst.UA_NAME, "null") } else { url(url) } }.decompressed().text("utf-8") }.onSuccess { success.invoke(it) }.onError { errorLive.postValue( it.localizedMessage ?: context.getString(R.string.unknown_error) ) } } fun getBytes(url: String, success: (bytes: ByteArray) -> Unit) { execute { okHttpClient.newCallResponseBody { if (url.endsWith("#requestWithoutUA")) { url(url.substringBeforeLast("#requestWithoutUA")) header(AppConst.UA_NAME, "null") } else { url(url) } }.bytes() }.onSuccess { success.invoke(it) }.onError { errorLive.postValue( it.localizedMessage ?: context.getString(R.string.unknown_error) ) } } fun importReadConfig(bytes: ByteArray, finally: (title: String, msg: String) -> Unit) { execute { val config = ReadBookConfig.import(bytes) ReadBookConfig.configList.forEachIndexed { index, c -> if (c.name == config.name) { ReadBookConfig.configList[index] = config return@execute config.name } ReadBookConfig.configList.add(config) return@execute config.name } }.onSuccess { finally.invoke(context.getString(R.string.success), "导入排版成功") }.onError { finally.invoke( context.getString(R.string.error), it.localizedMessage ?: context.getString(R.string.unknown_error) ) } } fun determineType(url: String, finally: (title: String, msg: String) -> Unit) { execute { val rs = okHttpClient.newCallResponseBody { if (url.endsWith("#requestWithoutUA")) { url(url.substringBeforeLast("#requestWithoutUA")) header(AppConst.UA_NAME, "null") } else { url(url) } } when (rs.contentType()) { "application/zip".toMediaType(), "application/octet-stream".toMediaType() -> { importReadConfig(rs.bytes(), finally) } else -> { val inputStream = rs.byteStream() val file = FileUtils.createFileIfNotExist( appCtx.externalCache, "download", "scheme_import_cache.json" ) file.outputStream().use { out -> inputStream.use { it.copyTo(out) } } importJson(file.toUri()) } } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/association/OpenUrlConfirmActivity.kt ================================================ package io.legado.app.ui.association import android.os.Bundle import io.legado.app.base.BaseActivity import io.legado.app.constant.SourceType import io.legado.app.databinding.ActivityTranslucenceBinding import io.legado.app.utils.showDialogFragment import io.legado.app.utils.viewbindingdelegate.viewBinding class OpenUrlConfirmActivity : BaseActivity() { override val binding by viewBinding(ActivityTranslucenceBinding::inflate) override fun onActivityCreated(savedInstanceState: Bundle?) { intent.getStringExtra("uri")?.let { val mimeType = intent.getStringExtra("mimeType") val sourceOrigin = intent.getStringExtra("sourceOrigin") val sourceName = intent.getStringExtra("sourceName") val sourceType = intent.getIntExtra("sourceType", SourceType.book) showDialogFragment(OpenUrlConfirmDialog(it, mimeType, sourceOrigin, sourceName, sourceType)) } ?: finish() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/association/OpenUrlConfirmDialog.kt ================================================ package io.legado.app.ui.association import android.content.Intent import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.core.net.toUri import androidx.fragment.app.viewModels import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.constant.AppLog import io.legado.app.databinding.DialogOpenUrlConfirmBinding import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.primaryColor import io.legado.app.utils.applyTint import io.legado.app.utils.setLayout import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding import splitties.init.appCtx class OpenUrlConfirmDialog() : BaseDialogFragment(R.layout.dialog_open_url_confirm), Toolbar.OnMenuItemClickListener { constructor( uri: String, mimeType: String?, sourceOrigin: String? = null, sourceName: String? = null, sourceType: Int ) : this() { arguments = Bundle().apply { putString("uri", uri) putString("mimeType", mimeType) putString("sourceOrigin", sourceOrigin) putString("sourceName", sourceName) putInt("sourceType", sourceType) } } val binding by viewBinding(DialogOpenUrlConfirmBinding::bind) val viewModel by viewModels() override fun onStart() { super.onStart() setLayout(1f, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { initMenu() val arguments = arguments ?: return viewModel.initData(arguments) if (viewModel.uri.isBlank()) { dismiss() return } binding.toolBar.setBackgroundColor(primaryColor) binding.toolBar.subtitle = viewModel.sourceName initView() } private fun initMenu() { binding.toolBar.setOnMenuItemClickListener(this) binding.toolBar.inflateMenu(R.menu.open_url_confirm) binding.toolBar.menu.applyTint(requireContext()) } private fun initView() { binding.message.text = "${viewModel.sourceName} 正在请求跳转链接/应用,是否跳转?" binding.btnNegative.setOnClickListener { dismiss() } binding.btnPositive.setOnClickListener { openUrl() dismiss() } } private fun openUrl() { try { val uri = viewModel.uri.toUri() val mimeType = viewModel.mimeType // 创建目标 Intent 并设置类型 val targetIntent = Intent(Intent.ACTION_VIEW).apply { // 同时设置 Data 和 Type if (!mimeType.isNullOrBlank()) { setDataAndType(uri, mimeType) } else { data = uri } addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } // 验证是否有应用可以处理 if (targetIntent.resolveActivity(appCtx.packageManager) != null) { startActivity(targetIntent) } else { toastOnUi(R.string.can_not_open) } } catch (e: Exception) { AppLog.put("打开链接失败", e, true) } } override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_disable_source -> { viewModel.disableSource { dismiss() } } R.id.menu_delete_source -> { alert(R.string.draw) { setMessage(getString(R.string.sure_del) + "\n" + viewModel.sourceName) noButton() yesButton { viewModel.deleteSource { dismiss() } } } } } return false } override fun onDestroy() { super.onDestroy() activity?.finish() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/association/OpenUrlConfirmViewModel.kt ================================================ package io.legado.app.ui.association import android.app.Application import android.os.Bundle import io.legado.app.base.BaseViewModel import io.legado.app.constant.SourceType import io.legado.app.data.appDb import io.legado.app.help.source.SourceHelp class OpenUrlConfirmViewModel(app: Application): BaseViewModel(app) { var uri = "" var mimeType: String? = null var sourceOrigin = "" var sourceName = "" var sourceType = SourceType.book fun initData(arguments: Bundle) { uri = arguments.getString("uri") ?: "" mimeType = arguments.getString("mimeType") sourceName = arguments.getString("sourceName") ?: "" sourceOrigin = arguments.getString("sourceOrigin") ?: "" sourceType = arguments.getInt("sourceType", SourceType.book) } fun disableSource(block: () -> Unit) { execute { SourceHelp.enableSource(sourceOrigin, sourceType, false) }.onSuccess { block.invoke() } } fun deleteSource(block: () -> Unit) { execute { SourceHelp.deleteSource(sourceOrigin, sourceType) }.onSuccess { block.invoke() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/association/VerificationCodeActivity.kt ================================================ package io.legado.app.ui.association import android.os.Bundle import io.legado.app.base.BaseActivity import io.legado.app.constant.SourceType import io.legado.app.databinding.ActivityTranslucenceBinding import io.legado.app.utils.showDialogFragment import io.legado.app.utils.viewbindingdelegate.viewBinding /** * 验证码 */ class VerificationCodeActivity : BaseActivity() { override val binding by viewBinding(ActivityTranslucenceBinding::inflate) override fun onActivityCreated(savedInstanceState: Bundle?) { intent.getStringExtra("imageUrl")?.let { val sourceOrigin = intent.getStringExtra("sourceOrigin") val sourceName = intent.getStringExtra("sourceName") val sourceType = intent.getIntExtra("sourceType", SourceType.book) showDialogFragment( VerificationCodeDialog(it, sourceOrigin, sourceName, sourceType) ) } ?: finish() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/association/VerificationCodeDialog.kt ================================================ package io.legado.app.ui.association import android.annotation.SuppressLint import android.graphics.Bitmap import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.fragment.app.viewModels import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.target.Target import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.databinding.DialogVerificationCodeViewBinding import io.legado.app.help.glide.ImageLoader import io.legado.app.help.glide.OkHttpModelLoader import io.legado.app.help.source.SourceVerificationHelp import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.primaryColor import io.legado.app.model.ImageProvider import io.legado.app.ui.widget.dialog.PhotoDialog import io.legado.app.utils.applyTint import io.legado.app.utils.setLayout import io.legado.app.utils.showDialogFragment import io.legado.app.utils.viewbindingdelegate.viewBinding /** * 图片验证码对话框 * 结果保存在内存中 * val key = "${sourceOrigin ?: ""}_verificationResult" * CacheManager.get(key) */ class VerificationCodeDialog() : BaseDialogFragment(R.layout.dialog_verification_code_view), Toolbar.OnMenuItemClickListener { constructor( imageUrl: String, sourceOrigin: String? = null, sourceName: String? = null, sourceType: Int ) : this() { arguments = Bundle().apply { putString("imageUrl", imageUrl) putString("sourceOrigin", sourceOrigin) putString("sourceName", sourceName) putInt("sourceType", sourceType) } } val binding by viewBinding(DialogVerificationCodeViewBinding::bind) val viewModel by viewModels() override fun onStart() { super.onStart() setLayout(1f, ViewGroup.LayoutParams.WRAP_CONTENT) } private var sourceOrigin: String? = null override fun onFragmentCreated(view: View, savedInstanceState: Bundle?): Unit = binding.run { initMenu() val arguments = arguments ?: return@run viewModel.initData(arguments) toolBar.setBackgroundColor(primaryColor) toolBar.subtitle = arguments.getString("sourceName") sourceOrigin = arguments.getString("sourceOrigin") val imageUrl = arguments.getString("imageUrl") ?: return@run loadImage(imageUrl, sourceOrigin) verificationCodeImageView.setOnClickListener { showDialogFragment(PhotoDialog(imageUrl, sourceOrigin)) } } private fun initMenu() { binding.toolBar.setOnMenuItemClickListener(this) binding.toolBar.inflateMenu(R.menu.verification_code) binding.toolBar.menu.applyTint(requireContext()) } @SuppressLint("CheckResult") private fun loadImage(url: String, sourceUrl: String?) { ImageProvider.remove(url) ImageLoader.loadBitmap(requireContext(), url).apply { sourceUrl?.let { apply(RequestOptions().set(OkHttpModelLoader.sourceOriginOption, it)) } }.error(R.drawable.image_loading_error) .diskCacheStrategy(DiskCacheStrategy.NONE) .skipMemoryCache(true) .listener(object : RequestListener { override fun onLoadFailed( e: GlideException?, model: Any?, target: Target, isFirstResource: Boolean ): Boolean { return false } override fun onResourceReady( resource: Bitmap, model: Any, target: Target?, dataSource: DataSource, isFirstResource: Boolean ): Boolean { val bitmap = resource.copy(resource.config!!, true) ImageProvider.put(url, bitmap) // 传给 PhotoDialog return false } }) .into(binding.verificationCodeImageView) } @SuppressLint("InflateParams") override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_ok -> { val verificationCode = binding.verificationCode.text.toString() SourceVerificationHelp.setResult(sourceOrigin!!, verificationCode) dismiss() } R.id.menu_disable_source -> { viewModel.disableSource { dismiss() } } R.id.menu_delete_source -> { alert(R.string.draw) { setMessage(getString(R.string.sure_del) + "\n" + viewModel.sourceName) noButton() yesButton { viewModel.deleteSource { dismiss() } } } } } return false } override fun onDestroy() { SourceVerificationHelp.checkResult(sourceOrigin!!) super.onDestroy() activity?.finish() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/association/VerificationCodeViewModel.kt ================================================ package io.legado.app.ui.association import android.app.Application import android.os.Bundle import io.legado.app.base.BaseViewModel import io.legado.app.constant.SourceType import io.legado.app.help.source.SourceHelp class VerificationCodeViewModel(app: Application): BaseViewModel(app) { var sourceOrigin = "" var sourceName = "" private var sourceType = SourceType.book fun initData(arguments: Bundle) { sourceName = arguments.getString("sourceName") ?: "" sourceOrigin = arguments.getString("sourceOrigin") ?: "" sourceType = arguments.getInt("sourceType", SourceType.book) } fun disableSource(block: () -> Unit) { execute { SourceHelp.enableSource(sourceOrigin, sourceType, false) }.onSuccess { block.invoke() } } fun deleteSource(block: () -> Unit) { execute { SourceHelp.deleteSource(sourceOrigin, sourceType) }.onSuccess { block.invoke() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/audio/AudioPlayActivity.kt ================================================ package io.legado.app.ui.book.audio import android.annotation.SuppressLint import android.os.Build import android.os.Bundle import android.view.Gravity import android.view.Menu import android.view.MenuItem import android.widget.SeekBar import androidx.activity.viewModels import androidx.lifecycle.lifecycleScope import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.constant.BookType import io.legado.app.constant.EventBus import io.legado.app.constant.Status import io.legado.app.constant.Theme import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookSource import io.legado.app.databinding.ActivityAudioPlayBinding import io.legado.app.help.book.isAudio import io.legado.app.help.book.removeType import io.legado.app.help.config.AppConfig import io.legado.app.lib.dialogs.alert import io.legado.app.model.AudioPlay import io.legado.app.model.BookCover import io.legado.app.service.AudioPlayService import io.legado.app.ui.about.AppLogDialog import io.legado.app.ui.book.changesource.ChangeBookSourceDialog import io.legado.app.ui.book.source.edit.BookSourceEditActivity import io.legado.app.ui.book.toc.TocActivityResult import io.legado.app.ui.login.SourceLoginActivity import io.legado.app.ui.widget.seekbar.SeekBarChangeListener import io.legado.app.utils.StartActivityContract import io.legado.app.utils.applyNavigationBarPadding import io.legado.app.utils.dpToPx import io.legado.app.utils.invisible import io.legado.app.utils.observeEvent import io.legado.app.utils.observeEventSticky import io.legado.app.utils.sendToClip import io.legado.app.utils.showDialogFragment import io.legado.app.utils.startActivity import io.legado.app.utils.startActivityForBook import io.legado.app.utils.toDurationTime import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import splitties.views.onLongClick import java.util.Locale /** * 音频播放 */ @SuppressLint("ObsoleteSdkInt") class AudioPlayActivity : VMBaseActivity(toolBarTheme = Theme.Dark), ChangeBookSourceDialog.CallBack, AudioPlay.CallBack { override val binding by viewBinding(ActivityAudioPlayBinding::inflate) override val viewModel by viewModels() private val timerSliderPopup by lazy { TimerSliderPopup(this) } private var adjustProgress = false private var playMode = AudioPlay.PlayMode.LIST_END_STOP private val tocActivityResult = registerForActivityResult(TocActivityResult()) { it?.let { if (it.first != AudioPlay.book?.durChapterIndex || it.second == 0 ) { AudioPlay.skipTo(it.first) } } } private val sourceEditResult = registerForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) { if (it.resultCode == RESULT_OK) { viewModel.upSource() } } override fun onActivityCreated(savedInstanceState: Bundle?) { binding.titleBar.setBackgroundResource(R.color.transparent) AudioPlay.register(this) viewModel.titleData.observe(this) { binding.titleBar.title = it } viewModel.coverData.observe(this) { upCover(it) } viewModel.initData(intent) initView() } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.audio_play, menu) return super.onCompatCreateOptionsMenu(menu) } override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { menu.findItem(R.id.menu_login)?.isVisible = !AudioPlay.bookSource?.loginUrl.isNullOrBlank() menu.findItem(R.id.menu_wake_lock)?.isChecked = AppConfig.audioPlayUseWakeLock return super.onMenuOpened(featureId, menu) } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_change_source -> AudioPlay.book?.let { showDialogFragment(ChangeBookSourceDialog(it.name, it.author)) } R.id.menu_login -> AudioPlay.bookSource?.let { startActivity { putExtra("type", "bookSource") putExtra("key", it.bookSourceUrl) } } R.id.menu_wake_lock -> AppConfig.audioPlayUseWakeLock = !AppConfig.audioPlayUseWakeLock R.id.menu_copy_audio_url -> sendToClip(AudioPlayService.url) R.id.menu_edit_source -> AudioPlay.bookSource?.let { sourceEditResult.launch { putExtra("sourceUrl", it.bookSourceUrl) } } R.id.menu_log -> showDialogFragment() } return super.onCompatOptionsItemSelected(item) } private fun initView() { binding.ivPlayMode.setOnClickListener { AudioPlay.changePlayMode() } observeEventSticky(EventBus.PLAY_MODE_CHANGED) { playMode = it updatePlayModeIcon() } binding.fabPlayStop.setOnClickListener { playButton() } binding.fabPlayStop.onLongClick { AudioPlay.stop() } binding.ivSkipNext.setOnClickListener { AudioPlay.next() } binding.ivSkipPrevious.setOnClickListener { AudioPlay.prev() } binding.playerProgress.setOnSeekBarChangeListener(object : SeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { binding.tvDurTime.text = progress.toDurationTime() } override fun onStartTrackingTouch(seekBar: SeekBar) { adjustProgress = true } override fun onStopTrackingTouch(seekBar: SeekBar) { adjustProgress = false AudioPlay.adjustProgress(seekBar.progress) } }) binding.ivChapter.setOnClickListener { AudioPlay.book?.let { tocActivityResult.launch(it.bookUrl) } } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { binding.ivFastRewind.invisible() binding.ivFastForward.invisible() } binding.ivFastForward.setOnClickListener { AudioPlay.adjustSpeed(0.1f) } binding.ivFastRewind.setOnClickListener { AudioPlay.adjustSpeed(-0.1f) } binding.ivTimer.setOnClickListener { timerSliderPopup.showAsDropDown(it, 0, (-100).dpToPx(), Gravity.TOP) } binding.llPlayMenu.applyNavigationBarPadding() } private fun updatePlayModeIcon() { binding.ivPlayMode.setImageResource(playMode.iconRes) } private fun upCover(path: String?) { BookCover.load(this, path, sourceOrigin = AudioPlay.bookSource?.bookSourceUrl) { BookCover.loadBlur(this, path, sourceOrigin = AudioPlay.bookSource?.bookSourceUrl) .into(binding.ivBg) }.into(binding.ivCover) } private fun playButton() { when (AudioPlay.status) { Status.PLAY -> AudioPlay.pause(this) Status.PAUSE -> AudioPlay.resume(this) else -> AudioPlay.loadOrUpPlayUrl() } } override val oldBook: Book? get() = AudioPlay.book override fun changeTo(source: BookSource, book: Book, toc: List) { if (book.isAudio) { viewModel.changeTo(source, book, toc) } else { AudioPlay.stop() lifecycleScope.launch { withContext(IO) { AudioPlay.book?.migrateTo(book, toc) book.removeType(BookType.updateError) AudioPlay.book?.delete() appDb.bookDao.insert(book) } startActivityForBook(book) finish() } } } override fun finish() { val book = AudioPlay.book ?: return super.finish() if (AudioPlay.inBookshelf) { return super.finish() } if (!AppConfig.showAddToShelfAlert) { viewModel.removeFromBookshelf { super.finish() } } else { alert(title = getString(R.string.add_to_bookshelf)) { setMessage(getString(R.string.check_add_bookshelf, book.name)) okButton { AudioPlay.book?.removeType(BookType.notShelf) AudioPlay.book?.save() AudioPlay.inBookshelf = true setResult(RESULT_OK) } noButton { viewModel.removeFromBookshelf { super.finish() } } } } } override fun onDestroy() { super.onDestroy() if (AudioPlay.status != Status.PLAY) { AudioPlay.stop() } AudioPlay.unregister(this) } @SuppressLint("SetTextI18n") override fun observeLiveBus() { observeEvent(EventBus.MEDIA_BUTTON) { if (it) { playButton() } } observeEventSticky(EventBus.AUDIO_STATE) { AudioPlay.status = it if (it == Status.PLAY) { binding.fabPlayStop.setImageResource(R.drawable.ic_pause_24dp) } else { binding.fabPlayStop.setImageResource(R.drawable.ic_play_24dp) } } observeEventSticky(EventBus.AUDIO_SUB_TITLE) { binding.tvSubTitle.text = it binding.ivSkipPrevious.isEnabled = AudioPlay.durChapterIndex > 0 binding.ivSkipNext.isEnabled = AudioPlay.durChapterIndex < AudioPlay.simulatedChapterSize - 1 } observeEventSticky(EventBus.AUDIO_SIZE) { binding.playerProgress.max = it binding.tvAllTime.text = it.toDurationTime() } observeEventSticky(EventBus.AUDIO_PROGRESS) { if (!adjustProgress) binding.playerProgress.progress = it binding.tvDurTime.text = it.toDurationTime() } observeEventSticky(EventBus.AUDIO_BUFFER_PROGRESS) { binding.playerProgress.secondaryProgress = it } observeEventSticky(EventBus.AUDIO_SPEED) { binding.tvSpeed.text = String.format(Locale.ROOT, "%.1fX", it) binding.tvSpeed.visible() } observeEventSticky(EventBus.AUDIO_DS) { binding.tvTimer.text = "${it}m" binding.tvTimer.visible(it > 0) } } override fun upLoading(loading: Boolean) { runOnUiThread { binding.progressLoading.visible(loading) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/audio/AudioPlayViewModel.kt ================================================ package io.legado.app.ui.book.audio import android.app.Application import android.content.Intent import androidx.lifecycle.MutableLiveData import io.legado.app.R import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppLog import io.legado.app.constant.BookType import io.legado.app.constant.EventBus import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookSource import io.legado.app.help.book.getBookSource import io.legado.app.help.book.removeType import io.legado.app.help.book.simulatedTotalChapterNum import io.legado.app.model.AudioPlay import io.legado.app.model.webBook.WebBook import io.legado.app.utils.postEvent import io.legado.app.utils.toastOnUi class AudioPlayViewModel(application: Application) : BaseViewModel(application) { val titleData = MutableLiveData() val coverData = MutableLiveData() fun initData(intent: Intent) = AudioPlay.apply { execute { val bookUrl = intent.getStringExtra("bookUrl") ?: book?.bookUrl ?: return@execute val book = appDb.bookDao.getBook(bookUrl) ?: return@execute inBookshelf = intent.getBooleanExtra("inBookshelf", true) initBook(book) }.onFinally { saveRead() } } private suspend fun initBook(book: Book) { val isSameBook = AudioPlay.book?.bookUrl == book.bookUrl if (isSameBook) { AudioPlay.upData(book) } else { AudioPlay.resetData(book) } titleData.postValue(book.name) coverData.postValue(book.getDisplayCover()) if (book.tocUrl.isEmpty() && !loadBookInfo(book)) { return } if (AudioPlay.chapterSize == 0 && !loadChapterList(book)) { return } } private suspend fun loadBookInfo(book: Book): Boolean { val bookSource = AudioPlay.bookSource ?: return true try { WebBook.getBookInfoAwait(bookSource, book) return true } catch (e: Exception) { AppLog.put("详情页出错: ${e.localizedMessage}", e, true) return false } } private suspend fun loadChapterList(book: Book): Boolean { val bookSource = AudioPlay.bookSource ?: return true try { val oldBook = book.copy() val cList = WebBook.getChapterListAwait(bookSource, book).getOrThrow() if (oldBook.bookUrl == book.bookUrl) { appDb.bookDao.update(book) } else { appDb.bookDao.replace(oldBook, book) } appDb.bookChapterDao.delByBook(book.bookUrl) appDb.bookChapterDao.insert(*cList.toTypedArray()) AudioPlay.chapterSize = cList.size AudioPlay.simulatedChapterSize = book.simulatedTotalChapterNum() AudioPlay.upDurChapter() return true } catch (e: Exception) { context.toastOnUi(R.string.error_load_toc) return false } } fun upSource() { execute { val book = AudioPlay.book ?: return@execute AudioPlay.bookSource = book.getBookSource() } } fun changeTo(source: BookSource, book: Book, toc: List) { execute { AudioPlay.book?.migrateTo(book, toc) book.removeType(BookType.updateError) AudioPlay.book?.delete() appDb.bookDao.insert(book) AudioPlay.book = book AudioPlay.bookSource = source appDb.bookChapterDao.insert(*toc.toTypedArray()) AudioPlay.upDurChapter() }.onFinally { postEvent(EventBus.SOURCE_CHANGED, book.bookUrl) } } fun removeFromBookshelf(success: (() -> Unit)?) { execute { AudioPlay.book?.let { appDb.bookDao.delete(it) } }.onSuccess { success?.invoke() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/audio/TimerSliderPopup.kt ================================================ package io.legado.app.ui.book.audio import android.content.Context import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.PopupWindow import android.widget.SeekBar import io.legado.app.R import io.legado.app.databinding.PopupSeekBarBinding import io.legado.app.model.AudioPlay import io.legado.app.service.AudioPlayService import io.legado.app.ui.widget.seekbar.SeekBarChangeListener class TimerSliderPopup(private val context: Context) : PopupWindow(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) { private val binding = PopupSeekBarBinding.inflate(LayoutInflater.from(context)) init { contentView = binding.root isTouchable = true isOutsideTouchable = false isFocusable = true binding.seekBar.max = 180 setProcessTextValue(binding.seekBar.progress) binding.seekBar.setOnSeekBarChangeListener(object : SeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { setProcessTextValue(progress) if (fromUser) { AudioPlay.setTimer(progress) } } }) } override fun showAsDropDown(anchor: View?, xoff: Int, yoff: Int, gravity: Int) { super.showAsDropDown(anchor, xoff, yoff, gravity) binding.seekBar.progress = AudioPlayService.timeMinute } override fun showAtLocation(parent: View?, gravity: Int, x: Int, y: Int) { super.showAtLocation(parent, gravity, x, y) binding.seekBar.progress = AudioPlayService.timeMinute } private fun setProcessTextValue(process: Int) { binding.tvSeekValue.text = context.getString(R.string.timer_m, process) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkActivity.kt ================================================ package io.legado.app.ui.book.bookmark import android.os.Bundle import android.view.Menu import android.view.MenuItem import androidx.activity.viewModels import androidx.lifecycle.lifecycleScope import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.Bookmark import io.legado.app.databinding.ActivityAllBookmarkBinding import io.legado.app.ui.file.HandleFileContract import io.legado.app.utils.applyNavigationBarPadding import io.legado.app.utils.showDialogFragment import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch /** * 所有书签 */ class AllBookmarkActivity : VMBaseActivity(), BookmarkAdapter.Callback { override val viewModel by viewModels() override val binding by viewBinding(ActivityAllBookmarkBinding::inflate) private val adapter by lazy { BookmarkAdapter(this, this) } private val exportDir = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> when (it.requestCode) { 1 -> viewModel.exportBookmark(uri) 2 -> viewModel.exportBookmarkMd(uri) } } } override fun onActivityCreated(savedInstanceState: Bundle?) { initView() lifecycleScope.launch { appDb.bookmarkDao.flowAll().catch { AppLog.put("所有书签界面获取数据失败\n${it.localizedMessage}", it) }.flowOn(IO).collect { adapter.setItems(it) } } } private fun initView() { binding.recyclerView.addItemDecoration(BookmarkDecoration(adapter)) binding.recyclerView.adapter = adapter binding.recyclerView.applyNavigationBarPadding() } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.bookmark, menu) return super.onCompatCreateOptionsMenu(menu) } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_export -> exportDir.launch { requestCode = 1 } R.id.menu_export_md -> exportDir.launch { requestCode = 2 } } return super.onCompatOptionsItemSelected(item) } override fun onItemClick(bookmark: Bookmark, position: Int) { showDialogFragment(BookmarkDialog(bookmark, position)) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt ================================================ package io.legado.app.ui.book.bookmark import android.app.Application import android.net.Uri import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.utils.FileDoc import io.legado.app.utils.GSON import io.legado.app.utils.createFileIfNotExist import io.legado.app.utils.openOutputStream import io.legado.app.utils.toastOnUi import io.legado.app.utils.writeToOutputStream import java.text.SimpleDateFormat import java.util.Date import java.util.Locale class AllBookmarkViewModel(application: Application) : BaseViewModel(application) { /** * 导出书签 */ fun exportBookmark(treeUri: Uri) { execute { val dateFormat = SimpleDateFormat("yyMMddHHmmss", Locale.getDefault()) val fileName = "bookmark-${dateFormat.format(Date())}.json" val dirDoc = FileDoc.fromUri(treeUri, true) dirDoc.createFileIfNotExist(fileName).openOutputStream().getOrThrow().use { GSON.writeToOutputStream(it, appDb.bookmarkDao.all) } }.onError { AppLog.put("导出失败\n${it.localizedMessage}", it, true) }.onSuccess { context.toastOnUi("导出成功") } } fun exportBookmarkMd(treeUri: Uri) { execute { val dateFormat = SimpleDateFormat("yyMMddHHmmss", Locale.getDefault()) val fileName = "bookmark-${dateFormat.format(Date())}.md" val dirDoc = FileDoc.fromUri(treeUri, true) val fileDoc = dirDoc.createFileIfNotExist(fileName).openOutputStream().getOrThrow() fileDoc.use { outputStream -> var name = "" var author = "" appDb.bookmarkDao.all.forEach { if (it.bookName != name && it.bookAuthor != author) { name = it.bookName author = it.bookAuthor outputStream.write("## ${it.bookName} ${it.bookAuthor}\n\n".toByteArray()) } outputStream.write("#### ${it.chapterName}\n\n".toByteArray()) outputStream.write("###### 原文\n ${it.bookText}\n\n".toByteArray()) outputStream.write("###### 摘要\n ${it.content}\n\n".toByteArray()) } } }.onError { AppLog.put("导出失败\n${it.localizedMessage}", it, true) }.onSuccess { context.toastOnUi("导出成功") } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/bookmark/BookmarkAdapter.kt ================================================ package io.legado.app.ui.book.bookmark import android.content.Context import android.view.ViewGroup import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.data.entities.Bookmark import io.legado.app.databinding.ItemBookmarkBinding import io.legado.app.utils.gone import splitties.views.onClick class BookmarkAdapter(context: Context, val callback: Callback) : RecyclerAdapter(context) { override fun getViewBinding(parent: ViewGroup): ItemBookmarkBinding { return ItemBookmarkBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemBookmarkBinding, item: Bookmark, payloads: MutableList ) { binding.tvChapterName.text = item.chapterName binding.tvBookText.gone(item.bookText.isEmpty()) binding.tvBookText.text = item.bookText binding.tvContent.gone(item.content.isEmpty()) binding.tvContent.text = item.content } override fun registerListener(holder: ItemViewHolder, binding: ItemBookmarkBinding) { binding.root.onClick { getItemByLayoutPosition(holder.layoutPosition)?.let { callback.onItemClick(it, holder.layoutPosition) } } } fun getHeaderText(position: Int): String { return with(getItem(position)) { "${this?.bookName ?: ""}(${this?.bookAuthor ?: ""})" } } fun isItemHeader(position: Int): Boolean { if (position == 0) return true val lastItem = getItem(position - 1) val curItem = getItem(position) return !(lastItem?.bookName == curItem?.bookName && lastItem?.bookAuthor == curItem?.bookAuthor) } interface Callback { fun onItemClick(bookmark: Bookmark, position: Int) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/bookmark/BookmarkDecoration.kt ================================================ package io.legado.app.ui.book.bookmark import android.graphics.Canvas import android.graphics.Paint import android.graphics.Rect import android.text.TextPaint import android.view.View import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.backgroundColor import io.legado.app.utils.dpToPx import io.legado.app.utils.spToPx import splitties.init.appCtx import kotlin.math.min class BookmarkDecoration(val adapter: BookmarkAdapter) : RecyclerView.ItemDecoration() { private val headerLeft = 16f.dpToPx() private val headerHeight = 32f.dpToPx() private val headerPaint = Paint().apply { color = appCtx.backgroundColor } private val textPaint = TextPaint().apply { textSize = 16f.spToPx() color = appCtx.accentColor isAntiAlias = true } private val textRect = Rect() override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { val count = parent.childCount for (i in 0 until count) { val view = parent.getChildAt(i) val position = parent.getChildLayoutPosition(view) val isHeader = adapter.isItemHeader(position) if (isHeader) { c.drawRect( 0f, view.top - headerHeight, parent.width.toFloat(), view.top.toFloat(), headerPaint ) val headerText = adapter.getHeaderText(position) textPaint.getTextBounds(headerText, 0, headerText.length, textRect) c.drawText( headerText, headerLeft, (view.top - headerHeight) + headerHeight / 2 + textRect.height() / 2, textPaint ) } } } override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { val position = (parent.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() val view = parent.findViewHolderForAdapterPosition(position)?.itemView ?: return val isHeader = adapter.isItemHeader(position + 1) val headerText = adapter.getHeaderText(position) if (isHeader) { val bottom = min(headerHeight.toInt(), view.bottom) c.drawRect( 0f, view.top - headerHeight, parent.width.toFloat(), bottom.toFloat(), headerPaint ) textPaint.getTextBounds(headerText, 0, headerText.length, textRect) c.drawText( headerText, headerLeft, headerHeight / 2 + textRect.height() / 2 - (headerHeight - bottom), textPaint ) } else { c.drawRect( 0f, 0f, parent.width.toFloat(), headerHeight, headerPaint ) textPaint.getTextBounds(headerText, 0, headerText.length, textRect) c.drawText( headerText, headerLeft, headerHeight / 2 + textRect.height() / 2, textPaint ) } c.save() } override fun getItemOffsets( outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State ) { val position = parent.getChildLayoutPosition(view) val isHeader = adapter.isItemHeader(position) if (isHeader) { outRect.top = headerHeight.toInt() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/bookmark/BookmarkDialog.kt ================================================ package io.legado.app.ui.book.bookmark import android.os.Bundle import android.view.View import android.view.ViewGroup import androidx.lifecycle.lifecycleScope import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.data.appDb import io.legado.app.data.entities.Bookmark import io.legado.app.databinding.DialogBookmarkBinding import io.legado.app.lib.theme.primaryColor import io.legado.app.utils.setLayout import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class BookmarkDialog() : BaseDialogFragment(R.layout.dialog_bookmark, true) { constructor(bookmark: Bookmark, editPos: Int = -1) : this() { arguments = Bundle().apply { putInt("editPos", editPos) putParcelable("bookmark", bookmark) } } private val binding by viewBinding(DialogBookmarkBinding::bind) override fun onStart() { super.onStart() setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) val arguments = arguments ?: let { dismiss() return } @Suppress("DEPRECATION") val bookmark = arguments.getParcelable("bookmark") bookmark ?: let { dismiss() return } val editPos = arguments.getInt("editPos", -1) binding.tvFooterLeft.visible(editPos >= 0) binding.run { tvChapterName.text = bookmark.chapterName editBookText.setText(bookmark.bookText) editContent.setText(bookmark.content) tvCancel.setOnClickListener { dismiss() } tvOk.setOnClickListener { bookmark.bookText = editBookText.text?.toString() ?: "" bookmark.content = editContent.text?.toString() ?: "" lifecycleScope.launch { withContext(IO) { appDb.bookmarkDao.insert(bookmark) } dismiss() } } tvFooterLeft.setOnClickListener { lifecycleScope.launch { withContext(IO) { appDb.bookmarkDao.delete(bookmark) } dismiss() } } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/cache/CacheActivity.kt ================================================ package io.legado.app.ui.book.cache import android.annotation.SuppressLint import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.View import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.PopupMenu import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.textfield.TextInputLayout import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.constant.AppConst.charsets import io.legado.app.constant.AppLog import io.legado.app.constant.EventBus import io.legado.app.constant.IntentAction import io.legado.app.data.AppDatabase import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookGroup import io.legado.app.databinding.ActivityCacheBookBinding import io.legado.app.databinding.DialogEditTextBinding import io.legado.app.databinding.DialogSelectSectionExportBinding import io.legado.app.help.book.getExportFileName import io.legado.app.help.book.isAudio import io.legado.app.help.book.tryParesExportFileName import io.legado.app.help.config.AppConfig import io.legado.app.lib.dialogs.SelectItem import io.legado.app.lib.dialogs.alert import io.legado.app.lib.dialogs.selector import io.legado.app.model.CacheBook import io.legado.app.service.ExportBookService import io.legado.app.ui.about.AppLogDialog import io.legado.app.ui.file.HandleFileContract import io.legado.app.utils.ACache import io.legado.app.utils.FileDoc import io.legado.app.utils.applyNavigationBarPadding import io.legado.app.utils.applyOpenTint import io.legado.app.utils.applyTint import io.legado.app.utils.checkWrite import io.legado.app.utils.cnCompare import io.legado.app.utils.enableCustomExport import io.legado.app.utils.flowWithLifecycleAndDatabaseChange import io.legado.app.utils.iconItemOnLongClick import io.legado.app.utils.isContentScheme import io.legado.app.utils.observeEvent import io.legado.app.utils.setIconCompat import io.legado.app.utils.showDialogFragment import io.legado.app.utils.startService import io.legado.app.utils.toastOnUi import io.legado.app.utils.verificationField import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Job import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import splitties.init.appCtx import kotlin.math.max /** * cache/download 缓存界面 */ class CacheActivity : VMBaseActivity(), PopupMenu.OnMenuItemClickListener, CacheAdapter.CallBack { override val binding by viewBinding(ActivityCacheBookBinding::inflate) override val viewModel by viewModels() private val exportBookPathKey = "exportBookPath" private val exportTypes = arrayListOf("txt", "epub") private val layoutManager by lazy { LinearLayoutManager(this) } private val adapter by lazy { CacheAdapter(this, this) } private var booksFlowJob: Job? = null private var menu: Menu? = null private val groupList: ArrayList = arrayListOf() private var groupId: Long = -1 private val exportDir = registerForActivityResult(HandleFileContract()) { result -> var isReadyPath = false var dirPath = "" result.uri?.let { uri -> if (uri.isContentScheme()) { ACache.get().put(exportBookPathKey, uri.toString()) dirPath = uri.toString() isReadyPath = true } else { uri.path?.let { path -> ACache.get().put(exportBookPathKey, path) dirPath = path isReadyPath = true } } } if (!isReadyPath) { return@registerForActivityResult } if (enableCustomExport()) {// 启用自定义导出 and 导出类型为Epub configExportSection(dirPath, result.requestCode) } else { startExport(dirPath, result.requestCode) } } override fun onActivityCreated(savedInstanceState: Bundle?) { groupId = intent.getLongExtra("groupId", -1) lifecycleScope.launch { binding.titleBar.subtitle = withContext(IO) { appDb.bookGroupDao.getByID(groupId)?.groupName ?: getString(R.string.no_group) } } initRecyclerView() initGroupData() initBookData() } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.book_cache, menu) menu.iconItemOnLongClick(R.id.menu_download) { PopupMenu(this, it).apply { inflate(R.menu.book_cache_download) this.menu.applyOpenTint(this@CacheActivity) setOnMenuItemClickListener(this@CacheActivity) }.show() } return super.onCompatCreateOptionsMenu(menu) } override fun onPrepareOptionsMenu(menu: Menu): Boolean { this.menu = menu upMenu() return super.onPrepareOptionsMenu(menu) } override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { menu.findItem(R.id.menu_enable_replace)?.isChecked = AppConfig.exportUseReplace // 菜单打开时读取状态[enableCustomExport] menu.findItem(R.id.menu_enable_custom_export)?.isChecked = AppConfig.enableCustomExport menu.findItem(R.id.menu_export_no_chapter_name)?.isChecked = AppConfig.exportNoChapterName menu.findItem(R.id.menu_export_web_dav)?.isChecked = AppConfig.exportToWebDav menu.findItem(R.id.menu_export_pics_file)?.isChecked = AppConfig.exportPictureFile menu.findItem(R.id.menu_parallel_export)?.isChecked = AppConfig.parallelExportBook menu.findItem(R.id.menu_export_type)?.title = "${getString(R.string.export_type)}(${getTypeName()})" menu.findItem(R.id.menu_export_charset)?.title = "${getString(R.string.export_charset)}(${AppConfig.exportCharset})" return super.onMenuOpened(featureId, menu) } private fun upMenu() { menu?.findItem(R.id.menu_book_group)?.subMenu?.let { subMenu -> subMenu.removeGroup(R.id.menu_group) groupList.forEach { bookGroup -> subMenu.add(R.id.menu_group, bookGroup.order, Menu.NONE, bookGroup.groupName) } } } /** * 菜单按下回调 */ override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_download, R.id.menu_download_after -> { if (!CacheBook.isRun) sureCacheBook { adapter.getItems().forEach { book -> CacheBook.start( this@CacheActivity, book, book.durChapterIndex, book.lastChapterIndex ) } } else { CacheBook.stop(this@CacheActivity) } } R.id.menu_download_all -> { if (!CacheBook.isRun) sureCacheBook { adapter.getItems().forEach { book -> CacheBook.start( this@CacheActivity, book, 0, book.lastChapterIndex ) } } else { CacheBook.stop(this@CacheActivity) } } R.id.menu_export_all -> exportAll() R.id.menu_enable_replace -> AppConfig.exportUseReplace = !item.isChecked // 更改菜单状态[enableCustomExport] R.id.menu_enable_custom_export -> AppConfig.enableCustomExport = !item.isChecked R.id.menu_export_no_chapter_name -> AppConfig.exportNoChapterName = !item.isChecked R.id.menu_export_web_dav -> AppConfig.exportToWebDav = !item.isChecked R.id.menu_export_pics_file -> AppConfig.exportPictureFile = !item.isChecked R.id.menu_parallel_export -> AppConfig.parallelExportBook = !item.isChecked R.id.menu_export_folder -> { selectExportFolder(-1) } R.id.menu_export_file_name -> alertExportFileName() R.id.menu_export_type -> showExportTypeConfig() R.id.menu_export_charset -> showCharsetConfig() R.id.menu_log -> showDialogFragment() else -> if (item.groupId == R.id.menu_group) { binding.titleBar.subtitle = item.title groupId = appDb.bookGroupDao.getByName(item.title.toString())?.groupId ?: 0 initBookData() } } return super.onCompatOptionsItemSelected(item) } override fun onMenuItemClick(item: MenuItem): Boolean { return onCompatOptionsItemSelected(item) } private fun initRecyclerView() { binding.recyclerView.layoutManager = layoutManager binding.recyclerView.adapter = adapter binding.recyclerView.applyNavigationBarPadding() } private fun initBookData() { booksFlowJob?.cancel() booksFlowJob = lifecycleScope.launch { appDb.bookDao.flowByGroup(groupId).map { books -> val booksDownload = books.filter { !it.isAudio } when (AppConfig.getBookSortByGroupId(groupId)) { 1 -> booksDownload.sortedByDescending { it.latestChapterTime } 2 -> booksDownload.sortedWith { o1, o2 -> o1.name.cnCompare(o2.name) } 3 -> booksDownload.sortedBy { it.order } 4 -> booksDownload.sortedByDescending { max(it.latestChapterTime, it.durChapterTime) } else -> booksDownload.sortedByDescending { it.durChapterTime } } }.flowWithLifecycleAndDatabaseChange( lifecycle, table = AppDatabase.BOOK_TABLE_NAME ).catch { AppLog.put("缓存管理界面获取书籍列表失败\n${it.localizedMessage}", it) }.flowOn(IO).conflate().collect { books -> adapter.setItems(books) viewModel.loadCacheFiles(books) } } } @SuppressLint("NotifyDataSetChanged") private fun initGroupData() { lifecycleScope.launch { appDb.bookGroupDao.flowAll().catch { AppLog.put("缓存管理界面获取分组数据失败\n${it.localizedMessage}", it) }.flowOn(IO).conflate().collect { groupList.clear() groupList.addAll(it) adapter.notifyDataSetChanged() upMenu() } } } private fun notifyItemChanged(bookUrl: String) { kotlin.runCatching { adapter.getItems().forEachIndexed { index, book -> if (bookUrl == book.bookUrl) { adapter.notifyItemChanged(index, true) return } } } } override fun observeLiveBus() { viewModel.upAdapterLiveData.observe(this) { notifyItemChanged(it) } observeEvent(EventBus.EXPORT_BOOK) { notifyItemChanged(it) } observeEvent(EventBus.UP_DOWNLOAD) { notifyItemChanged(it) } observeEvent(EventBus.UP_DOWNLOAD_STATE) { if (!CacheBook.isRun) { menu?.findItem(R.id.menu_download)?.let { item -> item.setIconCompat(R.drawable.ic_play_24dp) item.setTitle(R.string.download_start) } menu?.applyTint(this) } else { menu?.findItem(R.id.menu_download)?.let { item -> item.setIconCompat(R.drawable.ic_stop_black_24dp) item.setTitle(R.string.stop) } menu?.applyTint(this) } } observeEvent>(EventBus.SAVE_CONTENT) { (book, chapter) -> viewModel.cacheChapters[book.bookUrl]?.add(chapter.url) notifyItemChanged(book.bookUrl) } } override fun export(position: Int) { val path = ACache.get().getAsString(exportBookPathKey) lifecycleScope.launch { if (path.isNullOrEmpty() || withContext(IO) { !FileDoc.fromDir(path).checkWrite() } ) { selectExportFolder(position) } else if (enableCustomExport()) {// 启用自定义导出 and 导出类型为Epub configExportSection(path, position) } else { startExport(path, position) } } } private fun exportAll() { val path = ACache.get().getAsString(exportBookPathKey) if (path.isNullOrEmpty()) { selectExportFolder(-10) } else { startExport(path, -10) } } /** * 配置自定义导出对话框 * * @param path 导出路径 * @param position book位置 * @author Discut * @since 1.0.0 */ private fun configExportSection(path: String, position: Int) { val alertBinding = DialogSelectSectionExportBinding.inflate(layoutInflater) .apply { fun verifyExportFileNameJsStr(js: String): Boolean { return tryParesExportFileName(js) && etEpubFilename.text.toString() .isNotEmpty() } fun enableLyEtEpubFilenameIcon() { lyEtEpubFilename.endIconMode = TextInputLayout.END_ICON_CUSTOM lyEtEpubFilename.setEndIconOnClickListener { adapter.getItem(position)?.run { lyEtEpubFilename.helperText = if (verifyExportFileNameJsStr(etEpubFilename.text.toString())) "${resources.getString(R.string.result_analyzed)}: ${ getExportFileName( "epub", 1, etEpubFilename.text.toString() ) }" else "Error" } ?: run { lyEtEpubFilename.helperText = "Error" AppLog.put("未找到书籍,position is $position") } } } etEpubSize.setText("1") // lyEtEpubFilename.endIconMode = TextInputLayout.END_ICON_NONE etEpubFilename.text?.append(AppConfig.episodeExportFileName) // 存储解析文件名的jsStr etEpubFilename.let { it.setOnFocusChangeListener { _, hasFocus -> if (hasFocus) return@setOnFocusChangeListener it.text?.run { if (verifyExportFileNameJsStr(toString())) { AppConfig.episodeExportFileName = toString() } } } } tvAllExport.setOnClickListener { cbAllExport.callOnClick() } tvSelectExport.setOnClickListener { cbSelectExport.callOnClick() } cbSelectExport.onCheckedChangeListener = { _, isChecked -> if (isChecked) { etEpubSize.isEnabled = true etInputScope.isEnabled = true etEpubFilename.isEnabled = true enableLyEtEpubFilenameIcon() cbAllExport.isChecked = false } } cbAllExport.onCheckedChangeListener = { _, isChecked -> if (isChecked) { etEpubSize.isEnabled = false etInputScope.isEnabled = false etEpubFilename.isEnabled = false lyEtEpubFilename.endIconMode = TextInputLayout.END_ICON_NONE cbSelectExport.isChecked = false } } etInputScope.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus -> if (hasFocus) { etInputScope.hint = "1-5,8,10-18" } else { etInputScope.hint = "" } } // 默认选择自定义导出 cbSelectExport.callOnClick() } val alertDialog = alert(titleResource = R.string.select_section_export) { customView { alertBinding.root } positiveButton(R.string.ok) cancelButton() } alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { alertBinding.apply { if (cbAllExport.isChecked) { startExport(path, position) alertDialog.hide() return@apply } val epubScope = etInputScope.text.toString() if (!verificationField(epubScope)) { etInputScope.error = appCtx.getString(R.string.error_scope_input)//"请输入正确的范围" return@apply } etInputScope.error = null val epubSize = etEpubSize.text.toString().toIntOrNull() ?: 1 adapter.getItem(position)?.let { book -> startService { action = IntentAction.start putExtra("bookUrl", book.bookUrl) putExtra("exportType", "epub") putExtra("exportPath", path) putExtra("epubSize", epubSize) putExtra("epubScope", epubScope) } } alertDialog.hide() } } } private fun selectExportFolder(exportPosition: Int) { val default = arrayListOf>() val path = ACache.get().getAsString(exportBookPathKey) if (!path.isNullOrEmpty()) { default.add(SelectItem(path, -1)) } exportDir.launch { otherActions = default requestCode = exportPosition } } private fun startExport(path: String, exportPosition: Int) { val exportType = when (AppConfig.exportType) { 1 -> "epub" else -> "txt" } if (exportPosition == -10) { if (adapter.getItems().isNotEmpty()) { adapter.getItems().forEach { book -> startService { action = IntentAction.start putExtra("bookUrl", book.bookUrl) putExtra("exportType", exportType) putExtra("exportPath", path) } } } else { toastOnUi(R.string.no_book) } } else if (exportPosition >= 0) { adapter.getItem(exportPosition)?.let { book -> startService { action = IntentAction.start putExtra("bookUrl", book.bookUrl) putExtra("exportType", exportType) putExtra("exportPath", path) } } } } @SuppressLint("SetTextI18n") private fun alertExportFileName() { alert(R.string.export_file_name) { val message = "Variable: name, author." setMessage(message) val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = "file name js" editView.setText(AppConfig.bookExportFileName) } customView { alertBinding.root } okButton { AppConfig.bookExportFileName = alertBinding.editView.text?.toString() } cancelButton() } } private fun getTypeName(): String { return exportTypes.getOrElse(AppConfig.exportType) { exportTypes[0] } } private fun showExportTypeConfig() { selector(R.string.export_type, exportTypes) { _, i -> AppConfig.exportType = i } } private fun showCharsetConfig() { alert(R.string.set_charset) { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = "charset name" editView.setFilterValues(charsets) editView.setText(AppConfig.exportCharset) } customView { alertBinding.root } okButton { AppConfig.exportCharset = alertBinding.editView.text?.toString() ?: "UTF-8" } cancelButton() } } private fun sureCacheBook(action: () -> Unit) { alert(R.string.draw) { setMessage(R.string.sure_cache_book) noButton() yesButton { action.invoke() } } } override val cacheChapters: HashMap> get() = viewModel.cacheChapters override fun exportProgress(bookUrl: String): Int? { return ExportBookService.exportProgress[bookUrl] } override fun exportMsg(bookUrl: String): String? { return ExportBookService.exportMsg[bookUrl] } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/cache/CacheAdapter.kt ================================================ package io.legado.app.ui.book.cache import android.content.Context import android.view.ViewGroup import android.widget.ImageView import android.widget.ProgressBar import android.widget.TextView import androidx.recyclerview.widget.DiffUtil import io.legado.app.R import io.legado.app.base.adapter.DiffRecyclerAdapter import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.data.entities.Book import io.legado.app.databinding.ItemDownloadBinding import io.legado.app.help.book.isLocal import io.legado.app.model.CacheBook import io.legado.app.utils.gone import io.legado.app.utils.visible class CacheAdapter(context: Context, private val callBack: CallBack) : DiffRecyclerAdapter(context) { override val diffItemCallback: DiffUtil.ItemCallback get() = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: Book, newItem: Book): Boolean { return oldItem.bookUrl == newItem.bookUrl } override fun areContentsTheSame(oldItem: Book, newItem: Book): Boolean { return oldItem.name == newItem.name && oldItem.author == newItem.author } } override fun getViewBinding(parent: ViewGroup): ItemDownloadBinding { return ItemDownloadBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemDownloadBinding, item: Book, payloads: MutableList ) { binding.run { if (payloads.isEmpty()) { tvName.text = item.name tvAuthor.text = context.getString(R.string.author_show, item.getRealAuthor()) if (item.isLocal) { tvDownload.setText(R.string.local_book) } else { val cs = callBack.cacheChapters[item.bookUrl] if (cs == null) { tvDownload.setText(R.string.loading) } else { tvDownload.text = context.getString( R.string.download_count, cs.size, item.totalChapterNum ) } } } else { if (item.isLocal) { tvDownload.setText(R.string.local_book) } else { val cacheSize = callBack.cacheChapters[item.bookUrl]?.size ?: 0 tvDownload.text = context.getString(R.string.download_count, cacheSize, item.totalChapterNum) } } upDownloadIv(ivDownload, item) upExportInfo(tvMsg, progressExport, item) } } override fun registerListener(holder: ItemViewHolder, binding: ItemDownloadBinding) { binding.run { ivDownload.setOnClickListener { getItem(holder.layoutPosition)?.let { book -> CacheBook.cacheBookMap[book.bookUrl]?.let { if (!it.isStop()) { CacheBook.remove(context, book.bookUrl) } else { CacheBook.start(context, book, 0, book.lastChapterIndex) } } ?: let { CacheBook.start(context, book, 0, book.lastChapterIndex) } } } tvExport.setOnClickListener { callBack.export(holder.layoutPosition) } } } private fun upDownloadIv(iv: ImageView, book: Book) { if (book.isLocal) { iv.gone() } else { iv.visible() CacheBook.cacheBookMap[book.bookUrl]?.let { if (!it.isStop()) { iv.setImageResource(R.drawable.ic_stop_black_24dp) } else { iv.setImageResource(R.drawable.ic_play_24dp) } } ?: let { iv.setImageResource(R.drawable.ic_play_24dp) } } } private fun upExportInfo(msgView: TextView, progressView: ProgressBar, book: Book) { val msg = callBack.exportMsg(book.bookUrl) if (msg != null) { msgView.text = msg msgView.visible() progressView.gone() return } msgView.gone() val progress = callBack.exportProgress(book.bookUrl) if (progress != null) { progressView.max = book.totalChapterNum progressView.progress = progress progressView.visible() return } progressView.gone() } interface CallBack { val cacheChapters: HashMap> fun export(position: Int) fun exportProgress(bookUrl: String): Int? fun exportMsg(bookUrl: String): String? } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt ================================================ package io.legado.app.ui.book.cache import android.app.Application import androidx.lifecycle.MutableLiveData import io.legado.app.base.BaseViewModel import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.help.book.BookHelp import io.legado.app.help.book.isLocal import io.legado.app.help.coroutine.Coroutine import io.legado.app.utils.sendValue import kotlinx.coroutines.ensureActive import kotlin.collections.set class CacheViewModel(application: Application) : BaseViewModel(application) { val upAdapterLiveData = MutableLiveData() private var loadChapterCoroutine: Coroutine? = null val cacheChapters = hashMapOf>() fun loadCacheFiles(books: List) { loadChapterCoroutine?.cancel() loadChapterCoroutine = execute { books.forEach { book -> if (!book.isLocal && !cacheChapters.contains(book.bookUrl)) { val chapterCaches = hashSetOf() val cacheNames = BookHelp.getChapterFiles(book) if (cacheNames.isNotEmpty()) { appDb.bookChapterDao.getChapterList(book.bookUrl).also { book.totalChapterNum = it.size }.forEach { chapter -> if (cacheNames.contains(chapter.getFileName()) || chapter.isVolume) { chapterCaches.add(chapter.url) } } } cacheChapters[book.bookUrl] = chapterCaches upAdapterLiveData.sendValue(book.bookUrl) } ensureActive() } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverDialog.kt ================================================ package io.legado.app.ui.book.changecover import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle.State.STARTED import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.databinding.DialogChangeCoverBinding import io.legado.app.lib.theme.primaryColor import io.legado.app.utils.applyTint import io.legado.app.utils.setLayout import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.delay import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch /** * 换封面 */ class ChangeCoverDialog() : BaseDialogFragment(R.layout.dialog_change_cover), Toolbar.OnMenuItemClickListener, CoverAdapter.CallBack { constructor(name: String, author: String) : this() { arguments = Bundle().apply { putString("name", name) putString("author", author) } } private val binding by viewBinding(DialogChangeCoverBinding::bind) private val callBack: CallBack? get() = activity as? CallBack private val viewModel: ChangeCoverViewModel by viewModels() private val adapter by lazy { CoverAdapter(requireContext(), this) } private val startStopMenuItem: MenuItem? get() = binding.toolBar.menu.findItem(R.id.menu_start_stop) override fun onStart() { super.onStart() setLayout(1f, ViewGroup.LayoutParams.MATCH_PARENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) binding.toolBar.setTitle(R.string.change_cover_source) viewModel.initData(arguments) initMenu() initView() initData() } private fun initMenu() { binding.toolBar.inflateMenu(R.menu.change_cover) binding.toolBar.menu.applyTint(requireContext()) binding.toolBar.setOnMenuItemClickListener(this) } private fun initView() { binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 3) binding.recyclerView.adapter = adapter } private fun initData() { lifecycleScope.launch { lifecycle.currentStateFlow.first { it.isAtLeast(STARTED) } viewModel.dataFlow.conflate().collect { adapter.setItems(it) delay(1000) } } } override fun observeLiveBus() { super.observeLiveBus() viewModel.searchStateData.observe(viewLifecycleOwner) { binding.refreshProgressBar.isAutoLoading = it if (it) { startStopMenuItem?.let { item -> item.setIcon(R.drawable.ic_stop_black_24dp) item.setTitle(R.string.stop) } } else { startStopMenuItem?.let { item -> item.setIcon(R.drawable.ic_refresh_black_24dp) item.setTitle(R.string.refresh) } } binding.toolBar.menu.applyTint(requireContext()) } } override fun onMenuItemClick(item: MenuItem?): Boolean { when (item?.itemId) { R.id.menu_start_stop -> viewModel.startOrStopSearch() } return false } override fun changeTo(coverUrl: String) { callBack?.coverChangeTo(coverUrl) dismissAllowingStateLoss() } interface CallBack { fun coverChangeTo(coverUrl: String) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverViewModel.kt ================================================ package io.legado.app.ui.book.changecover import android.app.Application import android.os.Bundle import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog import io.legado.app.constant.AppPattern import io.legado.app.data.appDb import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.SearchBook import io.legado.app.help.config.AppConfig import io.legado.app.model.webBook.WebBook import io.legado.app.utils.mapParallelSafe import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.ExecutorCoroutineDispatcher import kotlinx.coroutines.Job import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import java.util.Collections import java.util.concurrent.Executors import kotlin.math.min class ChangeCoverViewModel(application: Application) : BaseViewModel(application) { private val threadCount = AppConfig.threadCount private var searchPool: ExecutorCoroutineDispatcher? = null private var searchSuccess: ((SearchBook) -> Unit)? = null private var upAdapter: (() -> Unit)? = null private var bookSourceParts = arrayListOf() private val defaultCover by lazy { listOf( SearchBook( originName = "默认封面", name = name, author = author, coverUrl = "use_default_cover" ) ) } private var task: Job? = null val searchStateData = MutableLiveData() var name: String = "" var author: String = "" val searchBooks: MutableList = Collections.synchronizedList(arrayListOf()) val dataFlow = callbackFlow { searchSuccess = { searchBook -> if (!searchBooks.contains(searchBook)) { searchBooks.add(searchBook) trySend(defaultCover + searchBooks.sortedBy { it.originOrder }) } } upAdapter = { trySend(defaultCover + searchBooks.sortedBy { it.originOrder }) } appDb.searchBookDao.getEnableHasCover(name, author).let { searchBooks.addAll(it) trySend(defaultCover + searchBooks.toList()) } if (searchBooks.size <= 1) { startSearch() } awaitClose { searchBooks.clear() searchSuccess = null upAdapter = null } }.flowOn(IO) fun initData(arguments: Bundle?) { arguments?.let { bundle -> bundle.getString("name")?.let { name = it } bundle.getString("author")?.let { author = it.replace(AppPattern.authorRegex, "") } } } private fun initSearchPool() { searchPool = Executors .newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher() } private fun startSearch() { execute { stopSearch() searchBooks.clear() upAdapter?.invoke() bookSourceParts.clear() bookSourceParts.addAll(appDb.bookSourceDao.allEnabledPart) initSearchPool() search() } } private fun search() { task = viewModelScope.launch(searchPool!!) { flow { for (bs in bookSourceParts) { bs.getBookSource()?.let { emit(it) } } }.onStart { searchStateData.postValue(true) }.mapParallelSafe(threadCount) { withTimeout(60000L) { search(it) } }.onCompletion { searchStateData.postValue(false) }.catch { AppLog.put("封面换源搜索出错\n${it.localizedMessage}", it) }.collect() } } private suspend fun search(source: BookSource) { if (source.getSearchRule().coverUrl.isNullOrBlank()) { return } val searchBook = WebBook.searchBookAwait( source, name, shouldBreak = { it > 0 }).firstOrNull() ?: return if (searchBook.name == name && searchBook.author == author && !searchBook.coverUrl.isNullOrEmpty() ) { appDb.searchBookDao.insert(searchBook) searchSuccess?.invoke(searchBook) } } fun startOrStopSearch() { if (task == null || !task!!.isActive) { startSearch() } else { stopSearch() } } private fun stopSearch() { task?.cancel() searchPool?.close() searchStateData.postValue(false) } override fun onCleared() { super.onCleared() searchPool?.close() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/changecover/CoverAdapter.kt ================================================ package io.legado.app.ui.book.changecover import android.content.Context import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import io.legado.app.base.adapter.DiffRecyclerAdapter import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.data.entities.SearchBook import io.legado.app.databinding.ItemCoverBinding class CoverAdapter(context: Context, val callBack: CallBack) : DiffRecyclerAdapter(context) { override val diffItemCallback: DiffUtil.ItemCallback get() = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: SearchBook, newItem: SearchBook): Boolean { return oldItem.bookUrl == newItem.bookUrl } override fun areContentsTheSame(oldItem: SearchBook, newItem: SearchBook): Boolean { return oldItem.originName == newItem.originName && oldItem.coverUrl == newItem.coverUrl } } override fun getViewBinding(parent: ViewGroup): ItemCoverBinding { return ItemCoverBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemCoverBinding, item: SearchBook, payloads: MutableList ) = binding.run { ivCover.load(item.coverUrl, item.name, item.author, false, item.origin) tvSource.text = item.originName } override fun registerListener(holder: ItemViewHolder, binding: ItemCoverBinding) { holder.itemView.apply { setOnClickListener { getItem(holder.layoutPosition)?.let { callBack.changeTo(it.coverUrl ?: "") } } } } interface CallBack { fun changeTo(coverUrl: String) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceAdapter.kt ================================================ package io.legado.app.ui.book.changesource import android.content.Context import android.os.Bundle import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.PopupMenu import androidx.core.graphics.drawable.DrawableCompat import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil import io.legado.app.R import io.legado.app.base.adapter.DiffRecyclerAdapter import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.data.entities.SearchBook import io.legado.app.databinding.ItemChangeSourceBinding import io.legado.app.help.config.AppConfig import io.legado.app.lib.dialogs.alert import io.legado.app.utils.getCompatColor import io.legado.app.utils.gone import io.legado.app.utils.invisible import io.legado.app.utils.visible import splitties.init.appCtx import splitties.views.onLongClick class ChangeBookSourceAdapter( context: Context, val viewModel: ChangeBookSourceViewModel, val callBack: CallBack ) : DiffRecyclerAdapter(context) { override val diffItemCallback = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: SearchBook, newItem: SearchBook): Boolean { return oldItem.bookUrl == newItem.bookUrl } override fun areContentsTheSame(oldItem: SearchBook, newItem: SearchBook): Boolean { return oldItem.originName == newItem.originName && oldItem.getDisplayLastChapterTitle() == newItem.getDisplayLastChapterTitle() && oldItem.chapterWordCountText == newItem.chapterWordCountText && oldItem.respondTime == newItem.respondTime } } override fun getViewBinding(parent: ViewGroup): ItemChangeSourceBinding { return ItemChangeSourceBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemChangeSourceBinding, item: SearchBook, payloads: MutableList ) { binding.apply { if (payloads.isEmpty()) { tvOrigin.text = item.originName tvAuthor.text = item.author tvLast.text = item.getDisplayLastChapterTitle() tvCurrentChapterWordCount.text = item.chapterWordCountText tvRespondTime.text = context.getString(R.string.respondTime, item.respondTime) if (callBack.oldBookUrl == item.bookUrl) { ivChecked.visible() } else { ivChecked.invisible() } } else { for (i in payloads.indices) { val bundle = payloads[i] as Bundle bundle.keySet().forEach { when (it) { "name" -> tvOrigin.text = item.originName "latest" -> tvLast.text = item.getDisplayLastChapterTitle() "upCurSource" -> if (callBack.oldBookUrl == item.bookUrl) { ivChecked.visible() } else { ivChecked.invisible() } } } } } val score = callBack.getBookScore(item) if (score > 0) { binding.ivBad.gone() binding.ivGood.visible() DrawableCompat.setTint( binding.ivGood.drawable, appCtx.getCompatColor(R.color.md_red_A200) ) DrawableCompat.setTint( binding.ivBad.drawable, appCtx.getCompatColor(R.color.md_blue_100) ) } else if (score < 0) { binding.ivGood.gone() binding.ivBad.visible() DrawableCompat.setTint( binding.ivGood.drawable, appCtx.getCompatColor(R.color.md_red_100) ) DrawableCompat.setTint( binding.ivBad.drawable, appCtx.getCompatColor(R.color.md_blue_A200) ) } else { binding.ivGood.visible() binding.ivBad.visible() DrawableCompat.setTint( binding.ivGood.drawable, appCtx.getCompatColor(R.color.md_red_100) ) DrawableCompat.setTint( binding.ivBad.drawable, appCtx.getCompatColor(R.color.md_blue_100) ) } if (AppConfig.changeSourceLoadWordCount && !item.chapterWordCountText.isNullOrBlank()) { tvCurrentChapterWordCount.visible() } else { tvCurrentChapterWordCount.gone() } if (AppConfig.changeSourceLoadWordCount && item.respondTime >= 0) { tvRespondTime.visible() } else { tvRespondTime.gone() } } } override fun registerListener(holder: ItemViewHolder, binding: ItemChangeSourceBinding) { binding.ivGood.setOnClickListener { if (binding.ivBad.isVisible) { DrawableCompat.setTint( binding.ivGood.drawable, appCtx.getCompatColor(R.color.md_red_A200) ) binding.ivBad.gone() getItem(holder.layoutPosition)?.let { callBack.setBookScore(it, 1) } } else { DrawableCompat.setTint( binding.ivGood.drawable, appCtx.getCompatColor(R.color.md_red_100) ) binding.ivBad.visible() getItem(holder.layoutPosition)?.let { callBack.setBookScore(it, 0) } } } binding.ivBad.setOnClickListener { if (binding.ivGood.isVisible) { DrawableCompat.setTint( binding.ivBad.drawable, appCtx.getCompatColor(R.color.md_blue_A200) ) binding.ivGood.gone() getItem(holder.layoutPosition)?.let { callBack.setBookScore(it, -1) } } else { DrawableCompat.setTint( binding.ivBad.drawable, appCtx.getCompatColor(R.color.md_blue_100) ) binding.ivGood.visible() getItem(holder.layoutPosition)?.let { callBack.setBookScore(it, 0) } } } holder.itemView.setOnClickListener { getItem(holder.layoutPosition)?.let { if (it.bookUrl != callBack.oldBookUrl) { callBack.changeTo(it) } } } holder.itemView.onLongClick { showMenu(holder.itemView, getItem(holder.layoutPosition)) } } private fun showMenu(view: View, searchBook: SearchBook?) { searchBook ?: return val popupMenu = PopupMenu(context, view) popupMenu.inflate(R.menu.change_source_item) popupMenu.setOnMenuItemClickListener { when (it.itemId) { R.id.menu_top_source -> { callBack.topSource(searchBook) } R.id.menu_bottom_source -> { callBack.bottomSource(searchBook) } R.id.menu_edit_source -> { callBack.editSource(searchBook) } R.id.menu_disable_source -> { callBack.disableSource(searchBook) } R.id.menu_delete_source -> context.alert(R.string.draw) { setMessage(context.getString(R.string.sure_del) + "\n" + searchBook.originName) noButton() yesButton { callBack.deleteSource(searchBook) updateItems(0, itemCount, listOf()) } } } true } popupMenu.show() } interface CallBack { val oldBookUrl: String? fun changeTo(searchBook: SearchBook) fun topSource(searchBook: SearchBook) fun bottomSource(searchBook: SearchBook) fun editSource(searchBook: SearchBook) fun disableSource(searchBook: SearchBook) fun deleteSource(searchBook: SearchBook) fun setBookScore(searchBook: SearchBook, score: Int) fun getBookScore(searchBook: SearchBook): Int } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceDialog.kt ================================================ package io.legado.app.ui.book.changesource import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.ImageButton import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.Toolbar import androidx.core.os.bundleOf import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle.State.STARTED import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.constant.AppLog import io.legado.app.constant.EventBus import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.SearchBook import io.legado.app.databinding.DialogBookChangeSourceBinding import io.legado.app.help.config.AppConfig import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.elevation import io.legado.app.lib.theme.getPrimaryTextColor import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.book.read.ReadBookActivity import io.legado.app.ui.book.source.edit.BookSourceEditActivity import io.legado.app.ui.book.source.manage.BookSourceActivity import io.legado.app.ui.widget.dialog.WaitDialog import io.legado.app.ui.widget.recycler.VerticalDivider import io.legado.app.utils.ColorUtils import io.legado.app.utils.StartActivityContract import io.legado.app.utils.applyTint import io.legado.app.utils.dpToPx import io.legado.app.utils.getCompatDrawable import io.legado.app.utils.observeEvent import io.legado.app.utils.setLayout import io.legado.app.utils.startActivity import io.legado.app.utils.transaction import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.delay import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch /** * 换源界面 */ class ChangeBookSourceDialog() : BaseDialogFragment(R.layout.dialog_book_change_source), Toolbar.OnMenuItemClickListener, ChangeBookSourceAdapter.CallBack { constructor(name: String, author: String) : this() { arguments = Bundle().apply { putString("name", name) putString("author", author) } } private val binding by viewBinding(DialogBookChangeSourceBinding::bind) private val groups = linkedSetOf() private val callBack: CallBack? get() = activity as? CallBack private val viewModel: ChangeBookSourceViewModel by viewModels() private val waitDialog by lazy { WaitDialog(requireContext()) } private val adapter by lazy { ChangeBookSourceAdapter(requireContext(), viewModel, this) } private val editSourceResult = registerForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) { val origin = it.data?.getStringExtra("origin") ?: return@registerForActivityResult viewModel.startSearch(origin) } private val searchFinishCallback: (isEmpty: Boolean) -> Unit = { if (it) { val searchGroup = AppConfig.searchGroup if (searchGroup.isNotEmpty()) { lifecycleScope.launch { context?.alert("搜索结果为空") { setMessage("${searchGroup}分组搜索结果为空,是否切换到全部分组") cancelButton() okButton { AppConfig.searchGroup = "" upGroupMenuName() viewModel.startSearch() } } } } } } override fun onStart() { super.onStart() setLayout(1f, ViewGroup.LayoutParams.MATCH_PARENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) viewModel.initData(arguments, callBack?.oldBook, activity is ReadBookActivity) showTitle() initMenu() initRecyclerView() initNavigationView() initSearchView() initBottomBar() initLiveData() viewModel.searchFinishCallback = searchFinishCallback } override fun onDestroy() { super.onDestroy() viewModel.searchFinishCallback = null } private fun showTitle() { binding.toolBar.title = viewModel.name binding.toolBar.subtitle = viewModel.author binding.toolBar.navigationIcon = getCompatDrawable(androidx.appcompat.R.drawable.abc_ic_ab_back_material) binding.toolBar.elevation = requireContext().elevation } private fun initMenu() { binding.toolBar.inflateMenu(R.menu.change_source) binding.toolBar.menu.applyTint(requireContext()) binding.toolBar.setOnMenuItemClickListener(this) binding.toolBar.menu.findItem(R.id.menu_check_author) ?.isChecked = AppConfig.changeSourceCheckAuthor binding.toolBar.menu.findItem(R.id.menu_load_info) ?.isChecked = AppConfig.changeSourceLoadInfo binding.toolBar.menu.findItem(R.id.menu_load_toc) ?.isChecked = AppConfig.changeSourceLoadToc binding.toolBar.menu.findItem(R.id.menu_load_word_count) ?.isChecked = AppConfig.changeSourceLoadWordCount } private fun initRecyclerView() { binding.recyclerView.addItemDecoration(VerticalDivider(requireContext())) binding.recyclerView.adapter = adapter adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { if (positionStart == 0) { binding.recyclerView.scrollToPosition(0) } } override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) { if (toPosition == 0) { binding.recyclerView.scrollToPosition(0) } } }) } private fun initSearchView() { val searchView = binding.toolBar.menu.findItem(R.id.menu_screen).actionView as SearchView searchView.setOnCloseListener { showTitle() false } searchView.setOnSearchClickListener { binding.toolBar.title = "" binding.toolBar.subtitle = "" binding.toolBar.navigationIcon = null } searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { return false } override fun onQueryTextChange(newText: String?): Boolean { viewModel.screen(newText) return false } }) } private fun initNavigationView() { binding.toolBar.navigationIcon = getCompatDrawable(androidx.appcompat.R.drawable.abc_ic_ab_back_material) binding.toolBar.setNavigationContentDescription( androidx.appcompat.R.string.abc_action_bar_up_description ) binding.toolBar.setNavigationOnClickListener { dismissAllowingStateLoss() } kotlin.runCatching { val mNavButtonViewField = Toolbar::class.java.getDeclaredField("mNavButtonView") mNavButtonViewField.isAccessible = true val navigationView = mNavButtonViewField.get(binding.toolBar) as ImageButton val isLight = ColorUtils.isColorLight(primaryColor) val textColor = requireContext().getPrimaryTextColor(isLight) navigationView.setColorFilter(textColor) } } private fun initBottomBar() { binding.tvDur.text = callBack?.oldBook?.originName binding.tvDur.setOnClickListener { scrollToDurSource() } binding.ivTop.setOnClickListener { binding.recyclerView.scrollToPosition(0) } binding.ivBottom.setOnClickListener { binding.recyclerView.scrollToPosition(adapter.itemCount - 1) } } private fun initLiveData() { viewModel.searchStateData.observe(viewLifecycleOwner) { binding.refreshProgressBar.isAutoLoading = it if (it) { startStopMenuItem?.let { item -> item.setIcon(R.drawable.ic_stop_black_24dp) item.setTitle(R.string.stop) } } else { startStopMenuItem?.let { item -> item.setIcon(R.drawable.ic_refresh_black_24dp) item.setTitle(R.string.refresh) } } binding.toolBar.menu.applyTint(requireContext()) } lifecycleScope.launch { lifecycle.currentStateFlow.first { it.isAtLeast(STARTED) } viewModel.searchDataFlow.conflate().collect { adapter.setItems(it) delay(1000) } } lifecycleScope.launch { repeatOnLifecycle(STARTED) { viewModel.changeSourceProgress .drop(1) .collect { (count, name) -> binding.tvDur.text = getString( R.string.change_source_progress, adapter.itemCount, count, viewModel.totalSourceCount, name ) delay(500) } } } lifecycleScope.launch { appDb.bookSourceDao.flowEnabledGroups().conflate().collect { groups.clear() groups.addAll(it) upGroupMenu() } } } private val startStopMenuItem: MenuItem? get() = binding.toolBar.menu.findItem(R.id.menu_start_stop) override fun onMenuItemClick(item: MenuItem?): Boolean { when (item?.itemId) { R.id.menu_check_author -> { AppConfig.changeSourceCheckAuthor = !item.isChecked item.isChecked = !item.isChecked viewModel.refresh() } R.id.menu_load_info -> { AppConfig.changeSourceLoadInfo = !item.isChecked item.isChecked = !item.isChecked } R.id.menu_load_toc -> { AppConfig.changeSourceLoadToc = !item.isChecked item.isChecked = !item.isChecked } R.id.menu_load_word_count -> { AppConfig.changeSourceLoadWordCount = !item.isChecked item.isChecked = !item.isChecked viewModel.onLoadWordCountChecked(item.isChecked) } R.id.menu_start_stop -> viewModel.startOrStopSearch() R.id.menu_source_manage -> startActivity() R.id.menu_close -> dismissAllowingStateLoss() R.id.menu_refresh_list -> viewModel.startRefreshList() else -> if (item?.groupId == R.id.source_group && !item.isChecked) { item.isChecked = true if (item.title.toString() == getString(R.string.all_source)) { AppConfig.searchGroup = "" } else { AppConfig.searchGroup = item.title.toString() } upGroupMenuName() lifecycleScope.launch(IO) { viewModel.stopSearch() if (viewModel.refresh()) { viewModel.startSearch() } } } } return false } private fun scrollToDurSource() { adapter.getItems().forEachIndexed { index, searchBook -> if (searchBook.bookUrl == oldBookUrl) { (binding.recyclerView.layoutManager as LinearLayoutManager) .scrollToPositionWithOffset(index, 60.dpToPx()) return } } } override fun changeTo(searchBook: SearchBook) { val oldBookType = callBack?.oldBook?.type ?: 0 if (searchBook.sameBookTypeLocal(oldBookType)) { changeSource(searchBook) { dismissAllowingStateLoss() } } else { alert( titleResource = R.string.book_type_different, messageResource = R.string.soure_change_source ) { okButton { changeSource(searchBook) { dismissAllowingStateLoss() } } cancelButton() } } } override val oldBookUrl: String? get() = callBack?.oldBook?.bookUrl override fun topSource(searchBook: SearchBook) { viewModel.topSource(searchBook) } override fun bottomSource(searchBook: SearchBook) { viewModel.bottomSource(searchBook) } override fun editSource(searchBook: SearchBook) { editSourceResult.launch { putExtra("sourceUrl", searchBook.origin) } } override fun disableSource(searchBook: SearchBook) { viewModel.disableSource(searchBook) } override fun deleteSource(searchBook: SearchBook) { viewModel.del(searchBook) if (oldBookUrl == searchBook.bookUrl) { viewModel.autoChangeSource(callBack?.oldBook?.type) { book, toc, source -> callBack?.changeTo(source, book, toc) } } } override fun setBookScore(searchBook: SearchBook, score: Int) { viewModel.setBookScore(searchBook, score) } override fun getBookScore(searchBook: SearchBook): Int { return viewModel.getBookScore(searchBook) } private fun changeSource(searchBook: SearchBook, onSuccess: (() -> Unit)? = null) { waitDialog.setText(R.string.load_toc) waitDialog.show() val book = viewModel.bookMap[searchBook.primaryStr()] ?: searchBook.toBook() val coroutine = viewModel.getToc(book, { toc, source -> waitDialog.dismiss() callBack?.changeTo(source, book, toc) onSuccess?.invoke() }, { waitDialog.dismiss() AppLog.put("换源获取目录出错\n$it", it, true) }) waitDialog.setOnCancelListener { coroutine.cancel() } } /** * 更新分组菜单 */ private fun upGroupMenu() { binding.toolBar.menu.findItem(R.id.menu_group)?.run { subMenu?.transaction { menu -> val selectedGroup = AppConfig.searchGroup menu.removeGroup(R.id.source_group) val allItem = menu.add(R.id.source_group, Menu.NONE, Menu.NONE, R.string.all_source) var hasSelectedGroup = false groups.forEach { group -> menu.add(R.id.source_group, Menu.NONE, Menu.NONE, group)?.let { if (group == selectedGroup) { it.isChecked = true hasSelectedGroup = true } } } menu.setGroupCheckable(R.id.source_group, true, true) if (hasSelectedGroup) { title = getString(R.string.group) + "(" + AppConfig.searchGroup + ")" } else { allItem.isChecked = true title = getString(R.string.group) } } } } /** * 更新分组菜单名 */ private fun upGroupMenuName() { val menuGroup = binding.toolBar.menu.findItem(R.id.menu_group) val searchGroup = AppConfig.searchGroup if (searchGroup.isEmpty()) { menuGroup?.title = getString(R.string.group) } else { menuGroup?.title = getString(R.string.group) + "($searchGroup)" } } override fun observeLiveBus() { observeEvent(EventBus.SOURCE_CHANGED) { adapter.notifyItemRangeChanged( 0, adapter.itemCount, bundleOf(Pair("upCurSource", oldBookUrl)) ) } } interface CallBack { val oldBook: Book? fun changeTo(source: BookSource, book: Book, toc: List) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt ================================================ package io.legado.app.ui.book.changesource import android.app.Application import android.os.Bundle import androidx.annotation.CallSuper import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog import io.legado.app.constant.AppPattern import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.SearchBook import io.legado.app.exception.NoStackTraceException import io.legado.app.help.book.BookHelp import io.legado.app.help.book.ContentProcessor import io.legado.app.help.book.primaryStr import io.legado.app.help.book.releaseHtmlData import io.legado.app.help.config.AppConfig import io.legado.app.help.config.SourceConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.source.SourceHelp import io.legado.app.model.webBook.WebBook import io.legado.app.utils.internString import io.legado.app.utils.mapParallel import io.legado.app.utils.mapParallelSafe import io.legado.app.utils.onEachIndexed import io.legado.app.utils.toastOnUi import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.ExecutorCoroutineDispatcher import kotlinx.coroutines.Job import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import java.util.Collections import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executors import kotlin.math.min @Suppress("MemberVisibilityCanBePrivate") open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(application) { private val threadCount = AppConfig.threadCount private var searchPool: ExecutorCoroutineDispatcher? = null val searchStateData = MutableLiveData() var searchFinishCallback: ((isEmpty: Boolean) -> Unit)? = null var name: String = "" var author: String = "" private var fromReadBookActivity = false private var oldBook: Book? = null private var screenKey: String = "" private var bookSourceParts = arrayListOf() val totalSourceCount: Int get() = bookSourceParts.size private var searchBookList = arrayListOf() private val searchBooks = Collections.synchronizedList(arrayListOf()) private val tocMap = ConcurrentHashMap>() private val _changeSourceProgress = MutableStateFlow(0 to "") val changeSourceProgress = _changeSourceProgress.asStateFlow() private var tocMapChapterCount = 0 private val contentProcessor by lazy { ContentProcessor.get(oldBook!!) } private var searchCallback: SourceCallback? = null private val chapterNumRegex = "^\\[(\\d+)]".toRegex() private val comparatorBase by lazy { compareByDescending { getBookScore(it) } .thenByDescending { SourceConfig.getSourceScore(it.origin) } } private val defaultComparator by lazy { comparatorBase.thenBy { it.originOrder } } private val wordCountComparator by lazy { comparatorBase.thenByDescending { it.chapterWordCount > 1000 } .thenByDescending { getChapterNum(it.chapterWordCountText) } .thenByDescending { it.chapterWordCount } .thenBy { it.originOrder } } private var task: Job? = null val bookMap = ConcurrentHashMap() val searchDataFlow = callbackFlow { searchCallback = object : SourceCallback { override fun searchSuccess(searchBook: SearchBook) { searchBook.releaseHtmlData() appDb.searchBookDao.insert(searchBook) when { screenKey.isEmpty() -> searchBooks.add(searchBook) searchBook.name.contains(screenKey) -> searchBooks.add(searchBook) else -> return } trySend(arrayOf(searchBooks)) } override fun upAdapter() { trySend(arrayOf(searchBooks)) } } getDbSearchBooks().let { searchBooks.clear() searchBooks.addAll(it) trySend(arrayOf(searchBooks)) } if (searchBooks.isEmpty()) { startSearch() } awaitClose { searchCallback = null } }.map { kotlin.runCatching { val comparator = if (AppConfig.changeSourceLoadWordCount) { wordCountComparator } else { defaultComparator } searchBooks.sortedWith(comparator) }.onFailure { AppLog.put("换源排序出错\n${it.localizedMessage}", it) }.getOrDefault(searchBooks) }.flowOn(IO) override fun onCleared() { super.onCleared() searchPool?.close() } @CallSuper open fun initData(arguments: Bundle?, book: Book?, fromReadBookActivity: Boolean) { arguments?.let { bundle -> bundle.getString("name")?.let { name = it } bundle.getString("author")?.let { author = it.replace(AppPattern.authorRegex, "") } this.fromReadBookActivity = fromReadBookActivity oldBook = book } } private fun initSearchPool() { searchPool = Executors .newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher() } fun refresh(): Boolean { getDbSearchBooks().let { searchBooks.clear() searchBooks.addAll(it) searchCallback?.upAdapter() } return searchBooks.isEmpty() } /** * 搜索书籍 */ fun startSearch() { execute { stopSearch() if (searchBooks.isNotEmpty()) { appDb.searchBookDao.delete(*searchBooks.toTypedArray()) searchBooks.clear() } searchCallback?.upAdapter() bookSourceParts.clear() tocMap.clear() bookMap.clear() tocMapChapterCount = 0 _changeSourceProgress.value = 0 to "" val searchGroup = AppConfig.searchGroup if (searchGroup.isBlank()) { bookSourceParts.addAll(appDb.bookSourceDao.allEnabledPart) } else { val sources = appDb.bookSourceDao.getEnabledPartByGroup(searchGroup) if (sources.isEmpty()) { AppConfig.searchGroup = "" bookSourceParts.addAll(appDb.bookSourceDao.allEnabledPart) } else { bookSourceParts.addAll(sources) } } initSearchPool() search() } } fun startSearch(origin: String) { execute { stopSearch() bookSourceParts.clear() tocMap.clear() bookMap.clear() tocMapChapterCount = 0 bookSourceParts.add(appDb.bookSourceDao.getBookSourcePart(origin)!!) searchBooks.removeIf { it.origin == origin } initSearchPool() search() } } private fun search() { task = viewModelScope.launch(searchPool!!) { flow { for (bs in bookSourceParts) { bs.getBookSource()?.let { emit(it) } } }.onStart { searchStateData.postValue(true) }.mapParallel(threadCount) { try { withTimeout(60000L) { search(it) } } catch (_: Throwable) { currentCoroutineContext().ensureActive() } it }.onEachIndexed { index, value -> _changeSourceProgress.update { _ -> index + 1 to value.bookSourceName } }.onCompletion { ensureActive() searchStateData.postValue(false) searchFinishCallback?.invoke(searchBooks.isEmpty()) }.catch { AppLog.put("换源搜索出错\n${it.localizedMessage}", it) }.collect() } } private suspend fun search(source: BookSource) { val checkAuthor = AppConfig.changeSourceCheckAuthor val loadInfo = AppConfig.changeSourceLoadInfo val loadToc = AppConfig.changeSourceLoadToc val loadWordCount = AppConfig.changeSourceLoadWordCount val resultBooks = WebBook.searchBookAwait( source, name, filter = { fName, fAuthor -> fName == name && (!checkAuthor || fAuthor.contains(author)) }) resultBooks.forEach { searchBook -> when { loadInfo || loadToc || loadWordCount -> { loadBookInfo(source, searchBook.toBook()) } else -> { searchCallback?.searchSuccess(searchBook) } } } } private suspend fun loadBookInfo(source: BookSource, book: Book) { if (book.tocUrl.isEmpty()) { WebBook.getBookInfoAwait(source, book) } if (AppConfig.changeSourceLoadToc || AppConfig.changeSourceLoadWordCount) { loadBookToc(source, book) } else { //从详情页里获取最新章节 val searchBook = book.toSearchBook() searchCallback?.searchSuccess(searchBook) } } private suspend fun loadBookToc(source: BookSource, book: Book) { val chapters = WebBook.getChapterListAwait(source, book).getOrThrow() for (chapter in chapters) { chapter.internString() } if (tocMapChapterCount < 30000) { tocMapChapterCount += chapters.size tocMap[book.primaryStr()] = chapters } bookMap[book.primaryStr()] = book book.releaseHtmlData() if (AppConfig.changeSourceLoadWordCount) { loadBookWordCount(source, book, chapters) } else { val searchBook = book.toSearchBook() searchCallback?.searchSuccess(searchBook) } } private suspend fun loadBookWordCount( source: BookSource, book: Book, chapters: List ) = coroutineScope { val chapterIndex = if (fromReadBookActivity) { BookHelp.getDurChapter(oldBook!!, chapters) } else { chapters.lastIndex } val bookChapter = chapters[chapterIndex] var title = bookChapter.title.trim() if (title.length > 20) { title = title.substring(0, 20) + "…" } val startTime = System.currentTimeMillis() val pair = try { val nextChapterUrl = chapters.getOrNull(chapterIndex + 1)?.url var content = WebBook.getContentAwait(source, book, bookChapter, nextChapterUrl, false) content = contentProcessor.getContent(oldBook!!, bookChapter, content, false).toString() val len = content.length len to "[${chapterIndex + 1}] ${title}\n字数:${len}" } catch (t: Throwable) { if (t is CancellationException) throw t -1 to "[${chapterIndex + 1}] ${title}\n获取字数失败:${t.localizedMessage}" } val endTime = System.currentTimeMillis() val searchBook = book.toSearchBook().apply { chapterWordCountText = pair.second chapterWordCount = pair.first respondTime = (endTime - startTime).toInt() } searchCallback?.searchSuccess(searchBook) } fun onLoadWordCountChecked(isChecked: Boolean) { if (isChecked) { startRefreshList(true) } } /** * 刷新列表 */ fun startRefreshList(onlyRefreshNoWordCountBook: Boolean = false) { execute { stopSearch() searchBookList.clear() if (onlyRefreshNoWordCountBook) { searchBooks.filterTo(searchBookList) { it.chapterWordCountText == null } searchBooks.removeIf { it.chapterWordCountText == null } } else { searchBookList.addAll(searchBooks) searchBooks.clear() } searchCallback?.upAdapter() initSearchPool() refreshList() } } private fun refreshList() { task = viewModelScope.launch(searchPool!!) { flow { for (searchBook in searchBookList) { emit(searchBook) } }.onStart { searchStateData.postValue(true) }.mapParallelSafe(threadCount) { val source = appDb.bookSourceDao.getBookSource(it.origin)!! withTimeout(60000L) { loadBookInfo(source, it.toBook()) } }.onCompletion { searchStateData.postValue(false) }.catch { AppLog.put("换源刷新列表出错\n${it.localizedMessage}", it) }.collect() } } private fun getDbSearchBooks(): List { return if (screenKey.isEmpty()) { if (AppConfig.changeSourceCheckAuthor) { appDb.searchBookDao.changeSourceByGroup( name, author, AppConfig.searchGroup ) } else { appDb.searchBookDao.changeSourceByGroup( name, "", AppConfig.searchGroup ) } } else { if (AppConfig.changeSourceCheckAuthor) { appDb.searchBookDao.changeSourceSearch( name, author, screenKey, AppConfig.searchGroup ) } else { appDb.searchBookDao.changeSourceSearch( name, "", screenKey, AppConfig.searchGroup ) } } } /** * 筛选 */ fun screen(key: String?) { screenKey = key?.trim() ?: "" execute { getDbSearchBooks().let { searchBooks.clear() searchBooks.addAll(it) searchCallback?.upAdapter() } } } fun startOrStopSearch() { if (task == null || !task!!.isActive) { startSearch() } else { stopSearch() } } fun stopSearch() { task?.cancel() searchPool?.close() searchStateData.postValue(false) } fun getToc( book: Book, onSuccess: (toc: List, source: BookSource) -> Unit, onError: (e: Throwable) -> Unit ): Coroutine, BookSource>> { return execute { val toc = tocMap[book.primaryStr()] if (toc != null) { val source = appDb.bookSourceDao.getBookSource(book.origin) return@execute Pair(toc, source!!) } val result = getToc(book).getOrThrow() tocMap[book.primaryStr()] = result.first return@execute result }.onSuccess { onSuccess.invoke(it.first, it.second) }.onError { onError.invoke(it) } } suspend fun getToc(book: Book): Result, BookSource>> { return kotlin.runCatching { val source = appDb.bookSourceDao.getBookSource(book.origin) ?: throw NoStackTraceException("书源不存在") if (book.tocUrl.isEmpty()) { WebBook.getBookInfoAwait(source, book) } val toc = WebBook.getChapterListAwait(source, book).getOrThrow() Pair(toc, source) } } fun disableSource(searchBook: SearchBook) { execute { appDb.bookSourceDao.getBookSource(searchBook.origin)?.let { source -> source.enabled = false appDb.bookSourceDao.update(source) } searchBooks.remove(searchBook) searchCallback?.upAdapter() } } fun topSource(searchBook: SearchBook) { execute { appDb.bookSourceDao.getBookSource(searchBook.origin)?.let { source -> val minOrder = appDb.bookSourceDao.minOrder - 1 source.customOrder = minOrder searchBook.originOrder = source.customOrder appDb.bookSourceDao.update(source) updateSource(searchBook) } searchCallback?.upAdapter() } } fun bottomSource(searchBook: SearchBook) { execute { appDb.bookSourceDao.getBookSource(searchBook.origin)?.let { source -> val maxOrder = appDb.bookSourceDao.maxOrder + 1 source.customOrder = maxOrder searchBook.originOrder = source.customOrder appDb.bookSourceDao.update(source) updateSource(searchBook) } searchCallback?.upAdapter() } } fun updateSource(searchBook: SearchBook) { appDb.searchBookDao.update(searchBook) } fun del(searchBook: SearchBook) { execute { SourceHelp.deleteBookSource(searchBook.origin) appDb.searchBookDao.delete(searchBook) } searchBooks.remove(searchBook) searchCallback?.upAdapter() } fun autoChangeSource( bookType: Int?, onSuccess: (book: Book, toc: List, source: BookSource) -> Unit ) { execute { searchBooks.forEach { if (it.type == bookType) { val book = it.toBook() val result = getToc(book).getOrNull() if (result != null) { return@execute Triple(book, result.first, result.second) } } } throw NoStackTraceException("没有有效源") }.onSuccess { onSuccess.invoke(it.first, it.second, it.third) }.onError { context.toastOnUi("自动换源失败\n${it.localizedMessage}") } } fun setBookScore(searchBook: SearchBook, score: Int) { execute { SourceConfig.setBookScore(searchBook.origin, searchBook.name, searchBook.author, score) searchCallback?.upAdapter() } } fun getBookScore(searchBook: SearchBook): Int { return SourceConfig.getBookScore(searchBook.origin, searchBook.name, searchBook.author) } private fun getChapterNum(wordCountText: String?): Int { wordCountText ?: return -1 return chapterNumRegex.find(wordCountText)?.groupValues?.get(1)?.toIntOrNull() ?: -1 } interface SourceCallback { fun searchSuccess(searchBook: SearchBook) fun upAdapter() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceAdapter.kt ================================================ package io.legado.app.ui.book.changesource import android.content.Context import android.os.Bundle import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.PopupMenu import androidx.core.graphics.drawable.DrawableCompat import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil import io.legado.app.R import io.legado.app.base.adapter.DiffRecyclerAdapter import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.data.entities.SearchBook import io.legado.app.databinding.ItemChangeSourceBinding import io.legado.app.help.config.AppConfig import io.legado.app.utils.getCompatColor import io.legado.app.utils.gone import io.legado.app.utils.invisible import io.legado.app.utils.visible import splitties.init.appCtx import splitties.views.onLongClick class ChangeChapterSourceAdapter( context: Context, val viewModel: ChangeChapterSourceViewModel, val callBack: CallBack ) : DiffRecyclerAdapter(context) { override val diffItemCallback = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: SearchBook, newItem: SearchBook): Boolean { return oldItem.bookUrl == newItem.bookUrl } override fun areContentsTheSame(oldItem: SearchBook, newItem: SearchBook): Boolean { return oldItem.originName == newItem.originName && oldItem.getDisplayLastChapterTitle() == newItem.getDisplayLastChapterTitle() } } override fun getViewBinding(parent: ViewGroup): ItemChangeSourceBinding { return ItemChangeSourceBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemChangeSourceBinding, item: SearchBook, payloads: MutableList ) { binding.apply { if (payloads.isEmpty()) { tvOrigin.text = item.originName tvAuthor.text = item.author tvLast.text = item.getDisplayLastChapterTitle() tvCurrentChapterWordCount.text = item.chapterWordCountText tvRespondTime.text = context.getString(R.string.respondTime, item.respondTime) if (callBack.oldBookUrl == item.bookUrl) { ivChecked.visible() } else { ivChecked.invisible() } } else { for (i in payloads.indices) { val bundle = payloads[i] as Bundle bundle.keySet().forEach { when (it) { "name" -> tvOrigin.text = item.originName "latest" -> tvLast.text = item.getDisplayLastChapterTitle() "upCurSource" -> if (callBack.oldBookUrl == item.bookUrl) { ivChecked.visible() } else { ivChecked.invisible() } } } } } val score = callBack.getBookScore(item) if (score > 0) { binding.ivBad.gone() binding.ivGood.visible() DrawableCompat.setTint(binding.ivGood.drawable, appCtx.getCompatColor(R.color.md_red_A200)) DrawableCompat.setTint(binding.ivBad.drawable, appCtx.getCompatColor(R.color.md_blue_100)) } else if (score < 0) { binding.ivGood.gone() binding.ivBad.visible() DrawableCompat.setTint( binding.ivGood.drawable, appCtx.getCompatColor(R.color.md_red_100) ) DrawableCompat.setTint( binding.ivBad.drawable, appCtx.getCompatColor(R.color.md_blue_A200) ) } else { binding.ivGood.visible() binding.ivBad.visible() DrawableCompat.setTint( binding.ivGood.drawable, appCtx.getCompatColor(R.color.md_red_100) ) DrawableCompat.setTint( binding.ivBad.drawable, appCtx.getCompatColor(R.color.md_blue_100) ) } if (AppConfig.changeSourceLoadWordCount && !item.chapterWordCountText.isNullOrBlank()) { tvCurrentChapterWordCount.visible() } else { tvCurrentChapterWordCount.gone() } if (AppConfig.changeSourceLoadWordCount && item.respondTime >= 0) { tvRespondTime.visible() } else { tvRespondTime.gone() } } } override fun registerListener(holder: ItemViewHolder, binding: ItemChangeSourceBinding) { binding.ivGood.setOnClickListener { if (binding.ivBad.isVisible) { DrawableCompat.setTint(binding.ivGood.drawable, appCtx.getCompatColor(R.color.md_red_A200)) binding.ivBad.gone() getItem(holder.layoutPosition)?.let { callBack.setBookScore(it, 1) } } else { DrawableCompat.setTint(binding.ivGood.drawable, appCtx.getCompatColor(R.color.md_red_100)) binding.ivBad.visible() getItem(holder.layoutPosition)?.let { callBack.setBookScore(it, 0) } } } binding.ivBad.setOnClickListener { if (binding.ivGood.isVisible) { DrawableCompat.setTint(binding.ivBad.drawable, appCtx.getCompatColor(R.color.md_blue_A200)) binding.ivGood.gone() getItem(holder.layoutPosition)?.let { callBack.setBookScore(it, -1) } } else { DrawableCompat.setTint(binding.ivBad.drawable, appCtx.getCompatColor(R.color.md_blue_100)) binding.ivGood.visible() getItem(holder.layoutPosition)?.let { callBack.setBookScore(it, 0) } } } holder.itemView.setOnClickListener { getItem(holder.layoutPosition)?.let { callBack.openToc(it) } } holder.itemView.onLongClick { showMenu(holder.itemView, getItem(holder.layoutPosition)) } } private fun showMenu(view: View, searchBook: SearchBook?) { searchBook ?: return val popupMenu = PopupMenu(context, view) popupMenu.inflate(R.menu.change_source_item) popupMenu.setOnMenuItemClickListener { when (it.itemId) { R.id.menu_top_source -> { callBack.topSource(searchBook) } R.id.menu_bottom_source -> { callBack.bottomSource(searchBook) } R.id.menu_edit_source -> { callBack.editSource(searchBook) } R.id.menu_disable_source -> { callBack.disableSource(searchBook) } R.id.menu_delete_source -> { callBack.deleteSource(searchBook) updateItems(0, itemCount, listOf()) } } true } popupMenu.show() } interface CallBack { val oldBookUrl: String? fun openToc(searchBook: SearchBook) fun topSource(searchBook: SearchBook) fun bottomSource(searchBook: SearchBook) fun editSource(searchBook: SearchBook) fun disableSource(searchBook: SearchBook) fun deleteSource(searchBook: SearchBook) fun setBookScore(searchBook: SearchBook, score: Int) fun getBookScore(searchBook: SearchBook): Int } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceDialog.kt ================================================ package io.legado.app.ui.book.changesource import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.activity.addCallback import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.Toolbar import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle.State.STARTED import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.constant.AppLog import io.legado.app.constant.EventBus import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.SearchBook import io.legado.app.databinding.DialogChapterChangeSourceBinding import io.legado.app.help.book.BookHelp import io.legado.app.help.config.AppConfig import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.elevation import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.book.read.ReadBookActivity import io.legado.app.ui.book.source.edit.BookSourceEditActivity import io.legado.app.ui.book.source.manage.BookSourceActivity import io.legado.app.ui.widget.recycler.VerticalDivider import io.legado.app.utils.StartActivityContract import io.legado.app.utils.applyTint import io.legado.app.utils.dpToPx import io.legado.app.utils.gone import io.legado.app.utils.observeEvent import io.legado.app.utils.setLayout import io.legado.app.utils.startActivity import io.legado.app.utils.toastOnUi import io.legado.app.utils.transaction import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.delay import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch class ChangeChapterSourceDialog() : BaseDialogFragment(R.layout.dialog_chapter_change_source), Toolbar.OnMenuItemClickListener, ChangeChapterSourceAdapter.CallBack, ChangeChapterTocAdapter.Callback { constructor(name: String, author: String, chapterIndex: Int, chapterTitle: String) : this() { arguments = Bundle().apply { putString("name", name) putString("author", author) putInt("chapterIndex", chapterIndex) putString("chapterTitle", chapterTitle) } } private val binding by viewBinding(DialogChapterChangeSourceBinding::bind) private val groups = linkedSetOf() private val callBack: CallBack? get() = activity as? CallBack private val viewModel: ChangeChapterSourceViewModel by viewModels() private val editSourceResult = registerForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) { viewModel.startSearch() } private val searchBookAdapter by lazy { ChangeChapterSourceAdapter(requireContext(), viewModel, this) } private val tocAdapter by lazy { ChangeChapterTocAdapter(requireContext(), this) } private val contentSuccess: (content: String) -> Unit = { binding.loadingToc.gone() callBack?.replaceContent(it) dismissAllowingStateLoss() } private var searchBook: SearchBook? = null private val searchFinishCallback: (isEmpty: Boolean) -> Unit = { if (it) { val searchGroup = AppConfig.searchGroup if (searchGroup.isNotEmpty()) { lifecycleScope.launch { context?.alert("搜索结果为空") { setMessage("${searchGroup}分组搜索结果为空,是否切换到全部分组") noButton() yesButton { AppConfig.searchGroup = "" upGroupMenu() viewModel.startSearch() } } } } } } override fun onStart() { super.onStart() setLayout(1f, ViewGroup.LayoutParams.MATCH_PARENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) viewModel.initData(arguments, callBack?.oldBook, activity is ReadBookActivity) showTitle() initMenu() initView() initRecyclerView() initSearchView() initBottomBar() initLiveData() viewModel.searchFinishCallback = searchFinishCallback activity?.onBackPressedDispatcher?.addCallback(this) { if (binding.clToc.isVisible) { binding.clToc.gone() return@addCallback } dismissAllowingStateLoss() } } override fun onDestroy() { super.onDestroy() viewModel.searchFinishCallback = null } private fun showTitle() { binding.toolBar.title = viewModel.chapterTitle } private fun initMenu() { binding.toolBar.inflateMenu(R.menu.change_source) binding.toolBar.menu.applyTint(requireContext()) binding.toolBar.setOnMenuItemClickListener(this) binding.toolBar.menu.findItem(R.id.menu_check_author) ?.isChecked = AppConfig.changeSourceCheckAuthor binding.toolBar.menu.findItem(R.id.menu_load_info) ?.isChecked = AppConfig.changeSourceLoadInfo binding.toolBar.menu.findItem(R.id.menu_load_toc) ?.isChecked = AppConfig.changeSourceLoadToc binding.toolBar.menu.findItem(R.id.menu_load_word_count) ?.isChecked = AppConfig.changeSourceLoadWordCount } private fun initView() { binding.ivHideToc.setOnClickListener { binding.clToc.gone() } binding.flHideToc.elevation = requireContext().elevation } private fun initRecyclerView() { binding.recyclerView.addItemDecoration(VerticalDivider(requireContext())) binding.recyclerView.adapter = searchBookAdapter searchBookAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { if (positionStart == 0) { binding.recyclerView.scrollToPosition(0) } } override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) { if (toPosition == 0) { binding.recyclerView.scrollToPosition(0) } } }) binding.recyclerViewToc.adapter = tocAdapter } private fun initSearchView() { val searchView = binding.toolBar.menu.findItem(R.id.menu_screen).actionView as SearchView searchView.setOnCloseListener { showTitle() false } searchView.setOnSearchClickListener { binding.toolBar.title = "" binding.toolBar.subtitle = "" } searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { return false } override fun onQueryTextChange(newText: String?): Boolean { viewModel.screen(newText) return false } }) } private fun initBottomBar() { binding.tvDur.text = callBack?.oldBook?.originName binding.tvDur.setOnClickListener { scrollToDurSource() } binding.ivTop.setOnClickListener { binding.recyclerView.scrollToPosition(0) } binding.ivBottom.setOnClickListener { binding.recyclerView.scrollToPosition(searchBookAdapter.itemCount - 1) } } private fun initLiveData() { viewModel.searchStateData.observe(viewLifecycleOwner) { binding.refreshProgressBar.isAutoLoading = it if (it) { startStopMenuItem?.let { item -> item.setIcon(R.drawable.ic_stop_black_24dp) item.setTitle(R.string.stop) } } else { startStopMenuItem?.let { item -> item.setIcon(R.drawable.ic_refresh_black_24dp) item.setTitle(R.string.refresh) } } binding.toolBar.menu.applyTint(requireContext()) } lifecycleScope.launch { lifecycle.currentStateFlow.first { it.isAtLeast(STARTED) } viewModel.searchDataFlow.conflate().collect { searchBookAdapter.setItems(it) delay(1000) } } lifecycleScope.launch { appDb.bookSourceDao.flowEnabledGroups().conflate().collect { groups.clear() groups.addAll(it) upGroupMenu() } } } private val startStopMenuItem: MenuItem? get() = binding.toolBar.menu.findItem(R.id.menu_start_stop) override fun onMenuItemClick(item: MenuItem?): Boolean { when (item?.itemId) { R.id.menu_check_author -> { AppConfig.changeSourceCheckAuthor = !item.isChecked item.isChecked = !item.isChecked viewModel.refresh() } R.id.menu_load_info -> { AppConfig.changeSourceLoadInfo = !item.isChecked item.isChecked = !item.isChecked } R.id.menu_load_toc -> { AppConfig.changeSourceLoadToc = !item.isChecked item.isChecked = !item.isChecked } R.id.menu_load_word_count -> { AppConfig.changeSourceLoadWordCount = !item.isChecked item.isChecked = !item.isChecked viewModel.onLoadWordCountChecked(item.isChecked) } R.id.menu_start_stop -> viewModel.startOrStopSearch() R.id.menu_source_manage -> startActivity() else -> if (item?.groupId == R.id.source_group && !item.isChecked) { item.isChecked = true if (item.title.toString() == getString(R.string.all_source)) { AppConfig.searchGroup = "" } else { AppConfig.searchGroup = item.title.toString() } lifecycleScope.launch(IO) { viewModel.stopSearch() if (viewModel.refresh()) { viewModel.startSearch() } } } } return false } private fun scrollToDurSource() { searchBookAdapter.getItems().forEachIndexed { index, searchBook -> if (searchBook.bookUrl == oldBookUrl) { (binding.recyclerView.layoutManager as LinearLayoutManager) .scrollToPositionWithOffset(index, 60.dpToPx()) return } } } override fun openToc(searchBook: SearchBook) { this.searchBook = searchBook tocAdapter.setItems(null) binding.clToc.visible() binding.loadingToc.visible() val book = searchBook.toBook() viewModel.getToc(book, { toc: List, _: BookSource -> tocAdapter.durChapterIndex = BookHelp.getDurChapter(viewModel.chapterIndex, viewModel.chapterTitle, toc) binding.loadingToc.gone() tocAdapter.setItems(toc) binding.recyclerViewToc.scrollToPosition(tocAdapter.durChapterIndex - 5) }, { binding.clToc.gone() AppLog.put("单章换源获取目录出错\n$it", it, true) }) } override val oldBookUrl: String? get() = callBack?.oldBook?.bookUrl override fun topSource(searchBook: SearchBook) { viewModel.topSource(searchBook) } override fun bottomSource(searchBook: SearchBook) { viewModel.bottomSource(searchBook) } override fun editSource(searchBook: SearchBook) { editSourceResult.launch { putExtra("sourceUrl", searchBook.origin) } } override fun disableSource(searchBook: SearchBook) { viewModel.disableSource(searchBook) } override fun deleteSource(searchBook: SearchBook) { viewModel.del(searchBook) if (oldBookUrl == searchBook.bookUrl) { viewModel.autoChangeSource(callBack?.oldBook?.type) { book, toc, source -> callBack?.changeTo(source, book, toc) } } } override fun setBookScore(searchBook: SearchBook, score: Int) { viewModel.setBookScore(searchBook, score) } override fun getBookScore(searchBook: SearchBook): Int { return viewModel.getBookScore(searchBook) } override fun clickChapter(bookChapter: BookChapter, nextChapterUrl: String?) { searchBook?.let { binding.loadingToc.visible() viewModel.getContent(it.toBook(), bookChapter, nextChapterUrl, contentSuccess) { msg -> binding.loadingToc.gone() binding.clToc.gone() toastOnUi(msg) } } } /** * 更新分组菜单 */ private fun upGroupMenu() { binding.toolBar.menu.findItem(R.id.menu_group)?.subMenu?.transaction { menu -> val selectedGroup = AppConfig.searchGroup menu.removeGroup(R.id.source_group) val allItem = menu.add(R.id.source_group, Menu.NONE, Menu.NONE, R.string.all_source) var hasSelectedGroup = false groups.forEach { group -> menu.add(R.id.source_group, Menu.NONE, Menu.NONE, group)?.let { if (group == selectedGroup) { it.isChecked = true hasSelectedGroup = true } } } menu.setGroupCheckable(R.id.source_group, true, true) if (!hasSelectedGroup) { allItem.isChecked = true } } } override fun observeLiveBus() { observeEvent(EventBus.SOURCE_CHANGED) { searchBookAdapter.notifyItemRangeChanged( 0, searchBookAdapter.itemCount, bundleOf(Pair("upCurSource", oldBookUrl)) ) } } interface CallBack { val oldBook: Book? fun changeTo(source: BookSource, book: Book, toc: List) fun replaceContent(content: String) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceViewModel.kt ================================================ package io.legado.app.ui.book.changesource import android.app.Application import android.os.Bundle import io.legado.app.data.appDb 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.webBook.WebBook @Suppress("MemberVisibilityCanBePrivate") class ChangeChapterSourceViewModel(application: Application) : ChangeBookSourceViewModel(application) { var chapterIndex: Int = 0 var chapterTitle: String = "" override fun initData(arguments: Bundle?, book: Book?, fromReadBookActivity: Boolean) { super.initData(arguments, book, fromReadBookActivity) arguments?.let { bundle -> bundle.getString("chapterTitle")?.let { chapterTitle = it } chapterIndex = bundle.getInt("chapterIndex") } } fun getContent( book: Book, chapter: BookChapter, nextChapterUrl: String?, success: (content: String) -> Unit, error: (msg: String) -> Unit ) { execute { val bookSource = appDb.bookSourceDao.getBookSource(book.origin) ?: throw NoStackTraceException("书源不存在") WebBook.getContentAwait(bookSource, book, chapter, nextChapterUrl, false) }.onSuccess { success.invoke(it) }.onError { error.invoke(it.localizedMessage ?: "获取正文出错") } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterTocAdapter.kt ================================================ package io.legado.app.ui.book.changesource import android.content.Context import android.view.ViewGroup import io.legado.app.R import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.data.entities.BookChapter import io.legado.app.databinding.ItemChapterListBinding import io.legado.app.lib.theme.ThemeUtils import io.legado.app.lib.theme.accentColor import io.legado.app.utils.getCompatColor import io.legado.app.utils.gone import io.legado.app.utils.visible class ChangeChapterTocAdapter(context: Context, val callback: Callback) : RecyclerAdapter(context) { var durChapterIndex = 0 override fun getViewBinding(parent: ViewGroup): ItemChapterListBinding { return ItemChapterListBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemChapterListBinding, item: BookChapter, payloads: MutableList ) { binding.run { val isDur = durChapterIndex == item.index if (isDur) { tvChapterName.setTextColor(context.accentColor) } else { tvChapterName.setTextColor(context.getCompatColor(R.color.primaryText)) } tvChapterName.text = item.title if (item.isVolume) { //卷名,如第一卷 突出显示 tvChapterItem.setBackgroundColor(context.getCompatColor(R.color.btn_bg_press)) } else { //普通章节 保持不变 tvChapterItem.background = ThemeUtils.resolveDrawable(context, android.R.attr.selectableItemBackground) } if (!item.tag.isNullOrEmpty() && !item.isVolume) { //卷名不显示tag(更新时间规则) tvTag.text = item.tag tvTag.visible() } else { tvTag.gone() } ivChecked.setImageResource(R.drawable.ic_check) ivChecked.visible(isDur) } } override fun registerListener(holder: ItemViewHolder, binding: ItemChapterListBinding) { holder.itemView.setOnClickListener { getItem(holder.layoutPosition)?.let { callback.clickChapter(it, getItem(holder.layoutPosition + 1)?.url) } } } interface Callback { fun clickChapter(bookChapter: BookChapter, nextChapterUrl: String?) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/explore/ExploreShowActivity.kt ================================================ package io.legado.app.ui.book.explore import android.os.Bundle import androidx.activity.viewModels import androidx.core.os.bundleOf import androidx.recyclerview.widget.RecyclerView import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.data.entities.SearchBook import io.legado.app.databinding.ActivityExploreShowBinding import io.legado.app.databinding.ViewLoadMoreBinding import io.legado.app.ui.book.info.BookInfoActivity import io.legado.app.ui.widget.recycler.LoadMoreView import io.legado.app.ui.widget.recycler.VerticalDivider import io.legado.app.utils.applyNavigationBarPadding import io.legado.app.utils.startActivity import io.legado.app.utils.viewbindingdelegate.viewBinding /** * 发现列表 */ class ExploreShowActivity : VMBaseActivity(), ExploreShowAdapter.CallBack { override val binding by viewBinding(ActivityExploreShowBinding::inflate) override val viewModel by viewModels() private val adapter by lazy { ExploreShowAdapter(this, this) } private val loadMoreView by lazy { LoadMoreView(this) } override fun onActivityCreated(savedInstanceState: Bundle?) { binding.titleBar.title = intent.getStringExtra("exploreName") initRecyclerView() viewModel.booksData.observe(this) { upData(it) } viewModel.initData(intent) viewModel.errorLiveData.observe(this) { loadMoreView.error(it) } viewModel.upAdapterLiveData.observe(this) { adapter.notifyItemRangeChanged(0, adapter.itemCount, bundleOf(it to null)) } } private fun initRecyclerView() { binding.recyclerView.addItemDecoration(VerticalDivider(this)) binding.recyclerView.adapter = adapter binding.recyclerView.applyNavigationBarPadding() adapter.addFooterView { ViewLoadMoreBinding.bind(loadMoreView) } loadMoreView.startLoad() loadMoreView.setOnClickListener { if (!loadMoreView.isLoading) { scrollToBottom(true) } } binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { super.onScrolled(recyclerView, dx, dy) if (!recyclerView.canScrollVertically(1)) { scrollToBottom() } } }) } private fun scrollToBottom(forceLoad: Boolean = false) { if ((loadMoreView.hasMore && !loadMoreView.isLoading) || forceLoad) { loadMoreView.hasMore() viewModel.explore() } } private fun upData(books: List) { loadMoreView.stopLoad() if (books.isEmpty() && adapter.isEmpty()) { loadMoreView.noMore(getString(R.string.empty)) } else if (adapter.getActualItemCount() == books.size) { loadMoreView.noMore() } else { adapter.setItems(books) } } override fun isInBookshelf(book: SearchBook): Boolean { return viewModel.isInBookShelf(book) } override fun showBookInfo(book: SearchBook) { startActivity { putExtra("name", book.name) putExtra("author", book.author) putExtra("bookUrl", book.bookUrl) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/explore/ExploreShowAdapter.kt ================================================ package io.legado.app.ui.book.explore import android.content.Context import android.os.Bundle import android.view.ViewGroup import androidx.core.view.isVisible import io.legado.app.R import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.data.entities.SearchBook import io.legado.app.databinding.ItemSearchBinding import io.legado.app.help.config.AppConfig import io.legado.app.utils.gone import io.legado.app.utils.visible class ExploreShowAdapter(context: Context, val callBack: CallBack) : RecyclerAdapter(context) { override fun getViewBinding(parent: ViewGroup): ItemSearchBinding { return ItemSearchBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemSearchBinding, item: SearchBook, payloads: MutableList ) { if (payloads.isEmpty()) { bind(binding, item) } else { for (i in payloads.indices) { val bundle = payloads[i] as Bundle bindChange(binding, item, bundle) } } } private fun bind(binding: ItemSearchBinding, item: SearchBook) { binding.run { tvName.text = item.name tvAuthor.text = context.getString(R.string.author_show, item.author) ivInBookshelf.isVisible = callBack.isInBookshelf(item) if (item.latestChapterTitle.isNullOrEmpty()) { tvLasted.gone() } else { tvLasted.text = context.getString(R.string.lasted_show, item.latestChapterTitle) tvLasted.visible() } tvIntroduce.text = item.trimIntro(context) val kinds = item.getKindList() if (kinds.isEmpty()) { llKind.gone() } else { llKind.visible() llKind.setLabels(kinds) } ivCover.load( item.coverUrl, item.name, item.author, AppConfig.loadCoverOnlyWifi, item.origin ) } } private fun bindChange(binding: ItemSearchBinding, item: SearchBook, bundle: Bundle) { binding.run { bundle.keySet().forEach { when (it) { "isInBookshelf" -> ivInBookshelf.isVisible = callBack.isInBookshelf(item) } } } } override fun registerListener(holder: ItemViewHolder, binding: ItemSearchBinding) { holder.itemView.setOnClickListener { getItem(holder.layoutPosition)?.let { callBack.showBookInfo(it) } } } interface CallBack { /** * 是否已经加入书架 */ fun isInBookshelf(book: SearchBook): Boolean fun showBookInfo(book: SearchBook) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt ================================================ package io.legado.app.ui.book.explore import android.app.Application import android.content.Intent import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import io.legado.app.BuildConfig import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.SearchBook import io.legado.app.help.book.isNotShelf import io.legado.app.model.webBook.WebBook import io.legado.app.utils.printOnDebug import io.legado.app.utils.stackTraceStr import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.mapLatest import java.util.concurrent.ConcurrentHashMap @OptIn(ExperimentalCoroutinesApi::class) class ExploreShowViewModel(application: Application) : BaseViewModel(application) { val bookshelf: MutableSet = ConcurrentHashMap.newKeySet() val upAdapterLiveData = MutableLiveData() val booksData = MutableLiveData>() val errorLiveData = MutableLiveData() private var bookSource: BookSource? = null private var exploreUrl: String? = null private var page = 1 private var books = linkedSetOf() init { execute { appDb.bookDao.flowAll().mapLatest { books -> val keys = arrayListOf() books.filterNot { it.isNotShelf } .forEach { keys.add("${it.name}-${it.author}") keys.add(it.name) keys.add(it.bookUrl) } keys }.catch { AppLog.put("发现列表界面获取书籍数据失败\n${it.localizedMessage}", it) }.collect { bookshelf.clear() bookshelf.addAll(it) upAdapterLiveData.postValue("isInBookshelf") } }.onError { AppLog.put("加载书架数据失败", it) } } fun initData(intent: Intent) { execute { val sourceUrl = intent.getStringExtra("sourceUrl") exploreUrl = intent.getStringExtra("exploreUrl") if (bookSource == null && sourceUrl != null) { bookSource = appDb.bookSourceDao.getBookSource(sourceUrl) } explore() } } fun explore() { val source = bookSource val url = exploreUrl if (source == null || url == null) return WebBook.exploreBook(viewModelScope, source, url, page) .timeout(if (BuildConfig.DEBUG) 0L else 30000L) .onSuccess(IO) { searchBooks -> books.addAll(searchBooks) booksData.postValue(books.toList()) appDb.searchBookDao.insert(*searchBooks.toTypedArray()) page++ }.onError { it.printOnDebug() errorLiveData.postValue(it.stackTraceStr) } } fun isInBookShelf(book: SearchBook): Boolean { val name = book.name val author = book.author val bookUrl = book.bookUrl val key = if (author.isNotBlank()) "$name-$author" else name return bookshelf.contains(key) || bookshelf.contains(bookUrl) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/group/GroupEditDialog.kt ================================================ package io.legado.app.ui.book.group import android.os.Bundle import android.view.View import android.view.ViewGroup import androidx.fragment.app.viewModels import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.data.entities.BookGroup import io.legado.app.databinding.DialogBookGroupEditBinding import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.file.HandleFileContract import io.legado.app.utils.FileUtils import io.legado.app.utils.MD5Utils import io.legado.app.utils.externalFiles import io.legado.app.utils.gone import io.legado.app.utils.inputStream import io.legado.app.utils.readUri import io.legado.app.utils.setLayout import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible import splitties.init.appCtx import splitties.views.onClick import java.io.FileOutputStream class GroupEditDialog() : BaseDialogFragment(R.layout.dialog_book_group_edit) { constructor(bookGroup: BookGroup? = null) : this() { arguments = Bundle().apply { putParcelable("group", bookGroup?.copy()) } } private val binding by viewBinding(DialogBookGroupEditBinding::bind) private val viewModel by viewModels() private var bookGroup: BookGroup? = null private val selectImage = registerForActivityResult(HandleFileContract()) { it.uri ?: return@registerForActivityResult readUri(it.uri) { fileDoc, inputStream -> try { var file = requireContext().externalFiles val suffix = fileDoc.name.substringAfterLast(".") val fileName = it.uri.inputStream(requireContext()).getOrThrow().use { tmp -> MD5Utils.md5Encode(tmp) + ".$suffix" } file = FileUtils.createFileIfNotExist(file, "covers", fileName) FileOutputStream(file).use { outputStream -> inputStream.copyTo(outputStream) } binding.ivCover.load(file.absolutePath) } catch (e: Exception) { appCtx.toastOnUi(e.localizedMessage) } } } override fun onStart() { super.onStart() setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) @Suppress("DEPRECATION") bookGroup = arguments?.getParcelable("group") bookGroup?.let { binding.btnDelete.visible(it.groupId > 0 || it.groupId == Long.MIN_VALUE) binding.tieGroupName.setText(it.groupName) binding.ivCover.load(it.cover) if (it.bookSort + 1 !in 0.. Unit) { alert(R.string.delete, R.string.sure_del) { yesButton { ok.invoke() } noButton() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/group/GroupManageDialog.kt ================================================ package io.legado.app.ui.book.group import android.content.Context import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.BookGroup import io.legado.app.databinding.DialogRecyclerViewBinding import io.legado.app.databinding.ItemBookGroupManageBinding import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.backgroundColor import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.widget.recycler.ItemTouchCallback import io.legado.app.ui.widget.recycler.VerticalDivider import io.legado.app.utils.applyTint import io.legado.app.utils.setLayout import io.legado.app.utils.showDialogFragment import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch /** * 书籍分组管理 */ class GroupManageDialog : BaseDialogFragment(R.layout.dialog_recycler_view), Toolbar.OnMenuItemClickListener { private val viewModel: GroupViewModel by viewModels() private val binding by viewBinding(DialogRecyclerViewBinding::bind) private val adapter by lazy { GroupAdapter(requireContext()) } override fun onStart() { super.onStart() setLayout(0.9f, 0.9f) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) binding.toolBar.setTitle(R.string.group_manage) initView() initData() initMenu() } private fun initView() { binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.addItemDecoration(VerticalDivider(requireContext())) binding.recyclerView.adapter = adapter val itemTouchCallback = ItemTouchCallback(adapter) itemTouchCallback.isCanDrag = true ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView) binding.tvOk.setTextColor(requireContext().accentColor) binding.tvOk.visible() binding.tvOk.setOnClickListener { dismissAllowingStateLoss() } } private fun initData() { lifecycleScope.launch { appDb.bookGroupDao.flowAll().catch { AppLog.put("书籍分组管理界面获取分组数据失败\n${it.localizedMessage}", it) }.flowOn(IO).conflate().collect { adapter.setItems(it) } } } private fun initMenu() { binding.toolBar.setOnMenuItemClickListener(this) binding.toolBar.inflateMenu(R.menu.book_group_manage) binding.toolBar.menu.applyTint(requireContext()) } override fun onMenuItemClick(item: MenuItem?): Boolean { when (item?.itemId) { R.id.menu_add -> { if (appDb.bookGroupDao.canAddGroup) { showDialogFragment(GroupEditDialog()) } else { toastOnUi("分组已达上限(64个)") } } } return true } private inner class GroupAdapter(context: Context) : RecyclerAdapter(context), ItemTouchCallback.Callback { private var isMoved = false override fun getViewBinding(parent: ViewGroup): ItemBookGroupManageBinding { return ItemBookGroupManageBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemBookGroupManageBinding, item: BookGroup, payloads: MutableList ) { binding.run { root.setBackgroundColor(context.backgroundColor) tvGroup.text = item.getManageName(context) swShow.isChecked = item.show } } override fun registerListener(holder: ItemViewHolder, binding: ItemBookGroupManageBinding) { binding.run { tvEdit.setOnClickListener { getItem(holder.layoutPosition)?.let { bookGroup -> showDialogFragment( GroupEditDialog(bookGroup) ) } } swShow.setOnUserCheckedChangeListener { isChecked -> getItem(holder.layoutPosition)?.let { viewModel.upGroup(it.copy(show = isChecked)) } } } } override fun swap(srcPosition: Int, targetPosition: Int): Boolean { swapItem(srcPosition, targetPosition) isMoved = true return true } override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { if (isMoved) { for ((index, item) in getItems().withIndex()) { item.order = index + 1 } viewModel.upGroup(*getItems().toTypedArray()) } isMoved = false } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/group/GroupSelectDialog.kt ================================================ package io.legado.app.ui.book.group import android.content.Context import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.data.appDb import io.legado.app.data.entities.BookGroup import io.legado.app.databinding.DialogBookGroupPickerBinding import io.legado.app.databinding.ItemGroupSelectBinding import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.backgroundColor import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.widget.recycler.ItemTouchCallback import io.legado.app.ui.widget.recycler.VerticalDivider import io.legado.app.utils.applyTint import io.legado.app.utils.setLayout import io.legado.app.utils.showDialogFragment import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.launch class GroupSelectDialog() : BaseDialogFragment(R.layout.dialog_book_group_picker), Toolbar.OnMenuItemClickListener { constructor(groupId: Long, requestCode: Int = -1) : this() { arguments = Bundle().apply { putLong("groupId", groupId) putInt("requestCode", requestCode) } } private val binding by viewBinding(DialogBookGroupPickerBinding::bind) private var requestCode: Int = -1 private val viewModel: GroupViewModel by viewModels() private val adapter by lazy { GroupAdapter(requireContext()) } private val callBack get() = (activity as? CallBack) private var groupId: Long = 0 override fun onStart() { super.onStart() setLayout(0.9f, 0.9f) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) arguments?.let { groupId = it.getLong("groupId") requestCode = it.getInt("requestCode", -1) } initView() initData() } private fun initView() { binding.toolBar.title = getString(R.string.group_select) binding.toolBar.inflateMenu(R.menu.book_group_manage) binding.toolBar.menu.applyTint(requireContext()) binding.toolBar.setOnMenuItemClickListener(this) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.addItemDecoration(VerticalDivider(requireContext())) binding.recyclerView.adapter = adapter val itemTouchCallback = ItemTouchCallback(adapter) itemTouchCallback.isCanDrag = true ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView) binding.tvCancel.setOnClickListener { dismissAllowingStateLoss() } binding.tvOk.setTextColor(requireContext().accentColor) binding.tvOk.setOnClickListener { callBack?.upGroup(requestCode, groupId) dismissAllowingStateLoss() } } private fun initData() { lifecycleScope.launch { appDb.bookGroupDao.flowSelect().conflate().collect { adapter.setItems(it) } } } override fun onMenuItemClick(item: MenuItem?): Boolean { when (item?.itemId) { R.id.menu_add -> showDialogFragment( GroupEditDialog() ) } return true } private inner class GroupAdapter(context: Context) : RecyclerAdapter(context), ItemTouchCallback.Callback { private var isMoved: Boolean = false override fun getViewBinding(parent: ViewGroup): ItemGroupSelectBinding { return ItemGroupSelectBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemGroupSelectBinding, item: BookGroup, payloads: MutableList ) { binding.run { root.setBackgroundColor(context.backgroundColor) cbGroup.text = item.groupName cbGroup.isChecked = (groupId and item.groupId) > 0 } } override fun registerListener(holder: ItemViewHolder, binding: ItemGroupSelectBinding) { binding.run { cbGroup.setOnUserCheckedChangeListener { isChecked -> getItem(holder.layoutPosition)?.let { groupId = if (isChecked) { groupId + it.groupId } else { groupId - it.groupId } } } tvEdit.setOnClickListener { showDialogFragment( GroupEditDialog(getItem(holder.layoutPosition)) ) } } } override fun swap(srcPosition: Int, targetPosition: Int): Boolean { swapItem(srcPosition, targetPosition) isMoved = true return true } override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { if (isMoved) { for ((index, item) in getItems().withIndex()) { item.order = index + 1 } viewModel.upGroup(*getItems().toTypedArray()) } isMoved = false } } interface CallBack { fun upGroup(requestCode: Int, groupId: Long) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/group/GroupViewModel.kt ================================================ package io.legado.app.ui.book.group import android.app.Application import io.legado.app.base.BaseViewModel import io.legado.app.data.appDb import io.legado.app.data.entities.BookGroup class GroupViewModel(application: Application) : BaseViewModel(application) { fun upGroup(vararg bookGroup: BookGroup, finally: (() -> Unit)? = null) { execute { appDb.bookGroupDao.update(*bookGroup) }.onFinally { finally?.invoke() } } fun addGroup( groupName: String, bookSort: Int, enableRefresh: Boolean, cover: String?, finally: () -> Unit ) { execute { val groupId = appDb.bookGroupDao.getUnusedId() val bookGroup = BookGroup( groupId = groupId, groupName = groupName, cover = cover, bookSort = bookSort, enableRefresh = enableRefresh, order = appDb.bookGroupDao.maxOrder.plus(1) ) appDb.bookGroupDao.getByID(groupId) ?: appDb.bookDao.removeGroup(groupId) appDb.bookGroupDao.insert(bookGroup) }.onFinally { finally() } } fun delGroup(bookGroup: BookGroup, finally: () -> Unit) { execute { appDb.bookGroupDao.delete(bookGroup) appDb.bookDao.removeGroup(bookGroup.groupId) }.onFinally { finally() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/import/BaseImportBookActivity.kt ================================================ package io.legado.app.ui.book.import import android.os.Bundle import androidx.appcompat.widget.SearchView import androidx.lifecycle.ViewModel import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.constant.AppPattern import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.databinding.ActivityImportBookBinding import io.legado.app.help.config.AppConfig import io.legado.app.lib.dialogs.alert import io.legado.app.lib.dialogs.selector import io.legado.app.lib.theme.primaryTextColor import io.legado.app.model.localBook.LocalBook import io.legado.app.ui.file.HandleFileContract import io.legado.app.utils.ArchiveUtils import io.legado.app.utils.FileDoc import io.legado.app.utils.applyTint import io.legado.app.utils.startActivityForBook import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume abstract class BaseImportBookActivity : VMBaseActivity() { final override val binding by viewBinding(ActivityImportBookBinding::inflate) private var localBookTreeSelectListener: ((Boolean) -> Unit)? = null protected val searchView: SearchView by lazy { binding.titleBar.findViewById(R.id.search_view) } val localBookTreeSelect = registerForActivityResult(HandleFileContract()) { it.uri?.let { treeUri -> AppConfig.defaultBookTreeUri = treeUri.toString() localBookTreeSelectListener?.invoke(true) } ?: localBookTreeSelectListener?.invoke(false) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) initSearchView() } /** * 设置书籍保存位置 */ protected suspend fun setBookStorage() = suspendCancellableCoroutine sc@{ block -> localBookTreeSelectListener = { localBookTreeSelectListener = null block.resume(it) } //测试书籍保存位置是否设置 if (!AppConfig.defaultBookTreeUri.isNullOrBlank()) { localBookTreeSelectListener = null block.resume(true) return@sc } //测试读写?? val storageHelp = String(assets.open("storageHelp.md").readBytes()) val hint = getString(R.string.select_book_folder) alert(hint, storageHelp) { okButton { localBookTreeSelect.launch { title = hint } } cancelButton { localBookTreeSelectListener = null block.resume(false) } onCancelled { localBookTreeSelectListener = null block.resume(false) } } } abstract fun onSearchTextChange(newText: String?) protected fun startReadBook(book: Book) { startActivityForBook(book) } protected fun onArchiveFileClick(fileDoc: FileDoc) { val fileNames = ArchiveUtils.getArchiveFilesName(fileDoc) { it.matches(AppPattern.bookFileRegex) } if (fileNames.size == 1) { val name = fileNames[0] appDb.bookDao.getBookByFileName(name)?.let { startReadBook(it) } ?: showImportAlert(fileDoc, name) } else { showSelectBookReadAlert(fileDoc, fileNames) } } private fun showSelectBookReadAlert(fileDoc: FileDoc, fileNames: List) { if (fileNames.isEmpty()) { toastOnUi(R.string.unsupport_archivefile_entry) return } selector( R.string.start_read, fileNames ) { _, name, _ -> appDb.bookDao.getBookByFileName(name)?.let { startReadBook(it) } ?: showImportAlert(fileDoc, name) } } /* 添加压缩包内指定文件到书架 */ private inline fun addArchiveToBookShelf( fileDoc: FileDoc, fileName: String, onSuccess: (Book) -> Unit ) { LocalBook.importArchiveFile(fileDoc.uri, fileName) { it.contains(fileName) }.firstOrNull()?.run { onSuccess.invoke(this) } } /* 提示是否重新导入所点击的压缩文件 */ private fun showImportAlert(fileDoc: FileDoc, fileName: String) { alert( R.string.draw, R.string.no_book_found_bookshelf ) { okButton { addArchiveToBookShelf(fileDoc, fileName) { startReadBook(it) } } noButton() } } private fun initSearchView() { searchView.applyTint(primaryTextColor) searchView.isSubmitButtonEnabled = true searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { return false } override fun onQueryTextChange(newText: String?): Boolean { onSearchTextChange(newText) return false } }) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/import/local/ImportBook.kt ================================================ package io.legado.app.ui.book.import.local import io.legado.app.model.localBook.LocalBook import io.legado.app.utils.FileDoc data class ImportBook( val file: FileDoc, var isOnBookShelf: Boolean = !file.isDir && LocalBook.isOnBookShelf(file.name) ) { val name get() = file.name val isDir get() = file.isDir val size get() = file.size val lastModified get() = file.lastModified } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/import/local/ImportBookActivity.kt ================================================ package io.legado.app.ui.book.import.local import android.annotation.SuppressLint import android.net.Uri import android.os.Bundle import android.view.Menu import android.view.MenuItem import androidx.activity.addCallback import androidx.activity.viewModels import androidx.appcompat.widget.PopupMenu import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.R import io.legado.app.constant.PreferKey import io.legado.app.data.appDb import io.legado.app.databinding.DialogEditTextBinding import io.legado.app.help.config.AppConfig import io.legado.app.lib.dialogs.alert import io.legado.app.lib.permission.Permissions import io.legado.app.lib.permission.PermissionsCompat import io.legado.app.lib.theme.backgroundColor import io.legado.app.ui.book.import.BaseImportBookActivity import io.legado.app.ui.file.HandleFileContract import io.legado.app.ui.widget.SelectActionBar import io.legado.app.utils.ArchiveUtils import io.legado.app.utils.FileDoc import io.legado.app.utils.gone import io.legado.app.utils.isContentScheme import io.legado.app.utils.isUri import io.legado.app.utils.launch import io.legado.app.utils.putPrefInt import io.legado.app.utils.visible import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Job import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File /** * 导入本地书籍界面 */ class ImportBookActivity : BaseImportBookActivity(), PopupMenu.OnMenuItemClickListener, ImportBookAdapter.CallBack, SelectActionBar.CallBack { override val viewModel by viewModels() private val adapter by lazy { ImportBookAdapter(this, this) } private var scanDocJob: Job? = null private val selectFolder = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> AppConfig.importBookPath = uri.toString() initRootDoc(true) } } override fun onActivityCreated(savedInstanceState: Bundle?) { searchView.queryHint = getString(R.string.screen) + " • " + getString(R.string.local_book) onBackPressedDispatcher.addCallback(this) { if (!goBackDir()) { finish() } } lifecycleScope.launch { initView() initEvent() if (setBookStorage() && AppConfig.importBookPath.isNullOrBlank()) { AppConfig.importBookPath = AppConfig.defaultBookTreeUri } initData() } } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.import_book, menu) return super.onCompatCreateOptionsMenu(menu) } override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { menu.findItem(R.id.menu_sort_name)?.isChecked = viewModel.sort == 0 menu.findItem(R.id.menu_sort_size)?.isChecked = viewModel.sort == 1 menu.findItem(R.id.menu_sort_time)?.isChecked = viewModel.sort == 2 return super.onMenuOpened(featureId, menu) } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_select_folder -> selectFolder.launch() R.id.menu_scan_folder -> scanFolder() R.id.menu_import_file_name -> alertImportFileName() R.id.menu_sort_name -> upSort(0) R.id.menu_sort_size -> upSort(1) R.id.menu_sort_time -> upSort(2) } return super.onCompatOptionsItemSelected(item) } override fun onMenuItemClick(item: MenuItem?): Boolean { when (item?.itemId) { R.id.menu_del_selection -> viewModel.deleteDoc(adapter.selected) { adapter.removeSelection() } } return false } override fun selectAll(selectAll: Boolean) { adapter.selectAll(selectAll) } override fun revertSelection() { adapter.revertSelection() } @SuppressLint("NotifyDataSetChanged") override fun onClickSelectBarMainAction() { viewModel.addToBookshelf(adapter.selected) { adapter.selected.forEach { it.isOnBookShelf = true } adapter.selected.clear() adapter.notifyDataSetChanged() } } private fun initView() { binding.layTop.setBackgroundColor(backgroundColor) binding.tvEmptyMsg.setText(R.string.empty_msg_import_book) binding.recyclerView.layoutManager = LinearLayoutManager(this) binding.recyclerView.adapter = adapter binding.recyclerView.recycledViewPool.setMaxRecycledViews(0, 15) binding.selectActionBar.setMainActionText(R.string.add_to_bookshelf) binding.selectActionBar.inflateMenu(R.menu.import_book_sel) binding.selectActionBar.setOnMenuItemClickListener(this) binding.selectActionBar.setCallBack(this) } private fun initEvent() { binding.tvGoBack.setOnClickListener { goBackDir() } } private fun initData() { viewModel.dataFlowStart = { initRootDoc() } lifecycleScope.launch { viewModel.dataFlow.conflate().collect { docs -> adapter.setItems(docs) } } } private fun initRootDoc(changedFolder: Boolean = false) { if (viewModel.rootDoc != null && !changedFolder) { upPath() } else { val lastPath = AppConfig.importBookPath if (lastPath.isNullOrBlank()) { binding.tvEmptyMsg.visible() selectFolder.launch() } else { val rootUri = if (lastPath.isUri()) { lastPath.toUri() } else { Uri.fromFile(File(lastPath)) } when { rootUri.isContentScheme() -> initRootPath(rootUri) else -> initRootPath(rootUri.path!!) } } } } private fun initRootPath(rootUri: Uri) { kotlin.runCatching { val doc = DocumentFile.fromTreeUri(this, rootUri) if (doc == null || doc.name.isNullOrEmpty() || !doc.isDirectory) { binding.tvEmptyMsg.visible() selectFolder.launch() } else { viewModel.subDocs.clear() viewModel.rootDoc = FileDoc.fromDocumentFile(doc) upPath() } }.onFailure { binding.tvEmptyMsg.visible() selectFolder.launch() } } private fun initRootPath(path: String) { binding.tvEmptyMsg.visible() PermissionsCompat.Builder() .addPermissions(*Permissions.Group.STORAGE) .rationale(R.string.tip_perm_request_storage) .onGranted { kotlin.runCatching { val file = File(path) if (!file.isDirectory) { binding.tvEmptyMsg.visible() selectFolder.launch() } else { viewModel.subDocs.clear() viewModel.rootDoc = FileDoc.fromFile(file) upPath() } }.onFailure { binding.tvEmptyMsg.visible() selectFolder.launch() } } .request() } private fun upSort(sort: Int) { viewModel.sort = sort putPrefInt(PreferKey.localBookImportSort, sort) if (scanDocJob?.isActive != true) { viewModel.dataCallback?.upAdapter() } } @Synchronized private fun upPath() { binding.tvGoBack.isEnabled = viewModel.subDocs.isNotEmpty() viewModel.rootDoc?.let { scanDocJob?.cancel() upDocs(it) } } private fun upDocs(rootDoc: FileDoc) { binding.tvEmptyMsg.gone() var path = rootDoc.name + File.separator var lastDoc = rootDoc for (doc in viewModel.subDocs) { lastDoc = doc path = path + doc.name + File.separator } binding.tvPath.text = path adapter.selected.clear() adapter.clearItems() viewModel.loadDoc(lastDoc) } /** * 扫描当前文件夹及所有子文件夹 */ private fun scanFolder() { viewModel.rootDoc?.let { doc -> adapter.clearItems() val lastDoc = viewModel.subDocs.lastOrNull() ?: doc binding.refreshProgressBar.isAutoLoading = true scanDocJob?.cancel() scanDocJob = lifecycleScope.launch(IO) { viewModel.scanDoc(lastDoc) withContext(Main) { binding.refreshProgressBar.isAutoLoading = false } } } } private fun alertImportFileName() { alert(R.string.import_file_name) { setMessage("""使用js处理文件名变量src,将书名作者分别赋值到变量name author""") val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = "js" editView.setText(AppConfig.bookImportFileName) } customView { alertBinding.root } okButton { AppConfig.bookImportFileName = alertBinding.editView.text?.toString() } cancelButton() } } @Synchronized override fun nextDoc(fileDoc: FileDoc) { viewModel.subDocs.add(fileDoc) upPath() } @Synchronized private fun goBackDir(): Boolean { return if (viewModel.subDocs.isNotEmpty()) { viewModel.subDocs.removeAt(viewModel.subDocs.lastIndex) upPath() true } else { false } } override fun onSearchTextChange(newText: String?) { viewModel.updateCallBackFlow(newText) } override fun upCountView() { binding.selectActionBar.upCountView(adapter.selected.size, adapter.checkableCount) } override fun startRead(fileDoc: FileDoc) { if (!ArchiveUtils.isArchive(fileDoc.name)) { appDb.bookDao.getBookByFileName(fileDoc.name)?.let { val filePath = fileDoc.toString() if (it.bookUrl != filePath) { it.bookUrl = filePath appDb.bookDao.insert(it) } startReadBook(it) } } else { onArchiveFileClick(fileDoc) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/import/local/ImportBookAdapter.kt ================================================ package io.legado.app.ui.book.import.local import android.annotation.SuppressLint import android.content.Context import android.view.ViewGroup import io.legado.app.R import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.constant.AppConst import io.legado.app.databinding.ItemImportBookBinding import io.legado.app.utils.ConvertUtils import io.legado.app.utils.FileDoc import io.legado.app.utils.gone import io.legado.app.utils.invisible import io.legado.app.utils.visible class ImportBookAdapter(context: Context, val callBack: CallBack) : RecyclerAdapter(context) { val selected = hashSetOf() var checkableCount = 0 override fun getViewBinding(parent: ViewGroup): ItemImportBookBinding { return ItemImportBookBinding.inflate(inflater, parent, false) } override fun onCurrentListChanged() { upCheckableCount() } override fun convert( holder: ItemViewHolder, binding: ItemImportBookBinding, item: ImportBook, payloads: MutableList ) { binding.run { if (payloads.isEmpty()) { if (item.isDir) { ivIcon.setImageResource(R.drawable.ic_folder) ivIcon.visible() cbSelect.invisible() llBrief.gone() cbSelect.isChecked = false } else { if (item.isOnBookShelf) { ivIcon.setImageResource(R.drawable.ic_book_has) ivIcon.visible() cbSelect.invisible() } else { ivIcon.invisible() cbSelect.visible() } llBrief.visible() tvTag.text = item.name.substringAfterLast(".") tvSize.text = ConvertUtils.formatFileSize(item.size) tvDate.text = AppConst.dateFormat.format(item.lastModified) cbSelect.isChecked = selected.contains(item) } tvName.text = item.name } else { cbSelect.isChecked = selected.contains(item) } } } override fun registerListener(holder: ItemViewHolder, binding: ItemImportBookBinding) { holder.itemView.setOnClickListener { getItem(holder.layoutPosition)?.let { if (it.isDir) { callBack.nextDoc(it.file) } else if (!it.isOnBookShelf) { if (!selected.contains(it)) { selected.add(it) } else { selected.remove(it) } notifyItemChanged(holder.layoutPosition, true) callBack.upCountView() } else { /* 点击开始阅读 */ callBack.startRead(it.file) } } } } private fun upCheckableCount() { checkableCount = 0 getItems().forEach { if (!it.isDir && !it.isOnBookShelf) { checkableCount++ } } callBack.upCountView() } @SuppressLint("NotifyDataSetChanged") fun selectAll(selectAll: Boolean) { if (selectAll) { getItems().forEach { if (!it.isDir && !it.isOnBookShelf) { selected.add(it) } } } else { selected.clear() } notifyDataSetChanged() callBack.upCountView() } fun revertSelection() { getItems().forEach { if (!it.isDir && !it.isOnBookShelf) { if (selected.contains(it)) { selected.remove(it) } else { selected.add(it) } } } notifyItemRangeChanged(0, itemCount, true) callBack.upCountView() } fun removeSelection() { for (i in getItems().lastIndex downTo 0) { if (getItem(i) in selected) { removeItem(i) } } } interface CallBack { fun nextDoc(fileDoc: FileDoc) fun upCountView() fun startRead(fileDoc: FileDoc) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/import/local/ImportBookViewModel.kt ================================================ package io.legado.app.ui.book.import.local import android.app.Application import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppLog import io.legado.app.constant.AppPattern.archiveFileRegex import io.legado.app.constant.AppPattern.bookFileRegex import io.legado.app.constant.PreferKey import io.legado.app.model.localBook.LocalBook import io.legado.app.utils.AlphanumComparator import io.legado.app.utils.FileDoc import io.legado.app.utils.delete import io.legado.app.utils.getPrefInt import io.legado.app.utils.list import io.legado.app.utils.mapParallel import io.legado.app.utils.toastOnUi import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.withContext import java.util.Collections class ImportBookViewModel(application: Application) : BaseViewModel(application) { var rootDoc: FileDoc? = null val subDocs = arrayListOf() var sort = context.getPrefInt(PreferKey.localBookImportSort) var dataCallback: DataCallback? = null var dataFlowStart: (() -> Unit)? = null var filterKey: String? = null val dataFlow = callbackFlow> { val list = Collections.synchronizedList(ArrayList()) dataCallback = object : DataCallback { override fun setItems(fileDocs: List) { list.clear() fileDocs.mapTo(list) { ImportBook(it) } trySend(list) } override fun addItems(fileDocs: List) { fileDocs.mapTo(list) { ImportBook(it) } trySend(list) } override fun clear() { list.clear() trySend(emptyList()) } override fun upAdapter() { trySend(list) } } withContext(Main) { dataFlowStart?.invoke() } awaitClose { dataCallback = null } }.map { docList -> val docList = docList.toList() val filterKey = filterKey val skipFilter = filterKey.isNullOrBlank() val comparator = when (sort) { 2 -> compareBy({ !it.isDir }, { -it.lastModified }) 1 -> compareBy({ !it.isDir }, { -it.size }) else -> compareBy { !it.isDir } } then compareBy(AlphanumComparator) { it.name } docList.asSequence().filter { skipFilter || it.name.contains(filterKey) }.sortedWith(comparator).toList() }.flowOn(IO) fun addToBookshelf(bookList: HashSet, finally: () -> Unit) { execute { val fileUris = bookList.map { it.file.uri } LocalBook.importFiles(fileUris) }.onError { context.toastOnUi("添加书架失败,请尝试重新选择文件夹") AppLog.put("添加书架失败\n${it.localizedMessage}", it) }.onSuccess { context.toastOnUi("添加书架成功") }.onFinally { finally.invoke() } } fun deleteDoc(bookList: HashSet, finally: () -> Unit) { execute { bookList.forEach { it.file.delete() } }.onFinally { finally.invoke() } } fun loadDoc(fileDoc: FileDoc) { execute { val docList = fileDoc.list { item -> when { item.name.startsWith(".") -> false item.isDir -> true else -> item.name.matches(bookFileRegex) || item.name.matches(archiveFileRegex) } } dataCallback?.setItems(docList!!) }.onError { context.toastOnUi("获取文件列表出错\n${it.localizedMessage}") } } suspend fun scanDoc(fileDoc: FileDoc) { dataCallback?.clear() val channel = Channel(UNLIMITED) var n = 1 channel.trySend(fileDoc) val list = arrayListOf() channel.consumeAsFlow() .mapParallel(16) { fileDoc -> fileDoc.list()!! }.onEach { fileDocs -> n-- list.clear() fileDocs.forEach { if (it.isDir) { n++ channel.trySend(it) } else if (it.name.matches(bookFileRegex) || it.name.matches(archiveFileRegex) ) { list.add(it) } } dataCallback?.addItems(list) }.takeWhile { n > 0 }.catch { context.toastOnUi("扫描文件夹出错\n${it.localizedMessage}") }.collect() } fun updateCallBackFlow(filterKey: String?) { this.filterKey = filterKey dataCallback?.upAdapter() } interface DataCallback { fun setItems(fileDocs: List) fun addItems(fileDocs: List) fun clear() fun upAdapter() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookActivity.kt ================================================ package io.legado.app.ui.book.import.remote import android.annotation.SuppressLint import android.net.Uri import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.SubMenu import androidx.activity.addCallback import androidx.activity.viewModels import androidx.core.view.isGone import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.R import io.legado.app.data.appDb import io.legado.app.help.config.AppConfig import io.legado.app.help.config.LocalConfig import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.backgroundColor import io.legado.app.model.remote.RemoteBook import io.legado.app.ui.about.AppLogDialog import io.legado.app.ui.book.import.BaseImportBookActivity import io.legado.app.ui.widget.SelectActionBar import io.legado.app.utils.ArchiveUtils import io.legado.app.utils.FileDoc import io.legado.app.utils.find import io.legado.app.utils.showDialogFragment import io.legado.app.utils.showHelp import kotlinx.coroutines.delay import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.launch import java.io.File /** * 展示远程书籍 */ class RemoteBookActivity : BaseImportBookActivity(), RemoteBookAdapter.CallBack, SelectActionBar.CallBack, ServersDialog.Callback { override val viewModel by viewModels() private val adapter by lazy { RemoteBookAdapter(this, this) } private var groupMenu: SubMenu? = null override fun onActivityCreated(savedInstanceState: Bundle?) { searchView.queryHint = getString(R.string.screen) + " • " + getString(R.string.remote_book) onBackPressedDispatcher.addCallback(this) { if (!goBackDir()) { finish() } } lifecycleScope.launch { if (!setBookStorage()) { finish() return@launch } initView() initEvent() launch { viewModel.dataFlow.conflate().collect { sortedRemoteBooks -> binding.refreshProgressBar.isAutoLoading = false binding.tvEmptyMsg.isGone = sortedRemoteBooks.isNotEmpty() adapter.setItems(sortedRemoteBooks) delay(500) } } viewModel.initData { upPath() } } } override fun observeLiveBus() { viewModel.permissionDenialLiveData.observe(this) { localBookTreeSelect.launch { title = getString(R.string.select_book_folder) } } } private fun initView() { binding.layTop.setBackgroundColor(backgroundColor) binding.recyclerView.layoutManager = LinearLayoutManager(this) binding.recyclerView.adapter = adapter binding.selectActionBar.setMainActionText(R.string.add_to_bookshelf) binding.selectActionBar.setCallBack(this) if (!LocalConfig.webDavBookHelpVersionIsLast) { showHelp("webDavBookHelp") } } private fun sortCheck(sortKey: RemoteBookSort) { if (viewModel.sortKey == sortKey) { viewModel.sortAscending = !viewModel.sortAscending } else { viewModel.sortAscending = true viewModel.sortKey = sortKey } } private fun initEvent() { binding.tvGoBack.setOnClickListener { goBackDir() } } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.book_remote, menu) return super.onCompatCreateOptionsMenu(menu) } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_refresh -> upPath() R.id.menu_server_config -> showDialogFragment() R.id.menu_log -> showDialogFragment() R.id.menu_help -> showHelp("webDavBookHelp") R.id.menu_sort_name -> { item.isChecked = true sortCheck(RemoteBookSort.Name) upPath() } R.id.menu_sort_time -> { item.isChecked = true sortCheck(RemoteBookSort.Default) upPath() } } return super.onCompatOptionsItemSelected(item) } override fun onPrepareOptionsMenu(menu: Menu): Boolean { groupMenu = menu.findItem(R.id.menu_sort)?.subMenu groupMenu?.setGroupCheckable(R.id.menu_group_sort, true, true) groupMenu?.findItem(R.id.menu_sort_name)?.isChecked = viewModel.sortKey == RemoteBookSort.Name groupMenu?.findItem(R.id.menu_sort_time)?.isChecked = viewModel.sortKey == RemoteBookSort.Default return super.onPrepareOptionsMenu(menu) } override fun revertSelection() { adapter.revertSelection() } override fun selectAll(selectAll: Boolean) { adapter.selectAll(selectAll) } @SuppressLint("NotifyDataSetChanged") override fun onClickSelectBarMainAction() { binding.refreshProgressBar.isAutoLoading = true viewModel.addToBookshelf(adapter.selected) { adapter.selected.clear() adapter.notifyDataSetChanged() binding.refreshProgressBar.isAutoLoading = false } } private fun goBackDir(): Boolean { if (viewModel.dirList.isEmpty()) { return false } viewModel.dirList.removeLastOrNull() upPath() return true } private fun upPath() { binding.tvGoBack.isEnabled = viewModel.dirList.isNotEmpty() var path = if (viewModel.isDefaultWebdav) { "books" + File.separator } else { File.separator } viewModel.dirList.forEach { path = path + it.filename + File.separator } binding.tvPath.text = path viewModel.dataCallback?.clear() adapter.selected.clear() viewModel.loadRemoteBookList( viewModel.dirList.lastOrNull()?.path ) { binding.refreshProgressBar.isAutoLoading = it } } override fun openDir(remoteBook: RemoteBook) { viewModel.dirList.add(remoteBook) upPath() } override fun upCountView() { binding.selectActionBar.upCountView(adapter.selected.size, adapter.checkableCount) } override fun onDialogDismiss(tag: String) { viewModel.initData { upPath() } } override fun onSearchTextChange(newText: String?) { viewModel.updateCallBackFlow(newText) } private fun showRemoteBookDownloadAlert( remoteBook: RemoteBook, onDownloadFinish: (() -> Unit)? = null ) { alert( R.string.draw, R.string.archive_not_found ) { okButton { viewModel.addToBookshelf(hashSetOf(remoteBook)) { onDownloadFinish?.invoke() } } noButton() } } override fun startRead(remoteBook: RemoteBook) { val downloadFileName = remoteBook.filename if (!ArchiveUtils.isArchive(downloadFileName)) { appDb.bookDao.getBookByFileName(downloadFileName)?.let { startReadBook(it) } } else { AppConfig.defaultBookTreeUri ?: return val downloadArchiveFileDoc = FileDoc.fromUri(Uri.parse(AppConfig.defaultBookTreeUri), true) .find(downloadFileName) if (downloadArchiveFileDoc == null) { showRemoteBookDownloadAlert(remoteBook) { startRead(remoteBook) } } else { onArchiveFileClick(downloadArchiveFileDoc) } } } override fun addToBookShelfAgain(remoteBook: RemoteBook) { alert(getString(R.string.sure), "是否重新加入书架?") { yesButton { binding.refreshProgressBar.isAutoLoading = true viewModel.addToBookshelf(hashSetOf(remoteBook)) { binding.refreshProgressBar.isAutoLoading = false } } noButton() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookAdapter.kt ================================================ package io.legado.app.ui.book.import.remote import android.annotation.SuppressLint import android.content.Context import android.view.ViewGroup import io.legado.app.R import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.constant.AppConst import io.legado.app.databinding.ItemImportBookBinding import io.legado.app.model.remote.RemoteBook import io.legado.app.utils.ConvertUtils import io.legado.app.utils.gone import io.legado.app.utils.invisible import io.legado.app.utils.visible /** * 适配器 * @author qianfanguojin */ class RemoteBookAdapter(context: Context, val callBack: CallBack) : RecyclerAdapter(context) { var selected = hashSetOf() var checkableCount = 0 override fun getViewBinding(parent: ViewGroup): ItemImportBookBinding { return ItemImportBookBinding.inflate(inflater, parent, false) } override fun onCurrentListChanged() { upCheckableCount() } /** * 绑定RecycleView 中每一个项的视图和数据 */ override fun convert( holder: ItemViewHolder, binding: ItemImportBookBinding, item: RemoteBook, payloads: MutableList ) { binding.run { if (payloads.isEmpty()) { if (item.isDir) { ivIcon.setImageResource(R.drawable.ic_folder) ivIcon.visible() cbSelect.invisible() llBrief.gone() cbSelect.isChecked = false } else { if (item.isOnBookShelf) { ivIcon.setImageResource(R.drawable.ic_book_has) ivIcon.visible() cbSelect.invisible() } else { ivIcon.invisible() cbSelect.visible() } llBrief.visible() tvTag.text = item.contentType tvSize.text = ConvertUtils.formatFileSize(item.size) tvDate.text = AppConst.dateFormat.format(item.lastModify) cbSelect.isChecked = selected.contains(item) } tvName.text = item.filename } else { cbSelect.isChecked = selected.contains(item) } } } override fun registerListener(holder: ItemViewHolder, binding: ItemImportBookBinding) { holder.itemView.setOnClickListener { getItem(holder.layoutPosition)?.let { if (it.isDir) { callBack.openDir(it) } else if (!it.isOnBookShelf) { if (!selected.contains(it)) { selected.add(it) } else { selected.remove(it) } notifyItemChanged(holder.layoutPosition, true) callBack.upCountView() } else { /* 点击开始阅读 */ callBack.startRead(it) } } } holder.itemView.setOnLongClickListener { getItem(holder.layoutPosition)?.let { remoteBook -> if (remoteBook.isOnBookShelf) { callBack.addToBookShelfAgain(remoteBook) } } true } } private fun upCheckableCount() { checkableCount = 0 getItems().forEach { if (!it.isDir && !it.isOnBookShelf) { checkableCount++ } } callBack.upCountView() } @SuppressLint("NotifyDataSetChanged") fun selectAll(selectAll: Boolean) { if (selectAll) { getItems().forEach { if (!it.isDir && !it.isOnBookShelf) { selected.add(it) } } } else { selected.clear() } notifyDataSetChanged() callBack.upCountView() } fun revertSelection() { getItems().forEach { if (!it.isDir && !it.isOnBookShelf) { if (selected.contains(it)) { selected.remove(it) } else { selected.add(it) } } } notifyItemRangeChanged(0, itemCount, true) callBack.upCountView() } fun removeSelection() { for (i in getItems().lastIndex downTo 0) { if (getItem(i) in selected) { removeItem(i) } } } interface CallBack { fun openDir(remoteBook: RemoteBook) fun upCountView() fun startRead(remoteBook: RemoteBook) fun addToBookShelfAgain(remoteBook: RemoteBook) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookSort.kt ================================================ package io.legado.app.ui.book.import.remote enum class RemoteBookSort { Default, Name } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookViewModel.kt ================================================ package io.legado.app.ui.book.import.remote import android.app.Application import androidx.lifecycle.MutableLiveData 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.exception.NoStackTraceException import io.legado.app.help.AppWebDav import io.legado.app.help.config.AppConfig import io.legado.app.lib.webdav.Authorization import io.legado.app.model.analyzeRule.CustomUrl import io.legado.app.model.localBook.LocalBook import io.legado.app.model.remote.RemoteBook import io.legado.app.model.remote.RemoteBookWebDav import io.legado.app.utils.AlphanumComparator import io.legado.app.utils.toastOnUi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import java.util.Collections class RemoteBookViewModel(application: Application) : BaseViewModel(application) { var sortKey = RemoteBookSort.Default var sortAscending = false val dirList = arrayListOf() val permissionDenialLiveData = MutableLiveData() var dataCallback: DataCallback? = null val dataFlow = callbackFlow> { val list = Collections.synchronizedList(ArrayList()) dataCallback = object : DataCallback { override fun setItems(remoteFiles: List) { list.clear() list.addAll(remoteFiles) trySend(list) } override fun addItems(remoteFiles: List) { list.addAll(remoteFiles) trySend(list) } override fun clear() { list.clear() trySend(emptyList()) } override fun screen(key: String?) { if (key.isNullOrBlank()) { trySend(list) } else { trySend( list.filter { it.filename.contains(key) } ) } } } awaitClose { dataCallback = null } }.map { list -> if (sortAscending) when (sortKey) { RemoteBookSort.Name -> list.sortedWith(compareBy { !it.isDir } then compareBy(AlphanumComparator) { it.filename }) else -> list.sortedWith(compareBy({ !it.isDir }, { it.lastModify })) } else when (sortKey) { RemoteBookSort.Name -> list.sortedWith { o1, o2 -> val compare = -compareValues(o1.isDir, o2.isDir) if (compare == 0) { return@sortedWith -AlphanumComparator.compare(o1.filename, o2.filename) } return@sortedWith compare } else -> list.sortedWith { o1, o2 -> val compare = -compareValues(o1.isDir, o2.isDir) if (compare == 0) { return@sortedWith -compareValues(o1.lastModify, o2.lastModify) } return@sortedWith compare } } }.flowOn(Dispatchers.IO) private var remoteBookWebDav: RemoteBookWebDav? = null var isDefaultWebdav = false fun initData(onSuccess: () -> Unit) { execute { isDefaultWebdav = false appDb.serverDao.get(AppConfig.remoteServerId)?.getWebDavConfig()?.let { val authorization = Authorization(it) remoteBookWebDav = RemoteBookWebDav(it.url, authorization, AppConfig.remoteServerId) return@execute } isDefaultWebdav = true remoteBookWebDav = AppWebDav.defaultBookWebDav ?: throw NoStackTraceException("webDav没有配置") }.onError { context.toastOnUi("初始化webDav出错:${it.localizedMessage}") }.onSuccess { onSuccess.invoke() } } fun loadRemoteBookList(path: String?, loadCallback: (loading: Boolean) -> Unit) { executeLazy { val bookWebDav = remoteBookWebDav ?: throw NoStackTraceException("没有配置webDav") dataCallback?.clear() val url = path ?: bookWebDav.rootBookUrl val bookList = bookWebDav.getRemoteBookList(url) dataCallback?.setItems(bookList) }.onError { AppLog.put("获取webDav书籍出错\n${it.localizedMessage}", it) context.toastOnUi("获取webDav书籍出错\n${it.localizedMessage}") }.onStart { loadCallback.invoke(true) }.onFinally { loadCallback.invoke(false) }.start() } fun addToBookshelf(remoteBooks: HashSet, finally: () -> Unit) { execute { val bookWebDav = remoteBookWebDav ?: throw NoStackTraceException("没有配置webDav") remoteBooks.forEach { remoteBook -> val downloadBookUri = bookWebDav.downloadRemoteBook(remoteBook) LocalBook.importFiles(downloadBookUri).forEach { book -> book.origin = BookType.webDavTag + CustomUrl(remoteBook.path) .putAttribute("serverID", bookWebDav.serverID) .toString() book.save() } remoteBook.isOnBookShelf = true } }.onError { AppLog.put("导入出错\n${it.localizedMessage}", it) context.toastOnUi("导入出错\n${it.localizedMessage}") if (it is SecurityException) { permissionDenialLiveData.postValue(1) } }.onFinally { finally.invoke() } } fun updateCallBackFlow(filterKey: String?) { dataCallback?.screen(filterKey) } interface DataCallback { fun setItems(remoteFiles: List) fun addItems(remoteFiles: List) fun clear() fun screen(key: String?) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/import/remote/ServerConfigDialog.kt ================================================ package io.legado.app.ui.book.import.remote import android.os.Bundle import android.text.InputType import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.fragment.app.viewModels import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.data.entities.Server import io.legado.app.data.entities.rule.RowUi import io.legado.app.databinding.DialogWebdavServerBinding import io.legado.app.databinding.ItemSourceEditBinding import io.legado.app.lib.theme.primaryColor import io.legado.app.utils.GSON import io.legado.app.utils.applyTint import io.legado.app.utils.setLayout import io.legado.app.utils.viewbindingdelegate.viewBinding import org.json.JSONObject class ServerConfigDialog() : BaseDialogFragment(R.layout.dialog_webdav_server, true), Toolbar.OnMenuItemClickListener { constructor(id: Long) : this() { arguments = Bundle().apply { putLong("id", id) } } private val binding by viewBinding(DialogWebdavServerBinding::bind) private val viewModel by viewModels() private val webDavServerUi = listOf( RowUi("url"), RowUi("username"), RowUi("password", RowUi.Type.password) ) override fun onStart() { super.onStart() setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) binding.toolBar.inflateMenu(R.menu.server_config) binding.toolBar.menu.applyTint(requireContext()) binding.toolBar.setOnMenuItemClickListener(this) viewModel.init(arguments?.getLong("id")) { upConfigView(viewModel.mServer) } } override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_save -> getServer().let { viewModel.save(it) { dismissAllowingStateLoss() } } } return true } private fun upConfigView(server: Server?) { binding.etName.setText(server?.name) binding.spType.setSelection( when (server?.type) { else -> 0 } ) when (server?.type) { else -> upWebDavServerUi(server?.getConfigJsonObject()) } } private fun upWebDavServerUi(config: JSONObject?) { webDavServerUi.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(config?.getString(rowUi.name)) } RowUi.Type.password -> ItemSourceEditBinding.inflate( layoutInflater, binding.root, false ).let { binding.flexbox.addView(it.root) it.root.id = index + 1000 it.textInputLayout.hint = rowUi.name it.editText.inputType = InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_CLASS_TEXT it.editText.setText(config?.getString(rowUi.name)) } } } } private fun getServer(): Server { val server = viewModel.mServer?.copy() ?: Server() server.name = binding.etName.text.toString() server.type = when (binding.spType.selectedItemPosition) { else -> Server.TYPE.WEBDAV } server.config = when (server.type) { else -> GSON.toJson(getWebDavConfig()) } return server } private fun getWebDavConfig(): HashMap { val data = hashMapOf() webDavServerUi.forEachIndexed { index, rowUi -> val rowView = binding.root.findViewById(index + 1000) ItemSourceEditBinding.bind(rowView).editText.text?.let { data[rowUi.name] = it.toString() } } return data } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/import/remote/ServerConfigViewModel.kt ================================================ package io.legado.app.ui.book.import.remote import android.app.Application import io.legado.app.base.BaseViewModel import io.legado.app.data.appDb import io.legado.app.data.entities.Server import io.legado.app.utils.toastOnUi class ServerConfigViewModel(application: Application): BaseViewModel(application) { var mServer: Server? = null fun init(id: Long?, onSuccess: () -> Unit) { //mServer不为空可能是旋转屏幕界面重新创建,不用更新数据 if (mServer != null) return execute { mServer = if (id != null) { appDb.serverDao.get(id) } else { Server() } }.onSuccess { onSuccess.invoke() } } fun save(server: Server, onSuccess: () -> Unit) { execute { mServer?.let { appDb.serverDao.delete(it) } mServer = server appDb.serverDao.insert(server) }.onSuccess { onSuccess.invoke() }.onError { context.toastOnUi("保存出错\n${it.localizedMessage}") } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/import/remote/ServersDialog.kt ================================================ package io.legado.app.ui.book.import.remote import android.content.Context import android.content.DialogInterface import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.constant.AppConst.DEFAULT_WEBDAV_ID import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.Server import io.legado.app.databinding.DialogRecyclerViewBinding import io.legado.app.databinding.ItemServerSelectBinding import io.legado.app.help.config.AppConfig import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.backgroundColor import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.widget.recycler.VerticalDivider import io.legado.app.utils.applyTint import io.legado.app.utils.setLayout import io.legado.app.utils.showDialogFragment import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch /** * 服务器配置 */ class ServersDialog : BaseDialogFragment(R.layout.dialog_recycler_view), Toolbar.OnMenuItemClickListener { val binding by viewBinding(DialogRecyclerViewBinding::bind) val viewModel by viewModels() private val callback get() = (activity as? Callback) private val adapter by lazy { ServersAdapter(requireContext()) } override fun onStart() { super.onStart() setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) binding.toolBar.setTitle(R.string.server_config) initView() initData() } private fun initView() { binding.toolBar.inflateMenu(R.menu.servers) binding.toolBar.menu.applyTint(requireContext()) binding.toolBar.setOnMenuItemClickListener(this) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.addItemDecoration(VerticalDivider(requireContext())) binding.recyclerView.adapter = adapter binding.tvFooterLeft.text = getString(R.string.text_default) binding.tvFooterLeft.visible() binding.tvFooterLeft.setOnClickListener { AppConfig.remoteServerId = DEFAULT_WEBDAV_ID dismissAllowingStateLoss() } binding.tvCancel.visible() binding.tvCancel.setOnClickListener { dismissAllowingStateLoss() } binding.tvOk.visible() binding.tvOk.setOnClickListener { AppConfig.remoteServerId = adapter.selectServerId dismissAllowingStateLoss() } } private fun initData() { lifecycleScope.launch { appDb.serverDao.observeAll().catch { AppLog.put("服务器配置界面获取数据失败\n${it.localizedMessage}", it) }.flowOn(IO).collect { adapter.setItems(it) } } } override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_add -> showDialogFragment(ServerConfigDialog()) } return true } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) callback?.onDialogDismiss("serversDialog") } inner class ServersAdapter(context: Context) : RecyclerAdapter(context) { var selectServerId: Long = AppConfig.remoteServerId override fun getViewBinding(parent: ViewGroup): ItemServerSelectBinding { return ItemServerSelectBinding.inflate(inflater, parent, false) } override fun registerListener(holder: ItemViewHolder, binding: ItemServerSelectBinding) { binding.rbServer.setOnUserCheckedChangeListener { isChecked -> if (isChecked) { selectServerId = getItemByLayoutPosition(holder.layoutPosition)!!.id adapter.updateItems(0, itemCount - 1, "upSelect") } } binding.ivEdit.setOnClickListener { getItemByLayoutPosition(holder.layoutPosition)?.let { server -> showDialogFragment(ServerConfigDialog(server.id)) } } binding.ivDelete.setOnClickListener { alert { setTitle(R.string.draw) setMessage(R.string.sure_del) yesButton { getItemByLayoutPosition(holder.layoutPosition)?.let { server -> viewModel.delete(server) } } noButton() } } } override fun convert( holder: ItemViewHolder, binding: ItemServerSelectBinding, item: Server, payloads: MutableList ) { if (payloads.isEmpty()) { binding.root.setBackgroundColor(context.backgroundColor) binding.rbServer.text = item.name binding.rbServer.isChecked = item.id == selectServerId } else { binding.rbServer.isChecked = item.id == selectServerId } } } interface Callback { fun onDialogDismiss(tag: String) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/import/remote/ServersViewModel.kt ================================================ package io.legado.app.ui.book.import.remote import android.app.Application import io.legado.app.base.BaseViewModel import io.legado.app.data.appDb import io.legado.app.data.entities.Server class ServersViewModel(application: Application): BaseViewModel(application) { fun delete(server: Server) { execute { appDb.serverDao.delete(server) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt ================================================ package io.legado.app.ui.book.info import android.annotation.SuppressLint import android.content.Intent import android.net.Uri import android.os.Build import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.MotionEvent import android.widget.CheckBox import android.widget.LinearLayout import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.lifecycle.lifecycleScope import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.constant.BookType import io.legado.app.constant.Theme import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookSource import io.legado.app.databinding.ActivityBookInfoBinding import io.legado.app.exception.NoStackTraceException import io.legado.app.help.AppWebDav import io.legado.app.help.book.addType import io.legado.app.help.book.getRemoteUrl import io.legado.app.help.book.isAudio import io.legado.app.help.book.isImage import io.legado.app.help.book.isLocal import io.legado.app.help.book.isLocalTxt import io.legado.app.help.book.isWebFile import io.legado.app.help.book.removeType import io.legado.app.help.config.AppConfig import io.legado.app.help.config.LocalConfig import io.legado.app.lib.dialogs.alert import io.legado.app.lib.dialogs.selector import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.backgroundColor import io.legado.app.lib.theme.bottomBackground import io.legado.app.lib.theme.getPrimaryTextColor import io.legado.app.model.BookCover import io.legado.app.model.remote.RemoteBookWebDav import io.legado.app.ui.about.AppLogDialog import io.legado.app.ui.book.audio.AudioPlayActivity import io.legado.app.ui.book.changecover.ChangeCoverDialog import io.legado.app.ui.book.changesource.ChangeBookSourceDialog import io.legado.app.ui.book.group.GroupSelectDialog import io.legado.app.ui.book.info.edit.BookInfoEditActivity import io.legado.app.ui.book.manga.ReadMangaActivity import io.legado.app.ui.book.read.ReadBookActivity import io.legado.app.ui.book.read.ReadBookActivity.Companion.RESULT_DELETED import io.legado.app.ui.book.search.SearchActivity import io.legado.app.ui.book.source.edit.BookSourceEditActivity import io.legado.app.ui.book.toc.TocActivityResult import io.legado.app.ui.file.HandleFileContract import io.legado.app.ui.login.SourceLoginActivity import io.legado.app.ui.widget.dialog.PhotoDialog import io.legado.app.ui.widget.dialog.VariableDialog import io.legado.app.ui.widget.dialog.WaitDialog import io.legado.app.utils.ColorUtils import io.legado.app.utils.ConvertUtils import io.legado.app.utils.FileDoc import io.legado.app.utils.GSON import io.legado.app.utils.StartActivityContract import io.legado.app.utils.applyNavigationBarPadding import io.legado.app.utils.dpToPx import io.legado.app.utils.gone import io.legado.app.utils.longToastOnUi import io.legado.app.utils.openFileUri import io.legado.app.utils.sendToClip import io.legado.app.utils.shareWithQr import io.legado.app.utils.showDialogFragment 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.Dispatchers.IO import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class BookInfoActivity : VMBaseActivity(toolBarTheme = Theme.Dark), GroupSelectDialog.CallBack, ChangeBookSourceDialog.CallBack, ChangeCoverDialog.CallBack, VariableDialog.Callback { private val tocActivityResult = registerForActivityResult(TocActivityResult()) { it?.let { viewModel.getBook(false)?.let { book -> lifecycleScope.launch { withContext(IO) { book.durChapterIndex = it.first book.durChapterPos = it.second chapterChanged = it.third appDb.bookDao.update(book) } startReadActivity(book) } } } ?: let { if (!viewModel.inBookshelf) { viewModel.delBook() } } } private val localBookTreeSelect = registerForActivityResult(HandleFileContract()) { it.uri?.let { treeUri -> AppConfig.defaultBookTreeUri = treeUri.toString() } } private val readBookResult = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { viewModel.upBook(intent) when (it.resultCode) { RESULT_OK -> { viewModel.inBookshelf = true upTvBookshelf() } RESULT_DELETED -> { setResult(RESULT_OK) finish() } } } private val infoEditResult = registerForActivityResult( StartActivityContract(BookInfoEditActivity::class.java) ) { if (it.resultCode == RESULT_OK) { viewModel.upEditBook() } } private val editSourceResult = registerForActivityResult( StartActivityContract(BookSourceEditActivity::class.java) ) { if (it.resultCode == RESULT_CANCELED) { return@registerForActivityResult } book?.let { book -> viewModel.bookSource = appDb.bookSourceDao.getBookSource(book.origin) viewModel.refreshBook(book) } } private var chapterChanged = false private val waitDialog by lazy { WaitDialog(this) } private var editMenuItem: MenuItem? = null private val book get() = viewModel.getBook(false) override val binding by viewBinding(ActivityBookInfoBinding::inflate) override val viewModel by viewModels() @SuppressLint("PrivateResource") override fun onActivityCreated(savedInstanceState: Bundle?) { binding.titleBar.setBackgroundResource(R.color.transparent) binding.refreshLayout?.setColorSchemeColors(accentColor) binding.arcView.setBgColor(backgroundColor) binding.llInfo.setBackgroundColor(backgroundColor) binding.flAction.setBackgroundColor(bottomBackground) binding.flAction.applyNavigationBarPadding() binding.tvShelf.setTextColor(getPrimaryTextColor(ColorUtils.isColorLight(bottomBackground))) binding.tvToc.text = getString(R.string.toc_s, getString(R.string.loading)) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { binding.tvIntro.revealOnFocusHint = false } viewModel.bookData.observe(this) { showBook(it) } viewModel.chapterListData.observe(this) { upLoading(false, it) } viewModel.waitDialogData.observe(this) { upWaitDialogStatus(it) } viewModel.initData(intent) initViewEvent() } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.book_info, menu) editMenuItem = menu.findItem(R.id.menu_edit) return super.onCompatCreateOptionsMenu(menu) } override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { menu.findItem(R.id.menu_can_update)?.isChecked = viewModel.bookData.value?.canUpdate ?: true menu.findItem(R.id.menu_split_long_chapter)?.isChecked = viewModel.bookData.value?.getSplitLongChapter() ?: true menu.findItem(R.id.menu_login)?.isVisible = !viewModel.bookSource?.loginUrl.isNullOrBlank() menu.findItem(R.id.menu_set_source_variable)?.isVisible = viewModel.bookSource != null menu.findItem(R.id.menu_set_book_variable)?.isVisible = viewModel.bookSource != null menu.findItem(R.id.menu_can_update)?.isVisible = viewModel.bookSource != null menu.findItem(R.id.menu_split_long_chapter)?.isVisible = viewModel.bookData.value?.isLocalTxt ?: false menu.findItem(R.id.menu_upload)?.isVisible = viewModel.bookData.value?.isLocal ?: false menu.findItem(R.id.menu_delete_alert)?.isChecked = LocalConfig.bookInfoDeleteAlert return super.onMenuOpened(featureId, menu) } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_edit -> { viewModel.getBook()?.let { infoEditResult.launch { putExtra("bookUrl", it.bookUrl) } } } R.id.menu_share_it -> { viewModel.getBook()?.let { val bookJson = GSON.toJson(it) val shareStr = "${it.bookUrl}#$bookJson" shareWithQr(shareStr, it.name) } } R.id.menu_refresh -> { refreshBook() } R.id.menu_login -> viewModel.bookSource?.let { startActivity { putExtra("type", "bookSource") putExtra("key", it.bookSourceUrl) } } R.id.menu_top -> viewModel.topBook() R.id.menu_set_source_variable -> setSourceVariable() R.id.menu_set_book_variable -> setBookVariable() R.id.menu_copy_book_url -> viewModel.getBook()?.bookUrl?.let { sendToClip(it) } R.id.menu_copy_toc_url -> viewModel.getBook()?.tocUrl?.let { sendToClip(it) } R.id.menu_can_update -> { viewModel.getBook()?.let { it.canUpdate = !it.canUpdate if (viewModel.inBookshelf) { if (!it.canUpdate) { it.removeType(BookType.updateError) } viewModel.saveBook(it) } } } R.id.menu_clear_cache -> viewModel.clearCache() R.id.menu_log -> showDialogFragment() R.id.menu_split_long_chapter -> { upLoading(true) viewModel.getBook()?.let { it.setSplitLongChapter(!item.isChecked) viewModel.loadBookInfo(it, false) } item.isChecked = !item.isChecked if (!item.isChecked) longToastOnUi(R.string.need_more_time_load_content) } R.id.menu_delete_alert -> LocalConfig.bookInfoDeleteAlert = !item.isChecked R.id.menu_upload -> { viewModel.getBook()?.let { book -> book.getRemoteUrl()?.let { alert(R.string.draw, R.string.sure_upload) { okButton { upLoadBook(book) } cancelButton() } } ?: upLoadBook(book) } } } return super.onCompatOptionsItemSelected(item) } override fun observeLiveBus() { viewModel.actionLive.observe(this) { when (it) { "selectBooksDir" -> localBookTreeSelect.launch { title = getString(R.string.select_book_folder) } } } } override fun dispatchTouchEvent(ev: MotionEvent): Boolean { if (ev.action == MotionEvent.ACTION_DOWN) { currentFocus?.let { if (it === binding.tvIntro && binding.tvIntro.hasSelection()) { it.clearFocus() } } } return super.dispatchTouchEvent(ev) } private fun refreshBook() { upLoading(true) viewModel.getBook()?.let { viewModel.refreshBook(it) } } private fun upLoadBook( book: Book, bookWebDav: RemoteBookWebDav? = AppWebDav.defaultBookWebDav, ) { lifecycleScope.launch { waitDialog.setText("上传中.....") waitDialog.show() try { bookWebDav ?.upload(book) ?: throw NoStackTraceException("未配置webDav") //更新书籍最后更新时间,使之比远程书籍的时间新 book.lastCheckTime = System.currentTimeMillis() viewModel.saveBook(book) } catch (e: Exception) { toastOnUi(e.localizedMessage) } finally { waitDialog.dismiss() } } } private fun showBook(book: Book) = binding.run { showCover(book) tvName.text = book.name tvAuthor.text = getString(R.string.author_show, book.getRealAuthor()) tvOrigin.text = getString(R.string.origin_show, book.originName) tvLasted.text = getString(R.string.lasted_show, book.latestChapterTitle) tvIntro.text = book.getDisplayIntro() llToc?.visible(!book.isWebFile) upTvBookshelf() upKinds(book) upGroup(book.group) } private fun upKinds(book: Book) = binding.run { lifecycleScope.launch { var kinds = book.getKindList() if (book.isLocal) { withContext(IO) { val size = FileDoc.fromFile(book.bookUrl).size if (size > 0) { kinds = kinds.toMutableList() kinds.add(ConvertUtils.formatFileSize(size)) } } } if (kinds.isEmpty()) { lbKind.gone() } else { lbKind.visible() lbKind.setLabels(kinds) } } } private fun showCover(book: Book) { binding.ivCover.load(book.getDisplayCover(), book.name, book.author, false, book.origin) { if (!AppConfig.isEInkMode) { BookCover.loadBlur(this, book.getDisplayCover(), false, book.origin) .into(binding.bgBook) } } } private fun upLoading(isLoading: Boolean, chapterList: List? = null) { when { isLoading -> { binding.tvToc.text = getString(R.string.toc_s, getString(R.string.loading)) } chapterList.isNullOrEmpty() -> { binding.tvToc.text = getString( R.string.toc_s, getString(R.string.error_load_toc) ) } else -> { book?.let { binding.tvToc.text = getString(R.string.toc_s, it.durChapterTitle) binding.tvLasted.text = getString(R.string.lasted_show, it.latestChapterTitle) } } } } private fun upTvBookshelf() { if (viewModel.inBookshelf) { binding.tvShelf.text = getString(R.string.remove_from_bookshelf) } else { binding.tvShelf.text = getString(R.string.add_to_bookshelf) } editMenuItem?.isVisible = viewModel.inBookshelf } private fun upGroup(groupId: Long) { viewModel.loadGroup(groupId) { if (it.isNullOrEmpty()) { binding.tvGroup.text = if (book?.isLocal == true) { getString(R.string.group_s, getString(R.string.local_no_group)) } else { getString(R.string.group_s, getString(R.string.no_group)) } } else { binding.tvGroup.text = getString(R.string.group_s, it) } } } private fun initViewEvent() = binding.run { ivCover.setOnClickListener { viewModel.getBook()?.let { showDialogFragment( ChangeCoverDialog(it.name, it.author) ) } } ivCover.setOnLongClickListener { viewModel.getBook()?.getDisplayCover()?.let { path -> showDialogFragment(PhotoDialog(path)) } true } tvRead.setOnClickListener { viewModel.getBook()?.let { book -> if (book.isWebFile) { showWebFileDownloadAlert { readBook(it) } } else { readBook(book) } } } tvShelf.setOnClickListener { viewModel.getBook()?.let { book -> if (viewModel.inBookshelf) { deleteBook() } else { if (book.isWebFile) { showWebFileDownloadAlert() } else { viewModel.addToBookshelf { upTvBookshelf() } } } } } tvOrigin.setOnClickListener { viewModel.getBook()?.let { book -> if (book.isLocal) return@let if (!appDb.bookSourceDao.has(book.origin)) { toastOnUi(R.string.error_no_source) return@let } editSourceResult.launch { putExtra("sourceUrl", book.origin) } } } tvChangeSource.setOnClickListener { viewModel.getBook()?.let { book -> showDialogFragment(ChangeBookSourceDialog(book.name, book.author)) } } tvTocView.setOnClickListener { if (viewModel.chapterListData.value.isNullOrEmpty()) { toastOnUi(R.string.chapter_list_empty) return@setOnClickListener } viewModel.getBook()?.let { book -> if (!viewModel.inBookshelf) { viewModel.saveBook(book) { viewModel.saveChapterList { openChapterList() } } } else { openChapterList() } } } tvChangeGroup.setOnClickListener { viewModel.getBook()?.let { showDialogFragment( GroupSelectDialog(it.group) ) } } tvAuthor.setOnClickListener { viewModel.getBook(false)?.let { book -> startActivity { putExtra("key", book.author) } } } tvName.setOnClickListener { viewModel.getBook(false)?.let { book -> startActivity { putExtra("key", book.name) } } } refreshLayout?.setOnRefreshListener { refreshLayout.isRefreshing = false refreshBook() } } private fun setSourceVariable() { lifecycleScope.launch { val source = viewModel.bookSource if (source == null) { toastOnUi("书源不存在") return@launch } val comment = source.getDisplayVariableComment("源变量可在js中通过source.getVariable()获取") val variable = withContext(IO) { source.getVariable() } showDialogFragment( VariableDialog( getString(R.string.set_source_variable), source.getKey(), variable, comment ) ) } } private fun setBookVariable() { lifecycleScope.launch { val source = viewModel.bookSource if (source == null) { toastOnUi("书源不存在") return@launch } val book = viewModel.getBook() ?: return@launch val variable = withContext(IO) { book.getCustomVariable() } val comment = source.getDisplayVariableComment( """书籍变量可在js中通过book.getVariable("custom")获取""" ) showDialogFragment( VariableDialog( getString(R.string.set_book_variable), book.bookUrl, variable, comment ) ) } } override fun setVariable(key: String, variable: String?) { when (key) { viewModel.bookSource?.getKey() -> viewModel.bookSource?.setVariable(variable) viewModel.bookData.value?.bookUrl -> viewModel.bookData.value?.let { it.putCustomVariable(variable) if (viewModel.inBookshelf) { viewModel.saveBook(it) } } } } @SuppressLint("InflateParams") private fun deleteBook() { viewModel.getBook()?.let { if (LocalConfig.bookInfoDeleteAlert) { alert( titleResource = R.string.draw, messageResource = R.string.sure_del ) { var checkBox: CheckBox? = null if (it.isLocal) { checkBox = CheckBox(this@BookInfoActivity).apply { setText(R.string.delete_book_file) isChecked = LocalConfig.deleteBookOriginal } val view = LinearLayout(this@BookInfoActivity).apply { setPadding(16.dpToPx(), 0, 16.dpToPx(), 0) addView(checkBox) } customView { view } } yesButton { if (checkBox != null) { LocalConfig.deleteBookOriginal = checkBox.isChecked } viewModel.delBook(LocalConfig.deleteBookOriginal) { setResult(RESULT_OK) finish() } } noButton() } } else { viewModel.delBook(LocalConfig.deleteBookOriginal) { setResult(RESULT_OK) finish() } } } } private fun openChapterList() { viewModel.getBook()?.let { tocActivityResult.launch(it.bookUrl) } } private fun showWebFileDownloadAlert( onClick: ((Book) -> Unit)? = null, ) { val webFiles = viewModel.webFiles if (webFiles.isEmpty()) { toastOnUi("Unexpected webFileData") return } selector( R.string.download_and_import_file, webFiles ) { _, webFile, _ -> if (webFile.isSupported) { /* import */ viewModel.importOrDownloadWebFile(webFile) { onClick?.invoke(it) } } else if (webFile.isSupportDecompress) { /* 解压筛选后再选择导入项 */ viewModel.importOrDownloadWebFile(webFile) { uri -> viewModel.getArchiveFilesName(uri) { fileNames -> if (fileNames.size == 1) { viewModel.importArchiveBook(uri, fileNames[0]) { onClick?.invoke(it) } } else { showDecompressFileImportAlert(uri, fileNames, onClick) } } } } else { alert( title = getString(R.string.draw), message = getString(R.string.file_not_supported, webFile.name) ) { neutralButton(R.string.open_fun) { /* download only */ viewModel.importOrDownloadWebFile(webFile) { openFileUri(it, "*/*") } } noButton() } } } } private fun showDecompressFileImportAlert( archiveFileUri: Uri, fileNames: List, success: ((Book) -> Unit)? = null, ) { if (fileNames.isEmpty()) { toastOnUi(R.string.unsupport_archivefile_entry) return } selector( R.string.import_select_book, fileNames ) { _, name, _ -> viewModel.importArchiveBook(archiveFileUri, name) { success?.invoke(it) } } } private fun readBook(book: Book) { if (!viewModel.inBookshelf) { book.addType(BookType.notShelf) viewModel.saveBook(book) { viewModel.saveChapterList { startReadActivity(book) } } } else { viewModel.saveBook(book) { startReadActivity(book) } } } private fun startReadActivity(book: Book) { when { book.isAudio -> readBookResult.launch( Intent(this, AudioPlayActivity::class.java) .putExtra("bookUrl", book.bookUrl) .putExtra("inBookshelf", viewModel.inBookshelf) ) else -> readBookResult.launch( Intent( this, if (!book.isLocal && book.isImage && AppConfig.showMangaUi) ReadMangaActivity::class.java else ReadBookActivity::class.java ) .putExtra("bookUrl", book.bookUrl) .putExtra("inBookshelf", viewModel.inBookshelf) .putExtra("chapterChanged", chapterChanged) ) } } override val oldBook: Book? get() = viewModel.bookData.value override fun changeTo(source: BookSource, book: Book, toc: List) { viewModel.changeTo(source, book, toc) } override fun coverChangeTo(coverUrl: String) { viewModel.bookData.value?.let { book -> book.customCoverUrl = coverUrl showCover(book) if (viewModel.inBookshelf) { viewModel.saveBook(book) } } } override fun upGroup(requestCode: Int, groupId: Long) { upGroup(groupId) viewModel.getBook()?.let { book -> book.group = groupId if (viewModel.inBookshelf) { viewModel.saveBook(book) } else if (groupId > 0) { viewModel.addToBookshelf { upTvBookshelf() } } } } private fun upWaitDialogStatus(isShow: Boolean) { val showText = "Loading....." if (isShow) { waitDialog.run { setText(showText) show() } } else { waitDialog.dismiss() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt ================================================ package io.legado.app.ui.book.info import android.app.Application import android.content.Intent import android.net.Uri import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import io.legado.app.R import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppLog import io.legado.app.constant.AppPattern import io.legado.app.constant.BookType import io.legado.app.constant.EventBus import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookSource import io.legado.app.exception.NoBooksDirException import io.legado.app.exception.NoStackTraceException import io.legado.app.help.AppWebDav import io.legado.app.help.book.BookHelp import io.legado.app.help.book.getExportFileName import io.legado.app.help.book.getRemoteUrl import io.legado.app.help.book.isLocal import io.legado.app.help.book.isNotShelf import io.legado.app.help.book.isSameNameAuthor import io.legado.app.help.book.isWebFile import io.legado.app.help.book.removeType import io.legado.app.help.book.updateTo import io.legado.app.help.coroutine.Coroutine import io.legado.app.lib.webdav.ObjectNotFoundException import io.legado.app.model.AudioPlay import io.legado.app.model.BookCover import io.legado.app.model.ReadBook import io.legado.app.model.ReadManga import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.model.localBook.LocalBook import io.legado.app.model.webBook.WebBook import io.legado.app.utils.ArchiveUtils import io.legado.app.utils.UrlUtil import io.legado.app.utils.isContentScheme import io.legado.app.utils.postEvent import io.legado.app.utils.toastOnUi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.IO class BookInfoViewModel(application: Application) : BaseViewModel(application) { val bookData = MutableLiveData() val chapterListData = MutableLiveData>() val webFiles = mutableListOf() var inBookshelf = false var bookSource: BookSource? = null private var changeSourceCoroutine: Coroutine<*>? = null val waitDialogData = MutableLiveData() val actionLive = MutableLiveData() fun initData(intent: Intent) { execute { val name = intent.getStringExtra("name") ?: "" val author = intent.getStringExtra("author") ?: "" val bookUrl = intent.getStringExtra("bookUrl") ?: "" appDb.bookDao.getBook(name, author)?.let { inBookshelf = !it.isNotShelf upBook(it) return@execute } if (bookUrl.isNotBlank()) { appDb.bookDao.getBook(bookUrl)?.let { inBookshelf = !it.isNotShelf upBook(it) return@execute } appDb.searchBookDao.getSearchBook(bookUrl)?.toBook()?.let { upBook(it) return@execute } } appDb.searchBookDao.getFirstByNameAuthor(name, author)?.toBook()?.let { upBook(it) return@execute } throw NoStackTraceException("未找到书籍") }.onError { AppLog.put(it.localizedMessage, it) context.toastOnUi(it.localizedMessage) } } fun upBook(intent: Intent) { execute { val name = intent.getStringExtra("name") ?: "" val author = intent.getStringExtra("author") ?: "" appDb.bookDao.getBook(name, author)?.let { book -> upBook(book) } } } private fun upBook(book: Book) { execute { bookData.postValue(book) upCoverByRule(book) bookSource = if (book.isLocal) null else appDb.bookSourceDao.getBookSource(book.origin) if (book.tocUrl.isEmpty() && !book.isLocal) { loadBookInfo(book, runPreUpdateJs = inBookshelf) } else { val chapterList = appDb.bookChapterDao.getChapterList(book.bookUrl) if (chapterList.isNotEmpty()) { chapterListData.postValue(chapterList) } else { loadChapter(book) } } } } private fun upCoverByRule(book: Book) { execute { if (book.coverUrl.isNullOrBlank() && book.customCoverUrl.isNullOrBlank()) { val coverUrl = BookCover.searchCover(book) if (coverUrl.isNullOrBlank()) { return@execute } book.customCoverUrl = coverUrl bookData.postValue(book) if (inBookshelf) { saveBook(book) } } } } fun refreshBook(book: Book) { executeLazy(executeContext = IO) { if (book.isLocal) { book.tocUrl = "" book.getRemoteUrl()?.let { val bookWebDav = AppWebDav.defaultBookWebDav ?: throw NoStackTraceException("webDav没有配置") val remoteBook = bookWebDav.getRemoteBook(it) if (remoteBook == null) { book.origin = BookType.localTag } else if (remoteBook.lastModify > book.lastCheckTime) { val uri = bookWebDav.downloadRemoteBook(remoteBook) book.bookUrl = if (uri.isContentScheme()) uri.toString() else uri.path!! book.lastCheckTime = remoteBook.lastModify } } } else { val bs = bookSource ?: return@executeLazy if (book.originName != bs.bookSourceName) { book.originName = bs.bookSourceName } } }.onError { when (it) { is ObjectNotFoundException -> { book.origin = BookType.localTag } else -> { AppLog.put("下载远程书籍<${book.name}>失败", it) } } }.onFinally { loadBookInfo(book, false) }.start() } fun loadBookInfo( book: Book, canReName: Boolean = true, runPreUpdateJs: Boolean = true, scope: CoroutineScope = viewModelScope ) { if (book.isLocal) { LocalBook.upBookInfo(book) bookData.postValue(book) loadChapter(book) } else { val bookSource = bookSource ?: let { chapterListData.postValue(emptyList()) context.toastOnUi(R.string.error_no_source) return } WebBook.getBookInfo(scope, bookSource, book, canReName = canReName) .onSuccess(IO) { val dbBook = appDb.bookDao.getBook(book.name, book.author) if (!inBookshelf && dbBook != null && !dbBook.isNotShelf && dbBook.origin == book.origin) { /** * book 来自搜索时(inBookshelf == false),搜索的书名不存在于书架,但是加载详情后,书名更新,存在同名书籍 * 此时 book 的数据会与数据库中的不同,需要更新 #3652 #4619 * book 加载详情后虽然书名作者相同,但是又可能不是数据库中(书源不同)的那本书 #3149 */ dbBook.updateTo(it) inBookshelf = true } bookData.postValue(it) if (inBookshelf) { it.save() } if (it.isWebFile) { loadWebFile(it) } else { loadChapter(it, runPreUpdateJs) } }.onError { AppLog.put("获取书籍信息失败\n${it.localizedMessage}", it) context.toastOnUi(R.string.error_get_book_info) } } } private fun loadChapter( book: Book, runPreUpdateJs: Boolean = true, scope: CoroutineScope = viewModelScope ) { if (book.isLocal) { execute(scope) { LocalBook.getChapterList(book).let { appDb.bookDao.update(book) appDb.bookChapterDao.delByBook(book.bookUrl) appDb.bookChapterDao.insert(*it.toTypedArray()) ReadBook.onChapterListUpdated(book) bookData.postValue(book) chapterListData.postValue(it) } }.onError { context.toastOnUi("LoadTocError:${it.localizedMessage}") } } else { val bookSource = bookSource ?: let { chapterListData.postValue(emptyList()) context.toastOnUi(R.string.error_no_source) return } val oldBook = book.copy() WebBook.getChapterList(scope, bookSource, book, runPreUpdateJs) .onSuccess(IO) { if (inBookshelf) { appDb.bookDao.replace(oldBook, book) /** * runPreUpdateJs 有可能会修改 book 的 bookUrl */ if (oldBook.bookUrl != book.bookUrl) { BookHelp.updateCacheFolder(oldBook, book) } appDb.bookChapterDao.delByBook(oldBook.bookUrl) appDb.bookChapterDao.insert(*it.toTypedArray()) ReadBook.onChapterListUpdated(book) } bookData.postValue(book) chapterListData.postValue(it) }.onError { chapterListData.postValue(emptyList()) AppLog.put("获取目录失败\n${it.localizedMessage}", it) context.toastOnUi(R.string.error_get_chapter_list) } } } fun loadGroup(groupId: Long, success: ((groupNames: String?) -> Unit)) { execute { appDb.bookGroupDao.getGroupNames(groupId).joinToString(",") }.onSuccess { success.invoke(it) } } private fun loadWebFile(book: Book) { execute { webFiles.clear() val fileNameNoExtension = if (book.author.isBlank()) book.name else "${book.name} 作者:${book.author}" book.downloadUrls!!.map { val analyzeUrl = AnalyzeUrl( it, source = bookSource, coroutineContext = coroutineContext ) val mFileName = UrlUtil.getFileName(analyzeUrl) ?: "${fileNameNoExtension}.${analyzeUrl.type}" WebFile(it, mFileName) } }.onError { context.toastOnUi("LoadWebFileError\n${it.localizedMessage}") }.onSuccess { webFiles.addAll(it) } } /* 导入或者下载在线文件 */ fun importOrDownloadWebFile(webFile: WebFile, success: ((T) -> Unit)?) { bookSource ?: return execute { waitDialogData.postValue(true) if (webFile.isSupported) { val book = LocalBook.importFileOnLine( webFile.url, bookData.value!!.getExportFileName(webFile.suffix), bookSource ) changeToLocalBook(book) } else { LocalBook.saveBookFile( webFile.url, bookData.value!!.getExportFileName(webFile.suffix), bookSource ) } }.onSuccess { @Suppress("unchecked_cast") success?.invoke(it as T) }.onError { when (it) { is NoBooksDirException -> actionLive.postValue("selectBooksDir") else -> { AppLog.put("ImportWebFileError\n${it.localizedMessage}", it) context.toastOnUi("ImportWebFileError\n${it.localizedMessage}") webFiles.remove(webFile) } } }.onFinally { waitDialogData.postValue(false) } } fun getArchiveFilesName(archiveFileUri: Uri, onSuccess: (List) -> Unit) { execute { ArchiveUtils.getArchiveFilesName(archiveFileUri) { AppPattern.bookFileRegex.matches(it) } }.onError { AppLog.put("getArchiveEntriesName Error:\n${it.localizedMessage}", it) context.toastOnUi("getArchiveEntriesName Error:\n${it.localizedMessage}") }.onSuccess { onSuccess.invoke(it) } } fun importArchiveBook( archiveFileUri: Uri, archiveEntryName: String, success: ((Book) -> Unit)? = null ) { execute { val suffix = archiveEntryName.substringAfterLast(".") LocalBook.importArchiveFile( archiveFileUri, bookData.value!!.getExportFileName(suffix) ) { it.contains(archiveEntryName) }.first() }.onSuccess { val book = changeToLocalBook(it) success?.invoke(book) }.onError { AppLog.put("importArchiveBook Error:\n${it.localizedMessage}", it) context.toastOnUi("importArchiveBook Error:\n${it.localizedMessage}") } } fun changeTo(source: BookSource, book: Book, toc: List) { changeSourceCoroutine?.cancel() changeSourceCoroutine = execute { bookSource = source bookData.value?.migrateTo(book, toc) if (inBookshelf) { book.removeType(BookType.updateError) bookData.value?.delete() appDb.bookDao.insert(book) appDb.bookChapterDao.insert(*toc.toTypedArray()) } bookData.postValue(book) chapterListData.postValue(toc) }.onFinally { postEvent(EventBus.SOURCE_CHANGED, book.bookUrl) } } fun topBook() { execute { bookData.value?.let { book -> val minOrder = appDb.bookDao.minOrder book.order = minOrder - 1 book.durChapterTime = System.currentTimeMillis() appDb.bookDao.update(book) } } } fun saveBook(book: Book?, success: (() -> Unit)? = null) { book ?: return execute { if (book.order == 0) { book.order = appDb.bookDao.minOrder - 1 } appDb.bookDao.getBook(book.name, book.author)?.let { book.durChapterIndex = it.durChapterIndex book.durChapterPos = it.durChapterPos book.durChapterTitle = it.durChapterTitle } book.save() if (ReadBook.book?.isSameNameAuthor(book) == true) { ReadBook.book = book } else if (AudioPlay.book?.isSameNameAuthor(book) == true) { AudioPlay.book = book } }.onSuccess { success?.invoke() } } fun saveChapterList(success: (() -> Unit)?) { execute { chapterListData.value?.let { appDb.bookChapterDao.insert(*it.toTypedArray()) } }.onSuccess { success?.invoke() } } fun addToBookshelf(success: (() -> Unit)?) { execute { bookData.value?.let { book -> book.removeType(BookType.notShelf) if (book.order == 0) { book.order = appDb.bookDao.minOrder - 1 } appDb.bookDao.getBook(book.name, book.author)?.let { book.durChapterIndex = it.durChapterIndex book.durChapterPos = it.durChapterPos book.durChapterTitle = it.durChapterTitle } if (ReadBook.book?.isSameNameAuthor(book) == true) { ReadBook.book = book } else if (AudioPlay.book?.isSameNameAuthor(book) == true) { AudioPlay.book = book } book.save() } chapterListData.value?.let { appDb.bookChapterDao.insert(*it.toTypedArray()) } inBookshelf = true }.onSuccess { success?.invoke() } } fun getBook(toastNull: Boolean = true): Book? { val book = bookData.value if (toastNull && book == null) { context.toastOnUi("book is null") } return book } fun delBook(deleteOriginal: Boolean = false, success: (() -> Unit)? = null) { execute { bookData.value?.let { it.delete() inBookshelf = false if (it.isLocal) { LocalBook.deleteBook(it, deleteOriginal) } } }.onSuccess { success?.invoke() } } fun clearCache() { execute { BookHelp.clearCache(bookData.value!!) if (ReadBook.book?.bookUrl == bookData.value!!.bookUrl) { ReadBook.clearTextChapter() } if (ReadManga.book?.bookUrl == bookData.value!!.bookUrl) { ReadManga.clearMangaChapter() } }.onSuccess { context.toastOnUi(R.string.clear_cache_success) }.onError { context.toastOnUi("清理缓存出错\n${it.localizedMessage}") } } fun upEditBook() { bookData.value?.let { appDb.bookDao.getBook(it.bookUrl)?.let { book -> bookData.postValue(book) } } } private fun changeToLocalBook(localBook: Book): Book { return LocalBook.mergeBook(localBook, bookData.value).let { bookData.postValue(it) loadChapter(it) inBookshelf = true it } } data class WebFile( val url: String, val name: String, ) { override fun toString(): String { return name } // 后缀 val suffix: String = UrlUtil.getSuffix(name) // txt epub umd pdf等文件 val isSupported: Boolean = AppPattern.bookFileRegex.matches(name) // 压缩包形式的txt epub umd pdf文件 val isSupportDecompress: Boolean = AppPattern.archiveFileRegex.matches(name) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditActivity.kt ================================================ package io.legado.app.ui.book.info.edit import android.net.Uri import android.os.Bundle import android.view.Menu import android.view.MenuItem import androidx.activity.viewModels import androidx.core.view.WindowInsetsCompat import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.constant.BookType import io.legado.app.data.entities.Book import io.legado.app.databinding.ActivityBookInfoEditBinding import io.legado.app.help.book.BookHelp import io.legado.app.help.book.addType import io.legado.app.help.book.isAudio import io.legado.app.help.book.isImage import io.legado.app.help.book.isLocal import io.legado.app.help.book.removeType import io.legado.app.ui.book.changecover.ChangeCoverDialog import io.legado.app.ui.file.HandleFileContract import io.legado.app.utils.FileUtils import io.legado.app.utils.MD5Utils import io.legado.app.utils.externalFiles import io.legado.app.utils.inputStream import io.legado.app.utils.readUri import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat import io.legado.app.utils.showDialogFragment import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding import splitties.init.appCtx import splitties.views.bottomPadding import java.io.FileOutputStream class BookInfoEditActivity : VMBaseActivity(), ChangeCoverDialog.CallBack { private val selectCover = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> coverChangeTo(uri) } } override val binding by viewBinding(ActivityBookInfoEditBinding::inflate) override val viewModel by viewModels() override fun onActivityCreated(savedInstanceState: Bundle?) { viewModel.bookData.observe(this) { upView(it) } if (viewModel.bookData.value == null) { intent.getStringExtra("bookUrl")?.let { viewModel.loadBook(it) } } initView() initEvent() } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.book_info_edit, menu) return super.onCompatCreateOptionsMenu(menu) } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_save -> saveData() } return super.onCompatOptionsItemSelected(item) } private fun initView() { binding.root.setOnApplyWindowInsetsListenerCompat { view, windowInsets -> val typeMask = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime() val insets = windowInsets.getInsets(typeMask) view.bottomPadding = insets.bottom windowInsets } } private fun initEvent() = binding.run { tvChangeCover.setOnClickListener { viewModel.bookData.value?.let { showDialogFragment( ChangeCoverDialog(it.name, it.author) ) } } tvSelectCover.setOnClickListener { selectCover.launch { mode = HandleFileContract.IMAGE } } tvRefreshCover.setOnClickListener { viewModel.book?.customCoverUrl = tieCoverUrl.text?.toString() upCover() } } private fun upView(book: Book) = binding.run { tieBookName.setText(book.name) tieBookAuthor.setText(book.author) spType.setSelection( when { book.isImage -> 2 book.isAudio -> 1 else -> 0 } ) tieCoverUrl.setText(book.getDisplayCover()) tieBookIntro.setText(book.getDisplayIntro()) upCover() } private fun upCover() { viewModel.book?.let { binding.ivCover.load(it.getDisplayCover(), it.name, it.author, false, it.origin) } } private fun saveData() = binding.run { val book = viewModel.book ?: return@run val oldBook = book.copy() book.name = tieBookName.text?.toString() ?: "" book.author = tieBookAuthor.text?.toString() ?: "" val local = if (book.isLocal) BookType.local else 0 val bookType = when (spType.selectedItemPosition) { 2 -> BookType.image or local 1 -> BookType.audio or local else -> BookType.text or local } book.removeType(BookType.local, BookType.image, BookType.audio, BookType.text) book.addType(bookType) val customCoverUrl = tieCoverUrl.text?.toString() book.customCoverUrl = if (customCoverUrl == book.coverUrl) null else customCoverUrl val customIntro = tieBookIntro.text?.toString() book.customIntro = if (customIntro == book.intro) null else customIntro BookHelp.updateCacheFolder(oldBook, book) viewModel.saveBook(book) { setResult(RESULT_OK) finish() } } override fun coverChangeTo(coverUrl: String) { viewModel.book?.customCoverUrl = coverUrl binding.tieCoverUrl.setText(coverUrl) upCover() } private fun coverChangeTo(uri: Uri) { readUri(uri) { fileDoc, inputStream -> runCatching { inputStream.use { var file = this.externalFiles val suffix = fileDoc.name.substringAfterLast(".") val fileName = uri.inputStream(this).getOrThrow().use { MD5Utils.md5Encode(it) + ".$suffix" } file = FileUtils.createFileIfNotExist(file, "covers", fileName) FileOutputStream(file).use { outputStream -> inputStream.copyTo(outputStream) } coverChangeTo(file.absolutePath) } }.onFailure { appCtx.toastOnUi(it.localizedMessage) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditViewModel.kt ================================================ package io.legado.app.ui.book.info.edit import android.app.Application import android.database.sqlite.SQLiteConstraintException import androidx.lifecycle.MutableLiveData import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.model.ReadBook class BookInfoEditViewModel(application: Application) : BaseViewModel(application) { var book: Book? = null val bookData = MutableLiveData() fun loadBook(bookUrl: String) { execute { book = appDb.bookDao.getBook(bookUrl) book?.let { bookData.postValue(it) } } } fun saveBook(book: Book, success: (() -> Unit)?) { execute { if (ReadBook.book?.bookUrl == book.bookUrl) { ReadBook.book = book } appDb.bookDao.update(book) }.onSuccess { success?.invoke() }.onError { if (it is SQLiteConstraintException) { AppLog.put("书籍信息保存失败,存在相同书名作者书籍\n$it", it, true) } else { AppLog.put("书籍信息保存失败\n$it", it, true) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/manage/BookAdapter.kt ================================================ package io.legado.app.ui.book.manage import android.annotation.SuppressLint import android.content.Context import android.view.View import android.view.ViewGroup import androidx.core.os.bundleOf import androidx.recyclerview.widget.RecyclerView import io.legado.app.R import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookGroup import io.legado.app.databinding.ItemArrangeBookBinding import io.legado.app.help.book.isLocal import io.legado.app.help.config.AppConfig import io.legado.app.lib.theme.backgroundColor import io.legado.app.ui.widget.recycler.DragSelectTouchHelper import io.legado.app.ui.widget.recycler.ItemTouchCallback import java.util.Collections class BookAdapter(context: Context, val callBack: CallBack) : RecyclerAdapter(context), ItemTouchCallback.Callback { val groupRequestCode = 12 private val selectedBooks: HashSet = hashSetOf() var actionItem: Book? = null val selection: List get() { return getItems().filter { selectedBooks.contains(it) } } override fun getViewBinding(parent: ViewGroup): ItemArrangeBookBinding { return ItemArrangeBookBinding.inflate(inflater, parent, false) } override fun onCurrentListChanged() { callBack.upSelectCount() } override fun convert( holder: ItemViewHolder, binding: ItemArrangeBookBinding, item: Book, payloads: MutableList ) { binding.apply { root.setBackgroundColor(context.backgroundColor) tvName.text = item.name tvAuthor.text = item.author tvAuthor.visibility = if (item.author.isEmpty()) View.GONE else View.VISIBLE tvGroupS.text = getGroupName(item.group) checkbox.isChecked = selectedBooks.contains(item) if (item.isLocal) { tvOrigin.setText(R.string.local_book) } else { tvOrigin.text = item.originName } } } override fun registerListener(holder: ItemViewHolder, binding: ItemArrangeBookBinding) { binding.apply { checkbox.setOnUserCheckedChangeListener { isChecked -> getItem(holder.layoutPosition)?.let { if (isChecked) { selectedBooks.add(it) } else { selectedBooks.remove(it) } callBack.upSelectCount() } } root.setOnClickListener { getItem(holder.layoutPosition)?.let { checkbox.isChecked = !checkbox.isChecked if (checkbox.isChecked) { selectedBooks.add(it) } else { selectedBooks.remove(it) } callBack.upSelectCount() } } if (AppConfig.openBookInfoByClickTitle) { tvName.setOnClickListener { getItem(holder.layoutPosition)?.let { callBack.openBook(it) } } } tvDelete.setOnClickListener { getItem(holder.layoutPosition)?.let { callBack.deleteBook(it) } } tvGroup.setOnClickListener { getItem(holder.layoutPosition)?.let { actionItem = it callBack.selectGroup(groupRequestCode, it.group) } } } } @SuppressLint("NotifyDataSetChanged") fun selectAll(selectAll: Boolean) { if (selectAll) { getItems().forEach { selectedBooks.add(it) } } else { selectedBooks.clear() } notifyDataSetChanged() callBack.upSelectCount() } @SuppressLint("NotifyDataSetChanged") fun revertSelection() { getItems().forEach { if (selectedBooks.contains(it)) { selectedBooks.remove(it) } else { selectedBooks.add(it) } } notifyDataSetChanged() callBack.upSelectCount() } fun checkSelectedInterval() { val selectedPosition = linkedSetOf() getItems().forEachIndexed { index, it -> if (selectedBooks.contains(it)) { selectedPosition.add(index) } } val minPosition = Collections.min(selectedPosition) val maxPosition = Collections.max(selectedPosition) val itemCount = maxPosition - minPosition + 1 for (i in minPosition..maxPosition) { getItem(i)?.let { selectedBooks.add(it) } } notifyItemRangeChanged(minPosition, itemCount, bundleOf(Pair("selected", null))) callBack.upSelectCount() } private fun getGroupList(groupId: Long): List { val groupNames = arrayListOf() callBack.groupList.forEach { if (it.groupId > 0 && it.groupId and groupId > 0) { groupNames.add(it.groupName) } } return groupNames } private fun getGroupName(groupId: Long): String { val groupNames = getGroupList(groupId) if (groupNames.isEmpty()) { return "" } return groupNames.joinToString(",") } private var isMoved = false override fun swap(srcPosition: Int, targetPosition: Int): Boolean { val srcItem = getItem(srcPosition) val targetItem = getItem(targetPosition) if (srcItem != null && targetItem != null) { if (srcItem.order == targetItem.order) { for ((index, item) in getItems().withIndex()) { item.order = index + 1 } } else { val pos = srcItem.order srcItem.order = targetItem.order targetItem.order = pos } } swapItem(srcPosition, targetPosition) isMoved = true return true } override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { if (isMoved) { callBack.updateBook(*getItems().toTypedArray()) } isMoved = false } val dragSelectCallback: DragSelectTouchHelper.Callback = object : DragSelectTouchHelper.AdvanceCallback(Mode.ToggleAndReverse) { override fun currentSelectedId(): MutableSet { return selectedBooks } override fun getItemId(position: Int): Book { return getItem(position)!! } override fun updateSelectState(position: Int, isSelected: Boolean): Boolean { getItem(position)?.let { if (isSelected) { selectedBooks.add(it) } else { selectedBooks.remove(it) } notifyItemChanged(position, bundleOf(Pair("selected", null))) callBack.upSelectCount() return true } return false } } interface CallBack { val groupList: List fun upSelectCount() fun updateBook(vararg book: Book) fun deleteBook(book: Book) fun selectGroup(requestCode: Int, groupId: Long) fun openBook(book: Book) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageActivity.kt ================================================ package io.legado.app.ui.book.manage import android.annotation.SuppressLint import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.widget.CheckBox import android.widget.LinearLayout import androidx.activity.viewModels import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.SearchView import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookGroup import io.legado.app.data.entities.BookSource import io.legado.app.databinding.ActivityArrangeBookBinding import io.legado.app.databinding.DialogEditTextBinding import io.legado.app.help.DirectLinkUpload import io.legado.app.help.book.contains import io.legado.app.help.book.isLocal import io.legado.app.help.config.AppConfig import io.legado.app.help.config.LocalConfig import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.primaryColor import io.legado.app.lib.theme.primaryTextColor import io.legado.app.ui.book.group.GroupManageDialog import io.legado.app.ui.book.group.GroupSelectDialog import io.legado.app.ui.book.info.BookInfoActivity import io.legado.app.ui.file.HandleFileContract import io.legado.app.ui.widget.SelectActionBar import io.legado.app.ui.widget.dialog.WaitDialog import io.legado.app.ui.widget.recycler.DragSelectTouchHelper import io.legado.app.ui.widget.recycler.ItemTouchCallback import io.legado.app.ui.widget.recycler.VerticalDivider import io.legado.app.utils.applyTint import io.legado.app.utils.cnCompare import io.legado.app.utils.dpToPx import io.legado.app.utils.isAbsUrl import io.legado.app.utils.sendToClip import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.showDialogFragment import io.legado.app.utils.startActivity import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Job import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.math.max /** * 书架管理 */ class BookshelfManageActivity : VMBaseActivity(), PopupMenu.OnMenuItemClickListener, SelectActionBar.CallBack, BookAdapter.CallBack, SourcePickerDialog.Callback, GroupSelectDialog.CallBack { override val binding by viewBinding(ActivityArrangeBookBinding::inflate) override val viewModel by viewModels() override val groupList: ArrayList = arrayListOf() private val groupRequestCode = 22 private val addToGroupRequestCode = 34 private val adapter by lazy { BookAdapter(this, this) } private val itemTouchCallback by lazy { ItemTouchCallback(adapter) } private var booksFlowJob: Job? = null private var menu: Menu? = null private val searchView: SearchView by lazy { binding.titleBar.findViewById(R.id.search_view) } private var books: List? = null private val waitDialog by lazy { WaitDialog(this) } private val exportDir = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> alert(R.string.export_success) { if (uri.toString().isAbsUrl()) { setMessage(DirectLinkUpload.getSummary()) } val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = getString(R.string.path) editView.setText(uri.toString()) } customView { alertBinding.root } okButton { sendToClip(uri.toString()) } } } } override fun onActivityCreated(savedInstanceState: Bundle?) { viewModel.groupId = intent.getLongExtra("groupId", -1) lifecycleScope.launch { viewModel.groupName = withContext(IO) { appDb.bookGroupDao.getByID(viewModel.groupId)?.groupName ?: getString(R.string.no_group) } upTitle() } initSearchView() initRecyclerView() initOtherView() initGroupData() upBookDataByGroupId() } override fun observeLiveBus() { viewModel.batchChangeSourceState.observe(this) { if (it) { waitDialog.setText(R.string.change_source_batch) waitDialog.show() } else { waitDialog.dismiss() } } viewModel.batchChangeSourceProcessLiveData.observe(this) { waitDialog.setText(it) } } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.bookshelf_manage, menu) return super.onCompatCreateOptionsMenu(menu) } override fun onPrepareOptionsMenu(menu: Menu): Boolean { this.menu = menu menu.findItem(R.id.menu_open_book_info_by_click_title)?.isChecked = AppConfig.openBookInfoByClickTitle upMenu() return super.onPrepareOptionsMenu(menu) } override fun selectAll(selectAll: Boolean) { adapter.selectAll(selectAll) } override fun revertSelection() { adapter.revertSelection() } override fun onClickSelectBarMainAction() { selectGroup(groupRequestCode, 0) } private fun upTitle() { searchView.queryHint = getString(R.string.screen) + " • " + viewModel.groupName } private fun initSearchView() { searchView.applyTint(primaryTextColor) searchView.isSubmitButtonEnabled = true searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { return false } override fun onQueryTextChange(newText: String?): Boolean { upBookData() return false } }) } private fun initRecyclerView() { binding.recyclerView.setEdgeEffectColor(primaryColor) binding.recyclerView.layoutManager = LinearLayoutManager(this) binding.recyclerView.addItemDecoration(VerticalDivider(this)) binding.recyclerView.adapter = adapter itemTouchCallback.isCanDrag = AppConfig.bookshelfSort == 3 val dragSelectTouchHelper: DragSelectTouchHelper = DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50) dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView) // When this page is opened, it is in selection mode dragSelectTouchHelper.activeSlideSelect() // Note: need judge selection first, so add ItemTouchHelper after it. ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView) } private fun initOtherView() { binding.selectActionBar.setMainActionText(R.string.move_to_group) binding.selectActionBar.inflateMenu(R.menu.bookshelf_menage_sel) binding.selectActionBar.setOnMenuItemClickListener(this) binding.selectActionBar.setCallBack(this) waitDialog.setOnCancelListener { viewModel.batchChangeSourceCoroutine?.cancel() } } @SuppressLint("NotifyDataSetChanged") private fun initGroupData() { lifecycleScope.launch { appDb.bookGroupDao.flowAll().catch { AppLog.put("书架管理界面获取分组数据失败\n${it.localizedMessage}", it) }.flowOn(IO).conflate().collect { groupList.clear() groupList.addAll(it) adapter.notifyDataSetChanged() upMenu() } } } private fun upBookDataByGroupId() { booksFlowJob?.cancel() booksFlowJob = lifecycleScope.launch { val bookSort = AppConfig.getBookSortByGroupId(viewModel.groupId) appDb.bookDao.flowByGroup(viewModel.groupId).map { list -> when (bookSort) { 1 -> list.sortedByDescending { it.latestChapterTime } 2 -> list.sortedWith { o1, o2 -> o1.name.cnCompare(o2.name) } 3 -> list.sortedBy { it.order } 4 -> list.sortedByDescending { max(it.latestChapterTime, it.durChapterTime) } else -> list.sortedByDescending { it.durChapterTime } } }.catch { AppLog.put("书架管理界面获取书籍列表失败\n${it.localizedMessage}", it) }.flowOn(IO) .conflate().collect { books = it upBookData() itemTouchCallback.isCanDrag = bookSort == 3 } } } private fun upBookData() { books?.let { books -> val searchKey = searchView.query if (searchKey.isNullOrEmpty()) { adapter.setItems(books) } else { books.filter { it.contains(searchKey.toString()) }.let { adapter.setItems(it) } } } } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_group_manage -> showDialogFragment() R.id.menu_open_book_info_by_click_title -> { AppConfig.openBookInfoByClickTitle = !item.isChecked adapter.notifyItemRangeChanged(0, adapter.itemCount) } R.id.menu_export_all_use_book_source -> viewModel.saveAllUseBookSourceToFile { file -> exportDir.launch { mode = HandleFileContract.EXPORT fileData = HandleFileContract.FileData( "bookSource.json", file, "application/json" ) } } else -> if (item.groupId == R.id.menu_group) { viewModel.groupName = item.title.toString() upTitle() viewModel.groupId = appDb.bookGroupDao.getByName(item.title.toString())?.groupId ?: 0 upBookDataByGroupId() } } return super.onCompatOptionsItemSelected(item) } override fun onMenuItemClick(item: MenuItem?): Boolean { when (item?.itemId) { R.id.menu_del_selection -> alertDelSelection() R.id.menu_update_enable -> viewModel.upCanUpdate(adapter.selection, true) R.id.menu_update_disable -> viewModel.upCanUpdate(adapter.selection, false) R.id.menu_add_to_group -> selectGroup(addToGroupRequestCode, 0) R.id.menu_change_source -> showDialogFragment() R.id.menu_clear_cache -> viewModel.clearCache(adapter.selection) R.id.menu_check_selected_interval -> adapter.checkSelectedInterval() } return false } private fun upMenu() { menu?.findItem(R.id.menu_book_group)?.subMenu?.let { subMenu -> subMenu.removeGroup(R.id.menu_group) groupList.forEach { bookGroup -> subMenu.add(R.id.menu_group, bookGroup.order, Menu.NONE, bookGroup.groupName) } } } private fun alertDelSelection() { alert(titleResource = R.string.draw, messageResource = R.string.sure_del) { val checkBox = CheckBox(this@BookshelfManageActivity).apply { setText(R.string.delete_book_file) isChecked = LocalConfig.deleteBookOriginal } val view = LinearLayout(this@BookshelfManageActivity).apply { setPadding(16.dpToPx(), 0, 16.dpToPx(), 0) addView(checkBox) } customView { view } okButton { LocalConfig.deleteBookOriginal = checkBox.isChecked viewModel.deleteBook(adapter.selection, checkBox.isChecked) } noButton() } } override fun selectGroup(requestCode: Int, groupId: Long) { showDialogFragment( GroupSelectDialog(groupId, requestCode) ) } override fun upGroup(requestCode: Int, groupId: Long) { when (requestCode) { groupRequestCode -> adapter.selection.let { books -> val array = Array(books.size) { books[it].copy(group = groupId) } viewModel.updateBook(*array) } adapter.groupRequestCode -> { adapter.actionItem?.let { viewModel.updateBook(it.copy(group = groupId)) } } addToGroupRequestCode -> adapter.selection.let { books -> val array = Array(books.size) { index -> val book = books[index] book.copy(group = book.group or groupId) } viewModel.updateBook(*array) } } } override fun upSelectCount() { binding.selectActionBar.upCountView(adapter.selection.size, adapter.getItems().size) } override fun updateBook(vararg book: Book) { viewModel.updateBook(*book) } override fun deleteBook(book: Book) { alert(titleResource = R.string.draw, messageResource = R.string.sure_del) { var checkBox: CheckBox? = null if (book.isLocal) { checkBox = CheckBox(this@BookshelfManageActivity).apply { setText(R.string.delete_book_file) isChecked = LocalConfig.deleteBookOriginal } val view = LinearLayout(this@BookshelfManageActivity).apply { setPadding(16.dpToPx(), 0, 16.dpToPx(), 0) addView(checkBox) } customView { view } } okButton { if (checkBox != null) { LocalConfig.deleteBookOriginal = checkBox.isChecked } viewModel.deleteBook(listOf(book), LocalConfig.deleteBookOriginal) } } } override fun openBook(book: Book) { startActivity { putExtra("name", book.name) putExtra("author", book.author) } } override fun sourceOnClick(source: BookSource) { viewModel.changeSource(adapter.selection, source) viewModel.batchChangeSourceState.value = true } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageViewModel.kt ================================================ package io.legado.app.ui.book.manage import android.app.Application import androidx.lifecycle.MutableLiveData import io.legado.app.R 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.Book import io.legado.app.data.entities.BookSource import io.legado.app.help.book.BookHelp import io.legado.app.help.book.isLocal import io.legado.app.help.book.removeType import io.legado.app.help.config.AppConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.model.localBook.LocalBook import io.legado.app.model.webBook.WebBook import io.legado.app.utils.FileUtils import io.legado.app.utils.GSON import io.legado.app.utils.stackTraceStr import io.legado.app.utils.toastOnUi import io.legado.app.utils.writeToOutputStream import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import java.io.File class BookshelfManageViewModel(application: Application) : BaseViewModel(application) { var groupId: Long = -1L var groupName: String? = null val batchChangeSourceState = MutableLiveData() val batchChangeSourceProcessLiveData = MutableLiveData() var batchChangeSourceCoroutine: Coroutine? = null fun upCanUpdate(books: List, canUpdate: Boolean) { execute { val array = Array(books.size) { books[it].copy(canUpdate = canUpdate).apply { if (!canUpdate) { removeType(BookType.updateError) } } } appDb.bookDao.update(*array) } } fun updateBook(vararg book: Book) { execute { appDb.bookDao.update(*book) } } fun deleteBook(books: List, deleteOriginal: Boolean = false) { execute { appDb.bookDao.delete(*books.toTypedArray()) books.forEach { if (it.isLocal) { LocalBook.deleteBook(it, deleteOriginal) } } } } fun saveAllUseBookSourceToFile(success: (file: File) -> Unit) { execute { val path = "${context.filesDir}/shareBookSource.json" FileUtils.delete(path) val file = FileUtils.createFileWithReplace(path) val sources = appDb.bookDao.getAllUseBookSource() file.outputStream().buffered().use { GSON.writeToOutputStream(it, sources) } file }.onSuccess { success.invoke(it) }.onError { context.toastOnUi(it.stackTraceStr) } } fun changeSource(books: List, source: BookSource) { batchChangeSourceCoroutine?.cancel() batchChangeSourceCoroutine = execute { val changeSourceDelay = AppConfig.batchChangeSourceDelay * 1000L books.forEachIndexed { index, book -> batchChangeSourceProcessLiveData.postValue("${index + 1} / ${books.size}") if (book.isLocal) return@forEachIndexed if (book.origin == source.bookSourceUrl) return@forEachIndexed val newBook = WebBook.preciseSearchAwait(source, book.name, book.author) .onFailure { AppLog.put("搜索书籍出错\n${it.localizedMessage}", it, true) }.getOrNull() ?: return@forEachIndexed kotlin.runCatching { if (newBook.tocUrl.isEmpty()) { WebBook.getBookInfoAwait(source, newBook) } }.onFailure { AppLog.put("获取书籍详情出错\n${it.localizedMessage}", it, true) return@forEachIndexed } WebBook.getChapterListAwait(source, newBook) .onFailure { AppLog.put("获取目录出错\n${it.localizedMessage}", it, true) }.getOrNull()?.let { toc -> book.migrateTo(newBook, toc) book.removeType(BookType.updateError) appDb.bookDao.insert(newBook) appDb.bookChapterDao.insert(*toc.toTypedArray()) } delay(changeSourceDelay) } }.onStart { batchChangeSourceState.postValue(true) }.onFinally { batchChangeSourceState.postValue(false) } } fun clearCache(books: List) { execute { books.forEach { BookHelp.clearCache(it) } }.onSuccess { context.toastOnUi(R.string.clear_cache_success) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/manage/SourcePickerDialog.kt ================================================ package io.legado.app.ui.book.manage import android.content.Context import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.Toolbar import androidx.core.view.setPadding import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSourcePart import io.legado.app.databinding.DialogSourcePickerBinding import io.legado.app.databinding.Item1lineTextBinding import io.legado.app.help.config.AppConfig import io.legado.app.lib.theme.primaryColor import io.legado.app.lib.theme.primaryTextColor import io.legado.app.ui.widget.number.NumberPickerDialog import io.legado.app.utils.applyTint import io.legado.app.utils.dpToPx import io.legado.app.utils.setLayout import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Job import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch import splitties.views.onClick /** * 书源选择 */ class SourcePickerDialog : BaseDialogFragment(R.layout.dialog_source_picker), Toolbar.OnMenuItemClickListener { private val binding by viewBinding(DialogSourcePickerBinding::bind) private val searchView: SearchView by lazy { binding.toolBar.findViewById(R.id.search_view) } private val toolBar: Toolbar by lazy { binding.toolBar.toolbar } private val adapter by lazy { SourceAdapter(requireContext()) } private var sourceFlowJob: Job? = null override fun onStart() { super.onStart() setLayout(1f, ViewGroup.LayoutParams.MATCH_PARENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { initView() initData() initMenu() } private fun initView() { binding.toolBar.setBackgroundColor(primaryColor) binding.toolBar.title = "选择书源" binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.adapter = adapter searchView.applyTint(primaryTextColor) searchView.isSubmitButtonEnabled = true searchView.queryHint = getString(R.string.search_book_source) searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { return false } override fun onQueryTextChange(newText: String?): Boolean { initData(newText) return false } }) } private fun initData(searchKey: String? = null) { sourceFlowJob?.cancel() sourceFlowJob = lifecycleScope.launch { when { searchKey.isNullOrEmpty() -> appDb.bookSourceDao.flowEnabled() else -> appDb.bookSourceDao.flowSearchEnabled(searchKey) }.catch { AppLog.put("书源选择界面获取书源数据失败\n${it.localizedMessage}", it) }.flowOn(IO).collect { adapter.setItems(it) } } } private fun initMenu() { toolBar.setOnMenuItemClickListener(this) toolBar.inflateMenu(R.menu.source_picker) toolBar.menu.applyTint(requireContext()) } override fun onMenuItemClick(item: MenuItem?): Boolean { when (item?.itemId) { R.id.menu_change_source_delay -> NumberPickerDialog(requireContext()) .setTitle(getString(R.string.change_source_delay)) .setMaxValue(9999) .setMinValue(0) .setValue(AppConfig.batchChangeSourceDelay) .show { AppConfig.batchChangeSourceDelay = it } } return true } inner class SourceAdapter(context: Context) : RecyclerAdapter(context) { override fun getViewBinding(parent: ViewGroup): Item1lineTextBinding { return Item1lineTextBinding.inflate(inflater, parent, false).apply { root.setPadding(16.dpToPx()) } } override fun convert( holder: ItemViewHolder, binding: Item1lineTextBinding, item: BookSourcePart, payloads: MutableList ) { binding.textView.text = item.getDisPlayNameGroup() } override fun registerListener(holder: ItemViewHolder, binding: Item1lineTextBinding) { binding.root.onClick { getItemByLayoutPosition(holder.layoutPosition)?.let { it.getBookSource()?.let { source -> callback?.sourceOnClick(source) } dismissAllowingStateLoss() } } } } private val callback: Callback? get() { return (parentFragment as? Callback) ?: activity as? Callback } interface Callback { fun sourceOnClick(source: BookSource) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt ================================================ package io.legado.app.ui.book.manga import android.annotation.SuppressLint import android.content.Intent import android.os.Build import android.os.Bundle import android.view.KeyEvent import android.view.Menu import android.view.MenuItem import android.view.WindowManager import android.view.animation.LinearInterpolator import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.PagerSnapHelper import com.bumptech.glide.Glide import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader import com.bumptech.glide.request.target.Target.SIZE_ORIGINAL import com.bumptech.glide.util.FixedPreloadSizeProvider import io.legado.app.BuildConfig import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.constant.BookType import io.legado.app.constant.EventBus import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookProgress import io.legado.app.data.entities.BookSource import io.legado.app.databinding.ActivityMangaBinding import io.legado.app.databinding.ViewLoadMoreBinding import io.legado.app.help.book.isImage import io.legado.app.help.book.removeType import io.legado.app.help.config.AppConfig import io.legado.app.help.storage.Backup import io.legado.app.lib.dialogs.alert import io.legado.app.model.ReadManga import io.legado.app.receiver.NetworkChangedListener import io.legado.app.ui.book.changesource.ChangeBookSourceDialog import io.legado.app.ui.book.info.BookInfoActivity import io.legado.app.ui.book.manga.config.MangaColorFilterConfig import io.legado.app.ui.book.manga.config.MangaColorFilterDialog import io.legado.app.ui.book.manga.config.MangaEpaperDialog import io.legado.app.ui.book.manga.config.MangaFooterConfig import io.legado.app.ui.book.manga.config.MangaFooterSettingDialog import io.legado.app.ui.book.manga.entities.BaseMangaPage import io.legado.app.ui.book.manga.entities.MangaPage import io.legado.app.ui.book.manga.recyclerview.MangaAdapter import io.legado.app.ui.book.manga.recyclerview.MangaLayoutManager import io.legado.app.ui.book.manga.recyclerview.ScrollTimer import io.legado.app.ui.book.read.MangaMenu import io.legado.app.ui.book.read.ReadBookActivity.Companion.RESULT_DELETED import io.legado.app.ui.book.toc.TocActivityResult import io.legado.app.ui.widget.number.NumberPickerDialog import io.legado.app.ui.widget.recycler.LoadMoreView import io.legado.app.utils.GSON import io.legado.app.utils.NetworkUtils import io.legado.app.utils.StartActivityContract import io.legado.app.utils.canScroll import io.legado.app.utils.fastBinarySearch import io.legado.app.utils.findCenterViewPosition import io.legado.app.utils.fromJsonObject import io.legado.app.utils.getCompatColor import io.legado.app.utils.gone import io.legado.app.utils.observeEvent import io.legado.app.utils.showDialogFragment import io.legado.app.utils.toastOnUi import io.legado.app.utils.toggleSystemBar import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.text.DecimalFormat import kotlin.math.ceil class ReadMangaActivity : VMBaseActivity(), ReadManga.Callback, ChangeBookSourceDialog.CallBack, MangaMenu.CallBack, MangaColorFilterDialog.Callback, ScrollTimer.ScrollCallback, MangaEpaperDialog.Callback { private val mLayoutManager by lazy { MangaLayoutManager(this) } private val mAdapter: MangaAdapter by lazy { MangaAdapter(this) } private val mSizeProvider by lazy { FixedPreloadSizeProvider(resources.displayMetrics.widthPixels, SIZE_ORIGINAL) } private val mPagerSnapHelper: PagerSnapHelper by lazy { PagerSnapHelper() } private lateinit var mMangaFooterConfig: MangaFooterConfig private val mLabelBuilder by lazy { StringBuilder() } private var mMenu: Menu? = null private var mRecyclerViewPreloader: RecyclerViewPreloader? = null private val networkChangedListener by lazy { NetworkChangedListener(this) } private var justInitData: Boolean = false private var syncDialog: AlertDialog? = null private val mScrollTimer by lazy { ScrollTimer(this, binding.recyclerView, lifecycleScope).apply { setSpeed(AppConfig.mangaAutoPageSpeed) } } private var enableAutoScrollPage = false private var enableAutoScroll = false private val mLinearInterpolator by lazy { LinearInterpolator() } private val loadMoreView by lazy { LoadMoreView(this).apply { setBackgroundColor(getCompatColor(R.color.book_ant_10)) setLoadingColor(R.color.white) setLoadingTextColor(R.color.white) } } //打开目录返回选择章节返回结果 private val tocActivity = registerForActivityResult(TocActivityResult()) { it?.let { viewModel.openChapter(it.first, it.second) } } private val bookInfoActivity = registerForActivityResult(StartActivityContract(BookInfoActivity::class.java)) { if (it.resultCode == RESULT_OK) { setResult(RESULT_DELETED) super.finish() } else { ReadManga.loadOrUpContent() } } override val binding by viewBinding(ActivityMangaBinding::inflate) override val viewModel by viewModels() private val loadingViewVisible get() = binding.flLoading.isVisible private val df by lazy { DecimalFormat("0.0%") } override fun onCreate(savedInstanceState: Bundle?) { upLayoutInDisplayCutoutMode() super.onCreate(savedInstanceState) } override fun onActivityCreated(savedInstanceState: Bundle?) { ReadManga.register(this) upSystemUiVisibility(false) initRecyclerView() binding.tvRetry.setOnClickListener { binding.llLoading.isVisible = true binding.llRetry.isGone = true ReadManga.loadOrUpContent() } binding.pbLoading.isVisible = !AppConfig.isEInkMode mAdapter.addFooterView { ViewLoadMoreBinding.bind(loadMoreView) } loadMoreView.setOnClickListener { if (!loadMoreView.isLoading && ReadManga.hasNextChapter) { loadMoreView.startLoad() ReadManga.loadOrUpContent() } } loadMoreView.gone() mMangaFooterConfig = GSON.fromJsonObject(AppConfig.mangaFooterConfig).getOrNull() ?: MangaFooterConfig() } override fun observeLiveBus() { observeEvent(EventBus.UP_MANGA_CONFIG) { mMangaFooterConfig = it val item = mAdapter.getItem(binding.recyclerView.findCenterViewPosition()) upInfoBar(item) } } private fun initRecyclerView() { val mangaColorFilter = GSON.fromJsonObject(AppConfig.mangaColorFilter).getOrNull() ?: MangaColorFilterConfig() mAdapter.run { setMangaImageColorFilter(mangaColorFilter) enableMangaEInk(AppConfig.enableMangaEInk, AppConfig.mangaEInkThreshold) enableGray(AppConfig.enableMangaGray) } setHorizontalScroll(AppConfig.enableMangaHorizontalScroll) binding.recyclerView.run { adapter = mAdapter itemAnimator = null layoutManager = mLayoutManager setHasFixedSize(true) setDisableClickScroll(AppConfig.disableClickScroll) setDisableMangaScale(AppConfig.disableMangaScale) setRecyclerViewPreloader(AppConfig.mangaPreDownloadNum) setPreScrollListener { _, _, _, position -> if (mAdapter.isNotEmpty()) { val item = mAdapter.getItem(position) if (item is BaseMangaPage) { if (ReadManga.durChapterIndex < item.chapterIndex) { ReadManga.moveToNextChapter() } else if (ReadManga.durChapterIndex > item.chapterIndex) { ReadManga.moveToPrevChapter() } else { ReadManga.durChapterPos = item.index ReadManga.curPageChanged() } if (item is MangaPage) { binding.mangaMenu.upSeekBar(item.index, item.imageCount) upInfoBar(item) } } } } } binding.webtoonFrame.run { onTouchMiddle { if (!binding.mangaMenu.isVisible && !loadingViewVisible) { binding.mangaMenu.runMenuIn() } } onNextPage { scrollToNext() } onPrevPage { scrollToPrev() } } } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) viewModel.initData(intent) } override fun onPostCreate(savedInstanceState: Bundle?) { super.onPostCreate(savedInstanceState) viewModel.initData(intent) justInitData = true } override fun upContent() { lifecycleScope.launch { setTitle(ReadManga.book?.name) val data = withContext(IO) { ReadManga.mangaContents } val pos = data.pos val list = data.items val curFinish = data.curFinish val nextFinish = data.nextFinish mAdapter.submitList(list) { if (loadingViewVisible && curFinish) { binding.infobar.isVisible = true upInfoBar(list[pos]) mLayoutManager.scrollToPositionWithOffset(pos, 0) binding.flLoading.isGone = true loadMoreView.visible() binding.mangaMenu.upSeekBar( ReadManga.durChapterPos, ReadManga.curMangaChapter!!.imageCount ) } if (curFinish) { if (!ReadManga.hasNextChapter) { loadMoreView.noMore("暂无章节了!") } else if (nextFinish) { loadMoreView.stopLoad() } else { loadMoreView.startLoad() } } } } } private fun upInfoBar(page: Any?) { if (page !is MangaPage) { return } val chapterIndex = page.chapterIndex val chapterSize = page.chapterSize val chapterPos = page.index val imageCount = page.imageCount val chapterName = page.mChapterName mMangaFooterConfig.run { mLabelBuilder.clear() binding.infobar.isGone = hideFooter binding.infobar.textInfoAlignment = footerOrientation if (!hideChapterName) { mLabelBuilder.append(chapterName).append(" ") } if (!hidePageNumber) { if (!hidePageNumberLabel) { mLabelBuilder.append(getString(R.string.manga_check_page_number)) } mLabelBuilder.append("${chapterPos + 1}/${imageCount}").append(" ") } if (!hideChapter) { if (!hideChapterLabel) { mLabelBuilder.append(getString(R.string.manga_check_chapter)) } mLabelBuilder.append("${chapterIndex + 1}/${chapterSize}").append(" ") } if (!hideProgressRatio) { if (!hideProgressRatioLabel) { mLabelBuilder.append(getString(R.string.manga_check_progress)) } val percent = if (chapterSize == 0 || imageCount == 0 && chapterIndex == 0) { "0.0%" } else if (imageCount == 0) { df.format((chapterIndex + 1.0f) / chapterSize.toDouble()) } else { var percent = df.format( chapterIndex * 1.0f / chapterSize + 1.0f / chapterSize * (chapterPos + 1) / imageCount.toDouble() ) if (percent == "100.0%" && (chapterIndex + 1 != chapterSize || chapterPos + 1 != imageCount)) { percent = "99.9%" } percent } mLabelBuilder.append(percent) } } binding.infobar.update( if (mLabelBuilder.isEmpty()) "" else mLabelBuilder.toString() ) } override fun onResume() { super.onResume() networkChangedListener.register() networkChangedListener.onNetworkChanged = { // 当网络是可用状态且无需初始化时同步进度(初始化中已有同步进度逻辑) if (AppConfig.syncBookProgressPlus && NetworkUtils.isAvailable() && !justInitData && ReadManga.inBookshelf) { ReadManga.syncProgress({ progress -> sureNewProgress(progress) }) } } if (enableAutoScrollPage) { mScrollTimer.isEnabledPage = true } if (enableAutoScroll) { mScrollTimer.isEnabled = true } } override fun onPause() { super.onPause() if (ReadManga.inBookshelf) { ReadManga.saveRead() if (!BuildConfig.DEBUG) { if (AppConfig.syncBookProgressPlus) { ReadManga.syncProgress() } else { ReadManga.uploadProgress() } } } if (!BuildConfig.DEBUG) { Backup.autoBack(this) } ReadManga.cancelPreDownloadTask() networkChangedListener.unRegister() mScrollTimer.isEnabledPage = false mScrollTimer.isEnabled = false } override fun loadFail(msg: String, retry: Boolean) { lifecycleScope.launch { if (loadingViewVisible) { binding.llLoading.isGone = true binding.llRetry.isVisible = true binding.tvRetry.isVisible = retry binding.tvMsg.text = msg } else { loadMoreView.error(null, "加载失败,点击重试") } } } override fun onDestroy() { ReadManga.unregister(this) super.onDestroy() } override fun onLowMemory() { super.onLowMemory() Glide.get(this).clearMemory() } override fun sureNewProgress(progress: BookProgress) { syncDialog?.dismiss() syncDialog = alert(R.string.get_book_progress) { setMessage(R.string.cloud_progress_exceeds_current) okButton { ReadManga.setProgress(progress) } noButton() } } override fun showLoading() { lifecycleScope.launch { binding.flLoading.isVisible = true } } override fun startLoad() { lifecycleScope.launch { loadMoreView.startLoad() } } override fun scrollBy(distance: Int) { if (!binding.recyclerView.canScroll(1)) { return } val time = ceil(16f / distance * 10000).toInt() binding.recyclerView.smoothScrollBy(10000, 10000, mLinearInterpolator, time) } override fun scrollPage() { scrollToNext() } override val oldBook: Book? get() = ReadManga.book override fun changeTo(source: BookSource, book: Book, toc: List) { if (book.isImage) { binding.flLoading.isVisible = true viewModel.changeTo(book, toc) } else { toastOnUi("所选择的源不是漫画源") } } override fun updateColorFilter(config: MangaColorFilterConfig) { mAdapter.setMangaImageColorFilter(config) updateWindowBrightness(config.l) } @SuppressLint("StringFormatMatches") override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.book_manga, menu) upMenu(menu) return super.onCompatCreateOptionsMenu(menu) } /** * 菜单 */ @SuppressLint("StringFormatMatches", "NotifyDataSetChanged") override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_change_source -> { binding.mangaMenu.runMenuOut() ReadManga.book?.let { showDialogFragment(ChangeBookSourceDialog(it.name, it.author)) } } R.id.menu_catalog -> { ReadManga.book?.let { tocActivity.launch(it.bookUrl) } } R.id.menu_refresh -> { binding.flLoading.isVisible = true ReadManga.book?.let { viewModel.refreshContentDur(it) } } R.id.menu_pre_manga_number -> { showNumberPickerDialog( 0, getString(R.string.pre_download), AppConfig.mangaPreDownloadNum ) { AppConfig.mangaPreDownloadNum = it item.title = getString(R.string.pre_download_m, it) setRecyclerViewPreloader(it) } } R.id.menu_disable_manga_scale -> { item.isChecked = !item.isChecked AppConfig.disableMangaScale = item.isChecked setDisableMangaScale(item.isChecked) } R.id.menu_disable_click_scroll -> { item.isChecked = !item.isChecked AppConfig.disableClickScroll = item.isChecked setDisableClickScroll(item.isChecked) } R.id.menu_enable_auto_page -> { item.isChecked = !item.isChecked val menuMangaAutoPageSpeed = mMenu?.findItem(R.id.menu_manga_auto_page_speed) mScrollTimer.isEnabledPage = item.isChecked menuMangaAutoPageSpeed?.isVisible = item.isChecked enableAutoScrollPage = item.isChecked enableAutoScroll = false mScrollTimer.isEnabled = false mMenu?.findItem(R.id.menu_enable_auto_scroll)?.isChecked = false } R.id.menu_manga_auto_page_speed -> { showNumberPickerDialog( 1, getString(R.string.setting_manga_auto_page_speed), AppConfig.mangaAutoPageSpeed ) { AppConfig.mangaAutoPageSpeed = it item.title = getString(R.string.manga_auto_page_speed, it) mScrollTimer.setSpeed(it) if (enableAutoScrollPage) { mScrollTimer.isEnabledPage = true } } } R.id.menu_manga_footer_config -> { showDialogFragment(MangaFooterSettingDialog()) } R.id.menu_enable_horizontal_scroll -> { item.isChecked = !item.isChecked AppConfig.enableMangaHorizontalScroll = item.isChecked mMenu?.findItem(R.id.menu_disable_horizontal_page_snap)?.isVisible = item.isChecked setHorizontalScroll(item.isChecked) mAdapter.notifyDataSetChanged() } R.id.menu_manga_color_filter -> { binding.mangaMenu.runMenuOut() showDialogFragment(MangaColorFilterDialog()) } R.id.menu_enable_auto_scroll -> { item.isChecked = !item.isChecked mScrollTimer.isEnabled = item.isChecked mMenu?.findItem(R.id.menu_enable_auto_page)?.isChecked = false enableAutoScroll = item.isChecked enableAutoScrollPage = false mScrollTimer.isEnabledPage = false mMenu?.findItem(R.id.menu_manga_auto_page_speed)?.isVisible = item.isChecked if (enableAutoScroll) { mPagerSnapHelper.attachToRecyclerView(null) } else if (AppConfig.enableMangaHorizontalScroll) { mPagerSnapHelper.attachToRecyclerView(binding.recyclerView) } } R.id.menu_hide_manga_title -> { item.isChecked = !item.isChecked AppConfig.hideMangaTitle = item.isChecked ReadManga.loadContent() } R.id.menu_epaper_manga -> { item.isChecked = !item.isChecked AppConfig.enableMangaEInk = item.isChecked mMenu?.findItem(R.id.menu_gray_manga)?.isChecked = false AppConfig.enableMangaGray = false mMenu?.findItem(R.id.menu_epaper_manga_setting)?.isVisible = item.isChecked mAdapter.enableMangaEInk(item.isChecked, AppConfig.mangaEInkThreshold) } R.id.menu_epaper_manga_setting -> { showDialogFragment(MangaEpaperDialog()) } R.id.menu_disable_horizontal_page_snap -> { item.isChecked = !item.isChecked AppConfig.disableHorizontalPageSnap = item.isChecked if (item.isChecked) { mPagerSnapHelper.attachToRecyclerView(null) } else { mPagerSnapHelper.attachToRecyclerView(binding.recyclerView) } } R.id.menu_disable_manga_page_anim -> { item.isChecked = !item.isChecked mMenu?.findItem(R.id.menu_disable_horizontal_page_snap)?.isVisible = !item.isChecked AppConfig.disableMangaPageAnim = item.isChecked if (item.isChecked) { mPagerSnapHelper.attachToRecyclerView(null) } else { if (AppConfig.enableMangaHorizontalScroll && !AppConfig.disableHorizontalPageSnap) { mPagerSnapHelper.attachToRecyclerView(binding.recyclerView) } } } R.id.menu_gray_manga -> { item.isChecked = !item.isChecked AppConfig.enableMangaGray = item.isChecked mMenu?.findItem(R.id.menu_epaper_manga)?.isChecked = false AppConfig.enableMangaEInk = false mMenu?.findItem(R.id.menu_epaper_manga_setting)?.isVisible = false mAdapter.enableGray(item.isChecked) } } return super.onCompatOptionsItemSelected(item) } override fun openBookInfoActivity() { ReadManga.book?.let { bookInfoActivity.launch { putExtra("name", it.name) putExtra("author", it.author) } } } override fun upSystemUiVisibility(menuIsVisible: Boolean) { toggleSystemBar(menuIsVisible) if (enableAutoScroll) { mScrollTimer.isEnabled = !menuIsVisible } if (enableAutoScrollPage) { mScrollTimer.isEnabledPage = !menuIsVisible } } override fun dispatchKeyEvent(event: KeyEvent): Boolean { val keyCode = event.keyCode val action = event.action val isDown = action == 0 if (keyCode == KeyEvent.KEYCODE_MENU) { if (isDown && !binding.mangaMenu.canShowMenu) { binding.mangaMenu.runMenuIn() return true } if (!isDown && !binding.mangaMenu.canShowMenu) { binding.mangaMenu.canShowMenu = true return true } } return super.dispatchKeyEvent(event) } private fun setRecyclerViewPreloader(maxPreload: Int) { if (mRecyclerViewPreloader != null) { binding.recyclerView.removeOnScrollListener(mRecyclerViewPreloader!!) } mRecyclerViewPreloader = RecyclerViewPreloader( Glide.with(this), mAdapter, mSizeProvider, maxPreload ) binding.recyclerView.addOnScrollListener(mRecyclerViewPreloader!!) } private fun setHorizontalScroll(enable: Boolean) { mAdapter.isHorizontal = enable if (enable) { if (!enableAutoScroll) { if (AppConfig.disableHorizontalPageSnap || AppConfig.disableMangaPageAnim) { mPagerSnapHelper.attachToRecyclerView(null) } else { mPagerSnapHelper.attachToRecyclerView(binding.recyclerView) } } mLayoutManager.orientation = LinearLayoutManager.HORIZONTAL } else { mPagerSnapHelper.attachToRecyclerView(null) mLayoutManager.orientation = LinearLayoutManager.VERTICAL } } @SuppressLint("StringFormatMatches") private fun upMenu(menu: Menu) { this.mMenu = menu menu.findItem(R.id.menu_pre_manga_number).title = getString(R.string.pre_download_m, AppConfig.mangaPreDownloadNum) menu.findItem(R.id.menu_disable_manga_scale).isChecked = AppConfig.disableMangaScale menu.findItem(R.id.menu_disable_click_scroll).isChecked = AppConfig.disableClickScroll menu.findItem(R.id.menu_manga_auto_page_speed).title = getString(R.string.manga_auto_page_speed, AppConfig.mangaAutoPageSpeed) menu.findItem(R.id.menu_enable_horizontal_scroll).isChecked = AppConfig.enableMangaHorizontalScroll menu.findItem(R.id.menu_epaper_manga).isChecked = AppConfig.enableMangaEInk menu.findItem(R.id.menu_epaper_manga_setting).isVisible = AppConfig.enableMangaEInk menu.findItem(R.id.menu_disable_horizontal_page_snap).run { isVisible = AppConfig.enableMangaHorizontalScroll && !AppConfig.disableMangaPageAnim isChecked = AppConfig.disableHorizontalPageSnap || AppConfig.disableMangaPageAnim } menu.findItem(R.id.menu_disable_manga_page_anim).isChecked = AppConfig.disableMangaPageAnim menu.findItem(R.id.menu_gray_manga).isChecked = AppConfig.enableMangaGray } private fun setDisableMangaScale(disable: Boolean) { binding.webtoonFrame.disableMangaScale = disable binding.recyclerView.disableMangaScale = disable if (disable) { binding.recyclerView.resetZoom() } } private fun setDisableClickScroll(disable: Boolean) { binding.webtoonFrame.disabledClickScroll = disable } private fun upLayoutInDisplayCutoutMode() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { window.attributes = window.attributes.apply { layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES } } } private fun scrollToNext() { scrollPageTo(1) } private fun scrollToPrev() { scrollPageTo(-1) } private fun scrollPageTo(direction: Int) { if (!binding.recyclerView.canScroll(direction)) { return } var dx = 0 var dy = 0 if (AppConfig.enableMangaHorizontalScroll) { dx = binding.recyclerView.run { width - paddingStart - paddingEnd } } else { dy = binding.recyclerView.run { height - paddingTop - paddingBottom } } dx *= direction dy *= direction if (AppConfig.disableMangaPageAnim) { binding.recyclerView.scrollBy(dx, dy) } else { binding.recyclerView.smoothScrollBy(dx, dy) } } private fun showNumberPickerDialog( min: Int, title: String, initValue: Int, callback: (Int) -> Unit, ) { NumberPickerDialog(this) .setTitle(title) .setMaxValue(9999) .setMinValue(min) .setValue(initValue) .show { callback.invoke(it) } } override fun finish() { val book = ReadManga.book ?: return super.finish() if (ReadManga.inBookshelf) { return super.finish() } if (!AppConfig.showAddToShelfAlert) { viewModel.removeFromBookshelf { super.finish() } } else { alert(title = getString(R.string.add_to_bookshelf)) { setMessage(getString(R.string.check_add_bookshelf, book.name)) okButton { ReadManga.book?.removeType(BookType.notShelf) ReadManga.book?.save() ReadManga.inBookshelf = true setResult(RESULT_OK) } noButton { viewModel.removeFromBookshelf { super.finish() } } } } } fun updateWindowBrightness(brightness: Int) { val layoutParams = window.attributes val normalizedBrightness = brightness.toFloat() / 255.0f layoutParams.screenBrightness = normalizedBrightness.coerceIn(0f, 1f) window.attributes = layoutParams // 强制刷新屏幕 window.decorView.postInvalidate() } override fun skipToPage(index: Int) { val durChapterIndex = ReadManga.durChapterIndex val itemPos = mAdapter.getItems().fastBinarySearch { val chapterIndex: Int val pageIndex: Int if (it is BaseMangaPage) { chapterIndex = it.chapterIndex pageIndex = it.index } else { error("unknown item type") } val delta = chapterIndex - durChapterIndex if (delta != 0) { delta } else { pageIndex - index } } if (itemPos > -1) { mLayoutManager.scrollToPositionWithOffset(itemPos, 0) upInfoBar(mAdapter.getItem(itemPos)) ReadManga.durChapterPos = index } } override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { when (keyCode) { KeyEvent.KEYCODE_VOLUME_UP -> { scrollToPrev() return true } KeyEvent.KEYCODE_VOLUME_DOWN -> { scrollToNext() return true } } return super.onKeyDown(keyCode, event) } override fun updateEepaper(value: Int) { mAdapter.updateThreshold(value) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/manga/ReadMangaViewModel.kt ================================================ package io.legado.app.ui.book.manga import android.app.Application import android.content.Intent import io.legado.app.R import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppLog import io.legado.app.constant.BookType import io.legado.app.constant.EventBus import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookProgress import io.legado.app.exception.NoStackTraceException import io.legado.app.help.AppWebDav import io.legado.app.help.book.BookHelp import io.legado.app.help.book.isLocal import io.legado.app.help.book.isLocalModified import io.legado.app.help.book.removeType import io.legado.app.help.book.simulatedTotalChapterNum import io.legado.app.help.config.AppConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.model.ReadManga import io.legado.app.model.localBook.LocalBook import io.legado.app.model.webBook.WebBook import io.legado.app.utils.mapParallelSafe import io.legado.app.utils.postEvent import io.legado.app.utils.toastOnUi import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEmpty import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.take import splitties.init.appCtx class ReadMangaViewModel(application: Application) : BaseViewModel(application) { private var changeSourceCoroutine: Coroutine<*>? = null /** * 初始化 */ fun initData(intent: Intent, success: (() -> Unit)? = null) { execute { ReadManga.inBookshelf = intent.getBooleanExtra("inBookshelf", true) ReadManga.chapterChanged = intent.getBooleanExtra("chapterChanged", false) val bookUrl = intent.getStringExtra("bookUrl") val book = when { bookUrl.isNullOrEmpty() -> appDb.bookDao.lastReadBook else -> appDb.bookDao.getBook(bookUrl) } ?: ReadManga.book when { book != null -> initManga(book) else -> { ReadManga.loadFail(context.getString(R.string.no_book), false) AppLog.put("未找到漫画书籍\nbookUrl:$bookUrl") } } }.onSuccess { success?.invoke() }.onError { val msg = "初始化数据失败\n${it.localizedMessage}" AppLog.put(msg, it) }.onFinally { ReadManga.saveRead() } } private suspend fun initManga(book: Book) { val isSameBook = ReadManga.book?.bookUrl == book.bookUrl if (isSameBook) { ReadManga.upData(book) } else { ReadManga.resetData(book) } if (!book.isLocal && book.tocUrl.isEmpty() && !loadBookInfo(book)) { return } if (book.isLocal && !checkLocalBookFileExist(book)) { return } if ((ReadManga.chapterSize == 0 || book.isLocalModified()) && !loadChapterListAwait(book)) { return } //开始加载内容 if (!isSameBook) { ReadManga.loadContent() } else { ReadManga.loadOrUpContent() } if (ReadManga.chapterChanged) { // 有章节跳转不同步阅读进度 ReadManga.chapterChanged = false } else if (ReadManga.inBookshelf) { if (AppConfig.syncBookProgressPlus) { ReadManga.syncProgress( { progress -> ReadManga.mCallback?.sureNewProgress(progress) }) } else { syncBookProgress(book) } } //自动换源 if (!book.isLocal && ReadManga.bookSource == null) { autoChangeSource(book.name, book.author) return } } private suspend fun loadChapterListAwait(book: Book): Boolean { val bookSource = ReadManga.bookSource ?: return true val oldBook = book.copy() WebBook.getChapterListAwait(bookSource, book, true).onSuccess { cList -> if (oldBook.bookUrl == book.bookUrl) { appDb.bookDao.update(book) } else { appDb.bookDao.replace(oldBook, book) BookHelp.updateCacheFolder(oldBook, book) } appDb.bookChapterDao.delByBook(oldBook.bookUrl) appDb.bookChapterDao.insert(*cList.toTypedArray()) ReadManga.onChapterListUpdated(book) return true }.onFailure { currentCoroutineContext().ensureActive() //加载章节出错 ReadManga.mCallback?.loadFail(appCtx.getString(R.string.error_load_toc)) return false } return true } /** * 加载详情页 */ private suspend fun loadBookInfo(book: Book): Boolean { val source = ReadManga.bookSource ?: return true try { WebBook.getBookInfoAwait(source, book, canReName = false) return true } catch (e: Throwable) { currentCoroutineContext().ensureActive() ReadManga.mCallback?.loadFail("详情页出错: ${e.localizedMessage}") return false } } /** * 自动换源 */ private fun autoChangeSource(name: String, author: String) { if (!AppConfig.autoChangeSource) return execute { val sources = appDb.bookSourceDao.allTextEnabledPart flow { for (source in sources) { source.getBookSource()?.let { emit(it) } } }.onStart { // 自动换源 }.mapParallelSafe(AppConfig.threadCount) { source -> val book = WebBook.preciseSearchAwait(source, name, author).getOrThrow() if (book.tocUrl.isEmpty()) { WebBook.getBookInfoAwait(source, book) } val toc = WebBook.getChapterListAwait(source, book).getOrThrow() val chapter = toc.getOrElse(book.durChapterIndex) { toc.last() } val nextChapter = toc.getOrElse(chapter.index) { toc.first() } WebBook.getContentAwait( bookSource = source, book = book, bookChapter = chapter, nextChapterUrl = nextChapter.url ) book to toc }.take(1).onEach { (book, toc) -> changeTo(book, toc) }.onEmpty { throw NoStackTraceException("没有合适书源") }.onCompletion { // 换源完成 }.catch { AppLog.put("自动换源失败\n${it.localizedMessage}", it) context.toastOnUi("自动换源失败\n${it.localizedMessage}") }.collect() } } /** * 同步进度 */ fun syncBookProgress( book: Book, alertSync: ((progress: BookProgress) -> Unit)? = null ) { if (!AppConfig.syncBookProgress) return execute { AppWebDav.getBookProgress(book) }.onError { AppLog.put("拉取阅读进度失败《${book.name}》\n${it.localizedMessage}", it) }.onSuccess { progress -> progress ?: return@onSuccess if (progress.durChapterIndex == book.durChapterIndex && progress.durChapterPos == book.durChapterPos) { return@onSuccess } if (progress.durChapterIndex < book.durChapterIndex || (progress.durChapterIndex == book.durChapterIndex && progress.durChapterPos < book.durChapterPos) ) { alertSync?.invoke(progress) } else if (progress.durChapterIndex < book.simulatedTotalChapterNum()) { ReadManga.setProgress(progress) AppLog.put("自动同步阅读进度成功《${book.name}》 ${progress.durChapterTitle}") context.toastOnUi("已同步最新漫画阅读进度") } } } /** * 换源 */ fun changeTo(book: Book, toc: List) { changeSourceCoroutine?.cancel() changeSourceCoroutine = execute { //换源中 ReadManga.book?.migrateTo(book, toc) book.removeType(BookType.updateError) ReadManga.book?.delete() appDb.bookDao.insert(book) appDb.bookChapterDao.insert(*toc.toTypedArray()) ReadManga.resetData(book) ReadManga.loadContent() }.onError { AppLog.put("换源失败\n$it", it, true) }.onFinally { postEvent(EventBus.SOURCE_CHANGED, book.bookUrl) } } private fun checkLocalBookFileExist(book: Book): Boolean { try { LocalBook.getBookInputStream(book) return true } catch (_: Throwable) { return false } } fun openChapter(index: Int, durChapterPos: Int = 0) { if (index < ReadManga.chapterSize) { ReadManga.showLoading() ReadManga.durChapterIndex = index ReadManga.durChapterPos = durChapterPos ReadManga.saveRead() ReadManga.loadContent() } } fun removeFromBookshelf(success: (() -> Unit)?) { val book = ReadManga.book Coroutine.async { book?.delete() }.onSuccess { success?.invoke() } } override fun onCleared() { super.onCleared() changeSourceCoroutine?.cancel() } fun refreshContentDur(book: Book) { execute { appDb.bookChapterDao.getChapter(book.bookUrl, ReadManga.durChapterIndex) ?.let { chapter -> BookHelp.delContent(book, chapter) openChapter(ReadManga.durChapterIndex, ReadManga.durChapterPos) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/manga/config/MangaColorFilterConfig.kt ================================================ package io.legado.app.ui.book.manga.config import io.legado.app.utils.GSON data class MangaColorFilterConfig( var r: Int = 0, var g: Int = 0, var b: Int = 0, var a: Int = 0, var l: Int = 0 ) { fun toJson(): String { if (r == 0 && g == 0 && b == 0 && a == 0 && l == 0) { return "" } return GSON.toJson(this) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/manga/config/MangaColorFilterDialog.kt ================================================ package io.legado.app.ui.book.manga.config import android.content.DialogInterface import android.os.Bundle import android.view.View import android.view.ViewGroup import android.view.WindowManager import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.databinding.DialogMangaColorFilterBinding import io.legado.app.help.config.AppConfig import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonObject import io.legado.app.utils.setLayout import io.legado.app.utils.viewbindingdelegate.viewBinding class MangaColorFilterDialog : BaseDialogFragment(R.layout.dialog_manga_color_filter) { private val binding by viewBinding(DialogMangaColorFilterBinding::bind) private val mConfig = GSON.fromJsonObject(AppConfig.mangaColorFilter).getOrNull() ?: MangaColorFilterConfig() private val callback get() = activity as? Callback override fun onStart() { super.onStart() dialog?.window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { initData() initView() } private fun initData() { binding.run { dsbBrightness.progress = mConfig.l dsbR.progress = mConfig.r dsbG.progress = mConfig.g dsbB.progress = mConfig.b dsbA.progress = mConfig.a } } private fun initView() { binding.run { dsbBrightness.onChanged = { mConfig.l = it callback?.updateColorFilter(mConfig) } dsbR.onChanged = { mConfig.r = it callback?.updateColorFilter(mConfig) } dsbG.onChanged = { mConfig.g = it callback?.updateColorFilter(mConfig) } dsbB.onChanged = { mConfig.b = it callback?.updateColorFilter(mConfig) } dsbA.onChanged = { mConfig.a = it callback?.updateColorFilter(mConfig) } } } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) AppConfig.mangaColorFilter = mConfig.toJson() } interface Callback { fun updateColorFilter(config: MangaColorFilterConfig) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/manga/config/MangaEpaperDialog.kt ================================================ package io.legado.app.ui.book.manga.config import android.content.DialogInterface import android.os.Bundle import android.view.View import android.view.ViewGroup import android.view.WindowManager import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.databinding.DialogMangaEpaperBinding import io.legado.app.help.config.AppConfig import io.legado.app.utils.setLayout import io.legado.app.utils.viewbindingdelegate.viewBinding class MangaEpaperDialog : BaseDialogFragment(R.layout.dialog_manga_epaper) { private val binding by viewBinding(DialogMangaEpaperBinding::bind) private val callback get() = activity as? Callback private var mMangaEInkThreshold = 150 override fun onStart() { super.onStart() dialog?.window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { initData() initView() } private fun initData() { binding.dsbEpaper.progress = AppConfig.mangaEInkThreshold } private fun initView() { binding.dsbEpaper.onChanged = { mMangaEInkThreshold = it callback?.updateEepaper(it) } } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) AppConfig.mangaEInkThreshold = mMangaEInkThreshold } interface Callback { fun updateEepaper(value: Int) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/manga/config/MangaFooterConfig.kt ================================================ package io.legado.app.ui.book.manga.config import androidx.annotation.Keep import io.legado.app.ui.widget.ReaderInfoBarView @Keep data class MangaFooterConfig( var hideChapterLabel: Boolean = false, var hideChapter: Boolean = false, var hidePageNumberLabel: Boolean = false, var hidePageNumber: Boolean = false, var hideProgressRatioLabel: Boolean = false, var hideProgressRatio: Boolean = false, var footerOrientation: Int = ReaderInfoBarView.ALIGN_LEFT,//默认靠左 var hideFooter: Boolean = false, var hideChapterName:Boolean=false, ) ================================================ FILE: app/src/main/java/io/legado/app/ui/book/manga/config/MangaFooterSettingDialog.kt ================================================ package io.legado.app.ui.book.manga.config import android.content.DialogInterface import android.os.Bundle import android.view.View import android.view.ViewGroup import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.constant.EventBus import io.legado.app.databinding.DialogMangaFooterSettingBinding import io.legado.app.help.config.AppConfig import io.legado.app.ui.widget.ReaderInfoBarView import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonObject import io.legado.app.utils.postEvent import io.legado.app.utils.setLayout import io.legado.app.utils.viewbindingdelegate.viewBinding class MangaFooterSettingDialog : BaseDialogFragment(R.layout.dialog_manga_footer_setting) { val config = GSON.fromJsonObject(AppConfig.mangaFooterConfig).getOrNull() ?: MangaFooterConfig() private val binding by viewBinding(DialogMangaFooterSettingBinding::bind) override fun onStart() { super.onStart() setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.cbChapterLabel.run { isChecked = config.hideChapterLabel setOnCheckedChangeListener { _, isChecked -> config.hideChapterLabel = isChecked postEvent(EventBus.UP_MANGA_CONFIG, config) } } binding.cbChapter.run { isChecked = config.hideChapter setOnCheckedChangeListener { _, isChecked -> config.hideChapter = isChecked postEvent(EventBus.UP_MANGA_CONFIG, config) } } binding.cbPageNumberLabel.run { isChecked = config.hidePageNumberLabel setOnCheckedChangeListener { _, isChecked -> config.hidePageNumberLabel = isChecked postEvent(EventBus.UP_MANGA_CONFIG, config) } } binding.cbPageNumber.run { isChecked = config.hidePageNumber setOnCheckedChangeListener { _, isChecked -> config.hidePageNumber = isChecked postEvent(EventBus.UP_MANGA_CONFIG, config) } } binding.cbProgressRatioLabel.run { isChecked = config.hideProgressRatioLabel setOnCheckedChangeListener { _, isChecked -> config.hideProgressRatioLabel = isChecked postEvent(EventBus.UP_MANGA_CONFIG, config) } } binding.cbProgressRatio.run { isChecked = config.hideProgressRatio setOnCheckedChangeListener { _, isChecked -> config.hideProgressRatio = isChecked postEvent(EventBus.UP_MANGA_CONFIG, config) } } binding.cbChapterName.run { isChecked = config.hideChapterName setOnCheckedChangeListener { _, isChecked -> config.hideChapterName = isChecked postEvent(EventBus.UP_MANGA_CONFIG, config) } } binding.rgFooterOrientation.check(if (config.footerOrientation == ReaderInfoBarView.ALIGN_CENTER) R.id.rb_center else R.id.rb_left) binding.rgFooterOrientation.setOnCheckedChangeListener { _, checkedId -> when (checkedId) { R.id.rb_left -> { config.footerOrientation = ReaderInfoBarView.ALIGN_LEFT } R.id.rb_center -> { config.footerOrientation = ReaderInfoBarView.ALIGN_CENTER } } postEvent(EventBus.UP_MANGA_CONFIG, config) } binding.rgFooter.check(if (config.hideFooter) R.id.rb_hide else R.id.rb_show) binding.rgFooter.setOnCheckedChangeListener { _, checkedId -> when (checkedId) { R.id.rb_show -> { config.hideFooter = false } R.id.rb_hide -> { config.hideFooter = true } } postEvent(EventBus.UP_MANGA_CONFIG, config) } } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) AppConfig.mangaFooterConfig = GSON.toJson(config) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/manga/entities/BaseMangaPage.kt ================================================ package io.legado.app.ui.book.manga.entities interface BaseMangaPage { val chapterIndex: Int val index: Int } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/manga/entities/EpaperTransformation.kt ================================================ package io.legado.app.ui.book.manga.entities import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color import android.graphics.ColorMatrix import android.graphics.ColorMatrixColorFilter import android.graphics.Paint import androidx.annotation.IntRange import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool import com.bumptech.glide.load.resource.bitmap.BitmapTransformation import java.security.MessageDigest /** * 墨水屏图片转换器。 * 将彩色图片转换为灰度图,并可选择进行简单的二值化处理,以提高墨水屏显示效果。 * * @param threshold 二值化的阈值(0-255)。低于此值的像素变为黑色,高于此值的像素变为白色。 * 仅当applyBinarization为true时有效。 */ class EpaperTransformation( @param:IntRange(0, 255) private val threshold: Int = 128, ) : BitmapTransformation() { private val ID = "io.legado.app.model.EpaperTransformation.${threshold}" private val ID_BYTES = ID.toByteArray(CHARSET) override fun transform( pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int, ): Bitmap { val resultBitmap = pool.get(outWidth, outHeight, Bitmap.Config.ARGB_8888) val canvas = Canvas(resultBitmap) val paint = Paint() val colorMatrix = ColorMatrix() colorMatrix.setSaturation(0f) val filter = ColorMatrixColorFilter(colorMatrix) paint.colorFilter = filter canvas.drawBitmap(toTransform, 0f, 0f, paint) val pixels = IntArray(outWidth * outHeight) resultBitmap.getPixels(pixels, 0, outWidth, 0, 0, outWidth, outHeight) for (i in pixels.indices) { val pixel = pixels[i] val gray = Color.red(pixel) pixels[i] = if (gray < threshold) Color.BLACK else Color.WHITE } resultBitmap.setPixels(pixels, 0, outWidth, 0, 0, outWidth, outHeight) return resultBitmap } override fun updateDiskCacheKey(messageDigest: MessageDigest) { messageDigest.update(ID_BYTES) } override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as EpaperTransformation if (threshold != other.threshold) return false if (ID != other.ID) return false return true } override fun hashCode(): Int { var result = 31 + threshold result = 31 * result + ID.hashCode() return result } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/manga/entities/GrayscaleTransformation.kt ================================================ package io.legado.app.ui.book.manga.entities import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.ColorMatrix import android.graphics.ColorMatrixColorFilter import android.graphics.Paint import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool import com.bumptech.glide.load.resource.bitmap.BitmapTransformation import java.nio.charset.StandardCharsets import java.security.MessageDigest class GrayscaleTransformation : BitmapTransformation() { private val ID = "io.legado.app.model.GrayscaleTransformation" private val ID_BYTES = ID.toByteArray(StandardCharsets.UTF_8) override fun transform( pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int, ): Bitmap { val resultBitmap = pool.get(outWidth, outHeight, Bitmap.Config.ARGB_8888) val canvas = Canvas(resultBitmap) val paint = Paint() val matrix = ColorMatrix( floatArrayOf( 0.299f, 0.587f, 0.114f, 0f, 0f, 0.299f, 0.587f, 0.114f, 0f, 0f, 0.299f, 0.587f, 0.114f, 0f, 0f, 0f, 0f, 0f, 1f, 0f ) ) val filter = ColorMatrixColorFilter(matrix) paint.colorFilter = filter canvas.drawBitmap(toTransform, 0f, 0f, paint) return resultBitmap } override fun updateDiskCacheKey(messageDigest: MessageDigest) { messageDigest.update(ID_BYTES) } override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as GrayscaleTransformation return ID == other.ID } override fun hashCode(): Int { return ID.hashCode() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/manga/entities/MangaChapter.kt ================================================ package io.legado.app.ui.book.manga.entities import io.legado.app.data.entities.BookChapter data class MangaChapter( val chapter: BookChapter, val pages: List, val imageCount: Int ) ================================================ FILE: app/src/main/java/io/legado/app/ui/book/manga/entities/MangaContent.kt ================================================ package io.legado.app.ui.book.manga.entities data class MangaContent( val pos: Int, val items: List, val curFinish: Boolean, val nextFinish: Boolean ) ================================================ FILE: app/src/main/java/io/legado/app/ui/book/manga/entities/MangaPage.kt ================================================ package io.legado.app.ui.book.manga.entities data class MangaPage( override val chapterIndex: Int = 0,//总章节位置 val chapterSize: Int,//总章节数量 val mImageUrl: String = "",//当前URL override val index: Int = 0,//当前章节位置 var imageCount: Int = 0,//当前章节内容总数 val mChapterName: String = "",//章节名称 ) : BaseMangaPage ================================================ FILE: app/src/main/java/io/legado/app/ui/book/manga/entities/ReaderLoading.kt ================================================ package io.legado.app.ui.book.manga.entities data class ReaderLoading( override val chapterIndex: Int = 0, override val index: Int = 0, val mMessage: String? = null, val isVolume: Boolean = false ) : BaseMangaPage ================================================ FILE: app/src/main/java/io/legado/app/ui/book/manga/recyclerview/GestureDetectorWithLongTap.kt ================================================ package io.legado.app.ui.book.manga.recyclerview import android.content.Context import android.os.Handler import android.os.Looper import android.view.GestureDetector import android.view.MotionEvent import android.view.ViewConfiguration import kotlin.math.abs open class GestureDetectorWithLongTap( context: Context, listener: Listener, ) : GestureDetector(context, listener) { private val handler = Handler(Looper.getMainLooper()) private val slop = ViewConfiguration.get(context).scaledTouchSlop private val longTapTime = ViewConfiguration.getLongPressTimeout().toLong() private val doubleTapTime = ViewConfiguration.getDoubleTapTimeout().toLong() private var downX = 0f private var downY = 0f private var lastUp = 0L private var lastDownEvent: MotionEvent? = null private val longTapFn = Runnable { listener.onLongTapConfirmed(lastDownEvent!!) } override fun onTouchEvent(ev: MotionEvent): Boolean { when (ev.actionMasked) { MotionEvent.ACTION_DOWN -> { lastDownEvent?.recycle() lastDownEvent = MotionEvent.obtain(ev) if (ev.downTime - lastUp > doubleTapTime) { downX = ev.rawX downY = ev.rawY handler.postDelayed(longTapFn, longTapTime) } } MotionEvent.ACTION_MOVE -> { if (abs(ev.rawX - downX) > slop || abs(ev.rawY - downY) > slop) { handler.removeCallbacks(longTapFn) } } MotionEvent.ACTION_UP -> { lastUp = ev.eventTime handler.removeCallbacks(longTapFn) } MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_POINTER_DOWN -> { handler.removeCallbacks(longTapFn) } } return super.onTouchEvent(ev) } open class Listener : SimpleOnGestureListener() { open fun onLongTapConfirmed(ev: MotionEvent) { } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/manga/recyclerview/MangaAdapter.kt ================================================ package io.legado.app.ui.book.manga.recyclerview import android.content.Context import android.graphics.ColorMatrix import android.graphics.ColorMatrixColorFilter import android.util.SparseArray import android.view.LayoutInflater import android.view.ViewGroup import android.view.ViewGroup.LayoutParams.MATCH_PARENT import androidx.annotation.IntRange import androidx.core.util.size import androidx.core.view.updateLayoutParams import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding import com.bumptech.glide.Glide import com.bumptech.glide.ListPreloader.PreloadModelProvider import com.bumptech.glide.RequestBuilder import com.bumptech.glide.load.resource.bitmap.BitmapTransformation import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter.Companion.TYPE_FOOTER_VIEW import io.legado.app.databinding.ItemBookMangaEdgeBinding import io.legado.app.databinding.ItemBookMangaPageBinding import io.legado.app.help.glide.progress.ProgressManager import io.legado.app.model.BookCover import io.legado.app.model.ReadManga import io.legado.app.ui.book.manga.config.MangaColorFilterConfig import io.legado.app.ui.book.manga.entities.EpaperTransformation import io.legado.app.ui.book.manga.entities.GrayscaleTransformation import io.legado.app.ui.book.manga.entities.MangaPage import io.legado.app.ui.book.manga.entities.ReaderLoading import io.legado.app.utils.dpToPx class MangaAdapter(private val context: Context) : RecyclerView.Adapter(), PreloadModelProvider { private val inflater: LayoutInflater = LayoutInflater.from(context) private lateinit var mConfig: MangaColorFilterConfig private var mTransformation: BitmapTransformation? = null private var currentMangaEInkThreshold = 0 companion object { private const val LOADING_VIEW = 0 private const val CONTENT_VIEW = 1 } var isHorizontal = false private val mDiffCallback: DiffUtil.ItemCallback = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: Any, newItem: Any): Boolean { return if (oldItem is ReaderLoading && newItem is ReaderLoading) { newItem.mMessage == oldItem.mMessage } else if (oldItem is MangaPage && newItem is MangaPage) { oldItem.mImageUrl == newItem.mImageUrl } else false } override fun areContentsTheSame(oldItem: Any, newItem: Any): Boolean { return if (oldItem is ReaderLoading && newItem is ReaderLoading) { oldItem == newItem } else if (oldItem is MangaPage && newItem is MangaPage) { oldItem == newItem } else false } } private val mDiffer = AsyncListDiffer(this, mDiffCallback) fun getItem(@IntRange(from = 0) position: Int) = mDiffer.currentList.getOrNull(position) fun getItems() = mDiffer.currentList fun isEmpty() = mDiffer.currentList.isEmpty() fun isNotEmpty() = !isEmpty() //全部替换数据 fun submitList(contents: List, runnable: Runnable? = null) { mDiffer.submitList(contents, runnable) } inner class PageViewHolder(binding: ItemBookMangaPageBinding) : MangaVH(binding, context) { init { initComponent( binding.loading, binding.image, binding.progress, binding.retry, binding.flProgress ) binding.retry.setOnClickListener { val item = mDiffer.currentList[layoutPosition] if (item is MangaPage) { val isLastImage = item.imageCount > 0 && item.index == item.imageCount - 1 loadImageWithRetry( item.mImageUrl, isHorizontal, isLastImage, mTransformation ) } } } fun onBind(item: MangaPage) { setImageColorFilter() val isLastImage = item.imageCount > 0 && item.index == item.imageCount - 1 loadImageWithRetry(item.mImageUrl, isHorizontal, isLastImage, mTransformation) } fun setImageColorFilter() { require( mConfig.r in 0..255 && mConfig.g in 0..255 && mConfig.b in 0..255 && mConfig.a in 0..255 ) { "ARGB values must be between 0-255" } val matrix = floatArrayOf( (255 - mConfig.r) / 255f, 0f, 0f, 0f, 0f, 0f, (255 - mConfig.g) / 255f, 0f, 0f, 0f, 0f, 0f, (255 - mConfig.b) / 255f, 0f, 0f, 0f, 0f, 0f, (255 - mConfig.a) / 255f, 0f ) binding.image.colorFilter = ColorMatrixColorFilter(ColorMatrix(matrix)) } } inner class PageMoreViewHolder(val binding: ItemBookMangaEdgeBinding) : RecyclerView.ViewHolder(binding.root) { fun onBind(item: ReaderLoading) { val message = item.mMessage binding.text.text = message itemView.updateLayoutParams { height = if (item.isVolume) { MATCH_PARENT } else { 96.dpToPx() } } } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return when { viewType >= TYPE_FOOTER_VIEW -> { ItemViewHolder(footerItems.get(viewType).invoke(parent)) } viewType == LOADING_VIEW -> { PageMoreViewHolder(ItemBookMangaEdgeBinding.inflate(inflater, parent, false)) } viewType == CONTENT_VIEW -> { PageViewHolder(ItemBookMangaPageBinding.inflate(inflater, parent, false)) } else -> error("Unknown view type!") } } override fun getItemCount(): Int = getActualItemCount() + getFooterCount() override fun getItemViewType(position: Int): Int { return when { isFooter(position) -> TYPE_FOOTER_VIEW + position - getActualItemCount() getItem(position) is MangaPage -> CONTENT_VIEW getItem(position) is ReaderLoading -> LOADING_VIEW else -> error("Unknown view type!") } } fun getFooterCount() = footerItems.size private fun isFooter(position: Int) = position >= getActualItemCount() override fun onViewRecycled(vh: RecyclerView.ViewHolder) { super.onViewRecycled(vh) when (vh) { is PageViewHolder -> { vh.itemView.updateLayoutParams { height = MATCH_PARENT } Glide.with(context).clear(vh.binding.image) if (vh.binding.image.tag is String) { ProgressManager.removeListener(vh.binding.image.tag as String) } } } } override fun onBindViewHolder(vh: RecyclerView.ViewHolder, position: Int) { when (vh) { is PageViewHolder -> vh.onBind(getItem(position) as MangaPage) is PageMoreViewHolder -> vh.onBind(getItem(position) as ReaderLoading) } } private val footerItems: SparseArray<(parent: ViewGroup) -> ViewBinding> by lazy { SparseArray() } @Synchronized fun addFooterView(footer: ((parent: ViewGroup) -> ViewBinding)) { kotlin.runCatching { val index = getActualItemCount() + footerItems.size footerItems.put(TYPE_FOOTER_VIEW + footerItems.size, footer) notifyItemInserted(index) } } /** * 除去header和footer */ fun getActualItemCount() = getItems().size @Synchronized fun removeFooterView(footer: ((parent: ViewGroup) -> ViewBinding)) { kotlin.runCatching { val index = footerItems.indexOfValue(footer) if (index >= 0) { footerItems.remove(index) notifyItemRemoved(getActualItemCount() + index - 2) } } } override fun getPreloadItems(position: Int): List { if (isEmpty() || position >= getItems().size) { return emptyList() } return getItems().subList(position, position + 1) } override fun getPreloadRequestBuilder(item: Any): RequestBuilder<*>? { if (item is MangaPage) { return BookCover.preloadManga( context, item.mImageUrl, sourceOrigin = ReadManga.book?.origin, ) } return null } fun setMangaImageColorFilter(config: MangaColorFilterConfig) { mConfig = config notifyItemRangeChanged(0, itemCount) } fun enableMangaEInk(enable: Boolean, value: Int) { if (enable) { currentMangaEInkThreshold = value mTransformation = EpaperTransformation(currentMangaEInkThreshold) } else { mTransformation = null } notifyItemRangeChanged(0, itemCount) } fun updateThreshold(mangaEInkThreshold: Int) { if (currentMangaEInkThreshold != mangaEInkThreshold) { currentMangaEInkThreshold = mangaEInkThreshold mTransformation = EpaperTransformation(currentMangaEInkThreshold) notifyItemRangeChanged(0, itemCount) } } //开启灰色图片 fun enableGray(enable: Boolean) { mTransformation = if (enable) { GrayscaleTransformation() } else { null } notifyItemRangeChanged(0, itemCount) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/manga/recyclerview/MangaLayoutManager.kt ================================================ package io.legado.app.ui.book.manga.recyclerview import android.content.Context import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView class MangaLayoutManager(context: Context) : LinearLayoutManager(context) { private val extraLayoutSpace = context.resources.displayMetrics.heightPixels * 3 / 4 @Deprecated("Deprecated in Java") override fun getExtraLayoutSpace(state: RecyclerView.State?): Int { return extraLayoutSpace } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/manga/recyclerview/MangaVH.kt ================================================ package io.legado.app.ui.book.manga.recyclerview import android.annotation.SuppressLint import android.content.Context import android.graphics.Bitmap import android.graphics.drawable.Drawable import android.view.Gravity import android.view.ViewGroup import android.widget.Button import android.widget.FrameLayout import android.widget.ImageView import android.widget.ProgressBar import android.widget.TextView import androidx.appcompat.widget.AppCompatImageView import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.Transformation import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.target.Target import io.legado.app.help.glide.progress.ProgressManager import io.legado.app.model.BookCover import io.legado.app.model.ReadManga import io.legado.app.utils.printOnDebug open class MangaVH(val binding: VB, private val context: Context) : RecyclerView.ViewHolder(binding.root) { protected lateinit var mLoading: ProgressBar protected lateinit var mImage: AppCompatImageView protected lateinit var mProgress: TextView protected lateinit var mFlProgress: FrameLayout protected var mRetry: Button? = null private val minHeight = context.resources.displayMetrics.heightPixels * 2 / 3 fun initComponent( loading: ProgressBar, image: AppCompatImageView, progress: TextView, button: Button? = null, flProgress: FrameLayout, ) { mLoading = loading mImage = image mRetry = button mProgress = progress mFlProgress = flProgress } @SuppressLint("CheckResult") fun loadImageWithRetry( imageUrl: String, isHorizontal: Boolean, isLastImage: Boolean, transformation: Transformation? ) { mFlProgress.isVisible = true mLoading.isVisible = true mRetry?.isGone = true mProgress.isVisible = true ProgressManager.removeListener(imageUrl) ProgressManager.addListener(imageUrl) { _, percentage, _, _ -> @SuppressLint("SetTextI18n") mProgress.text = "$percentage%" } try { mImage.tag = imageUrl BookCover.loadManga( context, imageUrl, sourceOrigin = ReadManga.book?.origin, transformation = transformation ).addListener(object : RequestListener { override fun onLoadFailed( e: GlideException?, model: Any?, target: Target, isFirstResource: Boolean, ): Boolean { mFlProgress.isVisible = true mLoading.isGone = true mRetry?.isVisible = true mProgress.isGone = true itemView.updateLayoutParams { height = ViewGroup.LayoutParams.MATCH_PARENT } return false } override fun onResourceReady( resource: Drawable, model: Any, target: Target?, dataSource: DataSource, isFirstResource: Boolean, ): Boolean { mFlProgress.isGone = true if (!isHorizontal) { itemView.updateLayoutParams { height = ViewGroup.LayoutParams.WRAP_CONTENT } mImage.updateLayoutParams { gravity = Gravity.NO_GRAVITY } if (isLastImage) { mImage.updateLayoutParams { height = ViewGroup.LayoutParams.WRAP_CONTENT } itemView.minimumHeight = minHeight } else { mImage.updateLayoutParams { height = ViewGroup.LayoutParams.MATCH_PARENT } itemView.minimumHeight = 0 } mImage.scaleType = ImageView.ScaleType.FIT_XY } else { itemView.updateLayoutParams { height = ViewGroup.LayoutParams.MATCH_PARENT } itemView.minimumHeight = 0 mImage.updateLayoutParams { height = ViewGroup.LayoutParams.MATCH_PARENT gravity = Gravity.CENTER } mImage.scaleType = ImageView.ScaleType.FIT_CENTER } return false } }).into(mImage) } catch (e: Exception) { e.printOnDebug() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/manga/recyclerview/ScrollTimer.kt ================================================ package io.legado.app.ui.book.manga.recyclerview import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch class ScrollTimer( private val callback: ScrollCallback, private val recyclerView: RecyclerView, private val coroutineScope: CoroutineScope, ) : RecyclerView.OnScrollListener() { private var distance = 1 private var mScrollPageJob: Job? = null var isEnabled: Boolean = false set(value) { if (field != value) { field = value if (value) { recyclerView.addOnScrollListener(this) startScroll() } else { recyclerView.removeOnScrollListener(this) recyclerView.stopScroll() } } } var isEnabledPage: Boolean = false set(value) { if (field != value) { field = value if (value) { mScrollPageJob?.cancel() startScrollPage() } else { mScrollPageJob?.cancel() } } } override fun onScrollStateChanged( recyclerView: RecyclerView, newState: Int, ) { if (newState == SCROLL_STATE_IDLE) { startScroll() } } fun setSpeed(distance: Int) { this.distance = distance } private fun startScroll() { callback.scrollBy(distance) } private fun startScrollPage() { mScrollPageJob = coroutineScope.launch { while (isActive) { delay(distance.times(1000L)) callback.scrollPage() } } } interface ScrollCallback { fun scrollBy(distance: Int) fun scrollPage() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/manga/recyclerview/WebtoonFrame.kt ================================================ package io.legado.app.ui.book.manga.recyclerview import android.content.Context import android.graphics.Rect import android.graphics.RectF import android.util.AttributeSet import android.view.GestureDetector import android.view.MotionEvent import android.view.ScaleGestureDetector import android.widget.FrameLayout class WebtoonFrame : FrameLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet) : super(context, attrs) constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super( context, attrs, defStyle ) private val scaleDetector = ScaleGestureDetector(context, ScaleListener()) private val flingDetector = GestureDetector(context, FlingListener()) var doubleTapZoom = true set(value) { field = value recycler?.doubleTapZoom = value scaleDetector.isQuickScaleEnabled = value } var disableMangaScale = false private val recycler: WebtoonRecyclerView? get() = getChildAt(0) as? WebtoonRecyclerView private val mcRect = RectF() private val blRect = RectF() private val brRect = RectF() private var mTouchMiddle: (() -> Unit)? = null fun onTouchMiddle(init: () -> Unit) = apply { this.mTouchMiddle = init } private var mNextPage: (() -> Unit)? = null fun onNextPage(init: () -> Unit) = apply { this.mNextPage = init } private var mPrevPage: (() -> Unit)? = null fun onPrevPage(init: () -> Unit) = apply { this.mPrevPage = init } var disabledClickScroll = false override fun onAttachedToWindow() { super.onAttachedToWindow() recycler?.tapListener = { ev -> when { mcRect.contains(ev.rawX, ev.rawY) -> { mTouchMiddle?.invoke() } blRect.contains(ev.rawX, ev.rawY) && !disabledClickScroll -> { mPrevPage?.invoke() } brRect.contains(ev.rawX, ev.rawY) && !disabledClickScroll -> { mNextPage?.invoke() } } } } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) mcRect.set(width * 0.33f, height * 0.33f, width * 0.66f, height * 0.66f) blRect.set(0f, height * 0.66f, width * 0.33f, height.toFloat()) brRect.set(width * 0.66f, height * 0.66f, width.toFloat(), height.toFloat()) } override fun dispatchTouchEvent(ev: MotionEvent): Boolean { if (!disableMangaScale) { scaleDetector.onTouchEvent(ev) flingDetector.onTouchEvent(ev) val recyclerRect = Rect() recycler?.getHitRect(recyclerRect) ?: return super.dispatchTouchEvent(ev) recyclerRect.inset(1, 1) if (recyclerRect.right < recyclerRect.left || recyclerRect.bottom < recyclerRect.top) { return super.dispatchTouchEvent(ev) } ev.setLocation( ev.x.coerceIn(recyclerRect.left.toFloat(), recyclerRect.right.toFloat()), ev.y.coerceIn(recyclerRect.top.toFloat(), recyclerRect.bottom.toFloat()), ) } return super.dispatchTouchEvent(ev) } inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() { override fun onScaleBegin(detector: ScaleGestureDetector): Boolean { recycler?.onScaleBegin() return true } override fun onScale(detector: ScaleGestureDetector): Boolean { recycler?.onScale(detector.scaleFactor) return true } override fun onScaleEnd(detector: ScaleGestureDetector) { recycler?.onScaleEnd() } } inner class FlingListener : GestureDetector.SimpleOnGestureListener() { override fun onDown(e: MotionEvent): Boolean { return true } override fun onFling( e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float, ): Boolean { return recycler?.zoomFling(velocityX.toInt(), velocityY.toInt()) ?: false } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/manga/recyclerview/WebtoonRecyclerView.kt ================================================ package io.legado.app.ui.book.manga.recyclerview import android.animation.AnimatorSet import android.animation.ValueAnimator import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet import android.view.HapticFeedbackConstants import android.view.MotionEvent import android.view.ViewConfiguration import android.view.animation.DecelerateInterpolator import androidx.core.animation.doOnEnd import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import io.legado.app.utils.findCenterViewPosition import kotlin.math.abs class WebtoonRecyclerView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyle: Int = 0, ) : RecyclerView(context, attrs, defStyle) { private var isZooming = false private var atLastPosition = false private var atFirstPosition = false private var halfWidth = 0 private var halfHeight = 0 private var originalHeight = 0 private var heightSet = false private var firstVisibleItemPosition = 0 private var lastVisibleItemPosition = 0 private var currentScale = DEFAULT_RATE private var mLastCenterViewPosition = 0 private var mPreScrollListener: IComicPreScroll? = null private var mNestedPreScrollListener: IComicPreScroll? = null private val listener = GestureListener() private val detector = Detector() var doubleTapZoom = true var tapListener: ((MotionEvent) -> Unit)? = null var longTapListener: ((MotionEvent) -> Boolean)? = null var disableMangaScale = false override fun onMeasure(widthSpec: Int, heightSpec: Int) { halfWidth = MeasureSpec.getSize(widthSpec) / 2 halfHeight = MeasureSpec.getSize(heightSpec) / 2 if (!heightSet) { originalHeight = MeasureSpec.getSize(heightSpec) heightSet = true } super.onMeasure(widthSpec, heightSpec) } @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(e: MotionEvent): Boolean { return detector.onTouchEvent(e) || super.onTouchEvent(e) } override fun onScrolled(dx: Int, dy: Int) { super.onScrolled(dx, dy) val layoutManager = layoutManager as LinearLayoutManager lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition() firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition() val position = findCenterViewPosition() if (position != NO_POSITION && position != mLastCenterViewPosition) { mLastCenterViewPosition = position mPreScrollListener?.onPreScrollListener(this, dx, dy, position) } } override fun onScrollStateChanged(state: Int) { super.onScrollStateChanged(state) val layoutManager = layoutManager val visibleItemCount = layoutManager?.childCount ?: 0 val totalItemCount = layoutManager?.itemCount ?: 0 atLastPosition = visibleItemCount > 0 && lastVisibleItemPosition == totalItemCount - 1 atFirstPosition = firstVisibleItemPosition == 0 } override fun dispatchNestedPreScroll( dx: Int, dy: Int, consumed: IntArray?, offsetInWindow: IntArray?, type: Int ): Boolean { val position = findCenterViewPosition() mNestedPreScrollListener?.onPreScrollListener(this, dx, dy, position) return super.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type) } private fun getPositionX(positionX: Float): Float { if (currentScale < 1) { return 0f } val maxPositionX = halfWidth * (currentScale - 1) return positionX.coerceIn(-maxPositionX, maxPositionX) } private fun getPositionY(positionY: Float): Float { if (currentScale < 1) { return (originalHeight / 2 - halfHeight).toFloat() } val maxPositionY = halfHeight * (currentScale - 1) return positionY.coerceIn(-maxPositionY, maxPositionY) } private fun zoom( fromRate: Float, toRate: Float, fromX: Float, toX: Float, fromY: Float, toY: Float, ) { isZooming = true val animatorSet = AnimatorSet() val translationXAnimator = ValueAnimator.ofFloat(fromX, toX) translationXAnimator.addUpdateListener { animation -> x = animation.animatedValue as Float } val translationYAnimator = ValueAnimator.ofFloat(fromY, toY) translationYAnimator.addUpdateListener { animation -> y = animation.animatedValue as Float } val scaleAnimator = ValueAnimator.ofFloat(fromRate, toRate) scaleAnimator.addUpdateListener { animation -> currentScale = animation.animatedValue as Float setScaleRate(currentScale) } animatorSet.playTogether(translationXAnimator, translationYAnimator, scaleAnimator) animatorSet.duration = ANIMATOR_DURATION_TIME.toLong() animatorSet.interpolator = DecelerateInterpolator() animatorSet.start() animatorSet.doOnEnd { isZooming = false currentScale = toRate } } fun zoomFling(velocityX: Int, velocityY: Int): Boolean { if (currentScale <= 1f) return false val distanceTimeFactor = 0.4f val animatorSet = AnimatorSet() if (velocityX != 0) { val dx = (distanceTimeFactor * velocityX / 2) val newX = getPositionX(x + dx) val translationXAnimator = ValueAnimator.ofFloat(x, newX) translationXAnimator.addUpdateListener { animation -> x = getPositionX(animation.animatedValue as Float) } animatorSet.play(translationXAnimator) } if (velocityY != 0 && (atFirstPosition || atLastPosition)) { val dy = (distanceTimeFactor * velocityY / 2) val newY = getPositionY(y + dy) val translationYAnimator = ValueAnimator.ofFloat(y, newY) translationYAnimator.addUpdateListener { animation -> y = getPositionY(animation.animatedValue as Float) } animatorSet.play(translationYAnimator) } animatorSet.duration = 400 animatorSet.interpolator = DecelerateInterpolator() animatorSet.start() return true } fun resetZoom() { zoom(currentScale, DEFAULT_RATE, x, 0f, y, 0f) } private fun zoomScrollBy(dx: Int, dy: Int) { if (dx != 0) { x = getPositionX(x + dx) } if (dy != 0) { y = getPositionY(y + dy) } } private fun setScaleRate(rate: Float) { scaleX = rate scaleY = rate } fun onScale(scaleFactor: Float) { currentScale *= scaleFactor currentScale = currentScale.coerceIn( MIN_RATE, MAX_SCALE_RATE, ) setScaleRate(currentScale) layoutParams.height = if (currentScale < 1) { (originalHeight / currentScale).toInt() } else { originalHeight } halfHeight = layoutParams.height / 2 if (currentScale != DEFAULT_RATE) { x = getPositionX(x) y = getPositionY(y) } else { x = 0f y = 0f } requestLayout() } fun onScaleBegin() { if (detector.isDoubleTapping) { detector.isQuickScaling = true } } fun onScaleEnd() { if (scaleX < MIN_RATE) { zoom(currentScale, MIN_RATE, x, 0f, y, 0f) } } inner class GestureListener : GestureDetectorWithLongTap.Listener() { override fun onSingleTapConfirmed(ev: MotionEvent): Boolean { tapListener?.invoke(ev) return false } override fun onDoubleTap(ev: MotionEvent): Boolean { detector.isDoubleTapping = true return false } fun onDoubleTapConfirmed(ev: MotionEvent) { if (!isZooming && doubleTapZoom) { if (scaleX != DEFAULT_RATE) { zoom(currentScale, DEFAULT_RATE, x, 0f, y, 0f) } else { val toScale = 2f val toX = (halfWidth - ev.x) * (toScale - 1) val toY = (halfHeight - ev.y) * (toScale - 1) zoom(DEFAULT_RATE, toScale, 0f, toX, 0f, toY) } } } override fun onLongTapConfirmed(ev: MotionEvent) { if (longTapListener?.invoke(ev) == true) { performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) } } } inner class Detector : GestureDetectorWithLongTap(context, listener) { private var scrollPointerId = 0 private var downX = 0 private var downY = 0 private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop private var isZoomDragging = false var isDoubleTapping = false var isQuickScaling = false override fun onTouchEvent(ev: MotionEvent): Boolean { val action = ev.actionMasked val actionIndex = ev.actionIndex when (action) { MotionEvent.ACTION_DOWN -> { scrollPointerId = ev.getPointerId(0) downX = (ev.x + 0.5f).toInt() downY = (ev.y + 0.5f).toInt() } MotionEvent.ACTION_POINTER_DOWN -> { scrollPointerId = ev.getPointerId(actionIndex) downX = (ev.getX(actionIndex) + 0.5f).toInt() downY = (ev.getY(actionIndex) + 0.5f).toInt() } MotionEvent.ACTION_MOVE -> { if (disableMangaScale) { return super.onTouchEvent(ev) } if (isDoubleTapping && isQuickScaling) { return true } val index = ev.findPointerIndex(scrollPointerId) if (index < 0) { return false } val x = (ev.getX(index) + 0.5f).toInt() val y = (ev.getY(index) + 0.5f).toInt() var dx = x - downX var dy = if (atFirstPosition || atLastPosition) y - downY else 0 if (!isZoomDragging && currentScale > 1f) { var startScroll = false if (abs(dx) > touchSlop) { if (dx < 0) { dx += touchSlop } else { dx -= touchSlop } startScroll = true } if (abs(dy) > touchSlop) { if (dy < 0) { dy += touchSlop } else { dy -= touchSlop } startScroll = true } if (startScroll) { isZoomDragging = true } } if (isZoomDragging) { zoomScrollBy(dx, dy) } } MotionEvent.ACTION_UP -> { if (isDoubleTapping && !isQuickScaling && !disableMangaScale) { listener.onDoubleTapConfirmed(ev) } isZoomDragging = false isDoubleTapping = false isQuickScaling = false } MotionEvent.ACTION_CANCEL -> { isZoomDragging = false isDoubleTapping = false isQuickScaling = false } } return super.onTouchEvent(ev) } } fun setPreScrollListener(iComicPreScroll: IComicPreScroll) { mPreScrollListener = iComicPreScroll } fun setNestedPreScrollListener(iComicPreScroll: IComicPreScroll) { mNestedPreScrollListener = iComicPreScroll } fun interface IComicPreScroll { fun onPreScrollListener(recyclerView: RecyclerView, dx: Int, dy: Int, position: Int) } } private const val ANIMATOR_DURATION_TIME = 200 private const val MIN_RATE = 0.5f private const val DEFAULT_RATE = 1f private const val MAX_SCALE_RATE = 3f ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/BaseReadBookActivity.kt ================================================ package io.legado.app.ui.book.read import android.annotation.SuppressLint import android.app.DatePickerDialog import android.content.pm.ActivityInfo import android.os.Build import android.os.Bundle import android.view.KeyEvent import android.view.View import android.view.WindowInsets import android.view.WindowManager import androidx.activity.viewModels import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.constant.AppConst.charsets import io.legado.app.constant.PreferKey import io.legado.app.databinding.ActivityBookReadBinding import io.legado.app.databinding.DialogDownloadChoiceBinding import io.legado.app.databinding.DialogEditTextBinding import io.legado.app.databinding.DialogSimulatedReadingBinding import io.legado.app.help.config.AppConfig import io.legado.app.help.config.LocalConfig import io.legado.app.help.config.ReadBookConfig import io.legado.app.lib.dialogs.alert import io.legado.app.lib.dialogs.selector import io.legado.app.lib.theme.ThemeStore import io.legado.app.lib.theme.bottomBackground import io.legado.app.model.CacheBook import io.legado.app.model.ReadBook import io.legado.app.ui.book.read.config.BgTextConfigDialog import io.legado.app.ui.book.read.config.ClickActionConfigDialog import io.legado.app.ui.book.read.config.PaddingConfigDialog import io.legado.app.ui.book.read.config.PageKeyDialog import io.legado.app.ui.file.HandleFileContract import io.legado.app.utils.ColorUtils import io.legado.app.utils.FileDoc import io.legado.app.utils.find import io.legado.app.utils.getPrefString import io.legado.app.utils.gone import io.legado.app.utils.isTv import io.legado.app.utils.setLightStatusBar import io.legado.app.utils.setNavigationBarColorAuto import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat import io.legado.app.utils.showDialogFragment import io.legado.app.utils.viewbindingdelegate.viewBinding import java.time.LocalDate import java.time.format.DateTimeFormatter /** * 阅读界面 */ abstract class BaseReadBookActivity : VMBaseActivity(imageBg = false) { override val binding by viewBinding(ActivityBookReadBinding::inflate) override val viewModel by viewModels() protected val menuLayoutIsVisible get() = bottomDialog > 0 || binding.readMenu.isVisible || binding.searchMenu.bottomMenuVisible var bottomDialog = 0 set(value) { if (field != value) { field = value onBottomDialogChange() } } private val selectBookFolderResult = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> ReadBook.book?.let { book -> FileDoc.fromUri(uri, true).find(book.originName)?.let { doc -> book.bookUrl = doc.uri.toString() book.save() viewModel.loadChapterList(book) } ?: ReadBook.upMsg("找不到文件") } } ?: ReadBook.upMsg("没有权限访问") } override fun onCreate(savedInstanceState: Bundle?) { ReadBook.msg = null setOrientation() upLayoutInDisplayCutoutMode() super.onCreate(savedInstanceState) binding.navigationBar.setOnApplyWindowInsetsListenerCompat { view, windowInsets -> val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) view.updateLayoutParams { height = insets.bottom } windowInsets } } override fun onActivityCreated(savedInstanceState: Bundle?) { binding.navigationBar.setBackgroundColor(bottomBackground) viewModel.permissionDenialLiveData.observe(this) { selectBookFolderResult.launch { mode = HandleFileContract.DIR_SYS title = "选择书籍所在文件夹" } } if (!LocalConfig.readHelpVersionIsLast) { if (isTv) { showCustomPageKeyConfig() } else { showClickRegionalConfig() } } } private fun onBottomDialogChange() { when (bottomDialog) { 0 -> onMenuHide() 1 -> onMenuShow() } } open fun onMenuShow() { } open fun onMenuHide() { } fun showPaddingConfig() { showDialogFragment() } fun showBgTextConfig() { showDialogFragment() } fun showClickRegionalConfig() { showDialogFragment() } private fun showCustomPageKeyConfig() { PageKeyDialog(this).show() } /** * 屏幕方向 */ @SuppressLint("SourceLockedOrientationActivity") fun setOrientation() { when (AppConfig.screenOrientation) { "0" -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED "1" -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT "2" -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE "3" -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR "4" -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT } } /** * 更新状态栏,导航栏 */ fun upSystemUiVisibility( isInMultiWindow: Boolean, toolBarHide: Boolean = true, useBgMeanColor: Boolean = false ) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { window.insetsController?.run { if (toolBarHide && ReadBookConfig.hideNavigationBar) { hide(WindowInsets.Type.navigationBars()) } else { show(WindowInsets.Type.navigationBars()) } if (toolBarHide && ReadBookConfig.hideStatusBar) { hide(WindowInsets.Type.statusBars()) } else { show(WindowInsets.Type.statusBars()) } } } upSystemUiVisibilityO(isInMultiWindow, toolBarHide) if (toolBarHide) { setLightStatusBar(ReadBookConfig.durConfig.curStatusIconDark()) } else { val statusBarColor = if (AppConfig.readBarStyleFollowPage && ReadBookConfig.durConfig.curBgType() == 0 || useBgMeanColor ) { ReadBookConfig.bgMeanColor } else { ThemeStore.statusBarColor(this, AppConfig.isTransparentStatusBar) } setLightStatusBar(ColorUtils.isColorLight(statusBarColor)) } } @Suppress("DEPRECATION") private fun upSystemUiVisibilityO( isInMultiWindow: Boolean, toolBarHide: Boolean = true ) { var flag = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_IMMERSIVE or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY) if (!isInMultiWindow) { flag = flag or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN } if (ReadBookConfig.hideNavigationBar) { flag = flag or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION if (toolBarHide) { flag = flag or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION } } if (ReadBookConfig.hideStatusBar && toolBarHide) { flag = flag or View.SYSTEM_UI_FLAG_FULLSCREEN } window.decorView.systemUiVisibility = flag } override fun upNavigationBarColor() { upNavigationBar() when { binding.readMenu.isVisible -> super.upNavigationBarColor() binding.searchMenu.bottomMenuVisible -> super.upNavigationBarColor() bottomDialog > 0 -> super.upNavigationBarColor() !AppConfig.immNavigationBar -> super.upNavigationBarColor() else -> setNavigationBarColorAuto(ReadBookConfig.bgMeanColor) } } @SuppressLint("RtlHardcoded") private fun upNavigationBar() { binding.navigationBar.gone(!menuLayoutIsVisible) } /** * 保持亮屏 */ fun keepScreenOn(on: Boolean) { val isScreenOn = (window.attributes.flags and WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) != 0 if (on == isScreenOn) return if (on) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } else { window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } } /** * 适配刘海 */ private fun upLayoutInDisplayCutoutMode() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { window.attributes = window.attributes.apply { layoutInDisplayCutoutMode = if (ReadBookConfig.readBodyToLh) { WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES } else { WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER } } } } @SuppressLint("InflateParams", "SetTextI18n") fun showDownloadDialog() { ReadBook.book?.let { book -> alert(titleResource = R.string.offline_cache) { val alertBinding = DialogDownloadChoiceBinding.inflate(layoutInflater).apply { editStart.setText((book.durChapterIndex + 1).toString()) editEnd.setText(book.totalChapterNum.toString()) } customView { alertBinding.root } okButton { alertBinding.run { val start = editStart.text!!.toString().let { if (it.isEmpty()) 0 else it.toInt() } val end = editEnd.text!!.toString().let { if (it.isEmpty()) book.totalChapterNum else it.toInt() } CacheBook.start(this@BaseReadBookActivity, book, start - 1, end - 1) } } cancelButton() } } } fun showSimulatedReading() { val book = ReadBook.book ?: return val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") val alertBinding = DialogSimulatedReadingBinding.inflate(layoutInflater).apply { srEnabled.isChecked = book.getReadSimulating() editStart.setText(book.getStartChapter().toString()) editNum.setText(book.getDailyChapters().toString()) startDate.setText(book.getStartDate()?.format(dateFormatter)) startDate.isFocusable = false // 设置为false,不允许获得焦点 startDate.isCursorVisible = false // 不显示光标 startDate.setOnClickListener { // 获取当前日期 val localStartDate = LocalDate.parse(startDate.text) // 创建 DatePickerDialog val datePickerDialog = DatePickerDialog( root.context, { _, yy, mm, dayOfMonth -> // 使用Java 8的日期和时间API来格式化日期 val date = LocalDate.of(yy, mm + 1, dayOfMonth) // Java 8的LocalDate,月份从1开始 val formattedDate = date.format(dateFormatter) startDate.setText(formattedDate) }, localStartDate.year, localStartDate.monthValue - 1, localStartDate.dayOfMonth ) datePickerDialog.show() } } alert(titleResource = R.string.simulated_reading) { customView { alertBinding.root } okButton { alertBinding.run { val start = editStart.text!!.toString().let { if (it.isEmpty()) 0 else it.toInt() } val num = editNum.text!!.toString().let { if (it.isEmpty()) book.totalChapterNum else it.toInt() } val enabled = srEnabled.isChecked val date = startDate.text!!.toString().let { if (it.isEmpty()) LocalDate.now() else LocalDate.parse(it, dateFormatter) } book.setStartDate(date) book.setDailyChapters(num) book.setStartChapter(start) book.setReadSimulating(enabled) book.save() ReadBook.clearTextChapter() viewModel.initData(intent) } } cancelButton() } } fun showCharsetConfig() { alert(R.string.set_charset) { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = "charset" editView.setFilterValues(charsets) editView.setText(ReadBook.book?.charset) } customView { alertBinding.root } okButton { alertBinding.editView.text?.toString()?.let { ReadBook.setCharset(it) } } cancelButton() } } fun showPageAnimConfig(success: () -> Unit) { val items = arrayListOf() items.add(getString(R.string.btn_default_s)) items.add(getString(R.string.page_anim_cover)) items.add(getString(R.string.page_anim_slide)) items.add(getString(R.string.page_anim_simulation)) items.add(getString(R.string.page_anim_scroll)) items.add(getString(R.string.page_anim_none)) selector(R.string.page_anim, items) { _, i -> ReadBook.book?.setPageAnim(i - 1) success() } } fun isPrevKey(keyCode: Int): Boolean { if (keyCode == KeyEvent.KEYCODE_UNKNOWN) { return false } val prevKeysStr = getPrefString(PreferKey.prevKeys) return prevKeysStr?.split(",")?.contains(keyCode.toString()) ?: false } fun isNextKey(keyCode: Int): Boolean { if (keyCode == KeyEvent.KEYCODE_UNKNOWN) { return false } val nextKeysStr = getPrefString(PreferKey.nextKeys) return nextKeysStr?.split(",")?.contains(keyCode.toString()) ?: false } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/ContentEditDialog.kt ================================================ package io.legado.app.ui.book.read import android.app.Application import android.content.DialogInterface import android.os.Bundle import android.view.View import android.view.ViewGroup import androidx.fragment.app.viewModels import androidx.lifecycle.MutableLiveData import androidx.lifecycle.lifecycleScope import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.BaseViewModel import io.legado.app.data.appDb import io.legado.app.data.entities.BookChapter import io.legado.app.databinding.DialogContentEditBinding import io.legado.app.databinding.DialogEditTextBinding import io.legado.app.help.book.BookHelp import io.legado.app.help.book.ContentProcessor import io.legado.app.help.book.isLocal import io.legado.app.help.coroutine.Coroutine import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.primaryColor import io.legado.app.model.ReadBook import io.legado.app.model.webBook.WebBook import io.legado.app.utils.applyTint import io.legado.app.utils.sendToClip import io.legado.app.utils.setLayout import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** * 内容编辑 */ class ContentEditDialog : BaseDialogFragment(R.layout.dialog_content_edit) { val binding by viewBinding(DialogContentEditBinding::bind) val viewModel by viewModels() override fun onStart() { super.onStart() setLayout(1f, ViewGroup.LayoutParams.MATCH_PARENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) binding.toolBar.title = ReadBook.curTextChapter?.title initMenu() binding.toolBar.setOnClickListener { lifecycleScope.launch { val book = ReadBook.book ?: return@launch val chapter = withContext(IO) { appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) } ?: return@launch editTitle(chapter) } } viewModel.loadStateLiveData.observe(viewLifecycleOwner) { if (it) { binding.rlLoading.visible() } else { binding.rlLoading.gone() } } viewModel.initContent { binding.contentView.setText(it) binding.contentView.post { binding.contentView.apply { val lineIndex = layout.getLineForOffset(ReadBook.durChapterPos) val lineHeight = layout.getLineTop(lineIndex) scrollTo(0, lineHeight) } } } } private fun initMenu() { binding.toolBar.inflateMenu(R.menu.content_edit) binding.toolBar.menu.applyTint(requireContext()) binding.toolBar.setOnMenuItemClickListener { when (it.itemId) { R.id.menu_save -> { save() dismiss() } R.id.menu_reset -> viewModel.initContent(true) { content -> binding.contentView.setText(content) ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false) } R.id.menu_copy_all -> requireContext() .sendToClip("${binding.toolBar.title}\n${binding.contentView.text}") } return@setOnMenuItemClickListener true } } private fun editTitle(chapter: BookChapter) { alert { setTitle(R.string.edit) val alertBinding = DialogEditTextBinding.inflate(layoutInflater) alertBinding.editView.setText(chapter.title) setCustomView(alertBinding.root) okButton { chapter.title = alertBinding.editView.text.toString() lifecycleScope.launch { withContext(IO) { appDb.bookChapterDao.update(chapter) } binding.toolBar.title = chapter.getDisplayTitle() ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false) } } } } override fun onCancel(dialog: DialogInterface) { super.onCancel(dialog) save() } private fun save() { val content = binding.contentView.text?.toString() ?: return Coroutine.async { val book = ReadBook.book ?: return@async val chapter = appDb.bookChapterDao .getChapter(book.bookUrl, ReadBook.durChapterIndex) ?: return@async BookHelp.saveText(book, chapter, content) ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false) } } class ContentEditViewModel(application: Application) : BaseViewModel(application) { val loadStateLiveData = MutableLiveData() var content: String? = null fun initContent(reset: Boolean = false, success: (String) -> Unit) { execute { val book = ReadBook.book ?: return@execute null val chapter = appDb.bookChapterDao .getChapter(book.bookUrl, ReadBook.durChapterIndex) ?: return@execute null if (reset) { content = null BookHelp.delContent(book, chapter) if (!book.isLocal) ReadBook.bookSource?.let { bookSource -> WebBook.getContentAwait(bookSource, book, chapter) } } return@execute content ?: let { val contentProcessor = ContentProcessor.get(book.name, book.origin) val content = BookHelp.getContent(book, chapter) ?: return@let null contentProcessor.getContent(book, chapter, content, includeTitle = false) .toString() } }.onStart { loadStateLiveData.postValue(true) }.onSuccess { content = it success.invoke(it ?: "") }.onFinally { loadStateLiveData.postValue(false) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/EffectiveReplacesDialog.kt ================================================ package io.legado.app.ui.book.read import android.content.Context import android.content.DialogInterface import android.os.Bundle import android.view.View import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.activityViewModels import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.data.entities.ReplaceRule import io.legado.app.databinding.DialogRecyclerViewBinding import io.legado.app.databinding.Item1lineTextBinding import io.legado.app.help.config.AppConfig import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.primaryColor import io.legado.app.model.ReadBook import io.legado.app.ui.replace.edit.ReplaceEditActivity import io.legado.app.utils.setLayout import io.legado.app.utils.viewbindingdelegate.viewBinding /** * 起效的替换规则 */ class EffectiveReplacesDialog : BaseDialogFragment(R.layout.dialog_recycler_view) { private val binding by viewBinding(DialogRecyclerViewBinding::bind) private val viewModel by activityViewModels() private val adapter by lazy { ReplaceAdapter(requireContext()) } private val chineseConvert by lazy { ReplaceRule(0, "繁简转换") } private var isEdit = false private val editActivity = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == AppCompatActivity.RESULT_OK) { isEdit = true } } override fun onStart() { super.onStart() setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.run { toolBar.setBackgroundColor(primaryColor) toolBar.setTitle(R.string.effective_replaces) recyclerView.layoutManager = LinearLayoutManager(requireContext()) recyclerView.adapter = adapter } val effectiveReplaceRules = ReadBook.curTextChapter?.effectiveReplaceRules ?: emptyList() if (AppConfig.chineseConverterType > 0) { adapter.setItems(effectiveReplaceRules + chineseConvert) } else { adapter.setItems(effectiveReplaceRules) } } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) if (isEdit) { viewModel.replaceRuleChanged() } } private fun showChineseConvertAlert() { alert(titleResource = R.string.chinese_converter) { items(resources.getStringArray(R.array.chinese_mode).toList()) { _, i -> if (AppConfig.chineseConverterType != i) { AppConfig.chineseConverterType = i isEdit = true } } } } private inner class ReplaceAdapter(context: Context) : RecyclerAdapter(context) { override fun getViewBinding(parent: ViewGroup): Item1lineTextBinding { return Item1lineTextBinding.inflate(inflater, parent, false) } override fun registerListener(holder: ItemViewHolder, binding: Item1lineTextBinding) { binding.root.setOnClickListener { getItem(holder.layoutPosition)?.let { item -> if (item == chineseConvert) { showChineseConvertAlert() return@let } editActivity.launch(ReplaceEditActivity.startIntent(requireContext(), item.id)) } } } override fun convert( holder: ItemViewHolder, binding: Item1lineTextBinding, item: ReplaceRule, payloads: MutableList ) { binding.textView.text = item.name } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/MangaMenu.kt ================================================ package io.legado.app.ui.book.read import android.annotation.SuppressLint import android.content.Context import android.graphics.drawable.GradientDrawable import android.util.AttributeSet import android.view.LayoutInflater import android.view.animation.Animation import android.widget.FrameLayout import android.widget.SeekBar import androidx.core.view.isGone import androidx.core.view.isVisible import io.legado.app.R import io.legado.app.databinding.ViewMangaMenuBinding import io.legado.app.help.config.AppConfig import io.legado.app.help.source.getSourceType import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.bottomBackground import io.legado.app.model.ReadBook import io.legado.app.model.ReadManga import io.legado.app.ui.browser.WebViewActivity import io.legado.app.ui.widget.seekbar.SeekBarChangeListener import io.legado.app.utils.ColorUtils import io.legado.app.utils.ConstraintModify import io.legado.app.utils.activity import io.legado.app.utils.applyNavigationBarPadding import io.legado.app.utils.dpToPx import io.legado.app.utils.gone import io.legado.app.utils.invisible import io.legado.app.utils.loadAnimation import io.legado.app.utils.modifyBegin import io.legado.app.utils.openUrl import io.legado.app.utils.startActivity import io.legado.app.utils.visible class MangaMenu @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, ) : FrameLayout(context, attrs) { private val binding = ViewMangaMenuBinding.inflate(LayoutInflater.from(context), this, true) private val callBack: CallBack get() = activity as CallBack var canShowMenu: Boolean = false private val menuTopIn: Animation by lazy { loadAnimation(context, R.anim.anim_readbook_top_in) } private val menuTopOut: Animation by lazy { loadAnimation(context, R.anim.anim_readbook_top_out) } private val menuBottomIn: Animation by lazy { loadAnimation(context, R.anim.anim_readbook_bottom_in) } private val menuBottomOut: Animation by lazy { loadAnimation(context, R.anim.anim_readbook_bottom_out) } private var isMenuOutAnimating = false private var bgColor = context.bottomBackground private val menuOutListener = object : Animation.AnimationListener { override fun onAnimationStart(animation: Animation) { isMenuOutAnimating = true binding.vwMenuBg.setOnClickListener(null) } override fun onAnimationEnd(animation: Animation) { this@MangaMenu.invisible() binding.titleBar.invisible() binding.bottomMenu.invisible() isMenuOutAnimating = false canShowMenu = false callBack.upSystemUiVisibility(false) } override fun onAnimationRepeat(animation: Animation) = Unit } private val menuInListener = object : Animation.AnimationListener { override fun onAnimationStart(animation: Animation) { binding.tvSourceAction.text = ReadManga.bookSource?.bookSourceName ?: context.getString(R.string.book_source) callBack.upSystemUiVisibility(true) binding.tvSourceAction.isGone = false } @SuppressLint("RtlHardcoded") override fun onAnimationEnd(animation: Animation) { binding.run { vwMenuBg.setOnClickListener { runMenuOut() } } } override fun onAnimationRepeat(animation: Animation) = Unit } init { initView() bindEvent() } private fun initView() = binding.run { initAnimation() val brightnessBackground = GradientDrawable() brightnessBackground.cornerRadius = 5F.dpToPx() brightnessBackground.setColor(ColorUtils.adjustAlpha(bgColor, 0.5f)) if (AppConfig.isEInkMode) { titleBar.setBackgroundResource(R.drawable.bg_eink_border_bottom) bottomMenu.setBackgroundResource(R.drawable.bg_eink_border_top) } else { bottomMenu.setBackgroundColor(bgColor) } if (AppConfig.showReadTitleBarAddition) { titleBarAddition.visible() } else { titleBarAddition.gone() } upBrightnessVwPos() /** * 确保视图不被导航栏遮挡 */ bottomMenu.applyNavigationBarPadding() } private fun upBrightnessVwPos() { if (AppConfig.brightnessVwPos) { binding.root.modifyBegin() .clear(R.id.ll_brightness, ConstraintModify.Anchor.LEFT) .rightToRightOf(R.id.ll_brightness, R.id.vw_menu_root) .commit() } else { binding.root.modifyBegin() .clear(R.id.ll_brightness, ConstraintModify.Anchor.RIGHT) .leftToLeftOf(R.id.ll_brightness, R.id.vw_menu_root) .commit() } } private fun initAnimation() { menuTopIn.setAnimationListener(menuInListener) menuTopOut.setAnimationListener(menuOutListener) } fun runMenuOut(anim: Boolean = !AppConfig.isEInkMode) { if (isMenuOutAnimating) { return } if (this.isVisible) { if (anim) { binding.titleBar.startAnimation(menuTopOut) binding.bottomMenu.startAnimation(menuBottomOut) } else { menuOutListener.onAnimationStart(menuBottomOut) menuOutListener.onAnimationEnd(menuBottomOut) } } } fun runMenuIn(anim: Boolean = !AppConfig.isEInkMode) { this.visible() binding.titleBar.visible() binding.bottomMenu.visible() if (anim) { binding.titleBar.startAnimation(menuTopIn) binding.bottomMenu.startAnimation(menuBottomIn) } else { menuInListener.onAnimationStart(menuBottomIn) menuInListener.onAnimationEnd(menuBottomIn) } } private fun bindEvent() = binding.run { vwMenuBg.setOnClickListener { runMenuOut() } titleBar.toolbar.setOnClickListener { callBack.openBookInfoActivity() } val chapterViewClickListener = OnClickListener { if (AppConfig.readUrlInBrowser) { context.openUrl(tvChapterUrl.text.toString().substringBefore(",{")) } else { context.startActivity { val url = tvChapterUrl.text.toString() val bookSource = ReadBook.bookSource putExtra("title", tvChapterName.text) putExtra("url", url) putExtra("sourceOrigin", bookSource?.bookSourceUrl) putExtra("sourceName", bookSource?.bookSourceName) putExtra("sourceType", bookSource?.getSourceType()) } } } val chapterViewLongClickListener = OnLongClickListener { context.alert(R.string.open_fun) { setMessage(R.string.use_browser_open) okButton { AppConfig.readUrlInBrowser = true } noButton { AppConfig.readUrlInBrowser = false } } true } tvChapterName.setOnClickListener(chapterViewClickListener) tvChapterName.setOnLongClickListener(chapterViewLongClickListener) tvChapterUrl.setOnClickListener(chapterViewClickListener) tvChapterUrl.setOnLongClickListener(chapterViewLongClickListener) tvNext.setOnClickListener { ReadManga.moveToNextChapter(true) } tvPre.setOnClickListener { ReadManga.moveToPrevChapter(true) } seekReadPage.setOnSeekBarChangeListener(object : SeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { if (fromUser) { callBack.skipToPage(seekBar.progress) } } override fun onStartTrackingTouch(seekBar: SeekBar) { binding.vwMenuBg.setOnClickListener(null) } override fun onStopTrackingTouch(seekBar: SeekBar) { binding.vwMenuBg.setOnClickListener { runMenuOut() } } }) } fun upSeekBar(value: Int, count: Int) { binding.seekReadPage.apply { max = count.minus(1) progress = value } } interface CallBack { fun openBookInfoActivity() fun upSystemUiVisibility(menuIsVisible: Boolean) fun skipToPage(index: Int) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt ================================================ package io.legado.app.ui.book.read import android.annotation.SuppressLint import android.content.Intent import android.content.res.Configuration import android.net.Uri import android.os.Bundle import android.os.Looper import android.view.Gravity import android.view.InputDevice import android.view.KeyEvent import android.view.Menu import android.view.MenuItem import android.view.MotionEvent import android.view.View import androidx.activity.addCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.PopupMenu import androidx.core.view.get import androidx.core.view.size import androidx.lifecycle.lifecycleScope import com.jaredrummler.android.colorpicker.ColorPickerDialogListener import io.legado.app.BuildConfig import io.legado.app.R import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog import io.legado.app.constant.BookType import io.legado.app.constant.EventBus import io.legado.app.constant.PreferKey import io.legado.app.constant.Status import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookProgress import io.legado.app.data.entities.BookSource import io.legado.app.exception.NoStackTraceException import io.legado.app.help.AppWebDav import io.legado.app.help.IntentData import io.legado.app.help.TTS import io.legado.app.help.book.BookHelp import io.legado.app.help.book.ContentProcessor import io.legado.app.help.book.isAudio import io.legado.app.help.book.isEpub import io.legado.app.help.book.isLocal import io.legado.app.help.book.isLocalTxt import io.legado.app.help.book.isMobi import io.legado.app.help.book.removeType import io.legado.app.help.book.update import io.legado.app.help.config.AppConfig import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.config.ReadTipConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.source.getSourceType import io.legado.app.help.storage.Backup import io.legado.app.lib.dialogs.SelectItem import io.legado.app.lib.dialogs.alert import io.legado.app.lib.dialogs.selector import io.legado.app.lib.theme.accentColor import io.legado.app.model.ReadAloud 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.localBook.EpubFile import io.legado.app.model.localBook.MobiFile import io.legado.app.receiver.NetworkChangedListener import io.legado.app.receiver.TimeBatteryReceiver import io.legado.app.service.BaseReadAloudService import io.legado.app.ui.about.AppLogDialog import io.legado.app.ui.book.bookmark.BookmarkDialog import io.legado.app.ui.book.changesource.ChangeBookSourceDialog import io.legado.app.ui.book.changesource.ChangeChapterSourceDialog import io.legado.app.ui.book.info.BookInfoActivity import io.legado.app.ui.book.read.config.AutoReadDialog import io.legado.app.ui.book.read.config.BgTextConfigDialog.Companion.BG_COLOR import io.legado.app.ui.book.read.config.BgTextConfigDialog.Companion.TEXT_COLOR import io.legado.app.ui.book.read.config.MoreConfigDialog import io.legado.app.ui.book.read.config.ReadAloudDialog import io.legado.app.ui.book.read.config.ReadStyleDialog import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.TIP_COLOR import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.TIP_DIVIDER_COLOR import io.legado.app.ui.book.read.page.ContentTextView import io.legado.app.ui.book.read.page.ReadView import io.legado.app.ui.book.read.page.entities.PageDirection import io.legado.app.ui.book.read.page.entities.TextPage import io.legado.app.ui.book.read.page.provider.ChapterProvider import io.legado.app.ui.book.read.page.provider.LayoutProgressListener import io.legado.app.ui.book.searchContent.SearchContentActivity import io.legado.app.ui.book.searchContent.SearchResult import io.legado.app.ui.book.source.edit.BookSourceEditActivity import io.legado.app.ui.book.toc.TocActivityResult 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.file.HandleFileContract import io.legado.app.ui.login.SourceLoginActivity import io.legado.app.ui.replace.ReplaceRuleActivity import io.legado.app.ui.replace.edit.ReplaceEditActivity import io.legado.app.ui.widget.PopupAction import io.legado.app.ui.widget.dialog.PhotoDialog import io.legado.app.utils.ACache import io.legado.app.utils.Debounce import io.legado.app.utils.LogUtils import io.legado.app.utils.NetworkUtils import io.legado.app.utils.StartActivityContract import io.legado.app.utils.applyOpenTint import io.legado.app.utils.buildMainHandler import io.legado.app.utils.dismissDialogFragment import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.getPrefString 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.isTrue import io.legado.app.utils.launch import io.legado.app.utils.navigationBarGravity import io.legado.app.utils.observeEvent import io.legado.app.utils.observeEventSticky import io.legado.app.utils.postEvent import io.legado.app.utils.showDialogFragment import io.legado.app.utils.showHelp import io.legado.app.utils.startActivity import io.legado.app.utils.startActivityForBook import io.legado.app.utils.sysScreenOffTime import io.legado.app.utils.throttle import io.legado.app.utils.toastOnUi import io.legado.app.utils.visible import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** * 阅读界面 */ class ReadBookActivity : BaseReadBookActivity(), View.OnTouchListener, ReadView.CallBack, TextActionMenu.CallBack, ContentTextView.CallBack, PopupMenu.OnMenuItemClickListener, ReadMenu.CallBack, SearchMenu.CallBack, ReadAloudDialog.CallBack, ChangeBookSourceDialog.CallBack, ChangeChapterSourceDialog.CallBack, ReadBook.CallBack, AutoReadDialog.CallBack, TxtTocRuleDialog.CallBack, ColorPickerDialogListener, LayoutProgressListener { private val tocActivity = registerForActivityResult(TocActivityResult()) { it?.let { viewModel.openChapter(it.first, it.second) } } private val sourceEditActivity = registerForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) { if (it.resultCode == RESULT_OK) { viewModel.upBookSource { upMenuView() } } } private val replaceActivity = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == RESULT_OK) { viewModel.replaceRuleChanged() } } private val searchContentActivity = registerForActivityResult(StartActivityContract(SearchContentActivity::class.java)) { val data = it.data ?: return@registerForActivityResult val key = data.getLongExtra("key", System.currentTimeMillis()) val index = data.getIntExtra("index", 0) val searchResult = IntentData.get("searchResult$key") val searchResultList = IntentData.get>("searchResultList$key") if (searchResult != null && searchResultList != null) { viewModel.searchContentQuery = searchResult.query binding.searchMenu.upSearchResultList(searchResultList) isShowingSearchResult = true viewModel.searchResultIndex = index binding.searchMenu.updateSearchResultIndex(index) binding.searchMenu.selectedSearchResult?.let { currentResult -> ReadBook.saveCurrentBookProgress() //退出全文搜索恢复此时进度 skipToSearch(currentResult) showActionMenu() } } } private val bookInfoActivity = registerForActivityResult(StartActivityContract(BookInfoActivity::class.java)) { if (it.resultCode == RESULT_OK) { setResult(RESULT_DELETED) super.finish() } else { ReadBook.loadOrUpContent() } } private val selectImageDir = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> ACache.get().put(AppConst.imagePathKey, uri.toString()) viewModel.saveImage(it.value, uri) } } private var menu: Menu? = null private var backupJob: Job? = null private var tts: TTS? = null val textActionMenu: TextActionMenu by lazy { TextActionMenu(this, this) } private val popupAction: PopupAction by lazy { PopupAction(this) } override val isInitFinish: Boolean get() = viewModel.isInitFinish override val isScroll: Boolean get() = binding.readView.isScroll private val isAutoPage get() = binding.readView.isAutoPage override var isShowingSearchResult = false override var isSelectingSearchResult = false set(value) { field = value && isShowingSearchResult } private val timeBatteryReceiver = TimeBatteryReceiver() private var screenTimeOut: Long = 0 private var loadStates: Boolean = false override val pageFactory get() = binding.readView.pageFactory override val pageDelegate get() = binding.readView.pageDelegate override val headerHeight: Int get() = binding.readView.curPage.headerHeight private val nextPageDebounce by lazy { Debounce { keyPage(PageDirection.NEXT) } } private val prevPageDebounce by lazy { Debounce { keyPage(PageDirection.PREV) } } private var bookChanged = false private var pageChanged = false private val handler by lazy { buildMainHandler() } private val screenOffRunnable by lazy { Runnable { keepScreenOn(false) } } private val executor = ReadBook.executor private val upSeekBarThrottle = throttle(200) { runOnUiThread { upSeekBarProgress() binding.readMenu.upSeekBar() } } //恢复跳转前进度对话框的交互结果 private var confirmRestoreProcess: Boolean? = null private val networkChangedListener by lazy { NetworkChangedListener(this) } private var justInitData: Boolean = false private var syncDialog: AlertDialog? = null @SuppressLint("ClickableViewAccessibility") override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) binding.cursorLeft.setColorFilter(accentColor) binding.cursorRight.setColorFilter(accentColor) binding.cursorLeft.setOnTouchListener(this) binding.cursorRight.setOnTouchListener(this) window.setBackgroundDrawable(null) upScreenTimeOut() ReadBook.register(this) onBackPressedDispatcher.addCallback(this) { if (isShowingSearchResult) { exitSearchMenu() restoreLastBookProcess() return@addCallback } //拦截返回供恢复阅读进度 if (ReadBook.lastBookProgress != null && confirmRestoreProcess != false) { restoreLastBookProcess() return@addCallback } if (BaseReadAloudService.isPlay()) { ReadAloud.pause(this@ReadBookActivity) toastOnUi(R.string.read_aloud_pause) return@addCallback } if (isAutoPage) { autoPageStop() return@addCallback } if (getPrefBoolean("disableReturnKey") && !menuLayoutIsVisible) { return@addCallback } finish() } } override fun onPostCreate(savedInstanceState: Bundle?) { super.onPostCreate(savedInstanceState) viewModel.initReadBookConfig(intent) Looper.myQueue().addIdleHandler { viewModel.initData(intent) false } justInitData = true } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) viewModel.initData(intent) } override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus) upSystemUiVisibility() if (hasFocus) { binding.readMenu.upBrightnessState() } else if (!menuLayoutIsVisible) { ReadBook.cancelPreDownloadTask() } } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) upSystemUiVisibility() binding.readView.upStatusBar() } override fun onTopResumedActivityChanged(isTopResumedActivity: Boolean) { if (!isTopResumedActivity) { ReadBook.cancelPreDownloadTask() } } @SuppressLint("UnspecifiedRegisterReceiverFlag") override fun onResume() { super.onResume() ReadBook.readStartTime = System.currentTimeMillis() if (bookChanged) { bookChanged = false ReadBook.callBack = this viewModel.initData(intent) justInitData = true } else { //web端阅读时,app处于阅读界面,本地记录会覆盖web保存的进度,在此处恢复 ReadBook.webBookProgress?.let { ReadBook.setProgress(it) ReadBook.webBookProgress = null } } upSystemUiVisibility() registerReceiver(timeBatteryReceiver, timeBatteryReceiver.filter) binding.readView.upTime() screenOffTimerStart() // 网络监听,当从无网切换到网络环境时同步进度(注意注册的同时就会收到监听,因此界面激活时无需重复执行同步操作) networkChangedListener.register() networkChangedListener.onNetworkChanged = { // 当网络是可用状态且无需初始化时同步进度(初始化中已有同步进度逻辑) if (AppConfig.syncBookProgressPlus && NetworkUtils.isAvailable() && !justInitData && ReadBook.inBookshelf) { ReadBook.syncProgress({ progress -> sureNewProgress(progress) }) } } } override fun onPause() { super.onPause() autoPageStop() backupJob?.cancel() ReadBook.saveRead() ReadBook.cancelPreDownloadTask() unregisterReceiver(timeBatteryReceiver) upSystemUiVisibility() if (!BuildConfig.DEBUG && ReadBook.inBookshelf) { if (AppConfig.syncBookProgressPlus) { ReadBook.syncProgress() } else { ReadBook.uploadProgress() } } if (!BuildConfig.DEBUG) { Backup.autoBack(this) } justInitData = false networkChangedListener.unRegister() } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.book_read, menu) menu.iconItemOnLongClick(R.id.menu_change_source) { PopupMenu(this, it).apply { inflate(R.menu.book_read_change_source) this.menu.applyOpenTint(this@ReadBookActivity) setOnMenuItemClickListener(this@ReadBookActivity) }.show() } menu.iconItemOnLongClick(R.id.menu_refresh) { PopupMenu(this, it).apply { inflate(R.menu.book_read_refresh) this.menu.applyOpenTint(this@ReadBookActivity) setOnMenuItemClickListener(this@ReadBookActivity) }.show() } binding.readMenu.refreshMenuColorFilter() return super.onCompatCreateOptionsMenu(menu) } override fun onPrepareOptionsMenu(menu: Menu): Boolean { this.menu = menu upMenu() return super.onPrepareOptionsMenu(menu) } override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { menu.findItem(R.id.menu_same_title_removed)?.isChecked = ReadBook.curTextChapter?.sameTitleRemoved == true return super.onMenuOpened(featureId, menu) } /** * 更新菜单 */ private fun upMenu() { val menu = menu ?: return val book = ReadBook.book ?: return val onLine = !book.isLocal for (i in 0 until menu.size) { val item = menu[i] when (item.groupId) { R.id.menu_group_on_line -> item.isVisible = onLine R.id.menu_group_local -> item.isVisible = !onLine R.id.menu_group_text -> item.isVisible = book.isLocalTxt R.id.menu_group_epub -> item.isVisible = book.isEpub else -> when (item.itemId) { R.id.menu_enable_replace -> item.isChecked = book.getUseReplaceRule() R.id.menu_re_segment -> item.isChecked = book.getReSegment() // R.id.menu_enable_review -> { // item.isVisible = BuildConfig.DEBUG // item.isChecked = AppConfig.enableReview // } R.id.menu_reverse_content -> item.isVisible = onLine R.id.menu_del_ruby_tag -> item.isChecked = book.getDelTag(Book.rubyTag) R.id.menu_del_h_tag -> item.isChecked = book.getDelTag(Book.hTag) } } } lifecycleScope.launch { val show = ReadBook.inBookshelf && withContext(IO) { AppWebDav.isOk } menu.findItem(R.id.menu_get_progress)?.isVisible = show menu.findItem(R.id.menu_cover_progress)?.isVisible = show } } /** * 菜单 */ override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_change_source, R.id.menu_book_change_source -> { binding.readMenu.runMenuOut() ReadBook.book?.let { showDialogFragment(ChangeBookSourceDialog(it.name, it.author)) } } R.id.menu_chapter_change_source -> lifecycleScope.launch { val book = ReadBook.book ?: return@launch val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) ?: return@launch binding.readMenu.runMenuOut() showDialogFragment( ChangeChapterSourceDialog(book.name, book.author, chapter.index, chapter.title) ) } R.id.menu_refresh, R.id.menu_refresh_dur -> { if (ReadBook.bookSource == null) { upContent() } else { ReadBook.book?.let { ReadBook.curTextChapter = null binding.readView.upContent() viewModel.refreshContentDur(it) } } } R.id.menu_refresh_after -> { if (ReadBook.bookSource == null) { upContent() } else { ReadBook.book?.let { ReadBook.clearTextChapter() binding.readView.upContent() viewModel.refreshContentAfter(it) } } } R.id.menu_refresh_all -> { if (ReadBook.bookSource == null) { upContent() } else { ReadBook.book?.let { refreshContentAll(it) } } } R.id.menu_download -> showDownloadDialog() R.id.menu_add_bookmark -> addBookmark() R.id.menu_simulated_reading -> showSimulatedReading() R.id.menu_edit_content -> showDialogFragment(ContentEditDialog()) R.id.menu_update_toc -> ReadBook.book?.let { if (it.isEpub) { BookHelp.clearCache(it) EpubFile.clear() } if (it.isMobi) { MobiFile.clear() } loadChapterList(it) } R.id.menu_enable_replace -> changeReplaceRuleState() R.id.menu_re_segment -> ReadBook.book?.let { it.setReSegment(!it.getReSegment()) item.isChecked = it.getReSegment() ReadBook.loadContent(false) } // R.id.menu_enable_review -> { // AppConfig.enableReview = !AppConfig.enableReview // item.isChecked = AppConfig.enableReview // ReadBook.loadContent(false) // } R.id.menu_del_ruby_tag -> ReadBook.book?.let { item.isChecked = !item.isChecked if (item.isChecked) { it.addDelTag(Book.rubyTag) } else { it.removeDelTag(Book.rubyTag) } refreshContentAll(it) } R.id.menu_del_h_tag -> ReadBook.book?.let { item.isChecked = !item.isChecked if (item.isChecked) { it.addDelTag(Book.hTag) } else { it.removeDelTag(Book.hTag) } refreshContentAll(it) } R.id.menu_page_anim -> showPageAnimConfig { binding.readView.upPageAnim() ReadBook.loadContent(false) } R.id.menu_log -> showDialogFragment() R.id.menu_toc_regex -> showDialogFragment( TxtTocRuleDialog(ReadBook.book?.tocUrl) ) R.id.menu_reverse_content -> ReadBook.book?.let { viewModel.reverseContent(it) } R.id.menu_set_charset -> showCharsetConfig() R.id.menu_image_style -> { val imgStyles = arrayListOf( Book.imgStyleDefault, Book.imgStyleFull, Book.imgStyleText, Book.imgStyleSingle ) selector( R.string.image_style, imgStyles ) { _, index -> val imageStyle = imgStyles[index] ReadBook.book?.setImageStyle(imageStyle) if (imageStyle == Book.imgStyleSingle) { ReadBook.book?.setPageAnim(0) // 切换图片样式single后,自动切换为覆盖 binding.readView.upPageAnim() } ReadBook.loadContent(false) } } R.id.menu_get_progress -> ReadBook.book?.let { viewModel.syncBookProgress(it) { progress -> sureSyncProgress(progress) } } R.id.menu_cover_progress -> ReadBook.book?.let { ReadBook.uploadProgress(true) { toastOnUi(R.string.upload_book_success) } } R.id.menu_same_title_removed -> { ReadBook.book?.let { val contentProcessor = ContentProcessor.get(it) val textChapter = ReadBook.curTextChapter if (textChapter != null && !textChapter.sameTitleRemoved && !contentProcessor.removeSameTitleCache.contains( textChapter.chapter.getFileName("nr") ) ) { toastOnUi("未找到可移除的重复标题") } } viewModel.reverseRemoveSameTitle() } R.id.menu_effective_replaces -> showDialogFragment() R.id.menu_help -> showHelp() } return super.onCompatOptionsItemSelected(item) } private fun refreshContentAll(book: Book) { ReadBook.clearTextChapter() binding.readView.upContent() viewModel.refreshContentAll(book) } override fun onMenuItemClick(item: MenuItem): Boolean { return onCompatOptionsItemSelected(item) } /** * 按键拦截,显示菜单 */ override fun dispatchKeyEvent(event: KeyEvent): Boolean { val keyCode = event.keyCode val action = event.action val isDown = action == 0 if (keyCode == KeyEvent.KEYCODE_MENU) { if (isDown && !binding.readMenu.canShowMenu) { binding.readMenu.runMenuIn() return true } if (!isDown && !binding.readMenu.canShowMenu) { binding.readMenu.canShowMenu = true return true } } return super.dispatchKeyEvent(event) } /** * 鼠标滚轮事件 */ override fun onGenericMotionEvent(event: MotionEvent): Boolean { if (0 != (event.source and InputDevice.SOURCE_CLASS_POINTER)) { if (event.action == MotionEvent.ACTION_SCROLL) { val axisValue = event.getAxisValue(MotionEvent.AXIS_VSCROLL) LogUtils.d("onGenericMotionEvent", "axisValue = $axisValue") // 获得垂直坐标上的滚动方向 if (axisValue < 0.0f) { // 滚轮向下滚 mouseWheelPage(PageDirection.NEXT) } else { // 滚轮向上滚 mouseWheelPage(PageDirection.PREV) } return true } } return super.onGenericMotionEvent(event) } /** * 按键事件 */ override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { if (menuLayoutIsVisible) { return super.onKeyDown(keyCode, event) } val longPress = event.repeatCount > 0 when { isPrevKey(keyCode) -> { handleKeyPage(PageDirection.PREV, longPress) return true } isNextKey(keyCode) -> { handleKeyPage(PageDirection.NEXT, longPress) return true } } when (keyCode) { KeyEvent.KEYCODE_VOLUME_UP -> if (volumeKeyPage(PageDirection.PREV, longPress)) { return true } KeyEvent.KEYCODE_VOLUME_DOWN -> if (volumeKeyPage(PageDirection.NEXT, longPress)) { return true } KeyEvent.KEYCODE_PAGE_UP -> { handleKeyPage(PageDirection.PREV, longPress) return true } KeyEvent.KEYCODE_PAGE_DOWN -> { handleKeyPage(PageDirection.NEXT, longPress) return true } KeyEvent.KEYCODE_SPACE -> { handleKeyPage(PageDirection.NEXT, longPress) return true } } return super.onKeyDown(keyCode, event) } /** * 松开按键事件 */ override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { when (keyCode) { KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN -> { if (volumeKeyPage(PageDirection.NONE, false)) { return true } } } return super.onKeyUp(keyCode, event) } /** * view触摸,文字选择 */ @SuppressLint("ClickableViewAccessibility") override fun onTouch(v: View, event: MotionEvent): Boolean = binding.run { if (!binding.readView.isTextSelected) { return false } when (event.action) { MotionEvent.ACTION_DOWN -> textActionMenu.dismiss() MotionEvent.ACTION_MOVE -> { when (v.id) { R.id.cursor_left -> if (!readView.curPage.getReverseStartCursor()) { readView.curPage.selectStartMove( event.rawX + cursorLeft.width, event.rawY - cursorLeft.height ) } else { readView.curPage.selectEndMove( event.rawX - cursorRight.width, event.rawY - cursorRight.height ) } R.id.cursor_right -> if (readView.curPage.getReverseEndCursor()) { readView.curPage.selectStartMove( event.rawX + cursorLeft.width, event.rawY - cursorLeft.height ) } else { readView.curPage.selectEndMove( event.rawX - cursorRight.width, event.rawY - cursorRight.height ) } } } MotionEvent.ACTION_UP -> { readView.curPage.resetReverseCursor() showTextActionMenu() } } return true } /** * 更新文字选择开始位置 */ override fun upSelectedStart(x: Float, y: Float, top: Float) = binding.run { cursorLeft.x = x - cursorLeft.width cursorLeft.y = y cursorLeft.visible(true) textMenuPosition.x = x textMenuPosition.y = top } /** * 更新文字选择结束位置 */ override fun upSelectedEnd(x: Float, y: Float) = binding.run { cursorRight.x = x cursorRight.y = y cursorRight.visible(true) } /** * 取消文字选择 */ override fun onCancelSelect() = binding.run { cursorLeft.invisible() cursorRight.invisible() textActionMenu.dismiss() } override fun onLongScreenshotTouchEvent(event: MotionEvent): Boolean { return binding.readView.onTouchEvent(event) } /** * 显示文本操作菜单 */ override fun showTextActionMenu() { val navigationBarHeight = if (!ReadBookConfig.hideNavigationBar && navigationBarGravity == Gravity.BOTTOM) binding.navigationBar.height else 0 textActionMenu.show( binding.textMenuPosition, binding.root.height + navigationBarHeight, binding.textMenuPosition.x.toInt(), binding.textMenuPosition.y.toInt(), binding.cursorLeft.y.toInt() + binding.cursorLeft.height, binding.cursorRight.x.toInt(), binding.cursorRight.y.toInt() + binding.cursorRight.height ) } /** * 当前选择的文本 */ override val selectedText: String get() = binding.readView.getSelectText() /** * 文本选择菜单操作 */ override fun onMenuItemSelected(itemId: Int): Boolean { when (itemId) { R.id.menu_aloud -> when (AppConfig.contentSelectSpeakMod) { 1 -> lifecycleScope.launch { binding.readView.aloudStartSelect() } else -> speak(binding.readView.getSelectText()) } R.id.menu_bookmark -> binding.readView.curPage.let { val bookmark = it.createBookmark() if (bookmark == null) { toastOnUi(R.string.create_bookmark_error) } else { showDialogFragment(BookmarkDialog(bookmark)) } return true } R.id.menu_replace -> { val scopes = arrayListOf() ReadBook.book?.name?.let { scopes.add(it) } ReadBook.bookSource?.bookSourceUrl?.let { scopes.add(it) } val text = selectedText.lineSequence().map { it.trim() }.joinToString("\n") replaceActivity.launch( ReplaceEditActivity.startIntent( this, pattern = text, scope = scopes.joinToString(";") ) ) return true } R.id.menu_search_content -> { viewModel.searchContentQuery = selectedText openSearchActivity(selectedText) return true } R.id.menu_dict -> { showDialogFragment(DictDialog(selectedText)) return true } } return false } /** * 文本选择菜单操作完成 */ override fun onMenuActionFinally() = binding.run { textActionMenu.dismiss() readView.cancelSelect() } private fun speak(text: String) { if (tts == null) { tts = TTS() } tts?.speak(text) } /** * 鼠标滚轮翻页 */ private fun mouseWheelPage(direction: PageDirection) { if (menuLayoutIsVisible || !AppConfig.mouseWheelPage) { return } keyPageDebounce(direction, mouseWheel = true, longPress = false) } /** * 音量键翻页 */ private fun volumeKeyPage(direction: PageDirection, longPress: Boolean): Boolean { if (!AppConfig.volumeKeyPage) { return false } if (!AppConfig.volumeKeyPageOnPlay && BaseReadAloudService.isPlay()) { return false } handleKeyPage(direction, longPress) return true } private fun handleKeyPage(direction: PageDirection, longPress: Boolean) { if (AppConfig.keyPageOnLongPress || direction == PageDirection.NONE) { keyPage(direction) } else { keyPageDebounce(direction, longPress = longPress) } } private fun keyPageDebounce( direction: PageDirection, mouseWheel: Boolean = false, longPress: Boolean ) { if (longPress) { return } nextPageDebounce.apply { wait = if (mouseWheel) 200L else 600L leading = !mouseWheel trailing = mouseWheel } prevPageDebounce.apply { wait = if (mouseWheel) 200L else 600L leading = !mouseWheel trailing = mouseWheel } when (direction) { PageDirection.NEXT -> nextPageDebounce.invoke() PageDirection.PREV -> prevPageDebounce.invoke() else -> {} } } private fun keyPage(direction: PageDirection) { binding.readView.cancelSelect() binding.readView.pageDelegate?.isCancel = false binding.readView.pageDelegate?.keyTurnPage(direction) } override fun upMenuView() { handler.post { upMenu() binding.readMenu.upBookView() } } override fun loadChapterList(book: Book) { ReadBook.upMsg(getString(R.string.toc_updateing)) viewModel.loadChapterList(book) } /** * 内容加载完成 */ override fun contentLoadFinish() { if (intent.getBooleanExtra("readAloud", false)) { intent.removeExtra("readAloud") ReadBook.readAloud() } loadStates = true } /** * 更新内容 */ override fun upContent( relativePosition: Int, resetPageOffset: Boolean, success: (() -> Unit)? ) { lifecycleScope.launch { binding.readView.upContent(relativePosition, resetPageOffset) if (relativePosition == 0) { upSeekBarProgress() } loadStates = false success?.invoke() } } override suspend fun upContentAwait( relativePosition: Int, resetPageOffset: Boolean, success: (() -> Unit)? ) = withContext(Main.immediate) { binding.readView.upContent(relativePosition, resetPageOffset) if (relativePosition == 0) { upSeekBarProgress() } loadStates = false } override fun upPageAnim(upRecorder: Boolean) { lifecycleScope.launch { binding.readView.upPageAnim(upRecorder) } } override fun notifyBookChanged() { bookChanged = true if (!ReadBook.inBookshelf) { viewModel.removeFromBookshelf { super.finish() } } } override fun cancelSelect() { runOnUiThread { binding.readView.cancelSelect() } } /** * 页面改变 */ override fun pageChanged() { pageChanged = true binding.readView.onPageChange() handler.post { upSeekBarProgress() } executor.execute { startBackupJob() } } /** * 更新进度条位置 */ private fun upSeekBarProgress() { val progress = when (AppConfig.progressBarBehavior) { "page" -> ReadBook.durPageIndex else /* chapter */ -> ReadBook.durChapterIndex } binding.readMenu.setSeekPage(progress) } /** * 显示菜单 */ override fun showMenuBar() { binding.readMenu.runMenuIn() } override val oldBook: Book? get() = ReadBook.book override fun changeTo(source: BookSource, book: Book, toc: List) { if (!book.isAudio) { viewModel.changeTo(book, toc) } else { ReadAloud.stop(this) lifecycleScope.launch { withContext(IO) { ReadBook.book?.migrateTo(book, toc) book.removeType(BookType.updateError) ReadBook.book?.delete() appDb.bookDao.insert(book) } startActivityForBook(book) finish() } } } override fun replaceContent(content: String) { ReadBook.book?.let { viewModel.saveContent(it, content) } } override fun showActionMenu() { when { BaseReadAloudService.isRun -> showReadAloudDialog() isAutoPage -> showDialogFragment() isShowingSearchResult -> binding.searchMenu.runMenuIn() else -> binding.readMenu.runMenuIn() } } /** * 显示朗读菜单 */ override fun showReadAloudDialog() { showDialogFragment() } /** * 自动翻页 */ override fun autoPage() { ReadAloud.stop(this) if (isAutoPage) { autoPageStop() } else { binding.readView.autoPager.start() binding.readMenu.setAutoPage(true) screenTimeOut = -1L screenOffTimerStart() } } override fun autoPageStop() { if (isAutoPage) { binding.readView.autoPager.stop() binding.readMenu.setAutoPage(false) dismissDialogFragment() upScreenTimeOut() } } override fun openSourceEditActivity() { ReadBook.bookSource?.let { sourceEditActivity.launch { putExtra("sourceUrl", it.bookSourceUrl) } } } override fun openBookInfoActivity() { ReadBook.book?.let { bookInfoActivity.launch { putExtra("name", it.name) putExtra("author", it.author) } } } /** * 替换 */ override fun openReplaceRule() { replaceActivity.launch(Intent(this, ReplaceRuleActivity::class.java)) } /** * 打开目录 */ override fun openChapterList() { ReadBook.book?.let { tocActivity.launch(it.bookUrl) } } /** * 打开搜索界面 */ override fun openSearchActivity(searchWord: String?) { val book = ReadBook.book ?: return searchContentActivity.launch { putExtra("bookUrl", book.bookUrl) putExtra("searchWord", searchWord ?: viewModel.searchContentQuery) putExtra("searchResultIndex", viewModel.searchResultIndex) viewModel.searchResultList?.first()?.let { if (it.query == viewModel.searchContentQuery) { IntentData.put("searchResultList", viewModel.searchResultList) } } } } /** * 禁用书源 */ override fun disableSource() { viewModel.disableSource() } /** * 显示阅读样式配置 */ override fun showReadStyle() { showDialogFragment() } /** * 显示更多设置 */ override fun showMoreSetting() { showDialogFragment() } override fun showSearchSetting() { showDialogFragment() } /** * 更新状态栏,导航栏 */ override fun upSystemUiVisibility() { upSystemUiVisibility(isInMultiWindow, !menuLayoutIsVisible, bottomDialog > 0) upNavigationBarColor() } // 退出全文搜索 override fun exitSearchMenu() { if (isShowingSearchResult) { isShowingSearchResult = false binding.searchMenu.invalidate() binding.searchMenu.invisible() ReadBook.clearSearchResult() binding.readView.cancelSelect(true) } } /* 恢复到 全文搜索/进度条跳转前的位置 */ private fun restoreLastBookProcess() { if (confirmRestoreProcess == true) { ReadBook.restoreLastBookProgress() } else if (confirmRestoreProcess == null) { alert(R.string.draw) { setMessage(R.string.restore_last_book_process) yesButton { confirmRestoreProcess = true ReadBook.restoreLastBookProgress() //恢复启动全文搜索前的进度 } noButton { ReadBook.lastBookProgress = null confirmRestoreProcess = false } onCancelled { ReadBook.lastBookProgress = null confirmRestoreProcess = false } } } } override fun showLogin() { ReadBook.bookSource?.let { startActivity { putExtra("type", "bookSource") putExtra("key", it.bookSourceUrl) } } } override fun payAction() { val book = ReadBook.book ?: return if (book.isLocal) return val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) if (chapter == null) { toastOnUi("no chapter") return } alert(R.string.chapter_pay) { setMessage(chapter.title) yesButton { Coroutine.async(lifecycleScope) { val source = ReadBook.bookSource ?: throw NoStackTraceException("no book source") val payAction = source.getContentRule().payAction if (payAction.isNullOrBlank()) { throw NoStackTraceException("no pay action") } val analyzeRule = AnalyzeRule(book, source) analyzeRule.setCoroutineContext(coroutineContext) analyzeRule.setBaseUrl(chapter.url) analyzeRule.setChapter(chapter) analyzeRule.evalJS(payAction).toString() }.onSuccess(IO) { if (it.isAbsUrl()) { startActivity { val bookSource = ReadBook.bookSource putExtra("title", getString(R.string.chapter_pay)) putExtra("url", it) putExtra("sourceOrigin", bookSource?.bookSourceUrl) putExtra("sourceName", bookSource?.bookSourceName) putExtra("sourceType", bookSource?.getSourceType()) } } else if (it.isTrue()) { //购买成功后刷新目录 ReadBook.book?.let { ReadBook.curTextChapter = null BookHelp.delContent(book, chapter) loadChapterList(book) } } }.onError { AppLog.put("执行购买操作出错\n${it.localizedMessage}", it, true) } } noButton() } } /** * 朗读按钮 */ override fun onClickReadAloud() { autoPageStop() when { !BaseReadAloudService.isRun -> { ReadAloud.upReadAloudClass() val scrollPageAnim = ReadBook.pageAnim() == 3 if (scrollPageAnim) { val pos = binding.readView.getReadAloudPos() if (pos != null) { val (index, line) = pos if (ReadBook.durChapterIndex != index) { ReadBook.openChapter(index, line.chapterPosition, false) { ReadBook.readAloud(startPos = line.pagePosition) } } else { ReadBook.durChapterPos = line.chapterPosition ReadBook.readAloud(startPos = line.pagePosition) } } else { ReadBook.readAloud() } } else { ReadBook.readAloud() } } BaseReadAloudService.pause -> { val scrollPageAnim = ReadBook.pageAnim() == 3 if (scrollPageAnim && pageChanged) { pageChanged = false val pos = binding.readView.getReadAloudPos() if (pos != null) { val (index, line) = pos if (ReadBook.durChapterIndex != index) { ReadBook.openChapter(index, line.chapterPosition, false) { ReadBook.readAloud(startPos = line.pagePosition) } } else { ReadBook.durChapterPos = line.chapterPosition ReadBook.readAloud(startPos = line.pagePosition) } } else { ReadBook.readAloud() } } else { ReadAloud.resume(this) } } else -> ReadAloud.pause(this) } } override fun showHelp() { showHelp("readMenuHelp") } /** * 长按图片 */ @SuppressLint("RtlHardcoded") override fun onImageLongPress(x: Float, y: Float, src: String) { popupAction.setItems( listOf( SelectItem(getString(R.string.show), "show"), SelectItem(getString(R.string.refresh), "refresh"), SelectItem(getString(R.string.action_save), "save"), SelectItem(getString(R.string.menu), "menu"), SelectItem(getString(R.string.select_folder), "selectFolder") ) ) popupAction.onActionClick = { when (it) { "show" -> showDialogFragment(PhotoDialog(src)) "refresh" -> viewModel.refreshImage(src) "save" -> { val path = ACache.get().getAsString(AppConst.imagePathKey) if (path.isNullOrEmpty()) { selectImageDir.launch { value = src } } else { viewModel.saveImage(src, Uri.parse(path)) } } "menu" -> showActionMenu() "selectFolder" -> selectImageDir.launch() } popupAction.dismiss() } val navigationBarHeight = if (!ReadBookConfig.hideNavigationBar && navigationBarGravity == Gravity.BOTTOM) binding.navigationBar.height else 0 popupAction.showAtLocation( binding.readView, Gravity.BOTTOM or Gravity.LEFT, x.toInt(), binding.root.height + navigationBarHeight - y.toInt() ) } /** * colorSelectDialog */ override fun onColorSelected(dialogId: Int, color: Int) = ReadBookConfig.durConfig.run { when (dialogId) { TEXT_COLOR -> { setCurTextColor(color) postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6, 9, 11)) if (AppConfig.readBarStyleFollowPage) { postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) } } BG_COLOR -> { setCurBg(0, "#${color.hexString}") postEvent(EventBus.UP_CONFIG, arrayListOf(1)) if (AppConfig.readBarStyleFollowPage) { postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) } } TIP_COLOR -> { ReadTipConfig.tipColor = color postEvent(EventBus.TIP_COLOR, "") postEvent(EventBus.UP_CONFIG, arrayListOf(2)) } TIP_DIVIDER_COLOR -> { ReadTipConfig.tipDividerColor = color postEvent(EventBus.TIP_COLOR, "") postEvent(EventBus.UP_CONFIG, arrayListOf(2)) } } } /** * colorSelectDialog */ override fun onDialogDismissed(dialogId: Int) = Unit override fun onTocRegexDialogResult(tocRegex: String) { ReadBook.book?.let { it.tocUrl = tocRegex loadChapterList(it) } } private fun sureSyncProgress(progress: BookProgress) { alert(R.string.get_book_progress) { setMessage(R.string.current_progress_exceeds_cloud) okButton { ReadBook.setProgress(progress) } noButton() } } /* 进度条跳转到指定章节 */ override fun skipToChapter(index: Int) { ReadBook.saveCurrentBookProgress() //退出章节跳转恢复此时进度 viewModel.openChapter(index) } /* 全文搜索跳转 */ override fun navigateToSearch(searchResult: SearchResult, index: Int) { viewModel.searchResultIndex = index skipToSearch(searchResult) } override fun onMenuShow() { binding.readView.autoPager.pause() } override fun onMenuHide() { binding.readView.autoPager.resume() } override fun onLayoutPageCompleted(index: Int, page: TextPage) { upSeekBarThrottle.invoke() binding.readView.onLayoutPageCompleted(index, page) } /* 全文搜索跳转 */ private fun skipToSearch(searchResult: SearchResult) { if (searchResult.chapterIndex != ReadBook.durChapterIndex) { viewModel.openChapter(searchResult.chapterIndex) { jumpToPosition(searchResult) } } else { jumpToPosition(searchResult) } } private fun jumpToPosition(searchResult: SearchResult) { val curTextChapter = ReadBook.curTextChapter ?: return binding.searchMenu.updateSearchInfo() val (pageIndex, lineIndex, charIndex, addLine, charIndex2) = viewModel.searchResultPositions(curTextChapter, searchResult) ReadBook.skipToPage(pageIndex) { isSelectingSearchResult = true binding.readView.curPage.selectStartMoveIndex(0, lineIndex, charIndex) when (addLine) { 0 -> binding.readView.curPage.selectEndMoveIndex( 0, lineIndex, charIndex + viewModel.searchContentQuery.length - 1 ) 1 -> binding.readView.curPage.selectEndMoveIndex( 0, lineIndex + 1, charIndex2 ) //consider change page, jump to scroll position -1 -> binding.readView.curPage.selectEndMoveIndex(1, 0, charIndex2) } binding.readView.isTextSelected = true isSelectingSearchResult = false } } override fun addBookmark() { val book = ReadBook.book val page = ReadBook.curTextChapter?.getPage(ReadBook.durPageIndex) if (book != null && page != null) { val bookmark = book.createBookMark().apply { chapterIndex = ReadBook.durChapterIndex chapterPos = ReadBook.durChapterPos chapterName = page.title bookText = page.text.trim() } showDialogFragment(BookmarkDialog(bookmark)) } } override fun changeReplaceRuleState() { ReadBook.book?.let { it.setUseReplaceRule(!it.getUseReplaceRule()) ReadBook.saveRead() menu?.findItem(R.id.menu_enable_replace)?.isChecked = it.getUseReplaceRule() viewModel.replaceRuleChanged() } } private fun startBackupJob() { backupJob?.cancel() backupJob = lifecycleScope.launch(IO) { delay(300000) ReadBook.book?.let { AppWebDav.uploadBookProgress(it) ensureActive() it.update() Backup.autoBack(this@ReadBookActivity) } } } override fun sureNewProgress(progress: BookProgress) { syncDialog?.dismiss() syncDialog = alert(R.string.get_book_progress) { setMessage(R.string.cloud_progress_exceeds_current) okButton { ReadBook.setProgress(progress) } noButton() } } override fun finish() { val book = ReadBook.book ?: return super.finish() if (ReadBook.inBookshelf) { return super.finish() } if (!AppConfig.showAddToShelfAlert) { viewModel.removeFromBookshelf { super.finish() } } else { alert(title = getString(R.string.add_to_bookshelf)) { setMessage(getString(R.string.check_add_bookshelf, book.name)) okButton { ReadBook.book?.removeType(BookType.notShelf) ReadBook.book?.save() ReadBook.inBookshelf = true setResult(RESULT_OK) } noButton { viewModel.removeFromBookshelf { super.finish() } } } } } override fun onDestroy() { super.onDestroy() tts?.clearTts() textActionMenu.dismiss() popupAction.dismiss() binding.readView.onDestroy() ReadBook.unregister(this) if (!ReadBook.inBookshelf && !isChangingConfigurations) { viewModel.removeFromBookshelf(null) } if (!BuildConfig.DEBUG) { Backup.autoBack(this) } } override fun observeLiveBus() = binding.run { observeEvent(EventBus.TIME_CHANGED) { readView.upTime() } observeEvent(EventBus.BATTERY_CHANGED) { readView.upBattery(it) } observeEvent(EventBus.MEDIA_BUTTON) { if (it) { onClickReadAloud() } else { ReadBook.readAloud(!BaseReadAloudService.pause) } } observeEvent>(EventBus.UP_CONFIG) { it.forEach { value -> when (value) { 0 -> upSystemUiVisibility() 1 -> readView.upBg() 2 -> readView.upStyle() 3 -> readView.upBgAlpha() 4 -> readView.upPageSlopSquare() 5 -> if (isInitFinish) ReadBook.loadContent(resetPageOffset = false) 6 -> readView.upContent(resetPageOffset = false) 8 -> ChapterProvider.upStyle() 9 -> readView.invalidateTextPage() 10 -> ChapterProvider.upLayout() 11 -> readView.submitRenderTask() } } } observeEvent(EventBus.ALOUD_STATE) { if (it == Status.STOP || it == Status.PAUSE) { ReadBook.curTextChapter?.let { textChapter -> val page = textChapter.getPageByReadPos(ReadBook.durChapterPos) if (page != null) { page.removePageAloudSpan() readView.upContent(resetPageOffset = false) } } } } observeEventSticky(EventBus.TTS_PROGRESS) { chapterStart -> lifecycleScope.launch(IO) { if (BaseReadAloudService.isPlay()) { ReadBook.curTextChapter?.let { textChapter -> ReadBook.durChapterPos = chapterStart val pageIndex = ReadBook.durPageIndex val aloudSpanStart = chapterStart - textChapter.getReadLength(pageIndex) textChapter.getPage(pageIndex) ?.upPageAloudSpan(aloudSpanStart) upContent() } } } } observeEvent(PreferKey.keepLight) { upScreenTimeOut() } observeEvent(PreferKey.textSelectAble) { readView.curPage.upSelectAble(it) } observeEvent(PreferKey.showBrightnessView) { readMenu.upBrightnessState() } observeEvent>(EventBus.SEARCH_RESULT) { viewModel.searchResultList = it } observeEvent(EventBus.UPDATE_READ_ACTION_BAR) { readMenu.reset() } observeEvent(EventBus.UP_SEEK_BAR) { readMenu.upSeekBar() } } private fun upScreenTimeOut() { val keepLightPrefer = getPrefString(PreferKey.keepLight)?.toInt() ?: 0 screenTimeOut = keepLightPrefer * 1000L screenOffTimerStart() } /** * 重置黑屏时间 */ override fun screenOffTimerStart() { handler.post { if (screenTimeOut < 0) { keepScreenOn(true) return@post } val t = screenTimeOut - sysScreenOffTime if (t > 0) { keepScreenOn(true) handler.removeCallbacks(screenOffRunnable) handler.postDelayed(screenOffRunnable, screenTimeOut) } else { keepScreenOn(false) } } } companion object { const val RESULT_DELETED = 100 } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt ================================================ package io.legado.app.ui.book.read import android.app.Application import android.content.Intent import android.net.Uri import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.MutableLiveData import io.legado.app.R import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppLog import io.legado.app.constant.BookType import io.legado.app.constant.EventBus import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookProgress import io.legado.app.exception.NoStackTraceException import io.legado.app.help.AppWebDav import io.legado.app.help.book.BookHelp import io.legado.app.help.book.ContentProcessor import io.legado.app.help.book.isLocal import io.legado.app.help.book.isLocalModified import io.legado.app.help.book.removeType import io.legado.app.help.book.simulatedTotalChapterNum import io.legado.app.help.config.AppConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.model.ImageProvider import io.legado.app.model.ReadAloud import io.legado.app.model.ReadBook import io.legado.app.model.localBook.LocalBook import io.legado.app.model.webBook.WebBook import io.legado.app.service.BaseReadAloudService import io.legado.app.ui.book.read.page.entities.TextChapter import io.legado.app.ui.book.searchContent.SearchResult import io.legado.app.utils.DocumentUtils import io.legado.app.utils.FileUtils import io.legado.app.utils.isContentScheme import io.legado.app.utils.mapParallelSafe import io.legado.app.utils.postEvent import io.legado.app.utils.toStringArray import io.legado.app.utils.toastOnUi import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEmpty import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.take import java.io.File import java.io.FileInputStream import java.io.FileNotFoundException import java.io.FileOutputStream /** * 阅读界面数据处理 */ class ReadBookViewModel(application: Application) : BaseViewModel(application) { val permissionDenialLiveData = MutableLiveData() var isInitFinish = false var searchContentQuery = "" var searchResultList: List? = null var searchResultIndex: Int = 0 private var changeSourceCoroutine: Coroutine<*>? = null init { AppConfig.detectClickArea() } fun initReadBookConfig(intent: Intent) { val bookUrl = intent.getStringExtra("bookUrl") val book = when { bookUrl.isNullOrEmpty() -> appDb.bookDao.lastReadBook else -> appDb.bookDao.getBook(bookUrl) } ?: return ReadBook.upReadBookConfig(book) } /** * 初始化 */ fun initData(intent: Intent, success: (() -> Unit)? = null) { execute { ReadBook.inBookshelf = intent.getBooleanExtra("inBookshelf", true) ReadBook.chapterChanged = intent.getBooleanExtra("chapterChanged", false) val bookUrl = intent.getStringExtra("bookUrl") val book = when { bookUrl.isNullOrEmpty() -> appDb.bookDao.lastReadBook else -> appDb.bookDao.getBook(bookUrl) } ?: ReadBook.book when { book != null -> initBook(book) else -> { ReadBook.upMsg(context.getString(R.string.no_book)) AppLog.put("未找到书籍\nbookUrl:$bookUrl") } } }.onSuccess { success?.invoke() }.onError { val msg = "初始化数据失败\n${it.localizedMessage}" ReadBook.upMsg(msg) AppLog.put(msg, it) }.onFinally { ReadBook.saveRead() } } private suspend fun initBook(book: Book) { val isSameBook = ReadBook.book?.bookUrl == book.bookUrl if (isSameBook) { ReadBook.upData(book) } else { ReadBook.resetData(book) } isInitFinish = true if (!book.isLocal && book.tocUrl.isEmpty() && !loadBookInfo(book)) { return } if (book.isLocal && !checkLocalBookFileExist(book)) { return } if ((ReadBook.chapterSize == 0 || book.isLocalModified()) && !loadChapterListAwait(book)) { return } ReadBook.upMsg(null) if (!isSameBook) { ReadBook.loadContent(resetPageOffset = true) } else { ReadBook.loadOrUpContent() } if (ReadBook.chapterChanged) { // 有章节跳转不同步阅读进度 ReadBook.chapterChanged = false } else if (!(isSameBook && BaseReadAloudService.isRun) && ReadBook.inBookshelf) { if (AppConfig.syncBookProgressPlus) { ReadBook.syncProgress({ progress -> ReadBook.callBack?.sureNewProgress(progress) }) } else { syncBookProgress(book) } } if (!book.isLocal && ReadBook.bookSource == null) { autoChangeSource(book.name, book.author) return } } private fun checkLocalBookFileExist(book: Book): Boolean { try { LocalBook.getBookInputStream(book) return true } catch (e: Throwable) { ReadBook.upMsg("打开本地书籍出错: ${e.localizedMessage}") if (e is SecurityException || e is FileNotFoundException) { permissionDenialLiveData.postValue(0) } return false } } /** * 加载详情页 */ private suspend fun loadBookInfo(book: Book): Boolean { val source = ReadBook.bookSource ?: return true try { WebBook.getBookInfoAwait(source, book, canReName = false) return true } catch (e: Throwable) { currentCoroutineContext().ensureActive() ReadBook.upMsg("详情页出错: ${e.localizedMessage}") return false } } /** * 加载目录 */ fun loadChapterList(book: Book) { execute { if (loadChapterListAwait(book)) { ReadBook.upMsg(null) } } } private suspend fun loadChapterListAwait(book: Book): Boolean { if (book.isLocal) { kotlin.runCatching { LocalBook.getChapterList(book).let { appDb.bookChapterDao.delByBook(book.bookUrl) appDb.bookChapterDao.insert(*it.toTypedArray()) appDb.bookDao.update(book) ReadBook.onChapterListUpdated(book) } return true }.onFailure { when (it) { is SecurityException, is FileNotFoundException -> { permissionDenialLiveData.postValue(1) } else -> { AppLog.put("LoadTocError:${it.localizedMessage}", it) ReadBook.upMsg("LoadTocError:${it.localizedMessage}") } } return false } } else { ReadBook.bookSource?.let { val oldBook = book.copy() WebBook.getChapterListAwait(it, book, true) .onSuccess { cList -> if (oldBook.bookUrl == book.bookUrl) { appDb.bookDao.update(book) } else { appDb.bookDao.replace(oldBook, book) BookHelp.updateCacheFolder(oldBook, book) } appDb.bookChapterDao.delByBook(oldBook.bookUrl) appDb.bookChapterDao.insert(*cList.toTypedArray()) ReadBook.onChapterListUpdated(book) return true }.onFailure { currentCoroutineContext().ensureActive() ReadBook.upMsg(context.getString(R.string.error_load_toc)) return false } } } return true } /** * 同步进度 */ fun syncBookProgress( book: Book, alertSync: ((progress: BookProgress) -> Unit)? = null ) { if (!AppConfig.syncBookProgress) return execute { AppWebDav.getBookProgress(book) }.onError { AppLog.put("拉取阅读进度失败《${book.name}》\n${it.localizedMessage}", it) }.onSuccess { progress -> progress ?: return@onSuccess if (progress.durChapterIndex == book.durChapterIndex && progress.durChapterPos == book.durChapterPos) { return@onSuccess } if (progress.durChapterIndex < book.durChapterIndex || (progress.durChapterIndex == book.durChapterIndex && progress.durChapterPos < book.durChapterPos) ) { alertSync?.invoke(progress) } else if (progress.durChapterIndex < book.simulatedTotalChapterNum()) { ReadBook.setProgress(progress) AppLog.put("自动同步阅读进度成功《${book.name}》 ${progress.durChapterTitle}") context.toastOnUi("已同步最新阅读进度") } } } /** * 换源 */ fun changeTo(book: Book, toc: List) { changeSourceCoroutine?.cancel() changeSourceCoroutine = execute { ReadBook.upMsg(context.getString(R.string.loading)) ReadBook.book?.migrateTo(book, toc) book.removeType(BookType.updateError) ReadBook.book?.delete() appDb.bookDao.insert(book) appDb.bookChapterDao.insert(*toc.toTypedArray()) ReadBook.resetData(book) ReadBook.upMsg(null) ReadBook.loadContent(resetPageOffset = true) }.onError { AppLog.put("换源失败\n$it", it, true) ReadBook.upMsg(null) }.onFinally { postEvent(EventBus.SOURCE_CHANGED, book.bookUrl) } } /** * 自动换源 */ private fun autoChangeSource(name: String, author: String) { if (!AppConfig.autoChangeSource) return execute { val sources = appDb.bookSourceDao.allTextEnabledPart flow { for (source in sources) { source.getBookSource()?.let { emit(it) } } }.onStart { ReadBook.upMsg(context.getString(R.string.source_auto_changing)) }.mapParallelSafe(AppConfig.threadCount) { source -> val book = WebBook.preciseSearchAwait(source, name, author).getOrThrow() if (book.tocUrl.isEmpty()) { WebBook.getBookInfoAwait(source, book) } val toc = WebBook.getChapterListAwait(source, book).getOrThrow() val chapter = toc.getOrElse(book.durChapterIndex) { toc.last() } val nextChapter = toc.getOrElse(chapter.index) { toc.first() } WebBook.getContentAwait( bookSource = source, book = book, bookChapter = chapter, nextChapterUrl = nextChapter.url ) book to toc }.take(1).onEach { (book, toc) -> changeTo(book, toc) }.onEmpty { throw NoStackTraceException("没有合适书源") }.onCompletion { ReadBook.upMsg(null) }.catch { AppLog.put("自动换源失败\n${it.localizedMessage}", it) context.toastOnUi("自动换源失败\n${it.localizedMessage}") }.collect() } } fun openChapter(index: Int, durChapterPos: Int = 0, success: (() -> Unit)? = null) { ReadBook.openChapter(index, durChapterPos, success = success) } fun removeFromBookshelf(success: (() -> Unit)?) { val book = ReadBook.book Coroutine.async { book?.delete() }.onSuccess { success?.invoke() } } fun upBookSource(success: (() -> Unit)?) { execute { ReadBook.book?.let { book -> ReadBook.bookSource = appDb.bookSourceDao.getBookSource(book.origin) } }.onSuccess { success?.invoke() } } fun refreshContentDur(book: Book) { execute { appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) ?.let { chapter -> BookHelp.delContent(book, chapter) ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false) } } } fun refreshContentAfter(book: Book) { execute { appDb.bookChapterDao.getChapterList( book.bookUrl, ReadBook.durChapterIndex, book.totalChapterNum ).forEach { chapter -> BookHelp.delContent(book, chapter) } ReadBook.loadContent(false) } } fun refreshContentAll(book: Book) { execute { BookHelp.clearCache(book) ReadBook.loadContent(false) } } /** * 保存内容 */ fun saveContent(book: Book, content: String) { execute { appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) ?.let { chapter -> BookHelp.saveText(book, chapter, content) ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false) } } } /** * 反转内容 */ fun reverseContent(book: Book) { execute { val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) ?: return@execute val content = BookHelp.getContent(book, chapter) ?: return@execute val stringBuilder = StringBuilder() content.toStringArray().forEach { stringBuilder.insert(0, it) } BookHelp.saveText(book, chapter, stringBuilder.toString()) ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false) } } /** * 内容搜索跳转 */ fun searchResultPositions( textChapter: TextChapter, searchResult: SearchResult ): Array { // calculate search result's pageIndex val pages = textChapter.pages val content = textChapter.getContent() val queryLength = searchContentQuery.length var count = 0 var index = content.indexOf(searchContentQuery) while (count != searchResult.resultCountWithinChapter) { index = content.indexOf(searchContentQuery, index + queryLength) count += 1 } val contentPosition = index var pageIndex = 0 var length = pages[pageIndex].text.length while (length < contentPosition && pageIndex + 1 < pages.size) { pageIndex += 1 length += pages[pageIndex].text.length } // calculate search result's lineIndex val currentPage = pages[pageIndex] val curTextLines = currentPage.lines var lineIndex = 0 var curLine = curTextLines[lineIndex] length = length - currentPage.text.length + curLine.text.length if (curLine.isParagraphEnd) length++ while (length <= contentPosition && lineIndex + 1 < curTextLines.size) { lineIndex += 1 curLine = curTextLines[lineIndex] length += curLine.text.length if (curLine.isParagraphEnd) length++ } // charIndex val currentLine = currentPage.lines[lineIndex] var curLineLength = currentLine.text.length if (currentLine.isParagraphEnd) curLineLength++ length -= curLineLength val charIndex = contentPosition - length var addLine = 0 var charIndex2 = 0 // change line if ((charIndex + queryLength) > curLineLength) { addLine = 1 charIndex2 = charIndex + queryLength - curLineLength - 1 } // changePage if ((lineIndex + addLine + 1) > currentPage.lines.size) { addLine = -1 charIndex2 = charIndex + queryLength - curLineLength - 1 } return arrayOf(pageIndex, lineIndex, charIndex, addLine, charIndex2) } /** * 翻转删除重复标题 */ fun reverseRemoveSameTitle() { execute { val book = ReadBook.book ?: return@execute val textChapter = ReadBook.curTextChapter ?: return@execute BookHelp.setRemoveSameTitle( book, textChapter.chapter, !textChapter.sameTitleRemoved ) ReadBook.loadContent(ReadBook.durChapterIndex) } } /** * 刷新图片 */ fun refreshImage(src: String) { execute { ReadBook.book?.let { book -> val vFile = BookHelp.getImage(book, src) ImageProvider.bitmapLruCache.remove(vFile.absolutePath) vFile.delete() } }.onFinally { ReadBook.loadContent(false) } } /** * 保存图片 */ fun saveImage(src: String?, uri: Uri) { src ?: return val book = ReadBook.book ?: return execute { val image = BookHelp.getImage(book, src) FileInputStream(image).use { input -> if (uri.isContentScheme()) { DocumentFile.fromTreeUri(context, uri)?.let { doc -> val imageDoc = DocumentUtils.createFileIfNotExist(doc, image.name)!! context.contentResolver.openOutputStream(imageDoc.uri)!!.use { output -> input.copyTo(output) } } } else { val dir = File(uri.path ?: uri.toString()) val file = FileUtils.createFileIfNotExist(dir, image.name) FileOutputStream(file).use { output -> input.copyTo(output) } } } }.onError { AppLog.put("保存图片出错\n${it.localizedMessage}", it) context.toastOnUi("保存图片出错\n${it.localizedMessage}") } } /** * 替换规则变化 */ fun replaceRuleChanged() { execute { ReadBook.book?.let { ContentProcessor.get(it.name, it.origin).upReplaceRules() ReadBook.loadContent(resetPageOffset = false) } } } fun disableSource() { execute { ReadBook.bookSource?.let { it.enabled = false appDb.bookSourceDao.update(it) } } } override fun onCleared() { super.onCleared() if (BaseReadAloudService.isRun && BaseReadAloudService.pause) { ReadAloud.stop(context) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/ReadMenu.kt ================================================ package io.legado.app.ui.book.read import android.annotation.SuppressLint import android.content.Context import android.content.res.ColorStateList import android.graphics.Color import android.graphics.PorterDuff import android.graphics.drawable.GradientDrawable import android.util.AttributeSet import android.view.LayoutInflater import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE import android.view.animation.Animation import android.widget.FrameLayout import android.widget.SeekBar import androidx.appcompat.widget.PopupMenu import androidx.core.view.isGone import androidx.core.view.isVisible import io.legado.app.R import io.legado.app.constant.PreferKey import io.legado.app.databinding.ViewReadMenuBinding import io.legado.app.help.config.AppConfig import io.legado.app.help.config.LocalConfig import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.config.ThemeConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.source.getSourceType import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.Selector import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.bottomBackground import io.legado.app.lib.theme.buttonDisabledColor import io.legado.app.lib.theme.getPrimaryTextColor import io.legado.app.lib.theme.primaryColor import io.legado.app.lib.theme.primaryTextColor import io.legado.app.model.ReadBook import io.legado.app.ui.browser.WebViewActivity import io.legado.app.ui.widget.seekbar.SeekBarChangeListener import io.legado.app.utils.ColorUtils import io.legado.app.utils.ConstraintModify import io.legado.app.utils.activity import io.legado.app.utils.applyNavigationBarPadding import io.legado.app.utils.dpToPx import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.gone import io.legado.app.utils.invisible import io.legado.app.utils.loadAnimation import io.legado.app.utils.modifyBegin import io.legado.app.utils.openUrl import io.legado.app.utils.putPrefBoolean import io.legado.app.utils.startActivity import io.legado.app.utils.visible import splitties.views.onClick import splitties.views.onLongClick /** * 阅读界面菜单 */ class ReadMenu @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : FrameLayout(context, attrs) { var canShowMenu: Boolean = false private val callBack: CallBack get() = activity as CallBack private val binding = ViewReadMenuBinding.inflate(LayoutInflater.from(context), this, true) private var confirmSkipToChapter: Boolean = false private var isMenuOutAnimating = false private val menuTopIn: Animation by lazy { loadAnimation(context, R.anim.anim_readbook_top_in) } private val menuTopOut: Animation by lazy { loadAnimation(context, R.anim.anim_readbook_top_out) } private val menuBottomIn: Animation by lazy { loadAnimation(context, R.anim.anim_readbook_bottom_in) } private val menuBottomOut: Animation by lazy { loadAnimation(context, R.anim.anim_readbook_bottom_out) } private val immersiveMenu: Boolean get() = AppConfig.readBarStyleFollowPage && ReadBookConfig.durConfig.curBgType() == 0 private var bgColor: Int = if (immersiveMenu) { kotlin.runCatching { Color.parseColor(ReadBookConfig.durConfig.curBgStr()) }.getOrDefault(context.bottomBackground) } else { context.bottomBackground } private var textColor: Int = if (immersiveMenu) { ReadBookConfig.durConfig.curTextColor() } else { context.getPrimaryTextColor(ColorUtils.isColorLight(bgColor)) } private var bottomBackgroundList: ColorStateList = Selector.colorBuild() .setDefaultColor(bgColor) .setPressedColor(ColorUtils.darkenColor(bgColor)) .create() private var onMenuOutEnd: (() -> Unit)? = null private val showBrightnessView get() = context.getPrefBoolean( PreferKey.showBrightnessView, true ) private val sourceMenu by lazy { PopupMenu(context, binding.tvSourceAction).apply { inflate(R.menu.book_read_source) setOnMenuItemClickListener { when (it.itemId) { R.id.menu_login -> callBack.showLogin() R.id.menu_chapter_pay -> callBack.payAction() R.id.menu_edit_source -> callBack.openSourceEditActivity() R.id.menu_disable_source -> callBack.disableSource() } true } } } private val menuInListener = object : Animation.AnimationListener { override fun onAnimationStart(animation: Animation) { binding.tvSourceAction.text = ReadBook.bookSource?.bookSourceName ?: context.getString(R.string.book_source) binding.tvSourceAction.isGone = ReadBook.isLocalBook callBack.upSystemUiVisibility() binding.llBrightness.visible(showBrightnessView) } @SuppressLint("RtlHardcoded") override fun onAnimationEnd(animation: Animation) { binding.vwMenuBg.setOnClickListener { runMenuOut() } callBack.upSystemUiVisibility() if (!LocalConfig.readMenuHelpVersionIsLast) { callBack.showHelp() } } override fun onAnimationRepeat(animation: Animation) = Unit } private val menuOutListener = object : Animation.AnimationListener { override fun onAnimationStart(animation: Animation) { isMenuOutAnimating = true binding.vwMenuBg.setOnClickListener(null) } override fun onAnimationEnd(animation: Animation) { this@ReadMenu.invisible() binding.titleBar.invisible() binding.bottomMenu.invisible() canShowMenu = false isMenuOutAnimating = false onMenuOutEnd?.invoke() callBack.upSystemUiVisibility() } override fun onAnimationRepeat(animation: Animation) = Unit } init { initView() upBrightnessState() bindEvent() } private fun initView(reset: Boolean = false) = binding.run { if (AppConfig.isNightTheme) { fabNightTheme.setImageResource(R.drawable.ic_daytime) } else { fabNightTheme.setImageResource(R.drawable.ic_brightness) } initAnimation() if (immersiveMenu) { val lightTextColor = ColorUtils.withAlpha(ColorUtils.lightenColor(textColor), 0.75f) titleBar.setTextColor(textColor) titleBar.setBackgroundColor(bgColor) titleBar.setColorFilter(textColor) tvChapterName.setTextColor(lightTextColor) tvChapterUrl.setTextColor(lightTextColor) } else if (reset) { val bgColor = context.primaryColor val textColor = context.primaryTextColor titleBar.setTextColor(textColor) titleBar.setBackgroundColor(bgColor) titleBar.setColorFilter(textColor) tvChapterName.setTextColor(textColor) tvChapterUrl.setTextColor(textColor) } val brightnessBackground = GradientDrawable() brightnessBackground.cornerRadius = 5F.dpToPx() brightnessBackground.setColor(ColorUtils.adjustAlpha(bgColor, 0.5f)) llBrightness.background = brightnessBackground if (AppConfig.isEInkMode) { titleBar.setBackgroundResource(R.drawable.bg_eink_border_bottom) llBottomBg.setBackgroundResource(R.drawable.bg_eink_border_top) } else { llBottomBg.setBackgroundColor(bgColor) } fabSearch.backgroundTintList = bottomBackgroundList fabSearch.setColorFilter(textColor) fabAutoPage.backgroundTintList = bottomBackgroundList fabAutoPage.setColorFilter(textColor) fabReplaceRule.backgroundTintList = bottomBackgroundList fabReplaceRule.setColorFilter(textColor) fabNightTheme.backgroundTintList = bottomBackgroundList fabNightTheme.setColorFilter(textColor) tvPre.setTextColor(textColor) tvNext.setTextColor(textColor) ivCatalog.setColorFilter(textColor, PorterDuff.Mode.SRC_IN) tvCatalog.setTextColor(textColor) ivReadAloud.setColorFilter(textColor, PorterDuff.Mode.SRC_IN) tvReadAloud.setTextColor(textColor) ivFont.setColorFilter(textColor, PorterDuff.Mode.SRC_IN) tvFont.setTextColor(textColor) ivSetting.setColorFilter(textColor, PorterDuff.Mode.SRC_IN) tvSetting.setTextColor(textColor) vwBrightnessPosAdjust.setColorFilter(textColor, PorterDuff.Mode.SRC_IN) llBrightness.setOnClickListener(null) seekBrightness.post { seekBrightness.progress = AppConfig.readBrightness } if (AppConfig.showReadTitleBarAddition) { titleBarAddition.visible() } else { titleBarAddition.gone() } upBrightnessVwPos() /** * 确保视图不被导航栏遮挡 */ applyNavigationBarPadding() } fun reset() { upColorConfig() initView(true) } fun refreshMenuColorFilter() { if (immersiveMenu) { binding.titleBar.setColorFilter(textColor) } } private fun upColorConfig() { bgColor = if (immersiveMenu) { kotlin.runCatching { Color.parseColor(ReadBookConfig.durConfig.curBgStr()) }.getOrDefault(context.bottomBackground) } else { context.bottomBackground } textColor = if (immersiveMenu) { ReadBookConfig.durConfig.curTextColor() } else { context.getPrimaryTextColor(ColorUtils.isColorLight(bgColor)) } bottomBackgroundList = Selector.colorBuild() .setDefaultColor(bgColor) .setPressedColor(ColorUtils.darkenColor(bgColor)) .create() } fun upBrightnessState() { if (brightnessAuto()) { binding.ivBrightnessAuto.setColorFilter(context.accentColor) binding.seekBrightness.isEnabled = false } else { binding.ivBrightnessAuto.setColorFilter(context.buttonDisabledColor) binding.seekBrightness.isEnabled = true } setScreenBrightness(AppConfig.readBrightness.toFloat()) } /** * 设置屏幕亮度 */ fun setScreenBrightness(value: Float) { activity?.run { var brightness = BRIGHTNESS_OVERRIDE_NONE if (!brightnessAuto() && value != BRIGHTNESS_OVERRIDE_NONE) { brightness = value if (brightness < 1f) brightness = 1f brightness /= 255f } val params = window.attributes params.screenBrightness = brightness window.attributes = params } } fun runMenuIn(anim: Boolean = !AppConfig.isEInkMode) { callBack.onMenuShow() this.visible() binding.titleBar.visible() binding.bottomMenu.visible() if (anim) { binding.titleBar.startAnimation(menuTopIn) binding.bottomMenu.startAnimation(menuBottomIn) } else { menuInListener.onAnimationStart(menuBottomIn) menuInListener.onAnimationEnd(menuBottomIn) } } fun runMenuOut(anim: Boolean = !AppConfig.isEInkMode, onMenuOutEnd: (() -> Unit)? = null) { if (isMenuOutAnimating) { return } callBack.onMenuHide() this.onMenuOutEnd = onMenuOutEnd if (this.isVisible) { if (anim) { binding.titleBar.startAnimation(menuTopOut) binding.bottomMenu.startAnimation(menuBottomOut) } else { menuOutListener.onAnimationStart(menuBottomOut) menuOutListener.onAnimationEnd(menuBottomOut) } } } private fun brightnessAuto(): Boolean { return context.getPrefBoolean("brightnessAuto", true) || !showBrightnessView } private fun bindEvent() = binding.run { vwMenuBg.setOnClickListener { runMenuOut() } titleBar.toolbar.setOnClickListener { callBack.openBookInfoActivity() } val chapterViewClickListener = OnClickListener { if (ReadBook.isLocalBook) { return@OnClickListener } if (AppConfig.readUrlInBrowser) { context.openUrl(tvChapterUrl.text.toString().substringBefore(",{")) } else { Coroutine.async { context.startActivity { val url = tvChapterUrl.text.toString() val bookSource = ReadBook.bookSource putExtra("title", tvChapterName.text) putExtra("url", url) putExtra("sourceOrigin", bookSource?.bookSourceUrl) putExtra("sourceName", bookSource?.bookSourceName) putExtra("sourceType", bookSource?.getSourceType()) } } } } val chapterViewLongClickListener = OnLongClickListener { if (ReadBook.isLocalBook) { return@OnLongClickListener true } context.alert(R.string.open_fun) { setMessage(R.string.use_browser_open) okButton { AppConfig.readUrlInBrowser = true } noButton { AppConfig.readUrlInBrowser = false } } true } tvChapterName.setOnClickListener(chapterViewClickListener) tvChapterName.setOnLongClickListener(chapterViewLongClickListener) tvChapterUrl.setOnClickListener(chapterViewClickListener) tvChapterUrl.setOnLongClickListener(chapterViewLongClickListener) //书源操作 tvSourceAction.onClick { sourceMenu.menu.findItem(R.id.menu_login).isVisible = !ReadBook.bookSource?.loginUrl.isNullOrEmpty() sourceMenu.menu.findItem(R.id.menu_chapter_pay).isVisible = !ReadBook.bookSource?.loginUrl.isNullOrEmpty() && ReadBook.curTextChapter?.isVip == true && ReadBook.curTextChapter?.isPay != true sourceMenu.show() } //亮度跟随 ivBrightnessAuto.setOnClickListener { context.putPrefBoolean("brightnessAuto", !brightnessAuto()) upBrightnessState() } //亮度调节 seekBrightness.setOnSeekBarChangeListener(object : SeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { if (fromUser) { setScreenBrightness(progress.toFloat()) } } override fun onStopTrackingTouch(seekBar: SeekBar) { AppConfig.readBrightness = seekBar.progress } }) vwBrightnessPosAdjust.setOnClickListener { AppConfig.brightnessVwPos = !AppConfig.brightnessVwPos upBrightnessVwPos() } //阅读进度 seekReadPage.setOnSeekBarChangeListener(object : SeekBarChangeListener { override fun onStartTrackingTouch(seekBar: SeekBar) { binding.vwMenuBg.setOnClickListener(null) } override fun onStopTrackingTouch(seekBar: SeekBar) { binding.vwMenuBg.setOnClickListener { runMenuOut() } when (AppConfig.progressBarBehavior) { "page" -> ReadBook.skipToPage(seekBar.progress) "chapter" -> { if (confirmSkipToChapter) { callBack.skipToChapter(seekBar.progress) } else { context.alert("章节跳转确认", "确定要跳转章节吗?") { yesButton { confirmSkipToChapter = true callBack.skipToChapter(seekBar.progress) } noButton { upSeekBar() } onCancelled { upSeekBar() } } } } } } }) //搜索 fabSearch.setOnClickListener { runMenuOut { callBack.openSearchActivity(null) } } //自动翻页 fabAutoPage.setOnClickListener { runMenuOut { callBack.autoPage() } } //替换 fabReplaceRule.setOnClickListener { callBack.openReplaceRule() } //夜间模式 fabNightTheme.setOnClickListener { AppConfig.isNightTheme = !AppConfig.isNightTheme ThemeConfig.applyDayNight(context) } //上一章 tvPre.setOnClickListener { ReadBook.moveToPrevChapter(upContent = true, toLast = false) } //下一章 tvNext.setOnClickListener { ReadBook.moveToNextChapter(true) } //目录 llCatalog.setOnClickListener { runMenuOut { callBack.openChapterList() } } //朗读 llReadAloud.setOnClickListener { runMenuOut { callBack.onClickReadAloud() } } llReadAloud.onLongClick { runMenuOut { callBack.showReadAloudDialog() } } //界面 llFont.setOnClickListener { runMenuOut { callBack.showReadStyle() } } //设置 llSetting.setOnClickListener { runMenuOut { callBack.showMoreSetting() } } } private fun initAnimation() { menuTopIn.setAnimationListener(menuInListener) menuTopOut.setAnimationListener(menuOutListener) } fun upBookView() { binding.titleBar.title = ReadBook.book?.name ReadBook.curTextChapter?.let { binding.tvChapterName.text = it.title binding.tvChapterName.visible() if (!ReadBook.isLocalBook) { binding.tvChapterUrl.text = it.chapter.getAbsoluteURL() binding.tvChapterUrl.visible() } else { binding.tvChapterUrl.gone() } upSeekBar() binding.tvPre.isEnabled = ReadBook.durChapterIndex != 0 binding.tvNext.isEnabled = ReadBook.durChapterIndex != ReadBook.simulatedChapterSize - 1 } ?: let { binding.tvChapterName.gone() binding.tvChapterUrl.gone() } } fun upSeekBar() { binding.seekReadPage.apply { when (AppConfig.progressBarBehavior) { "page" -> { ReadBook.curTextChapter?.let { max = it.pageSize.minus(1) progress = ReadBook.durPageIndex } } "chapter" -> { max = ReadBook.simulatedChapterSize - 1 progress = ReadBook.durChapterIndex } } } } fun setSeekPage(seek: Int) { binding.seekReadPage.progress = seek } fun setAutoPage(autoPage: Boolean) = binding.run { if (autoPage) { fabAutoPage.setImageResource(R.drawable.ic_auto_page_stop) fabAutoPage.contentDescription = context.getString(R.string.auto_next_page_stop) } else { fabAutoPage.setImageResource(R.drawable.ic_auto_page) fabAutoPage.contentDescription = context.getString(R.string.auto_next_page) } fabAutoPage.setColorFilter(textColor) } private fun upBrightnessVwPos() { if (AppConfig.brightnessVwPos) { binding.root.modifyBegin() .clear(R.id.ll_brightness, ConstraintModify.Anchor.LEFT) .rightToRightOf(R.id.ll_brightness, R.id.vw_menu_root) .commit() } else { binding.root.modifyBegin() .clear(R.id.ll_brightness, ConstraintModify.Anchor.RIGHT) .leftToLeftOf(R.id.ll_brightness, R.id.vw_menu_root) .commit() } } interface CallBack { fun autoPage() fun openReplaceRule() fun openChapterList() fun openSearchActivity(searchWord: String?) fun openSourceEditActivity() fun openBookInfoActivity() fun showReadStyle() fun showMoreSetting() fun showReadAloudDialog() fun upSystemUiVisibility() fun onClickReadAloud() fun showHelp() fun showLogin() fun payAction() fun disableSource() fun skipToChapter(index: Int) fun onMenuShow() fun onMenuHide() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/SearchMenu.kt ================================================ package io.legado.app.ui.book.read import android.annotation.SuppressLint import android.content.Context import android.content.res.ColorStateList import android.graphics.PorterDuff import android.util.AttributeSet import android.view.LayoutInflater import android.view.animation.Animation import android.widget.FrameLayout import androidx.core.view.isVisible import io.legado.app.R import io.legado.app.databinding.ViewSearchMenuBinding import io.legado.app.lib.theme.Selector import io.legado.app.lib.theme.bottomBackground import io.legado.app.lib.theme.getPrimaryTextColor import io.legado.app.model.ReadBook import io.legado.app.ui.book.searchContent.SearchResult import io.legado.app.utils.ColorUtils import io.legado.app.utils.activity import io.legado.app.utils.applyNavigationBarPadding import io.legado.app.utils.invisible import io.legado.app.utils.loadAnimation import io.legado.app.utils.visible /** * 搜索界面菜单 */ class SearchMenu @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : FrameLayout(context, attrs) { private val callBack: CallBack get() = activity as CallBack private val binding = ViewSearchMenuBinding.inflate(LayoutInflater.from(context), this, true) private val menuBottomIn: Animation = loadAnimation(context, R.anim.anim_readbook_bottom_in) private val menuBottomOut: Animation = loadAnimation(context, R.anim.anim_readbook_bottom_out) private val bgColor: Int = context.bottomBackground private val textColor: Int = context.getPrimaryTextColor(ColorUtils.isColorLight(bgColor)) private val bottomBackgroundList: ColorStateList = Selector.colorBuild().setDefaultColor(bgColor) .setPressedColor(ColorUtils.darkenColor(bgColor)).create() private var onMenuOutEnd: (() -> Unit)? = null private var isMenuOutAnimating = false private val searchResultList: MutableList = mutableListOf() private var currentSearchResultIndex: Int = -1 private var lastSearchResultIndex: Int = -1 private val hasSearchResult: Boolean get() = searchResultList.isNotEmpty() val selectedSearchResult: SearchResult? get() = searchResultList.getOrNull(currentSearchResultIndex) val previousSearchResult: SearchResult? get() = searchResultList.getOrNull(lastSearchResultIndex) val bottomMenuVisible get() = isVisible && binding.llBottomMenu.isVisible init { initAnimation() initView() bindEvent() updateSearchInfo() } fun upSearchResultList(resultList: List) { searchResultList.clear() searchResultList.addAll(resultList) updateSearchInfo() } private fun initView() = binding.run { llSearchBaseInfo.setBackgroundColor(bgColor) tvCurrentSearchInfo.setTextColor(bottomBackgroundList) llBottomBg.setBackgroundColor(bgColor) fabLeft.backgroundTintList = bottomBackgroundList fabLeft.setColorFilter(textColor, PorterDuff.Mode.SRC_IN) fabRight.backgroundTintList = bottomBackgroundList fabRight.setColorFilter(textColor, PorterDuff.Mode.SRC_IN) tvMainMenu.setTextColor(textColor) tvSearchResults.setTextColor(textColor) tvSearchExit.setTextColor(textColor) ivMainMenu.setColorFilter(textColor, PorterDuff.Mode.SRC_IN) ivSearchResults.setColorFilter(textColor, PorterDuff.Mode.SRC_IN) ivSearchExit.setColorFilter(textColor, PorterDuff.Mode.SRC_IN) ivSearchContentUp.setColorFilter(textColor, PorterDuff.Mode.SRC_IN) ivSearchContentDown.setColorFilter(textColor, PorterDuff.Mode.SRC_IN) tvCurrentSearchInfo.setTextColor(textColor) applyNavigationBarPadding() } fun runMenuIn() { this.visible() binding.llBottomMenu.visible() binding.vwMenuBg.visible() binding.llBottomMenu.startAnimation(menuBottomIn) } fun runMenuOut(onMenuOutEnd: (() -> Unit)? = null) { if (isMenuOutAnimating) { return } this.onMenuOutEnd = onMenuOutEnd if (this.isVisible) { binding.llBottomMenu.startAnimation(menuBottomOut) } } @SuppressLint("SetTextI18n") fun updateSearchInfo() { ReadBook.curTextChapter?.let { binding.tvCurrentSearchInfo.text = """${context.getString(R.string.search_content_size)}: ${searchResultList.size} / 当前章节: ${it.title}""" } } fun updateSearchResultIndex(updateIndex: Int) { lastSearchResultIndex = currentSearchResultIndex currentSearchResultIndex = when { updateIndex < 0 -> 0 updateIndex >= searchResultList.size -> searchResultList.size - 1 else -> updateIndex } } private fun bindEvent() = binding.run { //搜索结果 llSearchResults.setOnClickListener { runMenuOut { callBack.openSearchActivity(selectedSearchResult?.query) } } //主菜单 llMainMenu.setOnClickListener { runMenuOut { callBack.cancelSelect() callBack.showMenuBar() this@SearchMenu.invisible() } } //退出 llSearchExit.setOnClickListener { runMenuOut { callBack.exitSearchMenu() } } fabLeft.setOnClickListener { updateSearchResultIndex(currentSearchResultIndex - 1) callBack.navigateToSearch( searchResultList[currentSearchResultIndex], currentSearchResultIndex ) } ivSearchContentUp.setOnClickListener { updateSearchResultIndex(currentSearchResultIndex - 1) callBack.navigateToSearch( searchResultList[currentSearchResultIndex], currentSearchResultIndex ) } ivSearchContentDown.setOnClickListener { updateSearchResultIndex(currentSearchResultIndex + 1) callBack.navigateToSearch( searchResultList[currentSearchResultIndex], currentSearchResultIndex ) } fabRight.setOnClickListener { updateSearchResultIndex(currentSearchResultIndex + 1) callBack.navigateToSearch( searchResultList[currentSearchResultIndex], currentSearchResultIndex ) } } private fun initAnimation() { //显示菜单 menuBottomIn.setAnimationListener(object : Animation.AnimationListener { override fun onAnimationStart(animation: Animation) { callBack.upSystemUiVisibility() binding.fabLeft.visible(hasSearchResult) binding.fabRight.visible(hasSearchResult) } @SuppressLint("RtlHardcoded") override fun onAnimationEnd(animation: Animation) { binding.vwMenuBg.setOnClickListener { runMenuOut() } callBack.upSystemUiVisibility() } override fun onAnimationRepeat(animation: Animation) = Unit }) //隐藏菜单 menuBottomOut.setAnimationListener(object : Animation.AnimationListener { override fun onAnimationStart(animation: Animation) { isMenuOutAnimating = true binding.vwMenuBg.setOnClickListener(null) } override fun onAnimationEnd(animation: Animation) { isMenuOutAnimating = false binding.llBottomMenu.invisible() binding.vwMenuBg.invisible() binding.vwMenuBg.setOnClickListener { runMenuOut() } onMenuOutEnd?.invoke() callBack.upSystemUiVisibility() } override fun onAnimationRepeat(animation: Animation) = Unit }) } interface CallBack { var isShowingSearchResult: Boolean fun openSearchActivity(searchWord: String?) fun showSearchSetting() fun upSystemUiVisibility() fun exitSearchMenu() fun showMenuBar() fun navigateToSearch(searchResult: SearchResult, index: Int) fun onMenuShow() fun onMenuHide() fun cancelSelect() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/TextActionMenu.kt ================================================ package io.legado.app.ui.book.read import android.annotation.SuppressLint import android.app.SearchManager import android.content.Context import android.content.Intent import android.content.pm.ResolveInfo import android.net.Uri import android.os.Build import android.view.Gravity import android.view.LayoutInflater import android.view.Menu import android.view.View import android.view.ViewGroup import android.widget.PopupWindow import androidx.annotation.RequiresApi import androidx.appcompat.view.SupportMenuInflater import androidx.appcompat.view.menu.MenuBuilder import androidx.appcompat.view.menu.MenuItemImpl import androidx.core.view.isVisible import io.legado.app.R import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.constant.AppLog import io.legado.app.constant.PreferKey import io.legado.app.databinding.ItemTextBinding import io.legado.app.databinding.PopupActionMenuBinding import io.legado.app.help.config.AppConfig import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.gone import io.legado.app.utils.isAbsUrl import io.legado.app.utils.printOnDebug import io.legado.app.utils.sendToClip import io.legado.app.utils.share import io.legado.app.utils.toastOnUi import io.legado.app.utils.visible @SuppressLint("RestrictedApi") class TextActionMenu(private val context: Context, private val callBack: CallBack) : PopupWindow(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) { private val binding = PopupActionMenuBinding.inflate(LayoutInflater.from(context)) private val adapter = Adapter(context).apply { setHasStableIds(true) } private val menuItems: List private val visibleMenuItems = arrayListOf() private val moreMenuItems = arrayListOf() private val expandTextMenu get() = context.getPrefBoolean(PreferKey.expandTextMenu) init { @SuppressLint("InflateParams") contentView = binding.root isTouchable = true isOutsideTouchable = false isFocusable = false val myMenu = MenuBuilder(context) val otherMenu = MenuBuilder(context) SupportMenuInflater(context).inflate(R.menu.content_select_action, myMenu) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { onInitializeMenu(otherMenu) } menuItems = myMenu.visibleItems + otherMenu.visibleItems visibleMenuItems.addAll(menuItems.subList(0, 5)) moreMenuItems.addAll(menuItems.subList(5, menuItems.size)) binding.recyclerView.adapter = adapter binding.recyclerViewMore.adapter = adapter setOnDismissListener { if (!context.getPrefBoolean(PreferKey.expandTextMenu)) { binding.ivMenuMore.setImageResource(R.drawable.ic_more_vert) binding.recyclerViewMore.gone() adapter.setItems(visibleMenuItems) binding.recyclerView.visible() } } binding.ivMenuMore.setOnClickListener { if (binding.recyclerView.isVisible) { binding.ivMenuMore.setImageResource(R.drawable.ic_arrow_back) adapter.setItems(moreMenuItems) binding.recyclerView.gone() binding.recyclerViewMore.visible() } else { binding.ivMenuMore.setImageResource(R.drawable.ic_more_vert) binding.recyclerViewMore.gone() adapter.setItems(visibleMenuItems) binding.recyclerView.visible() } } upMenu() } fun upMenu() { if (expandTextMenu) { adapter.setItems(menuItems) binding.ivMenuMore.gone() } else { adapter.setItems(visibleMenuItems) binding.ivMenuMore.visible() } } fun show( view: View, windowHeight: Int, startX: Int, startTopY: Int, startBottomY: Int, endX: Int, endBottomY: Int ) { if (expandTextMenu) { when { startTopY > 500 -> { showAtLocation( view, Gravity.BOTTOM or Gravity.START, startX, windowHeight - startTopY ) } endBottomY - startBottomY > 500 -> { showAtLocation(view, Gravity.TOP or Gravity.START, startX, startBottomY) } else -> { showAtLocation(view, Gravity.TOP or Gravity.START, endX, endBottomY) } } } else { contentView.measure( View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED, ) val popupHeight = contentView.measuredHeight when { startBottomY > 500 -> { showAtLocation( view, Gravity.TOP or Gravity.START, startX, startTopY - popupHeight ) } endBottomY - startBottomY > 500 -> { showAtLocation( view, Gravity.TOP or Gravity.START, startX, startBottomY ) } else -> { showAtLocation( view, Gravity.TOP or Gravity.START, endX, endBottomY ) } } } } inner class Adapter(context: Context) : RecyclerAdapter(context) { override fun getItemId(position: Int): Long { return position.toLong() } override fun getViewBinding(parent: ViewGroup): ItemTextBinding { return ItemTextBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemTextBinding, item: MenuItemImpl, payloads: MutableList ) { with(binding) { textView.text = item.title } } override fun registerListener(holder: ItemViewHolder, binding: ItemTextBinding) { holder.itemView.setOnClickListener { getItem(holder.layoutPosition)?.let { if (!callBack.onMenuItemSelected(it.itemId)) { onMenuItemSelected(it) } } callBack.onMenuActionFinally() } holder.itemView.setOnLongClickListener { if (AppConfig.contentSelectSpeakMod == 0) { AppConfig.contentSelectSpeakMod = 1 context.toastOnUi("切换为从选择的地方开始一直朗读") } else { AppConfig.contentSelectSpeakMod = 0 context.toastOnUi("切换为朗读选择内容") } true } } } private fun onMenuItemSelected(item: MenuItemImpl) { when (item.itemId) { R.id.menu_copy -> context.sendToClip(callBack.selectedText) R.id.menu_share_str -> context.share(callBack.selectedText) R.id.menu_browser -> { kotlin.runCatching { val intent = if (callBack.selectedText.isAbsUrl()) { Intent(Intent.ACTION_VIEW).apply { data = Uri.parse(callBack.selectedText) } } else { Intent(Intent.ACTION_WEB_SEARCH).apply { putExtra(SearchManager.QUERY, callBack.selectedText) } } context.startActivity(intent) }.onFailure { it.printOnDebug() context.toastOnUi(it.localizedMessage ?: "ERROR") } } else -> item.intent?.let { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { kotlin.runCatching { it.putExtra(Intent.EXTRA_PROCESS_TEXT, callBack.selectedText) context.startActivity(it) }.onFailure { e -> AppLog.put("执行文本菜单操作出错\n$e", e, true) } } } } } @RequiresApi(Build.VERSION_CODES.M) private fun createProcessTextIntent(): Intent { return Intent() .setAction(Intent.ACTION_PROCESS_TEXT) .setType("text/plain") } @RequiresApi(Build.VERSION_CODES.M) private fun getSupportedActivities(): List { return context.packageManager .queryIntentActivities(createProcessTextIntent(), 0) } @RequiresApi(Build.VERSION_CODES.M) private fun createProcessTextIntentForResolveInfo(info: ResolveInfo): Intent { return createProcessTextIntent() .putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, false) .setClassName(info.activityInfo.packageName, info.activityInfo.name) } /** * Start with a menu Item order value that is high enough * so that your "PROCESS_TEXT" menu items appear after the * standard selection menu items like Cut, Copy, Paste. */ @RequiresApi(Build.VERSION_CODES.M) private fun onInitializeMenu(menu: Menu) { kotlin.runCatching { var menuItemOrder = 100 for (resolveInfo in getSupportedActivities()) { menu.add( Menu.NONE, Menu.NONE, menuItemOrder++, resolveInfo.loadLabel(context.packageManager) ).intent = createProcessTextIntentForResolveInfo(resolveInfo) } }.onFailure { context.toastOnUi("获取文字操作菜单出错:${it.localizedMessage}") } } interface CallBack { val selectedText: String fun onMenuItemSelected(itemId: Int): Boolean fun onMenuActionFinally() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/config/AutoReadDialog.kt ================================================ package io.legado.app.ui.book.read.config import android.content.DialogInterface import android.graphics.PorterDuff import android.os.Bundle import android.view.Gravity import android.view.View import android.view.ViewGroup import android.view.WindowManager import android.widget.SeekBar import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.databinding.DialogAutoReadBinding import io.legado.app.help.config.ReadBookConfig import io.legado.app.lib.theme.bottomBackground import io.legado.app.lib.theme.getPrimaryTextColor import io.legado.app.model.ReadAloud import io.legado.app.model.ReadBook import io.legado.app.service.BaseReadAloudService import io.legado.app.ui.book.read.BaseReadBookActivity import io.legado.app.ui.book.read.ReadBookActivity import io.legado.app.ui.widget.seekbar.SeekBarChangeListener import io.legado.app.utils.ColorUtils import io.legado.app.utils.viewbindingdelegate.viewBinding import java.util.Locale class AutoReadDialog : BaseDialogFragment(R.layout.dialog_auto_read) { private val binding by viewBinding(DialogAutoReadBinding::bind) private val callBack: CallBack? get() = activity as? CallBack override fun onStart() { super.onStart() dialog?.window?.run { clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) setBackgroundDrawableResource(R.color.background) decorView.setPadding(0, 0, 0, 0) val attr = attributes attr.dimAmount = 0.0f attr.gravity = Gravity.BOTTOM attributes = attr setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) } } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) (activity as ReadBookActivity).bottomDialog-- } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) = binding.run { val bottomDialog = (activity as ReadBookActivity).bottomDialog++ if (bottomDialog > 0) { dismiss() return@run } val bg = requireContext().bottomBackground val isLight = ColorUtils.isColorLight(bg) val textColor = requireContext().getPrimaryTextColor(isLight) root.setBackgroundColor(bg) tvReadSpeedTitle.setTextColor(textColor) tvReadSpeed.setTextColor(textColor) ivCatalog.setColorFilter(textColor, PorterDuff.Mode.SRC_IN) tvCatalog.setTextColor(textColor) ivMainMenu.setColorFilter(textColor, PorterDuff.Mode.SRC_IN) tvMainMenu.setTextColor(textColor) ivAutoPageStop.setColorFilter(textColor, PorterDuff.Mode.SRC_IN) tvAutoPageStop.setTextColor(textColor) ivSetting.setColorFilter(textColor, PorterDuff.Mode.SRC_IN) tvSetting.setTextColor(textColor) initOnChange() initData() initEvent() } private fun initData() { val speed = if (ReadBookConfig.autoReadSpeed < 1) 1 else ReadBookConfig.autoReadSpeed binding.tvReadSpeed.text = String.format(Locale.ROOT, "%ds", speed) binding.seekAutoRead.progress = speed } private fun initOnChange() { binding.seekAutoRead.setOnSeekBarChangeListener(object : SeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { val speed = if (progress < 1) 1 else progress binding.tvReadSpeed.text = String.format(Locale.ROOT, "%ds", speed) } override fun onStopTrackingTouch(seekBar: SeekBar) { ReadBookConfig.autoReadSpeed = if (binding.seekAutoRead.progress < 1) 1 else binding.seekAutoRead.progress upTtsSpeechRate() } }) } private fun initEvent() { binding.llMainMenu.setOnClickListener { callBack?.showMenuBar() dismissAllowingStateLoss() } binding.llSetting.setOnClickListener { (activity as BaseReadBookActivity).showPageAnimConfig { (activity as ReadBookActivity).upPageAnim() ReadBook.loadContent(false) } } binding.llCatalog.setOnClickListener { callBack?.openChapterList() } binding.llAutoPageStop.setOnClickListener { callBack?.autoPageStop() binding.llAutoPageStop.post { dismissAllowingStateLoss() } } } private fun upTtsSpeechRate() { ReadAloud.upTtsSpeechRate(requireContext()) if (!BaseReadAloudService.pause) { ReadAloud.pause(requireContext()) ReadAloud.resume(requireContext()) } } interface CallBack { fun showMenuBar() fun openChapterList() fun autoPageStop() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/config/BgAdapter.kt ================================================ package io.legado.app.ui.book.read.config import android.content.Context import android.view.ViewGroup import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.constant.EventBus import io.legado.app.databinding.ItemBgImageBinding import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.glide.ImageLoader import io.legado.app.utils.postEvent import java.io.File class BgAdapter(context: Context, val textColor: Int) : RecyclerAdapter(context) { override fun getViewBinding(parent: ViewGroup): ItemBgImageBinding { return ItemBgImageBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemBgImageBinding, item: String, payloads: MutableList ) { binding.run { ImageLoader.load( context, context.assets.open("bg${File.separator}$item").readBytes() ) .centerCrop() .into(ivBg) tvName.setTextColor(textColor) tvName.text = item.substringBeforeLast(".") } } override fun registerListener(holder: ItemViewHolder, binding: ItemBgImageBinding) { holder.itemView.apply { this.setOnClickListener { getItemByLayoutPosition(holder.layoutPosition)?.let { ReadBookConfig.durConfig.setCurBg(1, it) postEvent(EventBus.UP_CONFIG, arrayListOf(1)) } } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/config/BgTextConfigDialog.kt ================================================ package io.legado.app.ui.book.read.config import android.annotation.SuppressLint import android.content.DialogInterface import android.graphics.PorterDuff import android.net.Uri import android.os.Bundle import android.view.Gravity import android.view.View import android.view.ViewGroup import android.view.WindowManager import android.widget.SeekBar import androidx.appcompat.widget.TooltipCompat import androidx.core.graphics.toColorInt import androidx.core.view.isGone import com.jaredrummler.android.colorpicker.ColorPickerDialog import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.constant.AppLog import io.legado.app.constant.EventBus import io.legado.app.databinding.DialogEditTextBinding import io.legado.app.databinding.DialogReadBgTextBinding import io.legado.app.databinding.ItemBgImageBinding import io.legado.app.help.DefaultData import io.legado.app.help.book.isImage import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.http.newCallResponseBody import io.legado.app.help.http.okHttpClient import io.legado.app.lib.dialogs.SelectItem import io.legado.app.lib.dialogs.alert import io.legado.app.lib.dialogs.selector import io.legado.app.lib.theme.bottomBackground import io.legado.app.lib.theme.getPrimaryTextColor import io.legado.app.lib.theme.getSecondaryTextColor import io.legado.app.model.ReadBook import io.legado.app.ui.book.read.ReadBookActivity import io.legado.app.ui.file.HandleFileContract import io.legado.app.ui.widget.seekbar.SeekBarChangeListener import io.legado.app.utils.ColorUtils import io.legado.app.utils.FileDoc import io.legado.app.utils.FileUtils import io.legado.app.utils.GSON import io.legado.app.utils.MD5Utils import io.legado.app.utils.compress.ZipUtils import io.legado.app.utils.createFileIfNotExist import io.legado.app.utils.createFileReplace import io.legado.app.utils.createFolderReplace import io.legado.app.utils.delete import io.legado.app.utils.externalCache import io.legado.app.utils.externalFiles import io.legado.app.utils.find import io.legado.app.utils.getFile import io.legado.app.utils.inputStream import io.legado.app.utils.longToast import io.legado.app.utils.openInputStream import io.legado.app.utils.openOutputStream import io.legado.app.utils.outputStream import io.legado.app.utils.postEvent import io.legado.app.utils.printOnDebug import io.legado.app.utils.readBytes import io.legado.app.utils.readUri import io.legado.app.utils.stackTraceStr import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding import splitties.init.appCtx import java.io.File import java.io.FileOutputStream class BgTextConfigDialog : BaseDialogFragment(R.layout.dialog_read_bg_text) { companion object { const val TEXT_COLOR = 121 const val BG_COLOR = 122 } private val binding by viewBinding(DialogReadBgTextBinding::bind) private val configFileName = "readConfig.zip" private val adapter by lazy { BgAdapter(requireContext(), secondaryTextColor) } private var primaryTextColor = 0 private var secondaryTextColor = 0 private val importFormNet = "网络导入" private val selectBgImage = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> setBgFromUri(uri) } } private val selectExportDir = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> exportConfig(uri) } } private val selectImportDoc = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> if (uri.path == "/$importFormNet") { importNetConfigAlert() } else { importConfig(uri) } } } override fun onStart() { super.onStart() dialog?.window?.run { clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) setBackgroundDrawableResource(R.color.background) decorView.setPadding(0, 0, 0, 0) val attr = attributes attr.dimAmount = 0.0f attr.gravity = Gravity.BOTTOM attributes = attr setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) } } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { (activity as ReadBookActivity).bottomDialog++ initView() initData() initEvent() } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) ReadBookConfig.save() (activity as ReadBookActivity).bottomDialog-- } private fun initView() = binding.run { val bg = requireContext().bottomBackground val isLight = ColorUtils.isColorLight(bg) primaryTextColor = requireContext().getPrimaryTextColor(isLight) secondaryTextColor = requireContext().getSecondaryTextColor(isLight) rootView.setBackgroundColor(bg) tvNameTitle.setTextColor(primaryTextColor) tvName.setTextColor(secondaryTextColor) ivEdit.setColorFilter(secondaryTextColor, PorterDuff.Mode.SRC_IN) tvRestore.setTextColor(primaryTextColor) swDarkStatusIcon.setTextColor(primaryTextColor) swUnderline.setTextColor(primaryTextColor) ivImport.setColorFilter(primaryTextColor, PorterDuff.Mode.SRC_IN) ivExport.setColorFilter(primaryTextColor, PorterDuff.Mode.SRC_IN) ivDelete.setColorFilter(primaryTextColor, PorterDuff.Mode.SRC_IN) tvBgAlpha.setTextColor(primaryTextColor) tvBgImage.setTextColor(primaryTextColor) swUnderline.isGone = ReadBook.book?.isImage == true recyclerView.adapter = adapter adapter.addHeaderView { ItemBgImageBinding.inflate(layoutInflater, it, false).apply { tvName.setTextColor(secondaryTextColor) tvName.text = getString(R.string.select_image) ivBg.setImageResource(R.drawable.ic_image) ivBg.setColorFilter(primaryTextColor, PorterDuff.Mode.SRC_IN) root.setOnClickListener { selectBgImage.launch { mode = HandleFileContract.IMAGE } } } } requireContext().assets.list("bg")?.let { adapter.setItems(it.toList()) } } @SuppressLint("InflateParams") private fun initData() = with(ReadBookConfig.durConfig) { binding.tvName.text = name.ifBlank { "文字" } binding.swDarkStatusIcon.isChecked = curStatusIconDark() binding.swUnderline.isChecked = underline binding.sbBgAlpha.progress = bgAlpha } @SuppressLint("InflateParams") private fun initEvent() = with(ReadBookConfig.durConfig) { binding.ivEdit.setOnClickListener { alert(R.string.style_name) { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = "name" editView.setText(ReadBookConfig.durConfig.name) } customView { alertBinding.root } okButton { alertBinding.editView.text?.toString()?.let { binding.tvName.text = it ReadBookConfig.durConfig.name = it } } cancelButton() } } binding.tvRestore.setOnClickListener { val defaultConfigs = DefaultData.readConfigs val layoutNames = defaultConfigs.map { it.name } context?.selector("选择预设布局", layoutNames) { _, i -> if (i >= 0) { ReadBookConfig.durConfig = defaultConfigs[i].copy() initData() postEvent(EventBus.UP_CONFIG, arrayListOf(1, 2, 5)) } } } binding.swDarkStatusIcon.setOnCheckedChangeListener { _, isChecked -> setCurStatusIconDark(isChecked) (activity as? ReadBookActivity)?.upSystemUiVisibility() } binding.swUnderline.setOnCheckedChangeListener { _, isChecked -> underline = isChecked postEvent(EventBus.UP_CONFIG, arrayListOf(6, 9, 11)) } binding.tvTextColor.setOnClickListener { ColorPickerDialog.newBuilder() .setColor(curTextColor()) .setShowAlphaSlider(false) .setDialogType(ColorPickerDialog.TYPE_CUSTOM) .setDialogId(TEXT_COLOR) .show(requireActivity()) } binding.tvBgColor.setOnClickListener { val bgColor = if (curBgType() == 0) curBgStr().toColorInt() else "#015A86".toColorInt() ColorPickerDialog.newBuilder() .setColor(bgColor) .setShowAlphaSlider(false) .setDialogType(ColorPickerDialog.TYPE_CUSTOM) .setDialogId(BG_COLOR) .show(requireActivity()) } binding.tvBgColor.apply { TooltipCompat.setTooltipText(this, text) } binding.ivImport.setOnClickListener { selectImportDoc.launch { mode = HandleFileContract.FILE title = getString(R.string.import_str) allowExtensions = arrayOf("zip") otherActions = arrayListOf(SelectItem(importFormNet, -1)) } } binding.ivExport.setOnClickListener { selectExportDir.launch { title = getString(R.string.export_str) } } binding.ivDelete.setOnClickListener { if (ReadBookConfig.deleteDur()) { postEvent(EventBus.UP_CONFIG, arrayListOf(1, 2, 5)) dismissAllowingStateLoss() } else { toastOnUi("数量已是最少,不能删除.") } } binding.sbBgAlpha.setOnSeekBarChangeListener(object : SeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { ReadBookConfig.bgAlpha = progress postEvent(EventBus.UP_CONFIG, arrayListOf(3)) } override fun onStopTrackingTouch(seekBar: SeekBar) { postEvent(EventBus.UP_CONFIG, arrayListOf(3)) } }) } private fun exportConfig(uri: Uri) { val exportFileName = if (ReadBookConfig.config.name.isBlank()) { configFileName } else { "${ReadBookConfig.config.name}.zip" } execute { val exportFiles = arrayListOf() val configDir = requireContext().externalCache.getFile("readConfig") configDir.createFolderReplace() val configFile = configDir.getFile("readConfig.json") configFile.createFileReplace() val config = ReadBookConfig.getExportConfig() val fontPath = ReadBookConfig.textFont if (fontPath.isNotEmpty()) { val fontDoc = FileDoc.fromFile(fontPath) val fontName = fontDoc.name val fontInputStream = fontDoc.openInputStream().getOrNull() fontInputStream?.use { val fontExportFile = FileUtils.createFileIfNotExist(configDir, fontName) fontExportFile.outputStream().use { out -> it.copyTo(out) } config.textFont = fontName exportFiles.add(fontExportFile) } } configFile.writeText(GSON.toJson(config)) exportFiles.add(configFile) repeat(3) { val path = ReadBookConfig.durConfig.getBgPath(it) ?: return@repeat val bgExportFile = copyBgImage(path, configDir) ?: return@repeat exportFiles.add(bgExportFile) } val configZipPath = FileUtils.getPath(requireContext().externalCache, configFileName) if (ZipUtils.zipFiles(exportFiles, File(configZipPath))) { val exportDir = FileDoc.fromDir(uri) exportDir.find(exportFileName)?.delete() val exportFileDoc = exportDir.createFileIfNotExist(exportFileName) exportFileDoc.openOutputStream().getOrThrow().use { out -> File(configZipPath).inputStream().use { it.copyTo(out) } } } }.onSuccess { toastOnUi("导出成功, 文件名为 $exportFileName") }.onError { it.printOnDebug() AppLog.put("导出失败:${it.localizedMessage}", it) longToast("导出失败:${it.localizedMessage}") } } private fun copyBgImage(path: String, configDir: File): File? { val bgName = FileUtils.getName(path) val bgFile = File(path) if (bgFile.exists()) { val bgExportFile = File(FileUtils.getPath(configDir, bgName)) if (!bgExportFile.exists()) { bgFile.copyTo(bgExportFile) return bgExportFile } } return null } @SuppressLint("InflateParams") private fun importNetConfigAlert() { alert("输入地址") { val alertBinding = DialogEditTextBinding.inflate(layoutInflater) customView { alertBinding.root } okButton { alertBinding.editView.text?.toString()?.let { url -> importNetConfig(url) } } cancelButton() } } private fun importNetConfig(url: String) { execute { okHttpClient.newCallResponseBody { url(url) }.bytes().let { importConfig(it) } }.onError { longToast(it.stackTraceStr) } } private fun importConfig(uri: Uri) { execute { importConfig(uri.readBytes(requireContext())) }.onError { it.printOnDebug() longToast("导入失败:${it.localizedMessage}") } } private fun importConfig(byteArray: ByteArray) { execute { ReadBookConfig.import(byteArray) }.onSuccess { ReadBookConfig.durConfig = it postEvent(EventBus.UP_CONFIG, arrayListOf(1, 2, 5)) toastOnUi("导入成功") }.onError { it.printOnDebug() longToast("导入失败:${it.localizedMessage}") } } private fun setBgFromUri(uri: Uri) { readUri(uri) { fileDoc, inputStream -> kotlin.runCatching { var file = requireContext().externalFiles val suffix = fileDoc.name.substringAfterLast(".") val fileName = uri.inputStream(requireContext()).getOrThrow().use { MD5Utils.md5Encode(it) + ".$suffix" } file = FileUtils.createFileIfNotExist(file, "bg", fileName) FileOutputStream(file).use { outputStream -> inputStream.copyTo(outputStream) } ReadBookConfig.durConfig.setCurBg(2, fileName) postEvent(EventBus.UP_CONFIG, arrayListOf(1)) }.onFailure { appCtx.toastOnUi(it.localizedMessage) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/config/ChineseConverter.kt ================================================ package io.legado.app.ui.book.read.config import android.content.Context import android.text.Spannable import android.text.SpannableString import android.text.style.ForegroundColorSpan import android.util.AttributeSet import io.legado.app.R import io.legado.app.help.config.AppConfig import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.accentColor import io.legado.app.ui.widget.text.StrokeTextView class ChineseConverter(context: Context, attrs: AttributeSet?) : StrokeTextView(context, attrs) { private val spannableString = SpannableString("简/繁") private var enabledSpan: ForegroundColorSpan = ForegroundColorSpan(context.accentColor) private var onChanged: (() -> Unit)? = null init { text = spannableString if (!isInEditMode) { upUi(AppConfig.chineseConverterType) } setOnClickListener { selectType() } } private fun upUi(type: Int) { spannableString.removeSpan(enabledSpan) when (type) { 1 -> spannableString.setSpan(enabledSpan, 0, 1, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) 2 -> spannableString.setSpan(enabledSpan, 2, 3, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) } text = spannableString } private fun selectType() { context.alert(titleResource = R.string.chinese_converter) { items(context.resources.getStringArray(R.array.chinese_mode).toList()) { _, i -> AppConfig.chineseConverterType = i upUi(i) onChanged?.invoke() } } } fun onChanged(unit: () -> Unit) { onChanged = unit } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/config/ClickActionConfigDialog.kt ================================================ package io.legado.app.ui.book.read.config import android.content.DialogInterface import android.os.Bundle import android.view.View import android.view.ViewGroup import android.widget.TextView import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.constant.PreferKey import io.legado.app.databinding.DialogClickActionConfigBinding import io.legado.app.help.config.AppConfig import io.legado.app.lib.dialogs.selector import io.legado.app.ui.book.read.ReadBookActivity import io.legado.app.utils.getCompatColor import io.legado.app.utils.putPrefInt import io.legado.app.utils.viewbindingdelegate.viewBinding /** * 点击区域设置 */ class ClickActionConfigDialog : BaseDialogFragment(R.layout.dialog_click_action_config) { private val binding by viewBinding(DialogClickActionConfigBinding::bind) private val actions by lazy { linkedMapOf( Pair(-1, getString(R.string.non_action)), Pair(0, getString(R.string.menu)), Pair(1, getString(R.string.next_page)), Pair(2, getString(R.string.prev_page)), Pair(3, getString(R.string.next_chapter)), Pair(4, getString(R.string.previous_chapter)), Pair(5, getString(R.string.read_aloud_prev_paragraph)), Pair(6, getString(R.string.read_aloud_next_paragraph)), Pair(7, getString(R.string.bookmark_add)), Pair(8, getString(R.string.edit_content)), Pair(9, getString(R.string.replace_state_change)), Pair(10, getString(R.string.chapter_list)), Pair(11, getString(R.string.search_content)), Pair(12, getString(R.string.sync_book_progress_t)), Pair(13, getString(R.string.read_aloud_pause_resume)) ) } override fun onStart() { super.onStart() dialog?.window?.run { setBackgroundDrawableResource(R.color.transparent) setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) } } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) (activity as ReadBookActivity).bottomDialog-- } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { (activity as ReadBookActivity).bottomDialog++ view.setBackgroundColor(getCompatColor(R.color.translucent)) initData() initViewEvent() } private fun initData() = binding.run { tvTopLeft.text = actions[AppConfig.clickActionTL] tvTopCenter.text = actions[AppConfig.clickActionTC] tvTopRight.text = actions[AppConfig.clickActionTR] tvMiddleLeft.text = actions[AppConfig.clickActionML] tvMiddleCenter.text = actions[AppConfig.clickActionMC] tvMiddleRight.text = actions[AppConfig.clickActionMR] tvBottomLeft.text = actions[AppConfig.clickActionBL] tvBottomCenter.text = actions[AppConfig.clickActionBC] tvBottomRight.text = actions[AppConfig.clickActionBR] } private fun initViewEvent() { binding.ivClose.setOnClickListener { dismissAllowingStateLoss() } binding.tvTopLeft.setOnClickListener { selectAction { action -> putPrefInt(PreferKey.clickActionTL, action) (it as? TextView)?.text = actions[action] } } binding.tvTopCenter.setOnClickListener { selectAction { action -> putPrefInt(PreferKey.clickActionTC, action) (it as? TextView)?.text = actions[action] } } binding.tvTopRight.setOnClickListener { selectAction { action -> putPrefInt(PreferKey.clickActionTR, action) (it as? TextView)?.text = actions[action] } } binding.tvMiddleLeft.setOnClickListener { selectAction { action -> putPrefInt(PreferKey.clickActionML, action) (it as? TextView)?.text = actions[action] } } binding.tvMiddleCenter.setOnClickListener { selectAction { action -> putPrefInt(PreferKey.clickActionMC, action) (it as? TextView)?.text = actions[action] } } binding.tvMiddleRight.setOnClickListener { selectAction { action -> putPrefInt(PreferKey.clickActionMR, action) (it as? TextView)?.text = actions[action] } } binding.tvBottomLeft.setOnClickListener { selectAction { action -> putPrefInt(PreferKey.clickActionBL, action) (it as? TextView)?.text = actions[action] } } binding.tvBottomCenter.setOnClickListener { selectAction { action -> putPrefInt(PreferKey.clickActionBC, action) (it as? TextView)?.text = actions[action] } } binding.tvBottomRight.setOnClickListener { selectAction { action -> putPrefInt(PreferKey.clickActionBR, action) (it as? TextView)?.text = actions[action] } } } private fun selectAction(success: (action: Int) -> Unit) { context?.selector( getString(R.string.select_action), actions.values.toList() ) { _, index -> success.invoke(actions.keys.toList()[index]) } } override fun onDestroy() { super.onDestroy() AppConfig.detectClickArea() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/config/HttpTtsEditDialog.kt ================================================ package io.legado.app.ui.book.read.config import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.fragment.app.viewModels import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.data.entities.HttpTTS import io.legado.app.databinding.DialogHttpTtsEditBinding import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.about.AppLogDialog import io.legado.app.ui.login.SourceLoginActivity import io.legado.app.ui.widget.code.addJsPattern import io.legado.app.ui.widget.code.addJsonPattern import io.legado.app.ui.widget.code.addLegadoPattern import io.legado.app.utils.GSON import io.legado.app.utils.applyTint import io.legado.app.utils.sendToClip import io.legado.app.utils.setLayout import io.legado.app.utils.showDialogFragment import io.legado.app.utils.showHelp import io.legado.app.utils.startActivity import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding class HttpTtsEditDialog() : BaseDialogFragment(R.layout.dialog_http_tts_edit, true), Toolbar.OnMenuItemClickListener { constructor(id: Long) : this() { arguments = Bundle().apply { putLong("id", id) } } private val binding by viewBinding(DialogHttpTtsEditBinding::bind) private val viewModel by viewModels() override fun onStart() { super.onStart() setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) binding.tvUrl.run { addLegadoPattern() addJsonPattern() addJsPattern() } binding.tvLoginUrl.run { addLegadoPattern() addJsonPattern() addJsPattern() } binding.tvLoginUi.addJsonPattern() binding.tvLoginCheckJs.addJsPattern() binding.tvHeaders.run { addLegadoPattern() addJsonPattern() addJsPattern() } viewModel.initData(arguments) { initView(httpTTS = it) } initMenu() } fun initMenu() { binding.toolBar.inflateMenu(R.menu.speak_engine_edit) binding.toolBar.menu.applyTint(requireContext()) binding.toolBar.setOnMenuItemClickListener(this) } fun initView(httpTTS: HttpTTS) { binding.tvName.setText(httpTTS.name) binding.tvUrl.setText(httpTTS.url) binding.tvContentType.setText(httpTTS.contentType) binding.tvConcurrentRate.setText(httpTTS.concurrentRate) binding.tvLoginUrl.setText(httpTTS.loginUrl) binding.tvLoginUi.setText(httpTTS.loginUi) binding.tvLoginCheckJs.setText(httpTTS.loginCheckJs) binding.tvHeaders.setText(httpTTS.header) } override fun onMenuItemClick(item: MenuItem?): Boolean { when (item?.itemId) { R.id.menu_save -> viewModel.save(dataFromView()) { toastOnUi("保存成功") } R.id.menu_login -> dataFromView().let { httpTts -> if (httpTts.loginUrl.isNullOrBlank()) { toastOnUi("登录url不能为空") } else { viewModel.save(httpTts) { startActivity { putExtra("type", "httpTts") putExtra("key", httpTts.id.toString()) } } } } R.id.menu_show_login_header -> alert { setTitle(R.string.login_header) dataFromView().getLoginHeader()?.let { loginHeader -> setMessage(loginHeader) } } R.id.menu_del_login_header -> dataFromView().removeLoginHeader() R.id.menu_copy_source -> dataFromView().let { context?.sendToClip(GSON.toJson(it)) } R.id.menu_paste_source -> viewModel.importFromClip { initView(it) } R.id.menu_log -> showDialogFragment() R.id.menu_help -> showHelp("httpTTSHelp") } return true } private fun dataFromView(): HttpTTS { return HttpTTS( id = viewModel.id ?: System.currentTimeMillis(), name = binding.tvName.text.toString(), url = binding.tvUrl.text.toString(), contentType = binding.tvContentType.text?.toString(), concurrentRate = binding.tvConcurrentRate.text?.toString(), loginUrl = binding.tvLoginUrl.text?.toString(), loginUi = binding.tvLoginUi.text?.toString(), loginCheckJs = binding.tvLoginCheckJs.text?.toString(), header = binding.tvHeaders.text?.toString() ) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/config/HttpTtsEditViewModel.kt ================================================ package io.legado.app.ui.book.read.config import android.app.Application import android.os.Bundle import io.legado.app.base.BaseViewModel import io.legado.app.data.appDb import io.legado.app.data.entities.HttpTTS import io.legado.app.exception.NoStackTraceException import io.legado.app.model.ReadAloud import io.legado.app.utils.getClipText import io.legado.app.utils.isJsonArray import io.legado.app.utils.isJsonObject import io.legado.app.utils.toastOnUi class HttpTtsEditViewModel(app: Application) : BaseViewModel(app) { var id: Long? = null fun initData(arguments: Bundle?, success: (httpTTS: HttpTTS) -> Unit) { execute { if (id == null) { val argumentId = arguments?.getLong("id") if (argumentId != null && argumentId != 0L) { id = argumentId return@execute appDb.httpTTSDao.get(argumentId) } } return@execute null }.onSuccess { it?.let { success.invoke(it) } } } fun save(httpTTS: HttpTTS, success: (() -> Unit)? = null) { id = httpTTS.id execute { appDb.httpTTSDao.insert(httpTTS) if (ReadAloud.ttsEngine == httpTTS.id.toString()) ReadAloud.upReadAloudClass() }.onSuccess { success?.invoke() } } fun importFromClip(onSuccess: (httpTTS: HttpTTS) -> Unit) { val text = context.getClipText() if (text.isNullOrBlank()) { context.toastOnUi("剪贴板为空") } else { importSource(text, onSuccess) } } fun importSource(text: String, onSuccess: (httpTTS: HttpTTS) -> Unit) { val text1 = text.trim() execute { when { text1.isJsonObject() -> { HttpTTS.fromJson(text1).getOrThrow() } text1.isJsonArray() -> { HttpTTS.fromJsonArray(text1).getOrThrow().first() } else -> { throw NoStackTraceException("格式不对") } } }.onSuccess { onSuccess.invoke(it) }.onError { context.toastOnUi(it.localizedMessage) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/config/MoreConfigDialog.kt ================================================ package io.legado.app.ui.book.read.config import android.annotation.SuppressLint import android.content.DialogInterface import android.content.SharedPreferences import android.os.Bundle import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewConfiguration import android.view.ViewGroup import android.view.WindowManager import android.widget.LinearLayout import androidx.preference.Preference import io.legado.app.R import io.legado.app.base.BasePrefDialogFragment import io.legado.app.constant.EventBus import io.legado.app.constant.PreferKey import io.legado.app.help.config.AppConfig import io.legado.app.help.config.ReadBookConfig import io.legado.app.lib.prefs.fragment.PreferenceFragment import io.legado.app.lib.theme.bottomBackground import io.legado.app.lib.theme.primaryColor import io.legado.app.model.ReadBook import io.legado.app.ui.book.read.ReadBookActivity import io.legado.app.ui.book.read.page.provider.ChapterProvider import io.legado.app.ui.widget.number.NumberPickerDialog import io.legado.app.utils.canvasrecorder.CanvasRecorderFactory import io.legado.app.utils.dpToPx import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.postEvent import io.legado.app.utils.removePref import io.legado.app.utils.setEdgeEffectColor class MoreConfigDialog : BasePrefDialogFragment() { private val readPreferTag = "readPreferenceFragment" override fun onStart() { super.onStart() dialog?.window?.run { clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) setBackgroundDrawableResource(R.color.background) decorView.setPadding(0, 0, 0, 0) val attr = attributes attr.dimAmount = 0.0f attr.gravity = Gravity.BOTTOM attributes = attr setLayout(ViewGroup.LayoutParams.MATCH_PARENT, 360.dpToPx()) } } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { (activity as ReadBookActivity).bottomDialog++ val view = LinearLayout(context) view.setBackgroundColor(requireContext().bottomBackground) view.id = R.id.tag1 container?.addView(view) return view } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) var preferenceFragment = childFragmentManager.findFragmentByTag(readPreferTag) if (preferenceFragment == null) preferenceFragment = ReadPreferenceFragment() childFragmentManager.beginTransaction() .replace(view.id, preferenceFragment, readPreferTag) .commit() } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) (activity as ReadBookActivity).bottomDialog-- } class ReadPreferenceFragment : PreferenceFragment(), SharedPreferences.OnSharedPreferenceChangeListener { private val slopSquare by lazy { ViewConfiguration.get(requireContext()).scaledTouchSlop } @SuppressLint("RestrictedApi") override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_config_read) upPreferenceSummary(PreferKey.pageTouchSlop, slopSquare.toString()) if (!CanvasRecorderFactory.isSupport) { removePref(PreferKey.optimizeRender) preferenceScreen.removePreferenceRecursively(PreferKey.optimizeRender) } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) listView.setEdgeEffectColor(primaryColor) } override fun onResume() { super.onResume() preferenceManager .sharedPreferences ?.registerOnSharedPreferenceChangeListener(this) } override fun onPause() { preferenceManager .sharedPreferences ?.unregisterOnSharedPreferenceChangeListener(this) super.onPause() } override fun onSharedPreferenceChanged( sharedPreferences: SharedPreferences?, key: String? ) { when (key) { PreferKey.readBodyToLh -> activity?.recreate() PreferKey.hideStatusBar -> { ReadBookConfig.hideStatusBar = getPrefBoolean(PreferKey.hideStatusBar) postEvent(EventBus.UP_CONFIG, arrayListOf(0, 2)) } PreferKey.hideNavigationBar -> { ReadBookConfig.hideNavigationBar = getPrefBoolean(PreferKey.hideNavigationBar) postEvent(EventBus.UP_CONFIG, arrayListOf(0, 2)) } PreferKey.keepLight -> postEvent(key, true) PreferKey.textSelectAble -> postEvent(key, getPrefBoolean(key)) PreferKey.screenOrientation -> { (activity as? ReadBookActivity)?.setOrientation() } PreferKey.textFullJustify, PreferKey.textBottomJustify, PreferKey.useZhLayout -> { postEvent(EventBus.UP_CONFIG, arrayListOf(5)) } PreferKey.showBrightnessView -> { postEvent(PreferKey.showBrightnessView, "") } PreferKey.expandTextMenu -> { (activity as? ReadBookActivity)?.textActionMenu?.upMenu() } PreferKey.doublePageHorizontal -> { ChapterProvider.upLayout() ReadBook.loadContent(false) } PreferKey.showReadTitleAddition, PreferKey.readBarStyleFollowPage -> { postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) } PreferKey.progressBarBehavior -> { postEvent(EventBus.UP_SEEK_BAR, true) } PreferKey.noAnimScrollPage -> { ReadBook.callBack?.upPageAnim() } PreferKey.optimizeRender -> { ChapterProvider.upStyle() ReadBook.callBack?.upPageAnim(true) ReadBook.loadContent(false) } PreferKey.paddingDisplayCutouts -> { postEvent(EventBus.UP_CONFIG, arrayListOf(2)) } } } override fun onPreferenceTreeClick(preference: Preference): Boolean { when (preference.key) { "customPageKey" -> PageKeyDialog(requireContext()).show() "clickRegionalConfig" -> { (activity as? ReadBookActivity)?.showClickRegionalConfig() } PreferKey.pageTouchSlop -> { NumberPickerDialog(requireContext()) .setTitle(getString(R.string.page_touch_slop_dialog_title)) .setMaxValue(9999) .setMinValue(0) .setValue(AppConfig.pageTouchSlop) .show { AppConfig.pageTouchSlop = it postEvent(EventBus.UP_CONFIG, arrayListOf(4)) } } } return super.onPreferenceTreeClick(preference) } @Suppress("SameParameterValue") private fun upPreferenceSummary(preferenceKey: String, value: String?) { val preference = findPreference(preferenceKey) ?: return when (preferenceKey) { PreferKey.pageTouchSlop -> preference.summary = getString(R.string.page_touch_slop_summary, value) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/config/PaddingConfigDialog.kt ================================================ package io.legado.app.ui.book.read.config import android.content.DialogInterface import android.os.Bundle import android.view.View import android.view.ViewGroup import android.view.WindowManager import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.constant.EventBus import io.legado.app.databinding.DialogReadPaddingBinding import io.legado.app.help.config.ReadBookConfig import io.legado.app.utils.postEvent import io.legado.app.utils.setLayout import io.legado.app.utils.viewbindingdelegate.viewBinding class PaddingConfigDialog : BaseDialogFragment(R.layout.dialog_read_padding) { private val binding by viewBinding(DialogReadPaddingBinding::bind) override fun onStart() { super.onStart() dialog?.window?.let { it.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) val attr = it.attributes attr.dimAmount = 0.0f it.attributes = attr } setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { initData() initView() } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) ReadBookConfig.save() } private fun initData() = binding.run { //正文 dsbPaddingTop.progress = ReadBookConfig.paddingTop dsbPaddingBottom.progress = ReadBookConfig.paddingBottom dsbPaddingLeft.progress = ReadBookConfig.paddingLeft dsbPaddingRight.progress = ReadBookConfig.paddingRight //页眉 dsbHeaderPaddingTop.progress = ReadBookConfig.headerPaddingTop dsbHeaderPaddingBottom.progress = ReadBookConfig.headerPaddingBottom dsbHeaderPaddingLeft.progress = ReadBookConfig.headerPaddingLeft dsbHeaderPaddingRight.progress = ReadBookConfig.headerPaddingRight //页脚 dsbFooterPaddingTop.progress = ReadBookConfig.footerPaddingTop dsbFooterPaddingBottom.progress = ReadBookConfig.footerPaddingBottom dsbFooterPaddingLeft.progress = ReadBookConfig.footerPaddingLeft dsbFooterPaddingRight.progress = ReadBookConfig.footerPaddingRight cbShowTopLine.isChecked = ReadBookConfig.showHeaderLine cbShowBottomLine.isChecked = ReadBookConfig.showFooterLine } private fun initView() = binding.run { //正文 dsbPaddingTop.onChanged = { ReadBookConfig.paddingTop = it postEvent(EventBus.UP_CONFIG, arrayListOf(10, 5)) } dsbPaddingBottom.onChanged = { ReadBookConfig.paddingBottom = it postEvent(EventBus.UP_CONFIG, arrayListOf(10, 5)) } dsbPaddingLeft.onChanged = { ReadBookConfig.paddingLeft = it postEvent(EventBus.UP_CONFIG, arrayListOf(10, 5)) } dsbPaddingRight.onChanged = { ReadBookConfig.paddingRight = it postEvent(EventBus.UP_CONFIG, arrayListOf(10, 5)) } //页眉 dsbHeaderPaddingTop.onChanged = { ReadBookConfig.headerPaddingTop = it postEvent(EventBus.UP_CONFIG, arrayListOf(2)) } dsbHeaderPaddingBottom.onChanged = { ReadBookConfig.headerPaddingBottom = it postEvent(EventBus.UP_CONFIG, arrayListOf(2)) } dsbHeaderPaddingLeft.onChanged = { ReadBookConfig.headerPaddingLeft = it postEvent(EventBus.UP_CONFIG, arrayListOf(2)) } dsbHeaderPaddingRight.onChanged = { ReadBookConfig.headerPaddingRight = it postEvent(EventBus.UP_CONFIG, arrayListOf(2)) } //页脚 dsbFooterPaddingTop.onChanged = { ReadBookConfig.footerPaddingTop = it postEvent(EventBus.UP_CONFIG, arrayListOf(2)) } dsbFooterPaddingBottom.onChanged = { ReadBookConfig.footerPaddingBottom = it postEvent(EventBus.UP_CONFIG, arrayListOf(2)) } dsbFooterPaddingLeft.onChanged = { ReadBookConfig.footerPaddingLeft = it postEvent(EventBus.UP_CONFIG, arrayListOf(2)) } dsbFooterPaddingRight.onChanged = { ReadBookConfig.footerPaddingRight = it postEvent(EventBus.UP_CONFIG, arrayListOf(2)) } cbShowTopLine.onCheckedChangeListener = { _, isChecked -> ReadBookConfig.showHeaderLine = isChecked postEvent(EventBus.UP_CONFIG, arrayListOf(2)) } cbShowBottomLine.onCheckedChangeListener = { _, isChecked -> ReadBookConfig.showFooterLine = isChecked postEvent(EventBus.UP_CONFIG, arrayListOf(2)) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/config/PageKeyDialog.kt ================================================ package io.legado.app.ui.book.read.config import android.app.Dialog import android.content.Context import android.view.KeyEvent import android.view.ViewGroup import io.legado.app.constant.PreferKey import io.legado.app.databinding.DialogPageKeyBinding import io.legado.app.lib.theme.backgroundColor import io.legado.app.utils.getPrefString import io.legado.app.utils.hideSoftInput import io.legado.app.utils.putPrefString import io.legado.app.utils.setLayout import splitties.views.onClick class PageKeyDialog(context: Context) : Dialog(context) { private val binding = DialogPageKeyBinding.inflate(layoutInflater) override fun onStart() { super.onStart() setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT) } init { setContentView(binding.root) binding.run { contentView.setBackgroundColor(context.backgroundColor) etPrev.setText(context.getPrefString(PreferKey.prevKeys)) etNext.setText(context.getPrefString(PreferKey.nextKeys)) tvReset.onClick { etPrev.setText("") etNext.setText("") } tvOk.setOnClickListener { context.putPrefString(PreferKey.prevKeys, etPrev.text?.toString()) context.putPrefString(PreferKey.nextKeys, etNext.text?.toString()) dismiss() } } } override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { if (keyCode != KeyEvent.KEYCODE_BACK && keyCode != KeyEvent.KEYCODE_DEL) { if (binding.etPrev.hasFocus()) { val editableText = binding.etPrev.editableText if (editableText.isEmpty() or editableText.endsWith(",")) { editableText.append(keyCode.toString()) } else { editableText.append(",").append(keyCode.toString()) } return true } else if (binding.etNext.hasFocus()) { val editableText = binding.etNext.editableText if (editableText.isEmpty() or editableText.endsWith(",")) { editableText.append(keyCode.toString()) } else { editableText.append(",").append(keyCode.toString()) } return true } } return super.onKeyDown(keyCode, event) } override fun dismiss() { super.dismiss() currentFocus?.hideSoftInput() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudConfigDialog.kt ================================================ package io.legado.app.ui.book.read.config import android.content.SharedPreferences import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.LinearLayout import androidx.preference.ListPreference import androidx.preference.Preference import io.legado.app.R import io.legado.app.base.BasePrefDialogFragment import io.legado.app.constant.EventBus import io.legado.app.constant.PreferKey import io.legado.app.data.appDb import io.legado.app.help.IntentHelp import io.legado.app.help.config.AppConfig import io.legado.app.lib.dialogs.SelectItem import io.legado.app.lib.prefs.SwitchPreference import io.legado.app.lib.prefs.fragment.PreferenceFragment import io.legado.app.lib.theme.backgroundColor import io.legado.app.lib.theme.primaryColor import io.legado.app.model.ReadAloud import io.legado.app.service.BaseReadAloudService import io.legado.app.utils.GSON import io.legado.app.utils.StringUtils import io.legado.app.utils.fromJsonObject import io.legado.app.utils.postEvent import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.setLayout import io.legado.app.utils.showDialogFragment class ReadAloudConfigDialog : BasePrefDialogFragment() { private val readAloudPreferTag = "readAloudPreferTag" override fun onStart() { super.onStart() dialog?.window?.run { setBackgroundDrawableResource(R.color.transparent) setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT) } } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { val view = LinearLayout(requireContext()) view.setBackgroundColor(requireContext().backgroundColor) view.id = R.id.tag1 container?.addView(view) return view } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) var preferenceFragment = childFragmentManager.findFragmentByTag(readAloudPreferTag) if (preferenceFragment == null) preferenceFragment = ReadAloudPreferenceFragment() childFragmentManager.beginTransaction() .replace(view.id, preferenceFragment, readAloudPreferTag) .commit() } class ReadAloudPreferenceFragment : PreferenceFragment(), SpeakEngineDialog.CallBack, SharedPreferences.OnSharedPreferenceChangeListener { private val speakEngineSummary: String get() { val ttsEngine = ReadAloud.ttsEngine ?: return getString(R.string.system_tts) if (StringUtils.isNumeric(ttsEngine)) { return appDb.httpTTSDao.getName(ttsEngine.toLong()) ?: getString(R.string.system_tts) } return GSON.fromJsonObject>(ttsEngine).getOrNull()?.title ?: getString(R.string.system_tts) } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_config_aloud) upSpeakEngineSummary() findPreference(PreferKey.pauseReadAloudWhilePhoneCalls)?.let { it.isEnabled = AppConfig.ignoreAudioFocus } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) listView.setEdgeEffectColor(primaryColor) } override fun onResume() { super.onResume() preferenceManager.sharedPreferences?.registerOnSharedPreferenceChangeListener(this) } override fun onPause() { preferenceManager.sharedPreferences?.unregisterOnSharedPreferenceChangeListener(this) super.onPause() } override fun onPreferenceTreeClick(preference: Preference): Boolean { when (preference.key) { PreferKey.ttsEngine -> showDialogFragment(SpeakEngineDialog()) "sysTtsConfig" -> IntentHelp.openTTSSetting() } return super.onPreferenceTreeClick(preference) } override fun onSharedPreferenceChanged( sharedPreferences: SharedPreferences?, key: String? ) { when (key) { PreferKey.readAloudByPage, PreferKey.streamReadAloudAudio -> { if (BaseReadAloudService.isRun) { postEvent(EventBus.MEDIA_BUTTON, false) } } PreferKey.ignoreAudioFocus -> { findPreference(PreferKey.pauseReadAloudWhilePhoneCalls)?.let { it.isEnabled = AppConfig.ignoreAudioFocus } } } } private fun upPreferenceSummary(preference: Preference?, value: String) { when (preference) { is ListPreference -> { val index = preference.findIndexOfValue(value) preference.summary = if (index >= 0) preference.entries[index] else null } else -> { preference?.summary = value } } } override fun upSpeakEngineSummary() { upPreferenceSummary( findPreference(PreferKey.ttsEngine), speakEngineSummary ) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudDialog.kt ================================================ package io.legado.app.ui.book.read.config import android.annotation.SuppressLint import android.content.DialogInterface import android.os.Bundle import android.view.Gravity import android.view.View import android.view.ViewGroup import android.view.WindowManager import android.widget.SeekBar import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.constant.EventBus import io.legado.app.databinding.DialogReadAloudBinding import io.legado.app.help.config.AppConfig import io.legado.app.lib.dialogs.selector import io.legado.app.lib.theme.bottomBackground import io.legado.app.lib.theme.getPrimaryTextColor import io.legado.app.model.ReadAloud import io.legado.app.model.ReadBook import io.legado.app.service.BaseReadAloudService import io.legado.app.ui.book.read.ReadBookActivity import io.legado.app.ui.widget.seekbar.SeekBarChangeListener import io.legado.app.utils.* import io.legado.app.utils.viewbindingdelegate.viewBinding class ReadAloudDialog : BaseDialogFragment(R.layout.dialog_read_aloud) { private val callBack: CallBack? get() = activity as? CallBack private val binding by viewBinding(DialogReadAloudBinding::bind) override fun onStart() { super.onStart() dialog?.window?.run { clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) setBackgroundDrawableResource(R.color.background) decorView.setPadding(0, 0, 0, 0) val attr = attributes attr.dimAmount = 0.0f attr.gravity = Gravity.BOTTOM attributes = attr setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) } } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) (activity as ReadBookActivity).bottomDialog-- } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { val bottomDialog = (activity as ReadBookActivity).bottomDialog++ if (bottomDialog > 0) { dismiss() return } val bg = requireContext().bottomBackground val isLight = ColorUtils.isColorLight(bg) val textColor = requireContext().getPrimaryTextColor(isLight) binding.run { rootView.setBackgroundColor(bg) tvPre.setTextColor(textColor) tvNext.setTextColor(textColor) ivPlayPrev.setColorFilter(textColor) ivPlayPause.setColorFilter(textColor) ivPlayNext.setColorFilter(textColor) ivStop.setColorFilter(textColor) ivTimer.setColorFilter(textColor) tvTimer.setTextColor(textColor) ivTtsSpeechReduce.setColorFilter(textColor) tvTtsSpeed.setTextColor(textColor) tvTtsSpeedValue.setTextColor(textColor) ivTtsSpeechAdd.setColorFilter(textColor) ivCatalog.setColorFilter(textColor) tvCatalog.setTextColor(textColor) ivMainMenu.setColorFilter(textColor) tvMainMenu.setTextColor(textColor) ivToBackstage.setColorFilter(textColor) tvToBackstage.setTextColor(textColor) ivSetting.setColorFilter(textColor) tvSetting.setTextColor(textColor) cbTtsFollowSys.setTextColor(textColor) } initData() initEvent() } private fun initData() = binding.run { upPlayState() upTimerText(BaseReadAloudService.timeMinute) cbTtsFollowSys.isChecked = requireContext().getPrefBoolean("ttsFollowSys", true) upTtsSpeechRateEnabled(!cbTtsFollowSys.isChecked) upSeekTimer() } private fun initEvent() = binding.run { llMainMenu.setOnClickListener { callBack?.showMenuBar() dismissAllowingStateLoss() } llSetting.setOnClickListener { ReadAloudConfigDialog().show(childFragmentManager, "readAloudConfigDialog") } tvPre.setOnClickListener { ReadBook.moveToPrevChapter(upContent = true, toLast = false) } tvNext.setOnClickListener { ReadBook.moveToNextChapter(true) } ivStop.setOnClickListener { ReadAloud.stop(requireContext()) dismissAllowingStateLoss() } ivPlayPause.setOnClickListener { callBack?.onClickReadAloud() } ivPlayPrev.setOnClickListener { ReadAloud.prevParagraph(requireContext()) } ivPlayNext.setOnClickListener { ReadAloud.nextParagraph(requireContext()) } llCatalog.setOnClickListener { callBack?.openChapterList() } llToBackstage.setOnClickListener { callBack?.finish() } cbTtsFollowSys.setOnCheckedChangeListener { _, isChecked -> AppConfig.ttsFlowSys = isChecked upTtsSpeechRateEnabled(!isChecked) upTtsSpeechRate() } ivTtsSpeechReduce.setOnClickListener { seekTtsSpeechRate.progress = AppConfig.ttsSpeechRate - 1 AppConfig.ttsSpeechRate -= 1 upTtsSpeechRate() } ivTtsSpeechAdd.setOnClickListener { seekTtsSpeechRate.progress = AppConfig.ttsSpeechRate + 1 AppConfig.ttsSpeechRate += 1 upTtsSpeechRate() } ivTimer.setOnClickListener { AppConfig.ttsTimer = seekTimer.progress toastOnUi("保存设定时间成功!") } tvTimer.setOnClickListener { val times = intArrayOf(0, 5, 10, 15, 30, 60, 90, 180) val timeKeys = times.map { "$it 分钟" } context?.selector("设定时间", timeKeys) { _, index -> ReadAloud.setTimer(requireContext(), times[index]) } } //设置保存的默认值 seekTtsSpeechRate.progress = AppConfig.ttsSpeechRate seekTtsSpeechRate.setOnSeekBarChangeListener(object : SeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { super.onProgressChanged(seekBar, progress, fromUser) upTtsSpeechRateText(progress) } override fun onStopTrackingTouch(seekBar: SeekBar) { AppConfig.ttsSpeechRate = seekBar.progress upTtsSpeechRate() } }) seekTimer.setOnSeekBarChangeListener(object : SeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { upTimerText(progress) } override fun onStopTrackingTouch(seekBar: SeekBar) { ReadAloud.setTimer(requireContext(), seekTimer.progress) } }) } private fun upTtsSpeechRateEnabled(enabled: Boolean) { binding.run { upTtsSpeechRateText(AppConfig.ttsSpeechRate) tvTtsSpeedValue.visible(enabled) seekTtsSpeechRate.isEnabled = enabled ivTtsSpeechReduce.isEnabled = enabled ivTtsSpeechAdd.isEnabled = enabled } } private fun upPlayState() { if (!BaseReadAloudService.pause) { binding.ivPlayPause.setImageResource(R.drawable.ic_pause_24dp) binding.ivPlayPause.contentDescription = getString(R.string.pause) } else { binding.ivPlayPause.setImageResource(R.drawable.ic_play_24dp) binding.ivPlayPause.contentDescription = getString(R.string.audio_play) } val bg = requireContext().bottomBackground val isLight = ColorUtils.isColorLight(bg) val textColor = requireContext().getPrimaryTextColor(isLight) binding.ivPlayPause.setColorFilter(textColor) } private fun upSeekTimer() { binding.seekTimer.post { if (BaseReadAloudService.timeMinute > 0) { binding.seekTimer.progress = BaseReadAloudService.timeMinute } else { binding.seekTimer.progress = AppConfig.ttsTimer } } } private fun upTimerText(timeMinute: Int) { if (timeMinute < 0) { binding.tvTimer.text = requireContext().getString(R.string.timer_m, 0) } else { binding.tvTimer.text = requireContext().getString(R.string.timer_m, timeMinute) } } @SuppressLint("SetTextI18n") private fun upTtsSpeechRateText(value: Int) { binding.tvTtsSpeedValue.text = ((value + 5) / 10f).toString() } private fun upTtsSpeechRate() { ReadAloud.upTtsSpeechRate(requireContext()) if (!BaseReadAloudService.pause) { ReadAloud.pause(requireContext()) ReadAloud.resume(requireContext()) } } override fun observeLiveBus() { observeEvent(EventBus.ALOUD_STATE) { upPlayState() } observeEvent(EventBus.READ_ALOUD_DS) { binding.seekTimer.progress = it } } interface CallBack { fun showMenuBar() fun openChapterList() fun onClickReadAloud() fun finish() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/config/ReadStyleDialog.kt ================================================ package io.legado.app.ui.book.read.config import android.content.DialogInterface import android.os.Bundle import android.view.Gravity import android.view.View import android.view.ViewGroup import android.view.WindowManager import androidx.core.view.get import com.github.liuyueyi.quick.transfer.constants.TransType import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.constant.EventBus import io.legado.app.databinding.DialogReadBookStyleBinding import io.legado.app.databinding.ItemReadStyleBinding import io.legado.app.help.config.AppConfig import io.legado.app.help.config.ReadBookConfig import io.legado.app.lib.dialogs.selector import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.bottomBackground import io.legado.app.lib.theme.getPrimaryTextColor import io.legado.app.model.ReadBook import io.legado.app.ui.book.read.ReadBookActivity import io.legado.app.ui.font.FontSelectDialog import io.legado.app.utils.ChineseUtils import io.legado.app.utils.ColorUtils import io.legado.app.utils.dpToPx import io.legado.app.utils.getIndexById import io.legado.app.utils.postEvent import io.legado.app.utils.showDialogFragment import io.legado.app.utils.viewbindingdelegate.viewBinding import splitties.views.onLongClick class ReadStyleDialog : BaseDialogFragment(R.layout.dialog_read_book_style), FontSelectDialog.CallBack { private val binding by viewBinding(DialogReadBookStyleBinding::bind) private val callBack get() = activity as? ReadBookActivity private lateinit var styleAdapter: StyleAdapter override fun onStart() { super.onStart() dialog?.window?.run { clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) setBackgroundDrawableResource(R.color.background) decorView.setPadding(0, 0, 0, 0) val attr = attributes attr.dimAmount = 0.0f attr.gravity = Gravity.BOTTOM attributes = attr setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) } } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { (activity as ReadBookActivity).bottomDialog++ initView() initData() initViewEvent() } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) ReadBookConfig.save() (activity as ReadBookActivity).bottomDialog-- } private fun initView() = binding.run { val bg = requireContext().bottomBackground val isLight = ColorUtils.isColorLight(bg) val textColor = requireContext().getPrimaryTextColor(isLight) rootView.setBackgroundColor(bg) tvPageAnim.setTextColor(textColor) tvBgTs.setTextColor(textColor) tvShareLayout.setTextColor(textColor) dsbTextSize.valueFormat = { (it + 5).toString() } dsbTextLetterSpacing.valueFormat = { ((it - 50) / 100f).toString() } dsbLineSize.valueFormat = { ((it - 10) / 10f).toString() } dsbParagraphSpacing.valueFormat = { (it / 10f).toString() } styleAdapter = StyleAdapter() rvStyle.adapter = styleAdapter styleAdapter.addFooterView { ItemReadStyleBinding.inflate(layoutInflater, it, false).apply { ivStyle.setPadding(6.dpToPx(), 6.dpToPx(), 6.dpToPx(), 6.dpToPx()) ivStyle.setText(null) ivStyle.setColorFilter(textColor) ivStyle.borderColor = textColor ivStyle.setImageResource(R.drawable.ic_add) root.setOnClickListener { ReadBookConfig.configList.add(ReadBookConfig.Config()) showBgTextConfig(ReadBookConfig.configList.lastIndex) } } } } private fun initData() { binding.cbShareLayout.isChecked = ReadBookConfig.shareLayout upView() styleAdapter.setItems(ReadBookConfig.configList) } private fun initViewEvent() = binding.run { chineseConverter.onChanged { ChineseUtils.unLoad(*TransType.entries.toTypedArray()) postEvent(EventBus.UP_CONFIG, arrayListOf(5)) } textFontWeightConverter.onChanged { postEvent(EventBus.UP_CONFIG, arrayListOf(8, 9, 6)) } tvTextFont.setOnClickListener { showDialogFragment() } tvTextIndent.setOnClickListener { context?.selector( title = getString(R.string.text_indent), items = resources.getStringArray(R.array.indent).toList() ) { _, index -> ReadBookConfig.paragraphIndent = " ".repeat(index) postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) } } tvPadding.setOnClickListener { dismissAllowingStateLoss() callBack?.showPaddingConfig() } tvTip.setOnClickListener { TipConfigDialog().show(childFragmentManager, "tipConfigDialog") } rgPageAnim.setOnCheckedChangeListener { _, checkedId -> ReadBook.book?.setPageAnim(-1) ReadBookConfig.pageAnim = binding.rgPageAnim.getIndexById(checkedId) callBack?.upPageAnim() ReadBook.loadContent(false) } cbShareLayout.onCheckedChangeListener = { _, isChecked -> ReadBookConfig.shareLayout = isChecked upView() postEvent(EventBus.UP_CONFIG, arrayListOf(1, 2, 5)) } dsbTextSize.onChanged = { ReadBookConfig.textSize = it + 5 postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) } dsbTextLetterSpacing.onChanged = { ReadBookConfig.letterSpacing = (it - 50) / 100f postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) } dsbLineSize.onChanged = { ReadBookConfig.lineSpacingExtra = it postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) } dsbParagraphSpacing.onChanged = { ReadBookConfig.paragraphSpacing = it postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) } } private fun changeBgTextConfig(index: Int) { val oldIndex = ReadBookConfig.styleSelect if (index != oldIndex) { ReadBookConfig.styleSelect = index upView() styleAdapter.notifyItemChanged(oldIndex) styleAdapter.notifyItemChanged(index) postEvent(EventBus.UP_CONFIG, arrayListOf(1, 2, 5)) if (AppConfig.readBarStyleFollowPage) { postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) } } } private fun showBgTextConfig(index: Int): Boolean { dismissAllowingStateLoss() changeBgTextConfig(index) callBack?.showBgTextConfig() return true } private fun upView() = binding.run { textFontWeightConverter.upUi(ReadBookConfig.textBold) ReadBook.pageAnim().let { if (it >= 0 && it < rgPageAnim.childCount) { rgPageAnim.check(rgPageAnim[it].id) } } ReadBookConfig.let { dsbTextSize.progress = it.textSize - 5 dsbTextLetterSpacing.progress = (it.letterSpacing * 100).toInt() + 50 dsbLineSize.progress = it.lineSpacingExtra dsbParagraphSpacing.progress = it.paragraphSpacing } } override val curFontPath: String get() = ReadBookConfig.textFont override fun selectFont(path: String) { if (path != ReadBookConfig.textFont || path.isEmpty()) { ReadBookConfig.textFont = path postEvent(EventBus.UP_CONFIG, arrayListOf(2, 5)) } } inner class StyleAdapter : RecyclerAdapter(requireContext()) { override fun getViewBinding(parent: ViewGroup): ItemReadStyleBinding { return ItemReadStyleBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemReadStyleBinding, item: ReadBookConfig.Config, payloads: MutableList ) { binding.apply { ivStyle.setText(item.name.ifBlank { "文字" }) ivStyle.setTextColor(item.curTextColor()) ivStyle.setImageDrawable(item.curBgDrawable(100, 150)) if (ReadBookConfig.styleSelect == holder.layoutPosition) { ivStyle.borderColor = accentColor ivStyle.setTextBold(true) } else { ivStyle.borderColor = item.curTextColor() ivStyle.setTextBold(false) } } } override fun registerListener(holder: ItemViewHolder, binding: ItemReadStyleBinding) { binding.apply { ivStyle.setOnClickListener { if (ivStyle.isInView) { changeBgTextConfig(holder.layoutPosition) } } ivStyle.onLongClick(ivStyle.isInView) { if (ivStyle.isInView) { showBgTextConfig(holder.layoutPosition) } } } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/config/SpeakEngineDialog.kt ================================================ package io.legado.app.ui.book.read.config import android.content.Context import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.RadioButton import androidx.appcompat.widget.Toolbar import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.HttpTTS import io.legado.app.databinding.DialogEditTextBinding import io.legado.app.databinding.DialogRecyclerViewBinding import io.legado.app.databinding.ItemHttpTtsBinding import io.legado.app.help.DirectLinkUpload import io.legado.app.help.config.AppConfig import io.legado.app.lib.dialogs.SelectItem import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.primaryColor import io.legado.app.model.ReadAloud import io.legado.app.model.ReadBook import io.legado.app.ui.association.ImportHttpTtsDialog import io.legado.app.ui.file.HandleFileContract import io.legado.app.ui.login.SourceLoginActivity import io.legado.app.utils.ACache import io.legado.app.utils.GSON import io.legado.app.utils.applyTint import io.legado.app.utils.fromJsonObject import io.legado.app.utils.gone import io.legado.app.utils.isAbsUrl import io.legado.app.utils.isJsonObject import io.legado.app.utils.sendToClip import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.setLayout import io.legado.app.utils.showDialogFragment import io.legado.app.utils.splitNotBlank import io.legado.app.utils.startActivity import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch /** * tts引擎管理 */ class SpeakEngineDialog() : BaseDialogFragment(R.layout.dialog_recycler_view), Toolbar.OnMenuItemClickListener { private val binding by viewBinding(DialogRecyclerViewBinding::bind) private val viewModel: SpeakEngineViewModel by viewModels() private val ttsUrlKey = "ttsUrlKey" private val adapter by lazy { Adapter(requireContext()) } private var ttsEngine: String? = ReadAloud.ttsEngine private val sysTtsViews = arrayListOf() private val callBack: CallBack? get() = parentFragment as? CallBack private val importDocResult = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> showDialogFragment(ImportHttpTtsDialog(uri.toString())) } } private val exportDirResult = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> alert(R.string.export_success) { if (uri.toString().isAbsUrl()) { setMessage(DirectLinkUpload.getSummary()) } val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = getString(R.string.path) editView.setText(uri.toString()) } customView { alertBinding.root } okButton { requireContext().sendToClip(uri.toString()) } } } } override fun onStart() { super.onStart() setLayout(ViewGroup.LayoutParams.MATCH_PARENT, 0.9f) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { initView() initMenu() initData() } private fun initView() = binding.run { toolBar.setBackgroundColor(primaryColor) toolBar.setTitle(R.string.speak_engine) recyclerView.setEdgeEffectColor(primaryColor) recyclerView.layoutManager = LinearLayoutManager(requireContext()) recyclerView.adapter = adapter adapter.addHeaderView { ItemHttpTtsBinding.inflate(layoutInflater, recyclerView, false).apply { sysTtsViews.add(cbName) ivEdit.gone() ivMenuDelete.gone() labelSys.visible() cbName.text = "系统默认" cbName.tag = "" cbName.isChecked = ttsEngine == null || ttsEngine!!.isJsonObject() && GSON.fromJsonObject>(ttsEngine) .getOrNull()?.value.isNullOrEmpty() cbName.setOnClickListener { upTts(GSON.toJson(SelectItem("系统默认", ""))) } } } viewModel.sysEngines.forEach { engine -> adapter.addHeaderView { ItemHttpTtsBinding.inflate(layoutInflater, recyclerView, false).apply { sysTtsViews.add(cbName) ivEdit.gone() ivMenuDelete.gone() labelSys.visible() cbName.text = engine.label cbName.tag = engine.name cbName.isChecked = GSON.fromJsonObject>(ttsEngine) .getOrNull()?.value == cbName.tag cbName.setOnClickListener { upTts(GSON.toJson(SelectItem(engine.label, engine.name))) } } } } tvFooterLeft.setText(R.string.book) tvFooterLeft.visible() tvFooterLeft.setOnClickListener { ReadBook.book?.setTtsEngine(ttsEngine) callBack?.upSpeakEngineSummary() ReadAloud.upReadAloudClass() dismissAllowingStateLoss() } tvOk.setText(R.string.general) tvOk.visible() tvOk.setOnClickListener { ReadBook.book?.setTtsEngine(null) AppConfig.ttsEngine = ttsEngine callBack?.upSpeakEngineSummary() ReadAloud.upReadAloudClass() dismissAllowingStateLoss() } tvCancel.visible() tvCancel.setOnClickListener { dismissAllowingStateLoss() } } private fun initMenu() = binding.run { toolBar.inflateMenu(R.menu.speak_engine) toolBar.menu.applyTint(requireContext()) toolBar.setOnMenuItemClickListener(this@SpeakEngineDialog) } private fun initData() { lifecycleScope.launch { appDb.httpTTSDao.flowAll().catch { AppLog.put("朗读引擎界面获取数据失败\n${it.localizedMessage}", it) }.flowOn(IO).conflate().collect { adapter.setItems(it) } } } override fun onMenuItemClick(item: MenuItem?): Boolean { when (item?.itemId) { R.id.menu_add -> showDialogFragment() R.id.menu_default -> viewModel.importDefault() R.id.menu_import_local -> importDocResult.launch { mode = HandleFileContract.FILE allowExtensions = arrayOf("txt", "json") } R.id.menu_import_onLine -> importAlert() R.id.menu_export -> exportDirResult.launch { mode = HandleFileContract.EXPORT fileData = HandleFileContract.FileData( "httpTts.json", GSON.toJson(adapter.getItems()).toByteArray(), "application/json" ) } } return true } private fun importAlert() { val aCache = ACache.get(cacheDir = false) val cacheUrls: MutableList = aCache .getAsString(ttsUrlKey) ?.splitNotBlank(",") ?.toMutableList() ?: mutableListOf() alert(R.string.import_on_line) { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = "url" editView.setFilterValues(cacheUrls) editView.delCallBack = { cacheUrls.remove(it) aCache.put(ttsUrlKey, cacheUrls.joinToString(",")) } } customView { alertBinding.root } okButton { alertBinding.editView.text?.toString()?.let { url -> if (url.isAbsUrl() && !cacheUrls.contains(url)) { cacheUrls.add(0, url) aCache.put(ttsUrlKey, cacheUrls.joinToString(",")) } showDialogFragment(ImportHttpTtsDialog(url)) } } } } private fun upTts(tts: String) { ttsEngine = tts sysTtsViews.forEach { it.isChecked = GSON.fromJsonObject>(ttsEngine) .getOrNull()?.value == it.tag } adapter.notifyItemRangeChanged(adapter.getHeaderCount(), adapter.itemCount) } inner class Adapter(context: Context) : RecyclerAdapter(context) { override fun getViewBinding(parent: ViewGroup): ItemHttpTtsBinding { return ItemHttpTtsBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemHttpTtsBinding, item: HttpTTS, payloads: MutableList ) { binding.apply { cbName.text = item.name cbName.isChecked = item.id.toString() == ttsEngine } } override fun registerListener(holder: ItemViewHolder, binding: ItemHttpTtsBinding) { binding.run { cbName.setOnClickListener { getItemByLayoutPosition(holder.layoutPosition)?.let { httpTTS -> val id = httpTTS.id.toString() upTts(id) if (!httpTTS.loginUrl.isNullOrBlank() && httpTTS.getLoginInfo().isNullOrBlank() ) { startActivity { putExtra("type", "httpTts") putExtra("key", id) } } } } ivEdit.setOnClickListener { val id = getItemByLayoutPosition(holder.layoutPosition)!!.id showDialogFragment(HttpTtsEditDialog(id)) } ivMenuDelete.setOnClickListener { getItemByLayoutPosition(holder.layoutPosition)?.let { httpTTS -> appDb.httpTTSDao.delete(httpTTS) } } } } } interface CallBack { fun upSpeakEngineSummary() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/config/SpeakEngineViewModel.kt ================================================ package io.legado.app.ui.book.read.config import android.app.Application import android.speech.tts.TextToSpeech import io.legado.app.base.BaseViewModel import io.legado.app.help.DefaultData class SpeakEngineViewModel(application: Application) : BaseViewModel(application) { val sysEngines: List by lazy { val tts = TextToSpeech(context, null) val engines = tts.engines tts.shutdown() engines } fun importDefault() { execute { DefaultData.importDefaultHttpTTS() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/config/TextFontWeightConverter.kt ================================================ package io.legado.app.ui.book.read.config import android.content.Context import android.text.Spannable import android.text.SpannableString import android.text.style.ForegroundColorSpan import android.util.AttributeSet import io.legado.app.R import io.legado.app.help.config.ReadBookConfig import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.accentColor import io.legado.app.ui.widget.text.StrokeTextView class TextFontWeightConverter(context: Context, attrs: AttributeSet?) : StrokeTextView(context, attrs) { private val spannableString = SpannableString(context.getString(R.string.font_weight_text)) private var enabledSpan: ForegroundColorSpan = ForegroundColorSpan(context.accentColor) private var onChanged: (() -> Unit)? = null init { text = spannableString if (!isInEditMode) { upUi(ReadBookConfig.textBold) } setOnClickListener { selectType() } } @Suppress("MemberVisibilityCanBePrivate") fun upUi(type: Int) { spannableString.removeSpan(enabledSpan) when (type) { 0 -> spannableString.setSpan(enabledSpan, 0, 1, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) 1 -> spannableString.setSpan(enabledSpan, 2, 3, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) 2 -> spannableString.setSpan(enabledSpan, 4, 5, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) } text = spannableString } private fun selectType() { context.alert(titleResource = R.string.text_font_weight_converter) { items(context.resources.getStringArray(R.array.text_font_weight).toList()) { _, i -> ReadBookConfig.textBold = i upUi(i) onChanged?.invoke() } } } fun onChanged(unit: () -> Unit) { onChanged = unit } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/config/TipConfigDialog.kt ================================================ package io.legado.app.ui.book.read.config import android.os.Bundle import android.view.View import android.view.ViewGroup import androidx.core.view.indices import com.jaredrummler.android.colorpicker.ColorPickerDialog import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.constant.EventBus import io.legado.app.databinding.DialogTipConfigBinding import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.config.ReadTipConfig import io.legado.app.lib.dialogs.selector import io.legado.app.utils.checkByIndex import io.legado.app.utils.getIndexById import io.legado.app.utils.hexString import io.legado.app.utils.observeEvent import io.legado.app.utils.postEvent import io.legado.app.utils.setLayout import io.legado.app.utils.viewbindingdelegate.viewBinding class TipConfigDialog : BaseDialogFragment(R.layout.dialog_tip_config) { companion object { const val TIP_COLOR = 7897 const val TIP_DIVIDER_COLOR = 7898 } private val binding by viewBinding(DialogTipConfigBinding::bind) override fun onStart() { super.onStart() setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { initView() initEvent() observeEvent(EventBus.TIP_COLOR) { upTvTipColor() upTvTipDividerColor() } } private fun initView() { if (ReadBookConfig.titleMode !in binding.rgTitleMode.indices) { ReadBookConfig.titleMode = 0 } binding.rgTitleMode.checkByIndex(ReadBookConfig.titleMode) binding.dsbTitleSize.progress = ReadBookConfig.titleSize binding.dsbTitleTop.progress = ReadBookConfig.titleTopSpacing binding.dsbTitleBottom.progress = ReadBookConfig.titleBottomSpacing binding.tvHeaderShow.text = ReadTipConfig.getHeaderModes(requireContext())[ReadTipConfig.headerMode] binding.tvFooterShow.text = ReadTipConfig.getFooterModes(requireContext())[ReadTipConfig.footerMode] ReadTipConfig.run { tipNames.let { tipNames -> binding.tvHeaderLeft.text = tipNames.getOrElse(tipValues.indexOf(tipHeaderLeft)) { tipNames[none] } binding.tvHeaderMiddle.text = tipNames.getOrElse(tipValues.indexOf(tipHeaderMiddle)) { tipNames[none] } binding.tvHeaderRight.text = tipNames.getOrElse(tipValues.indexOf(tipHeaderRight)) { tipNames[none] } binding.tvFooterLeft.text = tipNames.getOrElse(tipValues.indexOf(tipFooterLeft)) { tipNames[none] } binding.tvFooterMiddle.text = tipNames.getOrElse(tipValues.indexOf(tipFooterMiddle)) { tipNames[none] } binding.tvFooterRight.text = tipNames.getOrElse(tipValues.indexOf(tipFooterRight)) { tipNames[none] } } } upTvTipColor() upTvTipDividerColor() } private fun upTvTipColor() { val tipColorNames = ReadTipConfig.tipColorNames val tipColor = ReadTipConfig.tipColor binding.tvTipColor.text = if (tipColor == 0) { tipColorNames.first() } else { "#${tipColor.hexString}" } } private fun upTvTipDividerColor() { val tipDividerColorNames = ReadTipConfig.tipDividerColorNames val tipDividerColor = ReadTipConfig.tipDividerColor binding.tvTipDividerColor.text = when (tipDividerColor) { -1, 0 -> tipDividerColorNames[tipDividerColor + 1] else -> "#${tipDividerColor.hexString}" } } private fun initEvent() = binding.run { rgTitleMode.setOnCheckedChangeListener { _, checkedId -> ReadBookConfig.titleMode = rgTitleMode.getIndexById(checkedId) postEvent(EventBus.UP_CONFIG, arrayListOf(5)) } dsbTitleSize.onChanged = { ReadBookConfig.titleSize = it postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) } dsbTitleTop.onChanged = { ReadBookConfig.titleTopSpacing = it postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) } dsbTitleBottom.onChanged = { ReadBookConfig.titleBottomSpacing = it postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) } llHeaderShow.setOnClickListener { val headerModes = ReadTipConfig.getHeaderModes(requireContext()) context?.selector(items = headerModes.values.toList()) { _, i -> ReadTipConfig.headerMode = headerModes.keys.toList()[i] tvHeaderShow.text = headerModes[ReadTipConfig.headerMode] postEvent(EventBus.UP_CONFIG, arrayListOf(2)) } } llFooterShow.setOnClickListener { val footerModes = ReadTipConfig.getFooterModes(requireContext()) context?.selector(items = footerModes.values.toList()) { _, i -> ReadTipConfig.footerMode = footerModes.keys.toList()[i] tvFooterShow.text = footerModes[ReadTipConfig.footerMode] postEvent(EventBus.UP_CONFIG, arrayListOf(2)) } } llHeaderLeft.setOnClickListener { context?.selector(items = ReadTipConfig.tipNames) { _, i -> val tipValue = ReadTipConfig.tipValues[i] clearRepeat(tipValue) ReadTipConfig.tipHeaderLeft = tipValue tvHeaderLeft.text = ReadTipConfig.tipNames[i] postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6)) } } llHeaderMiddle.setOnClickListener { context?.selector(items = ReadTipConfig.tipNames) { _, i -> val tipValue = ReadTipConfig.tipValues[i] clearRepeat(tipValue) ReadTipConfig.tipHeaderMiddle = tipValue tvHeaderMiddle.text = ReadTipConfig.tipNames[i] postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6)) } } llHeaderRight.setOnClickListener { context?.selector(items = ReadTipConfig.tipNames) { _, i -> val tipValue = ReadTipConfig.tipValues[i] clearRepeat(tipValue) ReadTipConfig.tipHeaderRight = tipValue tvHeaderRight.text = ReadTipConfig.tipNames[i] postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6)) } } llFooterLeft.setOnClickListener { context?.selector(items = ReadTipConfig.tipNames) { _, i -> val tipValue = ReadTipConfig.tipValues[i] clearRepeat(tipValue) ReadTipConfig.tipFooterLeft = tipValue tvFooterLeft.text = ReadTipConfig.tipNames[i] postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6)) } } llFooterMiddle.setOnClickListener { context?.selector(items = ReadTipConfig.tipNames) { _, i -> val tipValue = ReadTipConfig.tipValues[i] clearRepeat(tipValue) ReadTipConfig.tipFooterMiddle = tipValue tvFooterMiddle.text = ReadTipConfig.tipNames[i] postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6)) } } llFooterRight.setOnClickListener { context?.selector(items = ReadTipConfig.tipNames) { _, i -> val tipValue = ReadTipConfig.tipValues[i] clearRepeat(tipValue) ReadTipConfig.tipFooterRight = tipValue tvFooterRight.text = ReadTipConfig.tipNames[i] postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6)) } } llTipColor.setOnClickListener { context?.selector(items = ReadTipConfig.tipColorNames) { _, i -> when (i) { 0 -> { ReadTipConfig.tipColor = 0 upTvTipColor() postEvent(EventBus.UP_CONFIG, arrayListOf(2)) } 1 -> ColorPickerDialog.newBuilder() .setShowAlphaSlider(false) .setDialogType(ColorPickerDialog.TYPE_CUSTOM) .setDialogId(TIP_COLOR) .show(requireActivity()) } } } llTipDividerColor.setOnClickListener { context?.selector(items = ReadTipConfig.tipDividerColorNames) { _, i -> when (i) { 0, 1 -> { ReadTipConfig.tipDividerColor = i - 1 upTvTipDividerColor() postEvent(EventBus.UP_CONFIG, arrayListOf(2)) } 2 -> ColorPickerDialog.newBuilder() .setShowAlphaSlider(false) .setDialogType(ColorPickerDialog.TYPE_CUSTOM) .setDialogId(TIP_DIVIDER_COLOR) .show(requireActivity()) } } } } private fun clearRepeat(repeat: Int) = ReadTipConfig.apply { if (repeat != none) { if (tipHeaderLeft == repeat) { tipHeaderLeft = none binding.tvHeaderLeft.text = tipNames[none] } if (tipHeaderMiddle == repeat) { tipHeaderMiddle = none binding.tvHeaderMiddle.text = tipNames[none] } if (tipHeaderRight == repeat) { tipHeaderRight = none binding.tvHeaderRight.text = tipNames[none] } if (tipFooterLeft == repeat) { tipFooterLeft = none binding.tvFooterLeft.text = tipNames[none] } if (tipFooterMiddle == repeat) { tipFooterMiddle = none binding.tvFooterMiddle.text = tipNames[none] } if (tipFooterRight == repeat) { tipFooterRight = none binding.tvFooterRight.text = tipNames[none] } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/AutoPager.kt ================================================ package io.legado.app.ui.book.read.page import android.graphics.Canvas import android.graphics.Paint import android.os.SystemClock import androidx.core.graphics.withClip import io.legado.app.help.config.AppConfig import io.legado.app.help.config.ReadBookConfig import io.legado.app.lib.theme.ThemeStore import io.legado.app.ui.book.read.page.entities.PageDirection import io.legado.app.utils.canvasrecorder.CanvasRecorderFactory import io.legado.app.utils.canvasrecorder.recordIfNeeded /** * 自动翻页 */ class AutoPager(private val readView: ReadView) : Runnable { private var progress = 0 var isRunning = false private set private var isPausing = false private var isEInkMode = false private var scrollOffsetRemain = 0.0 private var scrollOffset = 0 private var lastTimeMillis = 0L private var canvasRecorder = CanvasRecorderFactory.create() private val paint by lazy { Paint() } fun start() { isRunning = true isEInkMode = AppConfig.isEInkMode readView.curPage.upSelectAble(false) if (isEInkMode) { readView.postDelayed(this, ReadBookConfig.autoReadSpeed * 1000L) } else { paint.color = ThemeStore.accentColor lastTimeMillis = SystemClock.uptimeMillis() readView.invalidate() } } fun stop() { if (!isRunning) { return } isRunning = false isPausing = false isEInkMode = false readView.removeCallbacks(this) readView.curPage.upSelectAble(AppConfig.textSelectAble) readView.invalidate() reset() canvasRecorder.recycle() } fun pause() { if (!isRunning) { return } isPausing = true readView.removeCallbacks(this) } fun resume() { if (!isRunning) { return } isPausing = false if (isEInkMode) { readView.postDelayed(this, ReadBookConfig.autoReadSpeed * 1000L) } else { lastTimeMillis = SystemClock.uptimeMillis() readView.invalidate() } } fun reset() { if (isEInkMode) { readView.removeCallbacks(this) readView.postDelayed(this, ReadBookConfig.autoReadSpeed * 1000L) } else { progress = 0 scrollOffsetRemain = 0.0 scrollOffset = 0 canvasRecorder.invalidate() } } fun upRecorder() { canvasRecorder.recycle() canvasRecorder = CanvasRecorderFactory.create() } fun onDraw(canvas: Canvas) { if (!isRunning || isEInkMode) { return } if (readView.isScroll) { if (!isPausing) { readView.curPage.scroll(-scrollOffset) scrollOffset = 0 } } else { val bottom = progress val width = readView.width canvasRecorder.recordIfNeeded(readView.nextPage) canvas.withClip(0, 0, width, bottom) { canvasRecorder.draw(this) } canvas.drawRect( 0f, bottom.toFloat() - 1, width.toFloat(), bottom.toFloat(), paint ) if (!isPausing) readView.postInvalidate() } } fun computeOffset() { if (!isRunning || isPausing || isEInkMode) { return } val currentTime = SystemClock.uptimeMillis() val elapsedTime = currentTime - lastTimeMillis lastTimeMillis = currentTime val readTime = ReadBookConfig.autoReadSpeed * 1000.0 val height = readView.height scrollOffsetRemain += height / readTime * elapsedTime if (scrollOffsetRemain < 1) { return } scrollOffset = scrollOffsetRemain.toInt() this.scrollOffsetRemain -= scrollOffset if (!readView.isScroll) { progress += scrollOffset if (progress >= height) { if (!readView.fillPage(PageDirection.NEXT)) { stop() } else { reset() } } } } override fun run() { if (!isRunning || isPausing) { return } if (!readView.fillPage(PageDirection.NEXT)) { stop() } else { readView.postDelayed(this, ReadBookConfig.autoReadSpeed * 1000L) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt ================================================ package io.legado.app.ui.book.read.page import android.content.Context import android.graphics.Canvas import android.graphics.Paint import android.util.AttributeSet import android.view.MotionEvent import android.view.View import io.legado.app.R import io.legado.app.data.entities.Bookmark import io.legado.app.help.config.AppConfig import io.legado.app.model.ReadBook 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 import io.legado.app.ui.book.read.page.entities.TextPos import io.legado.app.ui.book.read.page.entities.column.BaseColumn 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.provider.ChapterProvider import io.legado.app.ui.book.read.page.provider.TextPageFactory import io.legado.app.ui.widget.dialog.PhotoDialog 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.toastOnUi import java.util.concurrent.Executors import kotlin.math.max import kotlin.math.min /** * 阅读内容视图 */ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, attrs) { var selectAble = AppConfig.textSelectAble val selectedPaint by lazy { Paint().apply { color = context.getCompatColor(R.color.btn_bg_press_2) style = Paint.Style.FILL } } private var callBack: CallBack private val visibleRect = ChapterProvider.visibleRect val selectStart = TextPos(0, -1, -1) private val selectEnd = TextPos(0, -1, -1) var textPage: TextPage = TextPage() private set var isMainView = false var longScreenshot = false var reverseStartCursor = false var reverseEndCursor = false //滚动参数 private val pageFactory get() = callBack.pageFactory private val pageDelegate get() = callBack.pageDelegate private var pageOffset = 0 private var autoPager: AutoPager? = null private var isScroll = false private val renderRunnable by lazy { Runnable { preRenderPage() } } //绘制图片的paint val imagePaint by lazy { Paint().apply { isAntiAlias = AppConfig.useAntiAlias } } init { callBack = activity as CallBack } /** * 设置内容 */ fun setContent(textPage: TextPage) { this.textPage = textPage // 非滑动翻页动画需要同步重绘,不然翻页可能会出现闪烁 if (isScroll) { postInvalidate() } else { invalidate() } } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) if (!isMainView) return ChapterProvider.upViewSize(w, h) textPage.format() } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) autoPager?.onDraw(canvas) if (longScreenshot) { canvas.translate(0f, scrollY.toFloat()) } check(!visibleRect.isEmpty) { "visibleRect 为空" } canvas.clipRect(visibleRect) drawPage(canvas) } /** * 绘制页面 */ private fun drawPage(canvas: Canvas) { var relativeOffset = relativeOffset(0) textPage.draw(this, canvas, relativeOffset) if (!callBack.isScroll) return //滚动翻页 if (!pageFactory.hasNext()) return val textPage1 = relativePage(1) relativeOffset += textPage.height textPage1.draw(this, canvas, relativeOffset) if (!pageFactory.hasNextPlus()) return relativeOffset += textPage1.height if (relativeOffset < ChapterProvider.visibleHeight) { val textPage2 = relativePage(2) textPage2.draw(this, canvas, relativeOffset) } } override fun computeScroll() { pageDelegate?.computeScroll() autoPager?.computeOffset() } /** * 滚动事件 * pageOffset 向上滚动 减小 向下滚动 增大 * pageOffset 范围 0 ~ -textPage.height 大于0为上一页,小于-textPage.height为下一页 * 以内容显示区域顶端为界,pageOffset的绝对值为textPage上方的高度 * pageOffset + textPage.height 为 textPage 下方的高度 */ fun scroll(mOffset: Int) { pageOffset += mOffset if (longScreenshot) { scrollY += -mOffset } if (!pageFactory.hasPrev() && pageOffset > 0) { pageOffset = 0 pageDelegate?.abortAnim() } else if (!pageFactory.hasNext() && pageOffset < 0 && pageOffset + textPage.height < ChapterProvider.visibleHeight ) { val offset = (ChapterProvider.visibleHeight - textPage.height).toInt() pageOffset = min(0, offset) pageDelegate?.abortAnim() } else if (pageOffset > 0) { if (pageFactory.moveToPrev(true)) { pageOffset -= textPage.height.toInt() } else { pageOffset = 0 pageDelegate?.abortAnim() } } else if (pageOffset < -textPage.height) { val height = textPage.height if (pageFactory.moveToNext(upContent = true)) { pageOffset += height.toInt() } else { pageOffset = -height.toInt() pageDelegate?.abortAnim() } } postInvalidate() } fun submitRenderTask() { renderThread.submit(renderRunnable) } private fun preRenderPage() { val view = this var invalidate = false pageFactory.run { if (hasPrev() && prevPage.render(view)) { invalidate = true } if (curPage.render(view)) { invalidate = true } if (hasNext() && nextPage.render(view) && callBack.isScroll) { invalidate = true } if (hasNextPlus() && nextPlusPage.render(view) && callBack.isScroll && relativeOffset(2) < ChapterProvider.visibleHeight ) { invalidate = true } if (invalidate) { postInvalidate() pageDelegate?.postInvalidate() } } } /** * 重置滚动位置 */ fun resetPageOffset() { pageOffset = 0 } /** * 长按 */ fun longPress( x: Float, y: Float, select: (textPos: TextPos) -> Unit, ) { touch(x, y) { _, textPos, _, _, column -> when (column) { is ImageColumn -> callBack.onImageLongPress(x, y, column.src) is TextColumn -> { if (!selectAble) return@touch column.selected = true select(textPos) } } } } /** * 单击 * @return true:已处理, false:未处理 */ @Suppress("UNUSED_ANONYMOUS_PARAMETER") fun click(x: Float, y: Float): Boolean { var handled = false touch(x, y) { _, textPos, textPage, textLine, column -> when (column) { is ButtonColumn -> { context.toastOnUi("Button Pressed!") handled = true } is ReviewColumn -> { context.toastOnUi("Button Pressed!") handled = true } is ImageColumn -> if (AppConfig.previewImageByClick) { activity?.showDialogFragment(PhotoDialog(column.src)) handled = true } } } return handled } /** * 选择文字 */ fun selectText( x: Float, y: Float, select: (textPos: TextPos) -> Unit, ) { touchRough(x, y) { _, textPos, _, _, column -> if (column is TextColumn) { column.selected = true select(textPos) } } } /** * 开始选择符移动 */ fun selectStartMove(x: Float, y: Float) { touchRough(x, y) { _, textPos, _, _, _ -> if (selectStart.compare(textPos) == 0) { return@touchRough } if (textPos.compare(selectEnd) <= 0) { selectStartMoveIndex(textPos) } else { touchRough(x - 2 * cursorWidth, y) { _, textPos, _, _, _ -> if (textPos.compare(selectEnd) > 0) { reverseStartCursor = true reverseEndCursor = false selectEnd.columnIndex++ selectStartMoveIndex(selectEnd) selectEndMoveIndex(textPos) } } } } } /** * 结束选择符移动 */ fun selectEndMove(x: Float, y: Float) { touchRough(x, y) { _, textPos, _, _, _ -> if (textPos.compare(selectEnd) == 0) { return@touchRough } if (textPos.compare(selectStart) >= 0) { selectEndMoveIndex(textPos) } else { touchRough(x + 2 * cursorWidth, y) { _, textPos, _, _, _ -> if (textPos.compare(selectStart) < 0) { reverseEndCursor = true reverseStartCursor = false selectStart.columnIndex-- selectEndMoveIndex(selectStart) selectStartMoveIndex(textPos) } } } } } /** * 触碰位置信息 * @param touched 回调 */ private fun touch( x: Float, y: Float, touched: ( relativeOffset: Float, textPos: TextPos, textPage: TextPage, textLine: TextLine, column: BaseColumn ) -> Unit ) { if (!visibleRect.contains(x, y)) return var relativeOffset: Float for (relativePos in 0..2) { relativeOffset = relativeOffset(relativePos) if (relativePos > 0) { //滚动翻页 if (!callBack.isScroll) return if (relativeOffset >= ChapterProvider.visibleHeight) return } val textPage = relativePage(relativePos) for ((lineIndex, textLine) in textPage.lines.withIndex()) { if (textLine.isTouch(x, y, relativeOffset)) { for ((charIndex, textColumn) in textLine.columns.withIndex()) { if (textColumn.isTouch(x)) { touched.invoke( relativeOffset, TextPos(relativePos, lineIndex, charIndex), textPage, textLine, textColumn ) return } } return } } } } /** * 触碰位置信息 * 文本选择专用 * @param touched 回调 */ private fun touchRough( x: Float, y: Float, touched: ( relativeOffset: Float, textPos: TextPos, textPage: TextPage, textLine: TextLine, column: BaseColumn ) -> Unit ) { var relativeOffset: Float for (relativePos in 0..2) { relativeOffset = relativeOffset(relativePos) if (relativePos > 0) { //滚动翻页 if (!callBack.isScroll) return if (relativeOffset >= ChapterProvider.visibleHeight) return } val textPage = relativePage(relativePos) for (lineIndex in textPage.lines.indices) { val textLine = textPage.getLine(lineIndex) if (textLine.isTouchY(y, relativeOffset)) { if (textPage.doublePage) { val halfWidth = width / 2 if (textLine.isLeftLine && x > halfWidth) { continue } if (!textLine.isLeftLine && x < halfWidth) { continue } } val columns = textLine.columns for (charIndex in columns.indices) { val textColumn = columns[charIndex] if (textColumn.isTouch(x)) { touched.invoke( relativeOffset, TextPos(relativePos, lineIndex, charIndex), textPage, textLine, textColumn ) return } } val isLast = columns.first().start < x val charIndex = if (isLast) columns.lastIndex + 1 else -1 val textColumn = if (isLast) columns.last() else columns.first() touched.invoke( relativeOffset, TextPos(relativePos, lineIndex, charIndex), textPage, textLine, textColumn ) return } } } } fun getCurVisiblePage(): TextPage { val visiblePage = TextPage() var relativeOffset: Float for (relativePos in 0..2) { relativeOffset = relativeOffset(relativePos) if (relativePos > 0) { //滚动翻页 if (!callBack.isScroll) break if (relativeOffset >= ChapterProvider.visibleHeight) break } val textPage = relativePage(relativePos) val lines = textPage.lines for (i in lines.indices) { val textLine = lines[i] if (textLine.isVisible(relativeOffset)) { val visibleLine = textLine.copy().apply { lineTop += relativeOffset lineBottom += relativeOffset } visiblePage.addLine(visibleLine) } } } return visiblePage } fun getReadAloudPos(): Pair? { var relativeOffset: Float for (relativePos in 0..2) { relativeOffset = relativeOffset(relativePos) if (relativePos > 0) { //滚动翻页 if (!callBack.isScroll) break if (relativeOffset >= ChapterProvider.visibleHeight) break } val textPage = relativePage(relativePos) val lines = textPage.lines for (i in lines.indices) { val textLine = lines[i] if (textLine.isVisible(relativeOffset)) { val visibleLine = textLine.copy().apply { lineTop += relativeOffset lineBottom += relativeOffset } return textPage.chapterIndex to visibleLine } } } return null } /** * 选择开始文字 */ fun selectStartMoveIndex( relativePagePos: Int, lineIndex: Int, charIndex: Int, ) { selectStart.relativePagePos = relativePagePos selectStart.lineIndex = lineIndex selectStart.columnIndex = max(0, charIndex) val textLine = relativePage(relativePagePos).getLine(lineIndex) val textColumn = textLine.getColumn(charIndex) upSelectedStart( if (charIndex < textLine.columns.size) textColumn.start else textColumn.end, textLine.lineBottom + relativeOffset(relativePagePos), textLine.lineTop + relativeOffset(relativePagePos) ) upSelectChars() } fun selectStartMoveIndex(textPos: TextPos) = textPos.run { selectStartMoveIndex(relativePagePos, lineIndex, columnIndex) } /** * 选择结束文字 */ fun selectEndMoveIndex( relativePage: Int, lineIndex: Int, charIndex: Int, ) { selectEnd.relativePagePos = relativePage selectEnd.lineIndex = lineIndex val textLine = relativePage(relativePage).getLine(lineIndex) selectEnd.columnIndex = min(charIndex, textLine.columns.lastIndex) val textColumn = textLine.getColumn(charIndex) upSelectedEnd( if (charIndex > -1) textColumn.end else textColumn.start, textLine.lineBottom + relativeOffset(relativePage) ) upSelectChars() } fun selectEndMoveIndex(textPos: TextPos) = textPos.run { selectEndMoveIndex(relativePagePos, lineIndex, columnIndex) } private fun upSelectChars() { if (!selectStart.isSelected() && !selectEnd.isSelected()) { return } val last = if (callBack.isScroll) 2 else 0 val textPos = TextPos(0, 0, 0) for (relativePos in 0..last) { textPos.relativePagePos = relativePos val textPage = relativePage(relativePos) for ((lineIndex, textLine) in textPage.lines.withIndex()) { textPos.lineIndex = lineIndex for ((charIndex, column) in textLine.columns.withIndex()) { textPos.columnIndex = charIndex if (column is TextColumn) { val compareStart = textPos.compare(selectStart) val compareEnd = textPos.compare(selectEnd) column.selected = compareStart >= 0 && compareEnd <= 0 column.isSearchResult = column.selected && callBack.isSelectingSearchResult if (column.isSearchResult) { textPage.searchResult.add(column) } } } } } postInvalidate() } private fun upSelectedStart(x: Float, y: Float, top: Float) { callBack.run { upSelectedStart(x, y + headerHeight, top + headerHeight) } } private fun upSelectedEnd(x: Float, y: Float) { callBack.run { upSelectedEnd(x, y + headerHeight) } } fun resetReverseCursor() { reverseStartCursor = false reverseEndCursor = false } fun cancelSelect(clearSearchResult: Boolean = false) { val last = if (callBack.isScroll) 2 else 0 for (relativePos in 0..last) { val textPage = relativePage(relativePos) textPage.lines.forEach { textLine -> textLine.columns.forEach { if (it is TextColumn) { it.selected = false if (clearSearchResult) { it.isSearchResult = false textPage.searchResult.remove(it) } } } } } selectStart.reset() selectEnd.reset() postInvalidate() callBack.onCancelSelect() } fun getSelectedText(): String { val textPos = TextPos(0, 0, 0) val builder = StringBuilder() for (relativePos in selectStart.relativePagePos..selectEnd.relativePagePos) { val textPage = relativePage(relativePos) textPos.relativePagePos = relativePos textPage.lines.forEachIndexed { lineIndex, textLine -> textPos.lineIndex = lineIndex textLine.columns.forEachIndexed { charIndex, column -> textPos.columnIndex = charIndex val compareStart = textPos.compare(selectStart) val compareEnd = textPos.compare(selectEnd) if (column is TextColumn) { when { compareStart == -1 -> if ( selectStart.columnIndex == textLine.columns.size && charIndex == textLine.columns.lastIndex ) { builder.append("\n") } compareEnd == 1 -> if (selectEnd.columnIndex == -1 && charIndex == 0) { builder.append("\n") } compareStart >= 0 && compareEnd <= 0 -> { builder.append(column.charData) if ( textLine.isParagraphEnd && charIndex == textLine.columns.lastIndex && compareEnd != 0 ) { builder.append("\n") } } } } } } } return builder.toString() } fun createBookmark(): Bookmark? { val page = relativePage(selectStart.relativePagePos) page.getTextChapter().let { chapter -> ReadBook.book?.let { book -> return book.createBookMark().apply { chapterIndex = page.chapterIndex chapterPos = chapter.getReadLength(page.index) + page.getPosByLineColumn(selectStart.lineIndex, selectStart.columnIndex) chapterName = chapter.title bookText = getSelectedText() } } } return null } private fun relativeOffset(relativePos: Int): Float { return when (relativePos) { 0 -> pageOffset.toFloat() 1 -> pageOffset + textPage.height else -> pageOffset + textPage.height + pageFactory.nextPage.height } } fun relativePage(relativePos: Int): TextPage { return when (relativePos) { 0 -> textPage 1 -> pageFactory.nextPage else -> pageFactory.nextPlusPage } } fun setAutoPager(autoPager: AutoPager?) { this.autoPager = autoPager } fun setIsScroll(value: Boolean) { isScroll = value } override fun canScrollVertically(direction: Int): Boolean { return callBack.isScroll && pageFactory.hasNext() } override fun dispatchTouchEvent(event: MotionEvent): Boolean { when (event.action) { MotionEvent.ACTION_DOWN -> { longScreenshot = true scrollY = 0 } MotionEvent.ACTION_UP -> { longScreenshot = false scrollY = 0 } } return callBack.onLongScreenshotTouchEvent(event) } companion object { private val renderThread by lazy { Executors.newSingleThreadExecutor { Thread(it, "TextPageRender") } } private val cursorWidth = 24.dpToPx() } interface CallBack { val headerHeight: Int val pageFactory: TextPageFactory val pageDelegate: PageDelegate? val isScroll: Boolean var isSelectingSearchResult: Boolean fun upSelectedStart(x: Float, y: Float, top: Float) fun upSelectedEnd(x: Float, y: Float) fun onImageLongPress(x: Float, y: Float, src: String) fun onCancelSelect() fun onLongScreenshotTouchEvent(event: MotionEvent): Boolean } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt ================================================ package io.legado.app.ui.book.read.page import android.annotation.SuppressLint import android.content.Context import android.graphics.drawable.LayerDrawable import android.view.LayoutInflater import android.widget.FrameLayout import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toDrawable import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.isGone import androidx.core.view.isInvisible import io.legado.app.R import io.legado.app.constant.AppConst.timeFormat import io.legado.app.data.entities.Bookmark import io.legado.app.databinding.ViewBookPageBinding import io.legado.app.help.config.AppConfig import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.config.ReadTipConfig import io.legado.app.model.ReadBook import io.legado.app.ui.book.read.ReadBookActivity import io.legado.app.ui.book.read.page.entities.TextLine import io.legado.app.ui.book.read.page.entities.TextPage import io.legado.app.ui.book.read.page.entities.TextPos import io.legado.app.ui.book.read.page.provider.ChapterProvider import io.legado.app.ui.widget.BatteryView import io.legado.app.utils.activity import io.legado.app.utils.applyNavigationBarPadding import io.legado.app.utils.applyStatusBarPadding import io.legado.app.utils.dpToPx import io.legado.app.utils.gone import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat import io.legado.app.utils.setTextIfNotEqual import splitties.views.backgroundColor import java.util.Date /** * 页面视图 */ class PageView(context: Context) : FrameLayout(context) { private val binding = ViewBookPageBinding.inflate(LayoutInflater.from(context), this, true) private val readBookActivity get() = activity as? ReadBookActivity private var battery = 100 private var tvTitle: BatteryView? = null private var tvTime: BatteryView? = null private var tvBattery: BatteryView? = null private var tvBatteryP: BatteryView? = null private var tvPage: BatteryView? = null private var tvTotalProgress: BatteryView? = null private var tvTotalProgress1: BatteryView? = null private var tvPageAndTotal: BatteryView? = null private var tvBookName: BatteryView? = null private var tvTimeBattery: BatteryView? = null private var tvTimeBatteryP: BatteryView? = null private var isMainView = false var isScroll = false val headerHeight: Int get() { val h1 = if (binding.vwStatusBar.isGone) 0 else binding.vwStatusBar.height val h2 = if (binding.llHeader.isGone) 0 else binding.llHeader.height return h1 + h2 + binding.vwRoot.paddingTop } init { if (!isInEditMode) { upStyle() binding.vwStatusBar.applyStatusBarPadding() binding.vwNavigationBar.applyNavigationBarPadding() } } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) upBg() } fun upStyle() = binding.run { upTipStyle() ReadBookConfig.let { val textColor = it.textColor val tipColor = with(ReadTipConfig) { if (tipColor == 0) textColor else tipColor } val tipDividerColor = with(ReadTipConfig) { when (tipDividerColor) { -1 -> ContextCompat.getColor(context, R.color.divider) 0 -> textColor else -> tipDividerColor } } tvHeaderLeft.setColor(tipColor) tvHeaderMiddle.setColor(tipColor) tvHeaderRight.setColor(tipColor) tvFooterLeft.setColor(tipColor) tvFooterMiddle.setColor(tipColor) tvFooterRight.setColor(tipColor) vwTopDivider.backgroundColor = tipDividerColor vwBottomDivider.backgroundColor = tipDividerColor upStatusBar() upNavigationBar() upPaddingDisplayCutouts() llHeader.setPadding( it.headerPaddingLeft.dpToPx(), it.headerPaddingTop.dpToPx(), it.headerPaddingRight.dpToPx(), it.headerPaddingBottom.dpToPx() ) llFooter.setPadding( it.footerPaddingLeft.dpToPx(), it.footerPaddingTop.dpToPx(), it.footerPaddingRight.dpToPx(), it.footerPaddingBottom.dpToPx() ) vwTopDivider.gone(llHeader.isGone || !it.showHeaderLine) vwBottomDivider.gone(llFooter.isGone || !it.showFooterLine) } upTime() upBattery(battery) } /** * 显示状态栏时隐藏header */ fun upStatusBar() = with(binding.vwStatusBar) { // setPadding(paddingLeft, context.statusBarHeight, paddingRight, paddingBottom) isGone = ReadBookConfig.hideStatusBar || readBookActivity?.isInMultiWindow == true } fun upNavigationBar() { binding.vwNavigationBar.isGone = ReadBookConfig.hideNavigationBar } fun upPaddingDisplayCutouts() { if (AppConfig.paddingDisplayCutouts) { binding.vwRoot.setOnApplyWindowInsetsListenerCompat { _, windowInsets -> val insets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) binding.vwRoot.setPadding( insets.left, if (binding.vwStatusBar.isGone) insets.top else 0, insets.right, insets.bottom ) windowInsets } } else { ViewCompat.setOnApplyWindowInsetsListener(binding.vwRoot, null) binding.vwRoot.setPadding(0, 0, 0, 0) } } /** * 更新阅读信息 */ private fun upTipStyle() = binding.run { tvHeaderLeft.tag = null tvHeaderMiddle.tag = null tvHeaderRight.tag = null tvFooterLeft.tag = null tvFooterMiddle.tag = null tvFooterRight.tag = null llHeader.isGone = when (ReadTipConfig.headerMode) { 1 -> false 2 -> true else -> !ReadBookConfig.hideStatusBar } llFooter.isGone = when (ReadTipConfig.footerMode) { 1 -> true else -> false } ReadTipConfig.apply { tvHeaderLeft.isGone = tipHeaderLeft == none tvHeaderRight.isGone = tipHeaderRight == none tvHeaderMiddle.isGone = tipHeaderMiddle == none tvFooterLeft.isInvisible = tipFooterLeft == none tvFooterRight.isGone = tipFooterRight == none tvFooterMiddle.isGone = tipFooterMiddle == none } tvTitle = getTipView(ReadTipConfig.chapterTitle)?.apply { tag = ReadTipConfig.chapterTitle isBattery = false typeface = ChapterProvider.typeface textSize = 12f } tvTime = getTipView(ReadTipConfig.time)?.apply { tag = ReadTipConfig.time isBattery = false typeface = ChapterProvider.typeface textSize = 12f } tvBattery = getTipView(ReadTipConfig.battery)?.apply { tag = ReadTipConfig.battery isBattery = true textSize = 11f } tvPage = getTipView(ReadTipConfig.page)?.apply { tag = ReadTipConfig.page isBattery = false typeface = ChapterProvider.typeface textSize = 12f } tvTotalProgress = getTipView(ReadTipConfig.totalProgress)?.apply { tag = ReadTipConfig.totalProgress isBattery = false typeface = ChapterProvider.typeface textSize = 12f } tvTotalProgress1 = getTipView(ReadTipConfig.totalProgress1)?.apply { tag = ReadTipConfig.totalProgress1 isBattery = false typeface = ChapterProvider.typeface textSize = 12f } tvPageAndTotal = getTipView(ReadTipConfig.pageAndTotal)?.apply { tag = ReadTipConfig.pageAndTotal isBattery = false typeface = ChapterProvider.typeface textSize = 12f } tvBookName = getTipView(ReadTipConfig.bookName)?.apply { tag = ReadTipConfig.bookName isBattery = false typeface = ChapterProvider.typeface textSize = 12f } tvTimeBattery = getTipView(ReadTipConfig.timeBattery)?.apply { tag = ReadTipConfig.timeBattery isBattery = true typeface = ChapterProvider.typeface textSize = 11f } tvBatteryP = getTipView(ReadTipConfig.batteryPercentage)?.apply { tag = ReadTipConfig.batteryPercentage isBattery = false typeface = ChapterProvider.typeface textSize = 12f } tvTimeBatteryP = getTipView(ReadTipConfig.timeBatteryPercentage)?.apply { tag = ReadTipConfig.timeBatteryPercentage isBattery = false typeface = ChapterProvider.typeface textSize = 12f } } /** * 获取信息视图 * @param tip 信息类型 */ private fun getTipView(tip: Int): BatteryView? = binding.run { return when (tip) { ReadTipConfig.tipHeaderLeft -> tvHeaderLeft ReadTipConfig.tipHeaderMiddle -> tvHeaderMiddle ReadTipConfig.tipHeaderRight -> tvHeaderRight ReadTipConfig.tipFooterLeft -> tvFooterLeft ReadTipConfig.tipFooterMiddle -> tvFooterMiddle ReadTipConfig.tipFooterRight -> tvFooterRight else -> null } } /** * 更新背景 */ fun upBg() { binding.vwRoot.background = LayerDrawable( arrayOf( ReadBookConfig.bgMeanColor.toDrawable(), ReadBookConfig.bg ) ) upBgAlpha() } /** * 更新背景透明度 */ fun upBgAlpha() { ReadBookConfig.bg?.alpha = (ReadBookConfig.bgAlpha / 100f * 255).toInt() binding.vwRoot.invalidate() } /** * 更新时间信息 */ fun upTime() { tvTime?.text = timeFormat.format(Date(System.currentTimeMillis())) upTimeBattery() } /** * 更新电池信息 */ @SuppressLint("SetTextI18n") fun upBattery(battery: Int) { this.battery = battery tvBattery?.setBattery(battery) tvBatteryP?.text = "$battery%" upTimeBattery() } /** * 更新电池信息 */ @SuppressLint("SetTextI18n") private fun upTimeBattery() { val time = timeFormat.format(Date(System.currentTimeMillis())) tvTimeBattery?.setBattery(battery, time) tvTimeBatteryP?.text = "$time $battery%" } /** * 设置内容 */ fun setContent(textPage: TextPage, resetPageOffset: Boolean = true) { if (isMainView && !isScroll) { setProgress(textPage) } else { post { setProgress(textPage) } } if (resetPageOffset) { resetPageOffset() } binding.contentTextView.setContent(textPage) } fun invalidateContentView() { binding.contentTextView.invalidate() } /** * 设置无障碍文本 */ fun setContentDescription(content: String) { binding.contentTextView.contentDescription = content } /** * 重置滚动位置 */ fun resetPageOffset() { binding.contentTextView.resetPageOffset() } /** * 设置进度 */ @SuppressLint("SetTextI18n") fun setProgress(textPage: TextPage) = textPage.apply { tvBookName?.setTextIfNotEqual(ReadBook.book?.name) tvTitle?.setTextIfNotEqual(textPage.title) val readProgress = readProgress tvTotalProgress?.setTextIfNotEqual(readProgress) tvTotalProgress1?.setTextIfNotEqual("${chapterIndex.plus(1)}/${chapterSize}") if (textChapter.isCompleted) { tvPageAndTotal?.setTextIfNotEqual("${index.plus(1)}/$pageSize $readProgress") tvPage?.setTextIfNotEqual("${index.plus(1)}/$pageSize") } else { val pageSizeInt = pageSize val pageSize = if (pageSizeInt <= 0) "-" else "~$pageSizeInt" tvPageAndTotal?.setTextIfNotEqual("${index.plus(1)}/$pageSize $readProgress") tvPage?.setTextIfNotEqual("${index.plus(1)}/$pageSize") } } fun setAutoPager(autoPager: AutoPager?) { binding.contentTextView.setAutoPager(autoPager) } fun submitRenderTask() { binding.contentTextView.submitRenderTask() } fun setIsScroll(value: Boolean) { isScroll = value binding.contentTextView.setIsScroll(value) } /** * 滚动事件 */ fun scroll(offset: Int) { binding.contentTextView.scroll(offset) } /** * 更新是否开启选择功能 */ fun upSelectAble(selectAble: Boolean) { binding.contentTextView.selectAble = selectAble } /** * 优先处理页面内单击 * @return true:已处理, false:未处理 */ fun onClick(x: Float, y: Float): Boolean { return binding.contentTextView.click(x, y - headerHeight) } /** * 长按事件 */ fun longPress( x: Float, y: Float, select: (textPos: TextPos) -> Unit, ) { return binding.contentTextView.longPress(x, y - headerHeight, select) } /** * 选择文本 */ fun selectText( x: Float, y: Float, select: (textPos: TextPos) -> Unit, ) { return binding.contentTextView.selectText(x, y - headerHeight, select) } fun getCurVisiblePage(): TextPage { return binding.contentTextView.getCurVisiblePage() } fun getReadAloudPos(): Pair? { return binding.contentTextView.getReadAloudPos() } fun markAsMainView() { isMainView = true binding.contentTextView.isMainView = true } fun selectStartMove(x: Float, y: Float) { binding.contentTextView.selectStartMove(x, y - headerHeight) } fun selectStartMoveIndex( relativePagePos: Int, lineIndex: Int, charIndex: Int ) { binding.contentTextView.selectStartMoveIndex(relativePagePos, lineIndex, charIndex) } fun selectStartMoveIndex(textPos: TextPos) { binding.contentTextView.selectStartMoveIndex(textPos) } fun selectEndMove(x: Float, y: Float) { binding.contentTextView.selectEndMove(x, y - headerHeight) } fun selectEndMoveIndex( relativePagePos: Int, lineIndex: Int, charIndex: Int ) { binding.contentTextView.selectEndMoveIndex(relativePagePos, lineIndex, charIndex) } fun selectEndMoveIndex(textPos: TextPos) { binding.contentTextView.selectEndMoveIndex(textPos) } fun getReverseStartCursor(): Boolean { return binding.contentTextView.reverseStartCursor } fun getReverseEndCursor(): Boolean { return binding.contentTextView.reverseEndCursor } fun isLongScreenShot(): Boolean { return binding.contentTextView.longScreenshot } fun resetReverseCursor() { binding.contentTextView.resetReverseCursor() } fun cancelSelect(clearSearchResult: Boolean = false) { binding.contentTextView.cancelSelect(clearSearchResult) } fun createBookmark(): Bookmark? { return binding.contentTextView.createBookmark() } fun relativePage(relativePagePos: Int): TextPage { return binding.contentTextView.relativePage(relativePagePos) } val textPage get() = binding.contentTextView.textPage val selectedText: String get() = binding.contentTextView.getSelectedText() val selectStartPos get() = binding.contentTextView.selectStart } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt ================================================ package io.legado.app.ui.book.read.page import android.annotation.SuppressLint import android.content.Context import android.graphics.Canvas import android.graphics.RectF import android.os.Build import android.util.AttributeSet import android.view.MotionEvent import android.view.ViewConfiguration import android.view.WindowInsets import android.widget.FrameLayout import io.legado.app.R import io.legado.app.constant.PageAnim import io.legado.app.data.entities.BookProgress import io.legado.app.help.config.AppConfig import io.legado.app.help.config.ReadBookConfig import io.legado.app.model.ReadAloud import io.legado.app.model.ReadBook import io.legado.app.service.BaseReadAloudService import io.legado.app.ui.book.read.ContentEditDialog import io.legado.app.ui.book.read.page.api.DataSource import io.legado.app.ui.book.read.page.delegate.CoverPageDelegate import io.legado.app.ui.book.read.page.delegate.HorizontalPageDelegate import io.legado.app.ui.book.read.page.delegate.NoAnimPageDelegate import io.legado.app.ui.book.read.page.delegate.PageDelegate import io.legado.app.ui.book.read.page.delegate.ScrollPageDelegate import io.legado.app.ui.book.read.page.delegate.SimulationPageDelegate import io.legado.app.ui.book.read.page.delegate.SlidePageDelegate import io.legado.app.ui.book.read.page.entities.PageDirection 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 import io.legado.app.ui.book.read.page.entities.TextPos import io.legado.app.ui.book.read.page.entities.column.TextColumn import io.legado.app.ui.book.read.page.provider.ChapterProvider import io.legado.app.ui.book.read.page.provider.LayoutProgressListener import io.legado.app.ui.book.read.page.provider.TextPageFactory import io.legado.app.utils.activity import io.legado.app.utils.invisible import io.legado.app.utils.longToastOnUi import io.legado.app.utils.showDialogFragment import io.legado.app.utils.throttle import java.text.BreakIterator import java.util.Locale import kotlin.math.abs /** * 阅读视图 */ class ReadView(context: Context, attrs: AttributeSet) : FrameLayout(context, attrs), DataSource, LayoutProgressListener { val callBack: CallBack get() = activity as CallBack var pageFactory: TextPageFactory = TextPageFactory(this) var pageDelegate: PageDelegate? = null private set(value) { field?.onDestroy() field = null field = value upContent() } override var isScroll = false val prevPage by lazy { PageView(context) } val curPage by lazy { PageView(context) } val nextPage by lazy { PageView(context) } val defaultAnimationSpeed = 300 private var pressDown = false private var isMove = false //起始点 var startX: Float = 0f var startY: Float = 0f //上一个触碰点 var lastX: Float = 0f var lastY: Float = 0f //触碰点 var touchX: Float = 0f var touchY: Float = 0f //是否停止动画动作 var isAbortAnim = false //长按 private var longPressed = false private val longPressTimeout = 600L private val longPressRunnable = Runnable { longPressed = true onLongPress() } var isTextSelected = false private var pressOnTextSelected = false private val initialTextPos = TextPos(0, 0, 0) private val slopSquare by lazy { ViewConfiguration.get(context).scaledTouchSlop } private var pageSlopSquare: Int = slopSquare var pageSlopSquare2: Int = pageSlopSquare * pageSlopSquare private val tlRect = RectF() private val tcRect = RectF() private val trRect = RectF() private val mlRect = RectF() private val mcRect = RectF() private val mrRect = RectF() private val blRect = RectF() private val bcRect = RectF() private val brRect = RectF() private val boundary by lazy { BreakIterator.getWordInstance(Locale.getDefault()) } private val upProgressThrottle = throttle(200) { post { upProgress() } } val autoPager = AutoPager(this) val isAutoPage get() = autoPager.isRunning init { addView(nextPage) addView(curPage) addView(prevPage) prevPage.invisible() nextPage.invisible() curPage.markAsMainView() if (!isInEditMode) { upBg() setWillNotDraw(false) upPageAnim() upPageSlopSquare() } } private fun setRect9x() { tlRect.set(0f, 0f, width * 0.33f, height * 0.33f) tcRect.set(width * 0.33f, 0f, width * 0.66f, height * 0.33f) trRect.set(width * 0.36f, 0f, width.toFloat(), height * 0.33f) mlRect.set(0f, height * 0.33f, width * 0.33f, height * 0.66f) mcRect.set(width * 0.33f, height * 0.33f, width * 0.66f, height * 0.66f) mrRect.set(width * 0.66f, height * 0.33f, width.toFloat(), height * 0.66f) blRect.set(0f, height * 0.66f, width * 0.33f, height.toFloat()) bcRect.set(width * 0.33f, height * 0.66f, width * 0.66f, height.toFloat()) brRect.set(width * 0.66f, height * 0.66f, width.toFloat(), height.toFloat()) } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) setRect9x() prevPage.x = -w.toFloat() pageDelegate?.setViewSize(w, h) if (w > 0 && h > 0) { upBg() callBack.upSystemUiVisibility() } } override fun dispatchDraw(canvas: Canvas) { super.dispatchDraw(canvas) pageDelegate?.onDraw(canvas) autoPager.onDraw(canvas) } override fun computeScroll() { pageDelegate?.computeScroll() autoPager.computeOffset() } override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean { return true } /** * 触摸事件 */ @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val insets = this.rootWindowInsets.getInsetsIgnoringVisibility( WindowInsets.Type.mandatorySystemGestures() ) val height = activity?.windowManager?.currentWindowMetrics?.bounds?.height() if (height != null) { if (event.y > height.minus(insets.bottom) && event.action != MotionEvent.ACTION_UP && event.action != MotionEvent.ACTION_CANCEL ) { return true } } } //在多点触控时,事件不走ACTION_DOWN分支而产生的特殊事件处理 if (event.actionMasked == MotionEvent.ACTION_POINTER_DOWN || event.actionMasked == MotionEvent.ACTION_POINTER_UP) { pageDelegate?.onTouch(event) } when (event.action) { MotionEvent.ACTION_DOWN -> { callBack.screenOffTimerStart() if (isTextSelected) { curPage.cancelSelect() isTextSelected = false pressOnTextSelected = true } else { pressOnTextSelected = false } longPressed = false postDelayed(longPressRunnable, longPressTimeout) pressDown = true isMove = false pageDelegate?.onTouch(event) pageDelegate?.onDown() setStartPoint(event.x, event.y, false) } MotionEvent.ACTION_MOVE -> { if (!pressDown) return true val absX = abs(startX - event.x) val absY = abs(startY - event.y) if (!isMove) { isMove = absX > slopSquare || absY > slopSquare } if (isMove) { longPressed = false removeCallbacks(longPressRunnable) if (isTextSelected) { selectText(event.x, event.y) } else { pageDelegate?.onTouch(event) } } } MotionEvent.ACTION_UP -> { callBack.screenOffTimerStart() removeCallbacks(longPressRunnable) if (!pressDown) return true pressDown = false if (!pageDelegate!!.isMoved && !isMove) { if (!longPressed && !pressOnTextSelected) { if (!curPage.onClick(startX, startY)) { onSingleTapUp() } return true } } if (isTextSelected) { callBack.showTextActionMenu() } else if (pageDelegate!!.isMoved) { pageDelegate?.onTouch(event) } pressOnTextSelected = false } MotionEvent.ACTION_CANCEL -> { removeCallbacks(longPressRunnable) if (!pressDown) return true pressDown = false if (isTextSelected) { callBack.showTextActionMenu() } else if (pageDelegate!!.isMoved) { pageDelegate?.onTouch(event) } pressOnTextSelected = false autoPager.resume() } } return true } fun cancelSelect(clearSearchResult: Boolean = false) { if (isTextSelected) { curPage.cancelSelect(clearSearchResult) isTextSelected = false } } /** * 更新状态栏 */ fun upStatusBar() { curPage.upStatusBar() prevPage.upStatusBar() nextPage.upStatusBar() } /** * 保存开始位置 */ fun setStartPoint(x: Float, y: Float, invalidate: Boolean = true) { startX = x startY = y lastX = x lastY = y touchX = x touchY = y if (invalidate) { invalidate() } } /** * 保存当前位置 */ fun setTouchPoint(x: Float, y: Float, invalidate: Boolean = true) { lastX = touchX lastY = touchY touchX = x touchY = y if (invalidate) { invalidate() } pageDelegate?.onScroll() val offset = touchY - lastY touchY -= offset - offset.toInt() } /** * 长按选择 */ private fun onLongPress() { kotlin.runCatching { curPage.longPress(startX, startY) { textPos: TextPos -> isTextSelected = true pressOnTextSelected = true initialTextPos.upData(textPos) val startPos = textPos.copy() val endPos = textPos.copy() val page = curPage.relativePage(textPos.relativePagePos) val stringBuilder = StringBuilder() var cIndex = textPos.columnIndex var lineStart = textPos.lineIndex var lineEnd = textPos.lineIndex for (index in textPos.lineIndex - 1 downTo 0) { val textLine = page.getLine(index) if (textLine.isParagraphEnd) { break } else { stringBuilder.insert(0, textLine.text) lineStart -= 1 cIndex += textLine.charSize } } for (index in textPos.lineIndex until page.lineSize) { val textLine = page.getLine(index) stringBuilder.append(textLine.text) lineEnd += 1 if (textLine.isParagraphEnd) { break } } var start: Int var end: Int boundary.setText(stringBuilder.toString()) start = boundary.first() end = boundary.next() while (end != BreakIterator.DONE) { if (cIndex in start until end) { break } start = end end = boundary.next() } kotlin.run { var ci = 0 for (index in lineStart..lineEnd) { val textLine = page.getLine(index) for (j in textLine.columns.indices) { if (ci == start) { startPos.lineIndex = index startPos.columnIndex = j } else if (ci == end - 1) { endPos.lineIndex = index endPos.columnIndex = j return@run } val column = textLine.getColumn(j) if (column is TextColumn) { ci += column.charData.length } else { ci++ } } } } curPage.selectStartMoveIndex(startPos) curPage.selectEndMoveIndex(endPos) } } } /** * 单击 */ private fun onSingleTapUp() { when { isTextSelected -> Unit mcRect.contains(startX, startY) -> if (!isAbortAnim) { click(AppConfig.clickActionMC) } bcRect.contains(startX, startY) -> { click(AppConfig.clickActionBC) } blRect.contains(startX, startY) -> { click(AppConfig.clickActionBL) } brRect.contains(startX, startY) -> { click(AppConfig.clickActionBR) } mlRect.contains(startX, startY) -> { click(AppConfig.clickActionML) } mrRect.contains(startX, startY) -> { click(AppConfig.clickActionMR) } tlRect.contains(startX, startY) -> { click(AppConfig.clickActionTL) } tcRect.contains(startX, startY) -> { click(AppConfig.clickActionTC) } trRect.contains(startX, startY) -> { click(AppConfig.clickActionTR) } } } /** * 点击 */ private fun click(action: Int) { when (action) { 0 -> { pageDelegate?.dismissSnackBar() callBack.showActionMenu() } 1 -> pageDelegate?.nextPageByAnim(defaultAnimationSpeed) 2 -> pageDelegate?.prevPageByAnim(defaultAnimationSpeed) 3 -> ReadBook.moveToNextChapter(true) 4 -> ReadBook.moveToPrevChapter(upContent = true, toLast = false) 5 -> ReadAloud.prevParagraph(context) 6 -> ReadAloud.nextParagraph(context) 7 -> callBack.addBookmark() 8 -> activity?.showDialogFragment(ContentEditDialog()) 9 -> callBack.changeReplaceRuleState() 10 -> callBack.openChapterList() 11 -> callBack.openSearchActivity(null) 12 -> ReadBook.syncProgress( { progress -> callBack.sureNewProgress(progress) }, { context.longToastOnUi(context.getString(R.string.upload_book_success)) }, { context.longToastOnUi(context.getString(R.string.sync_book_progress_success)) }) 13 -> { if (BaseReadAloudService.isPlay()) { ReadAloud.pause(context) } else { ReadAloud.resume(context) } } } } /** * 选择文本 */ private fun selectText(x: Float, y: Float) { curPage.selectText(x, y) { textPos -> val compare = initialTextPos.compare(textPos) when { compare > 0 -> { curPage.selectStartMoveIndex(textPos) curPage.selectEndMoveIndex( initialTextPos.relativePagePos, initialTextPos.lineIndex, initialTextPos.columnIndex - 1 ) } else -> { curPage.selectStartMoveIndex(initialTextPos) curPage.selectEndMoveIndex(textPos) } } } } /** * 销毁事件 */ fun onDestroy() { pageDelegate?.onDestroy() curPage.cancelSelect() invalidateTextPage() } /** * 翻页动画完成后事件 * @param direction 翻页方向 */ fun fillPage(direction: PageDirection): Boolean { return when (direction) { PageDirection.PREV -> { pageFactory.moveToPrev(true) } PageDirection.NEXT -> { pageFactory.moveToNext(true) } else -> false } } /** * 更新翻页动画 */ fun upPageAnim(upRecorder: Boolean = false) { isScroll = ReadBook.pageAnim() == 3 ChapterProvider.upLayout() when (ReadBook.pageAnim()) { PageAnim.coverPageAnim -> if (pageDelegate !is CoverPageDelegate) { pageDelegate = CoverPageDelegate(this) } PageAnim.slidePageAnim -> if (pageDelegate !is SlidePageDelegate) { pageDelegate = SlidePageDelegate(this) } PageAnim.simulationPageAnim -> if (pageDelegate !is SimulationPageDelegate) { pageDelegate = SimulationPageDelegate(this) } PageAnim.scrollPageAnim -> if (pageDelegate !is ScrollPageDelegate) { pageDelegate = ScrollPageDelegate(this) } else -> if (pageDelegate !is NoAnimPageDelegate) { pageDelegate = NoAnimPageDelegate(this) } } (pageDelegate as? ScrollPageDelegate)?.noAnim = AppConfig.noAnimScrollPage if (upRecorder) { (pageDelegate as? HorizontalPageDelegate)?.upRecorder() autoPager.upRecorder() } pageDelegate?.setViewSize(width, height) if (isScroll) { curPage.setAutoPager(autoPager) } else { curPage.setAutoPager(null) } curPage.setIsScroll(isScroll) } /** * 更新阅读内容 * @param relativePosition 相对位置 -1 上一页 0 当前页 1 下一页 * @param resetPageOffset 滚动阅读是是否重置位置 */ override fun upContent(relativePosition: Int, resetPageOffset: Boolean) { post { curPage.setContentDescription(pageFactory.curPage.text) } if (isScroll && !isAutoPage) { if (relativePosition == 0) { curPage.setContent(pageFactory.curPage, resetPageOffset) } else { curPage.invalidateContentView() } } else { when (relativePosition) { -1 -> prevPage.setContent(pageFactory.prevPage) 1 -> nextPage.setContent(pageFactory.nextPage) else -> { curPage.setContent(pageFactory.curPage, resetPageOffset) nextPage.setContent(pageFactory.nextPage) prevPage.setContent(pageFactory.prevPage) } } } callBack.screenOffTimerStart() } private fun upProgress() { curPage.setProgress(pageFactory.curPage) } /** * 更新滑动距离 */ fun upPageSlopSquare() { val pageTouchSlop = AppConfig.pageTouchSlop this.pageSlopSquare = if (pageTouchSlop == 0) slopSquare else pageTouchSlop pageSlopSquare2 = this.pageSlopSquare * this.pageSlopSquare } /** * 更新样式 */ fun upStyle() { ChapterProvider.upStyle() curPage.upStyle() prevPage.upStyle() nextPage.upStyle() } /** * 更新背景 */ fun upBg() { ReadBookConfig.upBg(width, height) curPage.upBg() prevPage.upBg() nextPage.upBg() } /** * 更新背景透明度 */ fun upBgAlpha() { curPage.upBgAlpha() prevPage.upBgAlpha() nextPage.upBgAlpha() } /** * 更新时间信息 */ fun upTime() { curPage.upTime() prevPage.upTime() nextPage.upTime() } /** * 更新电量信息 */ fun upBattery(battery: Int) { curPage.upBattery(battery) prevPage.upBattery(battery) nextPage.upBattery(battery) } /** * 从选择位置开始朗读 */ suspend fun aloudStartSelect() { val selectStartPos = curPage.selectStartPos var pagePos = selectStartPos.relativePagePos val line = selectStartPos.lineIndex val column = selectStartPos.columnIndex while (pagePos > 0) { if (!ReadBook.moveToNextPage()) { ReadBook.moveToNextChapterAwait(false) } pagePos-- } val startPos = curPage.textPage.getPosByLineColumn(line, column) ReadBook.readAloud(startPos = startPos) } /** * @return 选择的文本 */ fun getSelectText(): String { return curPage.selectedText } fun getCurVisiblePage(): TextPage { return curPage.getCurVisiblePage() } fun getReadAloudPos(): Pair? { return curPage.getReadAloudPos() } fun invalidateTextPage() { if (!AppConfig.optimizeRender) { return } pageFactory.run { prevPage.invalidateAll() curPage.invalidateAll() nextPage.invalidateAll() nextPlusPage.invalidateAll() } } fun onScrollAnimStart() { autoPager.pause() } fun onScrollAnimStop() { autoPager.resume() } fun onPageChange() { autoPager.reset() submitRenderTask() } fun submitRenderTask() { if (!AppConfig.optimizeRender) { return } curPage.submitRenderTask() } fun isLongScreenShot(): Boolean { return curPage.isLongScreenShot() } override fun onLayoutPageCompleted(index: Int, page: TextPage) { upProgressThrottle.invoke() } override val currentChapter: TextChapter? get() { return if (callBack.isInitFinish) ReadBook.textChapter(0) else null } override val nextChapter: TextChapter? get() { return if (callBack.isInitFinish) ReadBook.textChapter(1) else null } override val prevChapter: TextChapter? get() { return if (callBack.isInitFinish) ReadBook.textChapter(-1) else null } override fun hasNextChapter(): Boolean { return ReadBook.durChapterIndex < ReadBook.simulatedChapterSize - 1 } override fun hasPrevChapter(): Boolean { return ReadBook.durChapterIndex > 0 } interface CallBack { val isInitFinish: Boolean fun showActionMenu() fun screenOffTimerStart() fun showTextActionMenu() fun autoPageStop() fun openChapterList() fun addBookmark() fun changeReplaceRuleState() fun openSearchActivity(searchWord: String?) fun upSystemUiVisibility() fun sureNewProgress(progress: BookProgress) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/api/DataSource.kt ================================================ package io.legado.app.ui.book.read.page.api import io.legado.app.model.ReadBook import io.legado.app.ui.book.read.page.entities.TextChapter interface DataSource { val pageIndex: Int get() = ReadBook.durPageIndex val currentChapter: TextChapter? val nextChapter: TextChapter? val prevChapter: TextChapter? val isScroll: Boolean fun hasNextChapter(): Boolean fun hasPrevChapter(): Boolean fun upContent(relativePosition: Int = 0, resetPageOffset: Boolean = true) } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/api/PageFactory.kt ================================================ package io.legado.app.ui.book.read.page.api abstract class PageFactory(protected val dataSource: DataSource) { abstract fun moveToFirst() abstract fun moveToLast() abstract fun moveToNext(upContent: Boolean): Boolean abstract fun moveToPrev(upContent: Boolean): Boolean abstract val nextPage: DATA abstract val prevPage: DATA abstract val curPage: DATA abstract val nextPlusPage: DATA abstract fun hasNext(): Boolean abstract fun hasPrev(): Boolean abstract fun hasNextPlus(): Boolean } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/delegate/CoverPageDelegate.kt ================================================ package io.legado.app.ui.book.read.page.delegate import android.graphics.Canvas import android.graphics.drawable.GradientDrawable import androidx.core.graphics.withClip import androidx.core.graphics.withTranslation import io.legado.app.ui.book.read.page.ReadView import io.legado.app.ui.book.read.page.entities.PageDirection import io.legado.app.utils.screenshot class CoverPageDelegate(readView: ReadView) : HorizontalPageDelegate(readView) { private val shadowDrawableR: GradientDrawable init { val shadowColors = intArrayOf(0x66111111, 0x00000000) shadowDrawableR = GradientDrawable( GradientDrawable.Orientation.LEFT_RIGHT, shadowColors ) shadowDrawableR.gradientType = GradientDrawable.LINEAR_GRADIENT } override fun onDraw(canvas: Canvas) { if (!isRunning) return val offsetX = touchX - startX if ((mDirection == PageDirection.NEXT && offsetX > 0) || (mDirection == PageDirection.PREV && offsetX < 0) ) { return } val distanceX = if (offsetX > 0) offsetX - viewWidth else offsetX + viewWidth if (mDirection == PageDirection.PREV) { if (offsetX <= viewWidth) { canvas.withTranslation(distanceX) { prevRecorder.draw(canvas) } addShadow(distanceX, canvas) } else { prevRecorder.draw(canvas) } } else if (mDirection == PageDirection.NEXT) { val width = nextRecorder.width.toFloat() val height = nextRecorder.height.toFloat() canvas.withClip(width + offsetX, 0f, width, height) { nextRecorder.draw(this) } canvas.withTranslation(distanceX - viewWidth) { curRecorder.draw(this) } addShadow(distanceX, canvas) } } override fun setBitmap() { when (mDirection) { PageDirection.PREV -> { prevPage.screenshot(prevRecorder) } PageDirection.NEXT -> { nextPage.screenshot(nextRecorder) curPage.screenshot(curRecorder) } else -> Unit } } private fun addShadow(left: Float, canvas: Canvas) { if (left == 0f) return val dx = if (left < 0) { left + viewWidth } else { left } canvas.withTranslation(dx) { shadowDrawableR.draw(canvas) } } override fun setViewSize(width: Int, height: Int) { super.setViewSize(width, height) shadowDrawableR.setBounds(0, 0, 30, viewHeight) } override fun onAnimStop() { if (!isCancel) { readView.fillPage(mDirection) } } override fun onAnimStart(animationSpeed: Int) { val distanceX: Float when (mDirection) { PageDirection.NEXT -> distanceX = if (isCancel) { var dis = viewWidth - startX + touchX if (dis > viewWidth) { dis = viewWidth.toFloat() } viewWidth - dis } else { -(touchX + (viewWidth - startX)) } else -> distanceX = if (isCancel) { -(touchX - startX) } else { viewWidth - (touchX - startX) } } startScroll(touchX.toInt(), 0, distanceX.toInt(), 0, animationSpeed) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/delegate/HorizontalPageDelegate.kt ================================================ package io.legado.app.ui.book.read.page.delegate import android.view.MotionEvent import io.legado.app.ui.book.read.page.ReadView import io.legado.app.ui.book.read.page.entities.PageDirection import io.legado.app.utils.canvasrecorder.CanvasRecorderFactory import io.legado.app.utils.screenshot abstract class HorizontalPageDelegate(readView: ReadView) : PageDelegate(readView) { protected var curRecorder = CanvasRecorderFactory.create() protected var prevRecorder = CanvasRecorderFactory.create() protected var nextRecorder = CanvasRecorderFactory.create() private val slopSquare get() = readView.pageSlopSquare2 override fun setDirection(direction: PageDirection) { super.setDirection(direction) setBitmap() } open fun setBitmap() { when (mDirection) { PageDirection.PREV -> { prevPage.screenshot(prevRecorder) curPage.screenshot(curRecorder) } PageDirection.NEXT -> { nextPage.screenshot(nextRecorder) curPage.screenshot(curRecorder) } else -> Unit } } fun upRecorder() { curRecorder.recycle() prevRecorder.recycle() nextRecorder.recycle() curRecorder = CanvasRecorderFactory.create() prevRecorder = CanvasRecorderFactory.create() nextRecorder = CanvasRecorderFactory.create() } override fun onTouch(event: MotionEvent) { when (event.action) { MotionEvent.ACTION_DOWN -> { abortAnim() } MotionEvent.ACTION_MOVE -> { onScroll(event) } MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { onAnimStart(readView.defaultAnimationSpeed) } } } private fun onScroll(event: MotionEvent) { val action: Int = event.action val pointerUp = action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_POINTER_UP val skipIndex = if (pointerUp) event.actionIndex else -1 // Determine focal point var sumX = 0f var sumY = 0f val count: Int = event.pointerCount for (i in 0 until count) { if (skipIndex == i) continue sumX += event.getX(i) sumY += event.getY(i) } val div = if (pointerUp) count - 1 else count val focusX = sumX / div val focusY = sumY / div //判断是否移动了 if (!isMoved) { val deltaX = (focusX - startX).toInt() val deltaY = (focusY - startY).toInt() val distance = deltaX * deltaX + deltaY * deltaY isMoved = distance > slopSquare if (isMoved) { if (sumX - startX > 0) { //如果上一页不存在 if (!hasPrev()) { noNext = true return } setDirection(PageDirection.PREV) } else { //如果不存在表示没有下一页了 if (!hasNext()) { noNext = true return } setDirection(PageDirection.NEXT) } readView.setStartPoint(event.x, event.y, false) } } if (isMoved) { isCancel = if (mDirection == PageDirection.NEXT) sumX > lastX else sumX < lastX isRunning = true //设置触摸点 readView.setTouchPoint(sumX, sumY) } } override fun abortAnim() { isStarted = false isMoved = false isRunning = false if (!scroller.isFinished) { readView.isAbortAnim = true scroller.abortAnimation() if (!isCancel) { readView.fillPage(mDirection) readView.invalidate() } } else { readView.isAbortAnim = false } } override fun nextPageByAnim(animationSpeed: Int) { abortAnim() if (!hasNext()) return setDirection(PageDirection.NEXT) val y = when { startY > viewHeight / 2 -> viewHeight.toFloat() * 0.9f else -> 1f } readView.setStartPoint(viewWidth.toFloat() * 0.9f, y, false) onAnimStart(animationSpeed) } override fun prevPageByAnim(animationSpeed: Int) { abortAnim() if (!hasPrev()) return setDirection(PageDirection.PREV) readView.setStartPoint(0f, viewHeight.toFloat(), false) onAnimStart(animationSpeed) } override fun onDestroy() { super.onDestroy() prevRecorder.recycle() curRecorder.recycle() nextRecorder.recycle() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/delegate/NoAnimPageDelegate.kt ================================================ package io.legado.app.ui.book.read.page.delegate import android.graphics.Canvas import io.legado.app.ui.book.read.page.ReadView class NoAnimPageDelegate(readView: ReadView) : HorizontalPageDelegate(readView) { override fun onAnimStart(animationSpeed: Int) { if (!isCancel) { readView.fillPage(mDirection) } stopScroll() } override fun setBitmap() { // nothing } override fun onDraw(canvas: Canvas) { // nothing } override fun onAnimStop() { // nothing } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/delegate/PageDelegate.kt ================================================ package io.legado.app.ui.book.read.page.delegate import android.content.Context import android.graphics.Canvas import android.view.MotionEvent import android.view.animation.LinearInterpolator import android.widget.Scroller import androidx.annotation.CallSuper import com.google.android.material.snackbar.Snackbar import io.legado.app.R import io.legado.app.ui.book.read.page.PageView import io.legado.app.ui.book.read.page.ReadView import io.legado.app.ui.book.read.page.entities.PageDirection import kotlin.math.abs abstract class PageDelegate(protected val readView: ReadView) { protected val context: Context = readView.context //起始点 protected val startX: Float get() = readView.startX protected val startY: Float get() = readView.startY //上一个触碰点 protected val lastX: Float get() = readView.lastX protected val lastY: Float get() = readView.lastY //触碰点 protected val touchX: Float get() = readView.touchX protected val touchY: Float get() = readView.touchY protected val nextPage: PageView get() = readView.nextPage protected val curPage: PageView get() = readView.curPage protected val prevPage: PageView get() = readView.prevPage protected var viewWidth: Int = readView.width protected var viewHeight: Int = readView.height protected val scroller: Scroller by lazy { Scroller(readView.context, LinearInterpolator()) } private val snackBar: Snackbar by lazy { Snackbar.make(readView, "", Snackbar.LENGTH_SHORT) } var isMoved = false var noNext = true //移动方向 var mDirection = PageDirection.NONE var isCancel = false var isRunning = false var isStarted = false private var selectedOnDown = false init { curPage.resetPageOffset() } open fun fling( startX: Int, startY: Int, velocityX: Int, velocityY: Int, minX: Int, maxX: Int, minY: Int, maxY: Int ) { scroller.fling(startX, startY, velocityX, velocityY, minX, maxX, minY, maxY) isRunning = true isStarted = true readView.invalidate() } protected fun startScroll(startX: Int, startY: Int, dx: Int, dy: Int, animationSpeed: Int) { val duration = if (dx != 0) { (animationSpeed * abs(dx)) / viewWidth } else { (animationSpeed * abs(dy)) / viewHeight } scroller.startScroll(startX, startY, dx, dy, duration) isRunning = true isStarted = true readView.invalidate() } protected fun stopScroll() { isStarted = false readView.post { isMoved = false isRunning = false readView.invalidate() } } @CallSuper open fun setViewSize(width: Int, height: Int) { viewWidth = width viewHeight = height } open fun computeScroll() { if (scroller.computeScrollOffset()) { readView.setTouchPoint(scroller.currX.toFloat(), scroller.currY.toFloat()) } else if (isStarted) { onAnimStop() stopScroll() } } open fun onScroll() = Unit abstract fun abortAnim() abstract fun onAnimStart(animationSpeed: Int) //scroller start abstract fun onDraw(canvas: Canvas) //绘制 abstract fun onAnimStop() //scroller finish abstract fun nextPageByAnim(animationSpeed: Int) abstract fun prevPageByAnim(animationSpeed: Int) open fun keyTurnPage(direction: PageDirection) { if (isRunning) return when (direction) { PageDirection.NEXT -> nextPageByAnim(100) PageDirection.PREV -> prevPageByAnim(100) else -> return } } @CallSuper open fun setDirection(direction: PageDirection) { mDirection = direction } /** * 触摸事件处理 */ abstract fun onTouch(event: MotionEvent) /** * 按下 */ fun onDown() { //是否移动 isMoved = false //是否存在下一章 noNext = false //是否正在执行动画 isRunning = false //取消 isCancel = false //是下一章还是前一章 setDirection(PageDirection.NONE) } /** * 判断是否有上一页 */ fun hasPrev(): Boolean { val hasPrev = readView.pageFactory.hasPrev() if (!hasPrev) { if (!snackBar.isShown) { snackBar.setText(R.string.no_prev_page) snackBar.show() } } return hasPrev } /** * 判断是否有下一页 */ fun hasNext(): Boolean { val hasNext = readView.pageFactory.hasNext() if (!hasNext) { readView.callBack.autoPageStop() if (!snackBar.isShown) { snackBar.setText(R.string.no_next_page) snackBar.show() } } return hasNext } fun dismissSnackBar() { // 判断snackBar是否显示,并关闭 if (snackBar.isShown) { snackBar.dismiss() } } fun postInvalidate() { if (isStarted && isRunning && this is HorizontalPageDelegate) { readView.post { if (isStarted && isRunning) { setBitmap() readView.invalidate() } } } } open fun onDestroy() { // run on destroy } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/delegate/ScrollPageDelegate.kt ================================================ package io.legado.app.ui.book.read.page.delegate import android.graphics.Canvas import android.view.MotionEvent import android.view.VelocityTracker import io.legado.app.data.entities.Book import io.legado.app.help.book.isImage import io.legado.app.model.ReadBook import io.legado.app.ui.book.read.page.ReadView import io.legado.app.ui.book.read.page.provider.ChapterProvider class ScrollPageDelegate(readView: ReadView) : PageDelegate(readView) { // 滑动追踪的时间 private val velocityDuration = 1000 //速度追踪器 private val mVelocity: VelocityTracker = VelocityTracker.obtain() private val slopSquare get() = readView.pageSlopSquare2 var noAnim: Boolean = false override fun onAnimStart(animationSpeed: Int) { readView.onScrollAnimStart() //惯性滚动 fling( 0, touchY.toInt(), 0, mVelocity.yVelocity.toInt(), 0, 0, -10 * viewHeight, 10 * viewHeight ) } override fun onAnimStop() { readView.onScrollAnimStop() } override fun onTouch(event: MotionEvent) { //在多点触控时,事件不走ACTION_DOWN分支而产生的特殊事件处理 if (event.actionMasked == MotionEvent.ACTION_POINTER_DOWN) { //当多个手指同时按下的情况,将最后一个按下的手指的坐标设置为起始坐标,所以只有最后一个手指的滑动事件被处理 readView.setStartPoint( event.getX(event.pointerCount - 1), event.getY(event.pointerCount - 1), false ) } else if (event.actionMasked == MotionEvent.ACTION_POINTER_UP) { //当多个手指同时按下的情况,当抬起一个手指时,起始坐标恢复为第一次按下的手指的坐标 readView.setStartPoint(event.x, event.y, false) return } when (event.action) { MotionEvent.ACTION_DOWN -> { abortAnim() mVelocity.clear() } MotionEvent.ACTION_MOVE -> { onScroll(event) } MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { onAnimStart(readView.defaultAnimationSpeed) } } } override fun onScroll() { curPage.scroll((touchY - lastY).toInt()) } override fun onDraw(canvas: Canvas) { // nothing } private fun onScroll(event: MotionEvent) { mVelocity.addMovement(event) mVelocity.computeCurrentVelocity(velocityDuration) //取最后添加(即最新的)一个触摸点来计算滚动位置 //多点触控时即最后按下的手指产生的事件点 val pointX = event.getX(event.pointerCount - 1) val pointY = event.getY(event.pointerCount - 1) if (isMoved || readView.isLongScreenShot()) { readView.setTouchPoint(pointX, pointY, false) } if (!isMoved) { val deltaX = (pointX - startX).toInt() val deltaY = (pointY - startY).toInt() val distance = deltaX * deltaX + deltaY * deltaY isMoved = distance > slopSquare if (isMoved) { readView.setStartPoint(event.x, event.y, false) } } if (isMoved) { isRunning = true } } override fun computeScroll() { if (scroller.computeScrollOffset()) { readView.setTouchPoint(scroller.currX.toFloat(), scroller.currY.toFloat(), false) } else if (isStarted) { onAnimStop() stopScroll() } } override fun onDestroy() { super.onDestroy() mVelocity.recycle() } override fun abortAnim() { readView.onScrollAnimStop() isStarted = false isMoved = false isRunning = false if (!scroller.isFinished) { readView.isAbortAnim = true scroller.abortAnimation() } else { readView.isAbortAnim = false } } override fun nextPageByAnim(animationSpeed: Int) { if (readView.isAbortAnim) { readView.isAbortAnim = false return } if (noAnim) { curPage.scroll(calcNextPageOffset()) return } readView.setStartPoint(0f, 0f, false) startScroll(0, 0, 0, calcNextPageOffset(), animationSpeed) } override fun prevPageByAnim(animationSpeed: Int) { if (readView.isAbortAnim) { readView.isAbortAnim = false return } if (noAnim) { curPage.scroll(calcPrevPageOffset()) return } readView.setStartPoint(0f, 0f, false) startScroll(0, 0, 0, calcPrevPageOffset(), animationSpeed) } /** * 计算点击翻页保留一行的滚动距离 * 图片页使用可视高度作为滚动距离 */ private fun calcNextPageOffset(): Int { val visibleHeight = ChapterProvider.visibleHeight val book = ReadBook.book if (book == null || book.isImage) { return -visibleHeight } val visiblePage = readView.getCurVisiblePage() val isTextStyle = book.getImageStyle().equals(Book.imgStyleText, true) if (!isTextStyle && visiblePage.hasImageOrEmpty()) { return -visibleHeight } val lastLineTop = visiblePage.lines.last().lineTop.toInt() val offset = lastLineTop - ChapterProvider.paddingTop return -offset } private fun calcPrevPageOffset(): Int { val visibleHeight = ChapterProvider.visibleHeight val book = ReadBook.book if (book == null || book.isImage) { return visibleHeight } val visiblePage = readView.getCurVisiblePage() val isTextStyle = book.getImageStyle().equals(Book.imgStyleText, true) if (!isTextStyle && visiblePage.hasImageOrEmpty()) { return visibleHeight } val firstLineBottom = visiblePage.lines.first().lineBottom.toInt() val offset = visibleHeight - (firstLineBottom - ChapterProvider.paddingTop) return offset } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/delegate/SimulationPageDelegate.kt ================================================ package io.legado.app.ui.book.read.page.delegate import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.ColorMatrix import android.graphics.ColorMatrixColorFilter import android.graphics.Matrix import android.graphics.Paint import android.graphics.Path import android.graphics.PointF import android.graphics.Region import android.graphics.drawable.GradientDrawable import android.os.Build import android.view.MotionEvent import io.legado.app.help.config.ReadBookConfig import io.legado.app.ui.book.read.page.ReadView import io.legado.app.ui.book.read.page.entities.PageDirection import io.legado.app.utils.screenshot import kotlin.math.abs import kotlin.math.atan2 import kotlin.math.cos import kotlin.math.hypot import kotlin.math.min import kotlin.math.sin @Suppress("DEPRECATION") class SimulationPageDelegate(readView: ReadView) : HorizontalPageDelegate(readView) { //不让x,y为0,否则在点计算时会有问题 private var mTouchX = 0.1f private var mTouchY = 0.1f // 拖拽点对应的页脚 private var mCornerX = 1 private var mCornerY = 1 private val mPath0: Path = Path() private val mPath1: Path = Path() // 贝塞尔曲线起始点 private val mBezierStart1 = PointF() // 贝塞尔曲线控制点 private val mBezierControl1 = PointF() // 贝塞尔曲线顶点 private val mBezierVertex1 = PointF() // 贝塞尔曲线结束点 private var mBezierEnd1 = PointF() // 另一条贝塞尔曲线 // 贝塞尔曲线起始点 private val mBezierStart2 = PointF() // 贝塞尔曲线控制点 private val mBezierControl2 = PointF() // 贝塞尔曲线顶点 private val mBezierVertex2 = PointF() // 贝塞尔曲线结束点 private var mBezierEnd2 = PointF() private var mMiddleX = 0f private var mMiddleY = 0f private var mDegrees = 0f private var mTouchToCornerDis = 0f private var mColorMatrixFilter = ColorMatrixColorFilter( ColorMatrix( floatArrayOf( 1f, 0f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 0f, 1f, 0f ) ) ) private val mMatrix: Matrix = Matrix() private val mMatrixArray = floatArrayOf(0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 1f) // 是否属于右上左下 private var mIsRtOrLb = false private var mMaxLength = hypot(viewWidth.toDouble(), viewHeight.toDouble()).toFloat() // 背面颜色组 private var mBackShadowColors: IntArray // 前面颜色组 private var mFrontShadowColors: IntArray // 有阴影的GradientDrawable private var mBackShadowDrawableLR: GradientDrawable private var mBackShadowDrawableRL: GradientDrawable private var mFolderShadowDrawableLR: GradientDrawable private var mFolderShadowDrawableRL: GradientDrawable private var mFrontShadowDrawableHBT: GradientDrawable private var mFrontShadowDrawableHTB: GradientDrawable private var mFrontShadowDrawableVLR: GradientDrawable private var mFrontShadowDrawableVRL: GradientDrawable private val mPaint: Paint = Paint().apply { style = Paint.Style.FILL } private var curBitmap: Bitmap? = null private var prevBitmap: Bitmap? = null private var nextBitmap: Bitmap? = null private var canvas: Canvas = Canvas() init { //设置颜色数组 val color = intArrayOf(0x333333, -0x4fcccccd) mFolderShadowDrawableRL = GradientDrawable(GradientDrawable.Orientation.RIGHT_LEFT, color) mFolderShadowDrawableRL.gradientType = GradientDrawable.LINEAR_GRADIENT mFolderShadowDrawableLR = GradientDrawable(GradientDrawable.Orientation.LEFT_RIGHT, color) mFolderShadowDrawableLR.gradientType = GradientDrawable.LINEAR_GRADIENT mBackShadowColors = intArrayOf(-0xeeeeef, 0x111111) mBackShadowDrawableRL = GradientDrawable(GradientDrawable.Orientation.RIGHT_LEFT, mBackShadowColors) mBackShadowDrawableRL.gradientType = GradientDrawable.LINEAR_GRADIENT mBackShadowDrawableLR = GradientDrawable(GradientDrawable.Orientation.LEFT_RIGHT, mBackShadowColors) mBackShadowDrawableLR.gradientType = GradientDrawable.LINEAR_GRADIENT mFrontShadowColors = intArrayOf(-0x7feeeeef, 0x111111) mFrontShadowDrawableVLR = GradientDrawable(GradientDrawable.Orientation.LEFT_RIGHT, mFrontShadowColors) mFrontShadowDrawableVLR.gradientType = GradientDrawable.LINEAR_GRADIENT mFrontShadowDrawableVRL = GradientDrawable(GradientDrawable.Orientation.RIGHT_LEFT, mFrontShadowColors) mFrontShadowDrawableVRL.gradientType = GradientDrawable.LINEAR_GRADIENT mFrontShadowDrawableHTB = GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, mFrontShadowColors) mFrontShadowDrawableHTB.gradientType = GradientDrawable.LINEAR_GRADIENT mFrontShadowDrawableHBT = GradientDrawable(GradientDrawable.Orientation.BOTTOM_TOP, mFrontShadowColors) mFrontShadowDrawableHBT.gradientType = GradientDrawable.LINEAR_GRADIENT } override fun setBitmap() { when (mDirection) { PageDirection.PREV -> { prevBitmap = prevPage.screenshot(prevBitmap, canvas) curBitmap = curPage.screenshot(curBitmap, canvas) } PageDirection.NEXT -> { nextBitmap = nextPage.screenshot(nextBitmap, canvas) curBitmap = curPage.screenshot(curBitmap, canvas) } else -> Unit } } override fun setViewSize(width: Int, height: Int) { super.setViewSize(width, height) mMaxLength = hypot(viewWidth.toDouble(), viewHeight.toDouble()).toFloat() } override fun onTouch(event: MotionEvent) { super.onTouch(event) when (event.action) { MotionEvent.ACTION_DOWN -> { calcCornerXY(event.x, event.y) } MotionEvent.ACTION_MOVE -> { if ((startY > viewHeight / 3 && startY < viewHeight * 2 / 3) || mDirection == PageDirection.PREV ) { readView.touchY = viewHeight.toFloat() } if (startY > viewHeight / 3 && startY < viewHeight / 2 && mDirection == PageDirection.NEXT ) { readView.touchY = 1f } } } } override fun setDirection(direction: PageDirection) { super.setDirection(direction) when (direction) { PageDirection.PREV -> //上一页滑动不出现对角 if (startX > viewWidth / 2) { calcCornerXY(startX, viewHeight.toFloat()) } else { calcCornerXY(viewWidth - startX, viewHeight.toFloat()) } PageDirection.NEXT -> if (viewWidth / 2 > startX) { calcCornerXY(viewWidth - startX, startY) } else -> Unit } } override fun onAnimStart(animationSpeed: Int) { var dx: Float val dy: Float // dy 垂直方向滑动的距离,负值会使滚动向上滚动 if (isCancel) { dx = if (mCornerX > 0 && mDirection == PageDirection.NEXT) { (viewWidth - touchX) } else { -touchX } if (mDirection != PageDirection.NEXT) { dx = -(viewWidth + touchX) } dy = if (mCornerY > 0) { (viewHeight - touchY) } else { -touchY // 防止mTouchY最终变为0 } } else { dx = if (mCornerX > 0 && mDirection == PageDirection.NEXT) { -(viewWidth + touchX) } else { viewWidth - touchX } dy = if (mCornerY > 0) { (viewHeight - touchY) } else { (1 - touchY) // 防止mTouchY最终变为0 } } startScroll(touchX.toInt(), touchY.toInt(), dx.toInt(), dy.toInt(), animationSpeed) } override fun onAnimStop() { if (!isCancel) { readView.fillPage(mDirection) } } override fun onDraw(canvas: Canvas) { if (!isRunning) return when (mDirection) { PageDirection.NEXT -> { calcPoints() drawCurrentPageArea(canvas, curBitmap) drawNextPageAreaAndShadow(canvas, nextBitmap) drawCurrentPageShadow(canvas) drawCurrentBackArea(canvas, curBitmap) } PageDirection.PREV -> { calcPoints() drawCurrentPageArea(canvas, prevBitmap) drawNextPageAreaAndShadow(canvas, curBitmap) drawCurrentPageShadow(canvas) drawCurrentBackArea(canvas, prevBitmap) } else -> return } } /** * 绘制翻起页背面 */ private fun drawCurrentBackArea( canvas: Canvas, bitmap: Bitmap? ) { bitmap ?: return val i = ((mBezierStart1.x + mBezierControl1.x) / 2).toInt() val f1 = abs(i - mBezierControl1.x) val i1 = ((mBezierStart2.y + mBezierControl2.y) / 2).toInt() val f2 = abs(i1 - mBezierControl2.y) val f3 = min(f1, f2) mPath1.reset() mPath1.moveTo(mBezierVertex2.x, mBezierVertex2.y) mPath1.lineTo(mBezierVertex1.x, mBezierVertex1.y) mPath1.lineTo(mBezierEnd1.x, mBezierEnd1.y) mPath1.lineTo(mTouchX, mTouchY) mPath1.lineTo(mBezierEnd2.x, mBezierEnd2.y) mPath1.close() val mFolderShadowDrawable: GradientDrawable val left: Int val right: Int if (mIsRtOrLb) { left = (mBezierStart1.x - 1).toInt() right = (mBezierStart1.x + f3 + 1).toInt() mFolderShadowDrawable = mFolderShadowDrawableLR } else { left = (mBezierStart1.x - f3 - 1).toInt() right = (mBezierStart1.x + 1).toInt() mFolderShadowDrawable = mFolderShadowDrawableRL } canvas.save() canvas.clipPath(mPath0) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { canvas.clipPath(mPath1) } else { canvas.clipPath(mPath1, Region.Op.INTERSECT) } mPaint.colorFilter = mColorMatrixFilter val dis = hypot( mCornerX - mBezierControl1.x.toDouble(), mBezierControl2.y - mCornerY.toDouble() ).toFloat() val f8 = (mCornerX - mBezierControl1.x) / dis val f9 = (mBezierControl2.y - mCornerY) / dis mMatrixArray[0] = 1 - 2 * f9 * f9 mMatrixArray[1] = 2 * f8 * f9 mMatrixArray[3] = mMatrixArray[1] mMatrixArray[4] = 1 - 2 * f8 * f8 mMatrix.reset() mMatrix.setValues(mMatrixArray) mMatrix.preTranslate(-mBezierControl1.x, -mBezierControl1.y) mMatrix.postTranslate(mBezierControl1.x, mBezierControl1.y) canvas.drawColor(ReadBookConfig.bgMeanColor) canvas.drawBitmap(bitmap, mMatrix, mPaint) mPaint.colorFilter = null canvas.rotate(mDegrees, mBezierStart1.x, mBezierStart1.y) mFolderShadowDrawable.setBounds( left, mBezierStart1.y.toInt(), right, (mBezierStart1.y + mMaxLength).toInt() ) mFolderShadowDrawable.draw(canvas) canvas.restore() } /** * 绘制翻起页的阴影 */ private fun drawCurrentPageShadow(canvas: Canvas) { val degree: Double = if (mIsRtOrLb) { Math.PI / 4 - atan2(mBezierControl1.y - mTouchY, mTouchX - mBezierControl1.x) } else { Math.PI / 4 - atan2(mTouchY - mBezierControl1.y, mTouchX - mBezierControl1.x) } // 翻起页阴影顶点与touch点的距离 val d1 = 25.toFloat() * 1.414 * cos(degree) val d2 = 25.toFloat() * 1.414 * sin(degree) val x = (mTouchX + d1).toFloat() val y: Float = if (mIsRtOrLb) { (mTouchY + d2).toFloat() } else { (mTouchY - d2).toFloat() } mPath1.reset() mPath1.moveTo(x, y) mPath1.lineTo(mTouchX, mTouchY) mPath1.lineTo(mBezierControl1.x, mBezierControl1.y) mPath1.lineTo(mBezierStart1.x, mBezierStart1.y) mPath1.close() canvas.save() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { canvas.clipOutPath(mPath0) } else { canvas.clipPath(mPath0, Region.Op.XOR) } canvas.clipPath(mPath1, Region.Op.INTERSECT) var leftX: Int var rightX: Int var mCurrentPageShadow: GradientDrawable if (mIsRtOrLb) { leftX = mBezierControl1.x.toInt() rightX = (mBezierControl1.x + 25).toInt() mCurrentPageShadow = mFrontShadowDrawableVLR } else { leftX = (mBezierControl1.x - 25).toInt() rightX = (mBezierControl1.x + 1).toInt() mCurrentPageShadow = mFrontShadowDrawableVRL } var rotateDegrees = Math.toDegrees( atan2(mTouchX - mBezierControl1.x, mBezierControl1.y - mTouchY).toDouble() ).toFloat() canvas.rotate(rotateDegrees, mBezierControl1.x, mBezierControl1.y) mCurrentPageShadow.setBounds( leftX, (mBezierControl1.y - mMaxLength).toInt(), rightX, mBezierControl1.y.toInt() ) mCurrentPageShadow.draw(canvas) canvas.restore() mPath1.reset() mPath1.moveTo(x, y) mPath1.lineTo(mTouchX, mTouchY) mPath1.lineTo(mBezierControl2.x, mBezierControl2.y) mPath1.lineTo(mBezierStart2.x, mBezierStart2.y) mPath1.close() canvas.save() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { canvas.clipOutPath(mPath0) } else { canvas.clipPath(mPath0, Region.Op.XOR) } canvas.clipPath(mPath1) if (mIsRtOrLb) { leftX = mBezierControl2.y.toInt() rightX = (mBezierControl2.y + 25).toInt() mCurrentPageShadow = mFrontShadowDrawableHTB } else { leftX = (mBezierControl2.y - 25).toInt() rightX = (mBezierControl2.y + 1).toInt() mCurrentPageShadow = mFrontShadowDrawableHBT } rotateDegrees = Math.toDegrees( atan2(mBezierControl2.y - mTouchY, mBezierControl2.x - mTouchX).toDouble() ).toFloat() canvas.rotate(rotateDegrees, mBezierControl2.x, mBezierControl2.y) val temp = if (mBezierControl2.y < 0) (mBezierControl2.y - viewHeight).toDouble() else mBezierControl2.y.toDouble() val hmg = hypot(mBezierControl2.x.toDouble(), temp) if (hmg > mMaxLength) mCurrentPageShadow.setBounds( (mBezierControl2.x - 25 - hmg).toInt(), leftX, (mBezierControl2.x + mMaxLength - hmg).toInt(), rightX ) else mCurrentPageShadow.setBounds( (mBezierControl2.x - mMaxLength).toInt(), leftX, mBezierControl2.x.toInt(), rightX ) mCurrentPageShadow.draw(canvas) canvas.restore() } // private fun drawNextPageAreaAndShadow( canvas: Canvas, bitmap: Bitmap? ) { bitmap ?: return mPath1.reset() mPath1.moveTo(mBezierStart1.x, mBezierStart1.y) mPath1.lineTo(mBezierVertex1.x, mBezierVertex1.y) mPath1.lineTo(mBezierVertex2.x, mBezierVertex2.y) mPath1.lineTo(mBezierStart2.x, mBezierStart2.y) mPath1.lineTo(mCornerX.toFloat(), mCornerY.toFloat()) mPath1.close() mDegrees = Math.toDegrees( atan2( (mBezierControl1.x - mCornerX).toDouble(), mBezierControl2.y - mCornerY.toDouble() ) ).toFloat() val leftX: Int val rightX: Int val mBackShadowDrawable: GradientDrawable if (mIsRtOrLb) { //左下及右上 leftX = mBezierStart1.x.toInt() rightX = (mBezierStart1.x + mTouchToCornerDis / 4).toInt() mBackShadowDrawable = mBackShadowDrawableLR } else { leftX = (mBezierStart1.x - mTouchToCornerDis / 4).toInt() rightX = mBezierStart1.x.toInt() mBackShadowDrawable = mBackShadowDrawableRL } canvas.save() canvas.clipPath(mPath0) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { canvas.clipPath(mPath1) } else { canvas.clipPath(mPath1, Region.Op.INTERSECT) } canvas.drawBitmap(bitmap, 0f, 0f, null) canvas.rotate(mDegrees, mBezierStart1.x, mBezierStart1.y) mBackShadowDrawable.setBounds( leftX, mBezierStart1.y.toInt(), rightX, (mMaxLength + mBezierStart1.y).toInt() ) //左上及右下角的xy坐标值,构成一个矩形 mBackShadowDrawable.draw(canvas) canvas.restore() } // private fun drawCurrentPageArea( canvas: Canvas, bitmap: Bitmap? ) { bitmap ?: return mPath0.reset() mPath0.moveTo(mBezierStart1.x, mBezierStart1.y) mPath0.quadTo(mBezierControl1.x, mBezierControl1.y, mBezierEnd1.x, mBezierEnd1.y) mPath0.lineTo(mTouchX, mTouchY) mPath0.lineTo(mBezierEnd2.x, mBezierEnd2.y) mPath0.quadTo(mBezierControl2.x, mBezierControl2.y, mBezierStart2.x, mBezierStart2.y) mPath0.lineTo(mCornerX.toFloat(), mCornerY.toFloat()) mPath0.close() canvas.save() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { canvas.clipOutPath(mPath0) } else { canvas.clipPath(mPath0, Region.Op.XOR) } canvas.drawBitmap(bitmap, 0f, 0f, null) canvas.restore() } /** * 计算拖拽点对应的拖拽脚 */ private fun calcCornerXY(x: Float, y: Float) { mCornerX = if (x <= viewWidth / 2) 0 else viewWidth mCornerY = if (y <= viewHeight / 2) 0 else viewHeight mIsRtOrLb = (mCornerX == 0 && mCornerY == viewHeight) || (mCornerY == 0 && mCornerX == viewWidth) } private fun calcPoints() { mTouchX = touchX mTouchY = touchY mMiddleX = (mTouchX + mCornerX) / 2 mMiddleY = (mTouchY + mCornerY) / 2 mBezierControl1.x = mMiddleX - (mCornerY - mMiddleY) * (mCornerY - mMiddleY) / (mCornerX - mMiddleX) mBezierControl1.y = mCornerY.toFloat() mBezierControl2.x = mCornerX.toFloat() val f4 = mCornerY - mMiddleY if (f4 == 0f) { mBezierControl2.y = mMiddleY - (mCornerX - mMiddleX) * (mCornerX - mMiddleX) / 0.1f } else { mBezierControl2.y = mMiddleY - (mCornerX - mMiddleX) * (mCornerX - mMiddleX) / (mCornerY - mMiddleY) } mBezierStart1.x = mBezierControl1.x - (mCornerX - mBezierControl1.x) / 2 mBezierStart1.y = mCornerY.toFloat() // 固定左边上下两个点 if (mTouchX > 0 && mTouchX < viewWidth) { if (mBezierStart1.x < 0 || mBezierStart1.x > viewWidth) { if (mBezierStart1.x < 0) mBezierStart1.x = viewWidth - mBezierStart1.x val f1 = abs(mCornerX - mTouchX) val f2 = viewWidth * f1 / mBezierStart1.x mTouchX = abs(mCornerX - f2) val f3 = abs(mCornerX - mTouchX) * abs(mCornerY - mTouchY) / f1 mTouchY = abs(mCornerY - f3) mMiddleX = (mTouchX + mCornerX) / 2 mMiddleY = (mTouchY + mCornerY) / 2 mBezierControl1.x = mMiddleX - (mCornerY - mMiddleY) * (mCornerY - mMiddleY) / (mCornerX - mMiddleX) mBezierControl1.y = mCornerY.toFloat() mBezierControl2.x = mCornerX.toFloat() val f5 = mCornerY - mMiddleY if (f5 == 0f) { mBezierControl2.y = mMiddleY - (mCornerX - mMiddleX) * (mCornerX - mMiddleX) / 0.1f } else { mBezierControl2.y = mMiddleY - (mCornerX - mMiddleX) * (mCornerX - mMiddleX) / (mCornerY - mMiddleY) } mBezierStart1.x = mBezierControl1.x - (mCornerX - mBezierControl1.x) / 2 } } mBezierStart2.x = mCornerX.toFloat() mBezierStart2.y = mBezierControl2.y - (mCornerY - mBezierControl2.y) / 2 mTouchToCornerDis = hypot( (mTouchX - mCornerX).toDouble(), (mTouchY - mCornerY).toDouble() ).toFloat() mBezierEnd1 = getCross( PointF(mTouchX, mTouchY), mBezierControl1, mBezierStart1, mBezierStart2 ) mBezierEnd2 = getCross( PointF(mTouchX, mTouchY), mBezierControl2, mBezierStart1, mBezierStart2 ) mBezierVertex1.x = (mBezierStart1.x + 2 * mBezierControl1.x + mBezierEnd1.x) / 4 mBezierVertex1.y = (2 * mBezierControl1.y + mBezierStart1.y + mBezierEnd1.y) / 4 mBezierVertex2.x = (mBezierStart2.x + 2 * mBezierControl2.x + mBezierEnd2.x) / 4 mBezierVertex2.y = (2 * mBezierControl2.y + mBezierStart2.y + mBezierEnd2.y) / 4 } /** * 求解直线P1P2和直线P3P4的交点坐标 */ private fun getCross(P1: PointF, P2: PointF, P3: PointF, P4: PointF): PointF { val crossP = PointF() // 二元函数通式: y=ax+b val a1 = (P2.y - P1.y) / (P2.x - P1.x) val b1 = (P1.x * P2.y - P2.x * P1.y) / (P1.x - P2.x) val a2 = (P4.y - P3.y) / (P4.x - P3.x) val b2 = (P3.x * P4.y - P4.x * P3.y) / (P3.x - P4.x) crossP.x = (b2 - b1) / (a1 - a2) crossP.y = a1 * crossP.x + b1 return crossP } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/delegate/SlidePageDelegate.kt ================================================ package io.legado.app.ui.book.read.page.delegate import android.graphics.Canvas import androidx.core.graphics.withTranslation import io.legado.app.ui.book.read.page.ReadView import io.legado.app.ui.book.read.page.entities.PageDirection class SlidePageDelegate(readView: ReadView) : HorizontalPageDelegate(readView) { override fun onAnimStart(animationSpeed: Int) { val distanceX: Float when (mDirection) { PageDirection.NEXT -> distanceX = if (isCancel) { var dis = viewWidth - startX + touchX if (dis > viewWidth) { dis = viewWidth.toFloat() } viewWidth - dis } else { -(touchX + (viewWidth - startX)) } else -> distanceX = if (isCancel) { -(touchX - startX) } else { viewWidth - (touchX - startX) } } startScroll(touchX.toInt(), 0, distanceX.toInt(), 0, animationSpeed) } override fun onDraw(canvas: Canvas) { val offsetX = touchX - startX if ((mDirection == PageDirection.NEXT && offsetX > 0) || (mDirection == PageDirection.PREV && offsetX < 0) ) return val distanceX = if (offsetX > 0) offsetX - viewWidth else offsetX + viewWidth if (!isRunning) return if (mDirection == PageDirection.PREV) { canvas.withTranslation(distanceX + viewWidth) { curRecorder.draw(this) } canvas.withTranslation(distanceX) { prevRecorder.draw(this) } } else if (mDirection == PageDirection.NEXT) { canvas.withTranslation(distanceX) { nextRecorder.draw(this) } canvas.withTranslation(distanceX - viewWidth) { curRecorder.draw(this) } } } override fun onAnimStop() { if (!isCancel) { readView.fillPage(mDirection) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/entities/PageDirection.kt ================================================ package io.legado.app.ui.book.read.page.entities enum class PageDirection { NONE, PREV, NEXT } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/entities/TextChapter.kt ================================================ package io.legado.app.ui.book.read.page.entities import androidx.annotation.Keep import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.ReplaceRule import io.legado.app.help.book.BookContent import io.legado.app.ui.book.read.page.provider.LayoutProgressListener import io.legado.app.ui.book.read.page.provider.TextChapterLayout import io.legado.app.utils.fastBinarySearchBy import kotlinx.coroutines.CoroutineScope import kotlin.math.abs import kotlin.math.min /** * 章节信息 */ @Keep @Suppress("unused") data class TextChapter( val chapter: BookChapter, val position: Int, val title: String, val chaptersSize: Int, val sameTitleRemoved: Boolean, val isVip: Boolean, val isPay: Boolean, //起效的替换规则 val effectiveReplaceRules: List? ) : LayoutProgressListener { private val textPages = arrayListOf() val pages: List get() = textPages private var layout: TextChapterLayout? = null val layoutChannel get() = layout!!.channel fun getPage(index: Int): TextPage? { return pages.getOrNull(index) } fun getPageByReadPos(readPos: Int): TextPage? { return getPage(getPageIndexByCharIndex(readPos)) } val lastPage: TextPage? get() = pages.lastOrNull() val lastIndex: Int get() = pages.lastIndex val lastReadLength: Int get() = getReadLength(lastIndex) val pageSize: Int get() = pages.size var listener: LayoutProgressListener? = null var isCompleted = false val paragraphs by lazy { paragraphsInternal } val pageParagraphs by lazy { pageParagraphsInternal } val paragraphsInternal: ArrayList get() { val paragraphs = arrayListOf() for (i in pages.indices) { val lines = pages[i].lines for (a in lines.indices) { val line = lines[a] if (line.paragraphNum <= 0) continue if (paragraphs.lastIndex < line.paragraphNum - 1) { paragraphs.add(TextParagraph(line.paragraphNum)) } paragraphs[line.paragraphNum - 1].textLines.add(line) } } return paragraphs } val pageParagraphsInternal: List get() { val paragraphs = arrayListOf() for (i in pages.indices) { paragraphs.addAll(pages[i].paragraphs) } for (i in paragraphs.indices) { paragraphs[i].num = i + 1 } return paragraphs } /** * @param index 页数 * @return 是否是最后一页 */ fun isLastIndex(index: Int): Boolean { return isCompleted && index >= pages.size - 1 } fun isLastIndexCurrent(index: Int): Boolean { return index >= pages.size - 1 } /** * @param pageIndex 页数 * @return 已读长度 */ fun getReadLength(pageIndex: Int): Int { if (pageIndex < 0) return 0 return pages[min(pageIndex, lastIndex)].chapterPosition /* var length = 0 val maxIndex = min(pageIndex, pages.size) for (index in 0 until maxIndex) { length += pages[index].charSize } return length */ } /** * @param length 当前页面文字在章节中的位置 * @return 下一页位置,如果没有下一页返回-1 */ fun getNextPageLength(length: Int): Int { val pageIndex = getPageIndexByCharIndex(length) if (pageIndex + 1 >= pageSize) { return -1 } return getReadLength(pageIndex + 1) } /** * @param length 当前页面文字在章节中的位置 * @return 上一页位置,如果没有上一页返回-1 */ fun getPrevPageLength(length: Int): Int { val pageIndex = getPageIndexByCharIndex(length) if (pageIndex - 1 < 0) { return -1 } return getReadLength(pageIndex - 1) } /** * 获取内容 */ fun getContent(): String { val stringBuilder = StringBuilder() pages.forEach { stringBuilder.append(it.text) } return stringBuilder.toString() } /** * @return 获取未读文字 */ fun getUnRead(pageIndex: Int): String { val stringBuilder = StringBuilder() if (pages.isNotEmpty()) { for (index in pageIndex..pages.lastIndex) { stringBuilder.append(pages[index].text) } } return stringBuilder.toString() } /** * @return 需要朗读的文本列表 * @param pageIndex 起始页 * @param pageSplit 是否分页 * @param startPos 从当前页什么地方开始朗读 */ fun getNeedReadAloud( pageIndex: Int, pageSplit: Boolean, startPos: Int, pageEndIndex: Int = pages.lastIndex ): String { val stringBuilder = StringBuilder() if (pages.isNotEmpty()) { for (index in pageIndex..min(pageEndIndex, pages.lastIndex)) { stringBuilder.append(pages[index].text) if (pageSplit && !stringBuilder.endsWith("\n")) { stringBuilder.append("\n") } } } return stringBuilder.substring(startPos).toString() } fun getParagraphNum( position: Int, pageSplit: Boolean, ): Int { val paragraphs = getParagraphs(pageSplit) paragraphs.forEach { paragraph -> if (position in paragraph.chapterIndices) { return paragraph.num } } return -1 } fun getParagraphs(pageSplit: Boolean): List { return if (pageSplit) { if (isCompleted) pageParagraphs else pageParagraphsInternal } else { if (isCompleted) paragraphs else paragraphsInternal } } fun getLastParagraphPosition(): Int { return pageParagraphs.last().chapterPosition } /** * @return 根据索引位置获取所在页 */ fun getPageIndexByCharIndex(charIndex: Int): Int { val pageSize = pages.size if (pageSize == 0) { return -1 } val bIndex = pages.fastBinarySearchBy(charIndex, 0, pageSize) { it.chapterPosition } val index = abs(bIndex + 1) - 1 // 判断是否已经排版到 charIndex ,没有则返回 -1 if (!isCompleted && index == pageSize - 1) { val page = pages[index] val pageEndPos = page.chapterPosition + page.charSize if (charIndex > pageEndPos) { return -1 } } return index /* var length = 0 for (i in pages.indices) { val page = pages[i] length += page.charSize if (length > charIndex) { return page.index } } return pages.lastIndex */ } fun clearSearchResult() { for (i in pages.indices) { val page = pages[i] page.searchResult.forEach { it.selected = false it.isSearchResult = false } page.searchResult.clear() } } fun createLayout(scope: CoroutineScope, book: Book, bookContent: BookContent) { if (layout != null) { throw IllegalStateException("已经排版过了") } layout = TextChapterLayout( scope, this, textPages, book, bookContent, ) } fun setProgressListener(l: LayoutProgressListener?) { if (isCompleted) { // no op } else if (layout?.exception != null) { l?.onLayoutException(layout?.exception!!) } else { listener = l } } override fun onLayoutPageCompleted(index: Int, page: TextPage) { listener?.onLayoutPageCompleted(index, page) } override fun onLayoutCompleted() { isCompleted = true listener?.onLayoutCompleted() listener = null } override fun onLayoutException(e: Throwable) { listener?.onLayoutException(e) listener = null } fun cancelLayout() { layout?.cancel() listener = null } companion object { val emptyTextChapter = TextChapter( BookChapter(), -1, "emptyTextChapter", -1, sameTitleRemoved = false, isVip = false, isPay = false, null ).apply { isCompleted = true } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/entities/TextLine.kt ================================================ package io.legado.app.ui.book.read.page.entities import android.annotation.SuppressLint import android.graphics.Canvas import android.graphics.Paint.FontMetrics import android.os.Build import androidx.annotation.Keep import io.legado.app.help.PaintPool import io.legado.app.help.book.isImage import io.legado.app.help.config.AppConfig import io.legado.app.help.config.ReadBookConfig import io.legado.app.lib.theme.ThemeStore import io.legado.app.model.ReadBook import io.legado.app.ui.book.read.page.ContentTextView import io.legado.app.ui.book.read.page.entities.TextPage.Companion.emptyTextPage import io.legado.app.ui.book.read.page.entities.column.BaseColumn import io.legado.app.ui.book.read.page.entities.column.TextColumn import io.legado.app.ui.book.read.page.provider.ChapterProvider import io.legado.app.utils.canvasrecorder.CanvasRecorderFactory import io.legado.app.utils.canvasrecorder.recordIfNeededThenDraw import io.legado.app.utils.dpToPx /** * 行信息 */ @Keep @Suppress("unused", "MemberVisibilityCanBePrivate") data class TextLine( var text: String = "", private val textColumns: ArrayList = arrayListOf(), var lineTop: Float = 0f, var lineBase: Float = 0f, var lineBottom: Float = 0f, var indentWidth: Float = 0f, var paragraphNum: Int = 0, var chapterPosition: Int = 0, var pagePosition: Int = 0, val isTitle: Boolean = false, var isParagraphEnd: Boolean = false, var isImage: Boolean = false, var startX: Float = 0f, var indentSize: Int = 0, var extraLetterSpacing: Float = 0f, var extraLetterSpacingOffsetX: Float = 0f, var wordSpacing: Float = 0f, var exceed: Boolean = false, var onlyTextColumn: Boolean = true, ) { val columns: List get() = textColumns val charSize: Int get() = text.length val lineStart: Float get() = textColumns.firstOrNull()?.start ?: 0f val lineEnd: Float get() = textColumns.lastOrNull()?.end ?: 0f val chapterIndices: IntRange get() = chapterPosition..chapterPosition + charSize val height: Float inline get() = lineBottom - lineTop val canvasRecorder = CanvasRecorderFactory.create() var searchResultColumnCount = 0 var isReadAloud: Boolean = false set(value) { if (field != value) { invalidate() } if (value) { textPage.hasReadAloudSpan = true } field = value } var textPage: TextPage = emptyTextPage var isLeftLine = true fun addColumn(column: BaseColumn) { if (column !is TextColumn) { onlyTextColumn = false } column.textLine = this textColumns.add(column) } fun getColumn(index: Int): BaseColumn { return textColumns.getOrElse(index) { textColumns.last() } } fun getColumnReverseAt(index: Int, offset: Int = 0): BaseColumn { return textColumns[textColumns.lastIndex - offset - index] } fun getColumnsCount(): Int { return textColumns.size } fun upTopBottom(durY: Float, textHeight: Float, fontMetrics: FontMetrics) { lineTop = ChapterProvider.paddingTop + durY lineBottom = lineTop + textHeight lineBase = lineBottom - fontMetrics.descent } fun isTouch(x: Float, y: Float, relativeOffset: Float): Boolean { return y > lineTop + relativeOffset && y < lineBottom + relativeOffset && x >= lineStart && x <= lineEnd } fun isTouchY(y: Float, relativeOffset: Float): Boolean { return y > lineTop + relativeOffset && y < lineBottom + relativeOffset } fun isVisible(relativeOffset: Float): Boolean { val top = lineTop + relativeOffset val bottom = lineBottom + relativeOffset val width = bottom - top val visibleTop = ChapterProvider.paddingTop val visibleBottom = ChapterProvider.visibleBottom val visible = when { // 完全可视 top >= visibleTop && bottom <= visibleBottom -> true top <= visibleTop && bottom >= visibleBottom -> true // 上方第一行部分可视 top < visibleTop && bottom > visibleTop && bottom < visibleBottom -> { if (isImage) { true } else { val visibleRate = (bottom - visibleTop) / width visibleRate > 0.6 } } // 下方第一行部分可视 top > visibleTop && top < visibleBottom && bottom > visibleBottom -> { if (isImage) { true } else { val visibleRate = (visibleBottom - top) / width visibleRate > 0.6 } } // 不可视 else -> false } return visible } fun draw(view: ContentTextView, canvas: Canvas) { if (AppConfig.optimizeRender) { canvasRecorder.recordIfNeededThenDraw(canvas, view.width, height.toInt()) { drawTextLine(view, this) } } else { drawTextLine(view, canvas) } } private fun drawTextLine(view: ContentTextView, canvas: Canvas) { if (checkFastDraw()) { fastDrawTextLine(view, canvas) } else { for (i in columns.indices) { columns[i].draw(view, canvas) } } // 墨水屏模式下的朗读和搜索下划线 if (AppConfig.isEInkMode && (isReadAloud || searchResultColumnCount > 0)) { val underlinePaint = PaintPool.obtain() underlinePaint.set(ChapterProvider.contentPaint) underlinePaint.strokeWidth = 1.dpToPx().toFloat() val lineY = height - 1.dpToPx() canvas.drawLine(lineStart + indentWidth, lineY, lineEnd, lineY, underlinePaint) PaintPool.recycle(underlinePaint) } if (ReadBookConfig.underline && !isImage && ReadBook.book?.isImage != true) { drawUnderline(canvas) } } @SuppressLint("NewApi") private fun fastDrawTextLine(view: ContentTextView, canvas: Canvas) { val textPaint = if (isTitle) { ChapterProvider.titlePaint } else { ChapterProvider.contentPaint } val textColor = if (isReadAloud) { ThemeStore.accentColor } else { ReadBookConfig.textColor } if (textPaint.color != textColor) { textPaint.color = textColor } val paint = PaintPool.obtain() paint.set(textPaint) val letterSpacing = paint.letterSpacing * paint.textSize val letterSpacingHalf = letterSpacing * 0.5f if (extraLetterSpacing != 0f) { paint.letterSpacing += extraLetterSpacing } if (wordSpacing != 0f) { paint.wordSpacing = wordSpacing } val offsetX = if (atLeastApi35) letterSpacingHalf else extraLetterSpacingOffsetX canvas.drawText(text, indentSize, text.length, startX + offsetX, lineBase - lineTop, paint) PaintPool.recycle(paint) for (i in columns.indices) { val column = columns[i] as TextColumn if (column.selected) { canvas.drawRect(column.start, 0f, column.end, height, view.selectedPaint) } } } /** * 绘制下划线 */ private fun drawUnderline(canvas: Canvas) { val lineY = height - 1.dpToPx() canvas.drawLine( lineStart + indentWidth, lineY, lineEnd, lineY, ChapterProvider.contentPaint ) } fun checkFastDraw(): Boolean { if (!AppConfig.optimizeRender || exceed || !onlyTextColumn || textPage.isMsgPage) { return false } if (wordSpacing != 0f && (!atLeastApi26 || !wordSpacingWorking)) { return false } return searchResultColumnCount == 0 } fun invalidate() { invalidateSelf() textPage.invalidate() } fun invalidateSelf() { canvasRecorder.invalidate() } fun recycleRecorder() { canvasRecorder.recycle() } @SuppressLint("NewApi") companion object { val emptyTextLine = TextLine() private val atLeastApi26 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O private val atLeastApi35 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM private val wordSpacingWorking by lazy { // issue 3785 3846 val paint = PaintPool.obtain() val text = "一二 三" val width1 = paint.measureText(text) try { paint.wordSpacing = 10f val width2 = paint.measureText(text) width2 - width1 == 10f } catch (e: NoSuchMethodError) { false } finally { PaintPool.recycle(paint) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/entities/TextPage.kt ================================================ package io.legado.app.ui.book.read.page.entities import android.graphics.Canvas import android.graphics.Paint import android.os.Build import android.text.Layout import android.text.StaticLayout import androidx.annotation.Keep import androidx.core.graphics.withTranslation import io.legado.app.R import io.legado.app.help.PaintPool import io.legado.app.help.config.AppConfig import io.legado.app.help.config.ReadBookConfig import io.legado.app.ui.book.read.page.ContentTextView import io.legado.app.ui.book.read.page.entities.TextChapter.Companion.emptyTextChapter import io.legado.app.ui.book.read.page.entities.column.TextColumn import io.legado.app.ui.book.read.page.provider.ChapterProvider import io.legado.app.utils.canvasrecorder.CanvasRecorderFactory import io.legado.app.utils.canvasrecorder.recordIfNeeded import io.legado.app.utils.dpToPx import splitties.init.appCtx import java.text.DecimalFormat import kotlin.math.ceil import kotlin.math.max import kotlin.math.min /** * 页面信息 */ @Keep @Suppress("unused", "MemberVisibilityCanBePrivate") data class TextPage( var index: Int = 0, var text: String = appCtx.getString(R.string.data_loading), var title: String = appCtx.getString(R.string.data_loading), private val textLines: ArrayList = arrayListOf(), var chapterSize: Int = 0, var chapterIndex: Int = 0, var height: Float = 0f, var leftLineSize: Int = 0, var renderHeight: Int = 0 ) { companion object { val readProgressFormatter = DecimalFormat("0.0%") val emptyTextPage = TextPage() } val lines: List get() = textLines val lineSize: Int get() = textLines.size val charSize: Int get() = text.length.coerceAtLeast(1) val chapterPosition: Int get() = textLines.first().chapterPosition val searchResult = hashSetOf() var isMsgPage: Boolean = false var canvasRecorder = CanvasRecorderFactory.create(true) var doublePage = false var paddingTop = ChapterProvider.paddingTop var isCompleted = false var hasReadAloudSpan = false @JvmField var textChapter = emptyTextChapter val pageSize get() = textChapter.pageSize val paragraphs by lazy { paragraphsInternal } val paragraphsInternal: ArrayList get() { val paragraphs = arrayListOf() val lines = textLines.filter { it.paragraphNum > 0 } val offset = lines.first().paragraphNum - 1 lines.forEach { line -> if (paragraphs.lastIndex < line.paragraphNum - offset - 1) { paragraphs.add(TextParagraph(0)) } paragraphs[line.paragraphNum - offset - 1].textLines.add(line) } return paragraphs } fun addLine(line: TextLine) { line.textPage = this textLines.add(line) } fun getLine(index: Int): TextLine { return textLines.getOrElse(index) { textLines.last() } } /** * 底部对齐更新行位置 */ fun upLinesPosition() { if (!ReadBookConfig.textBottomJustify) return if (textLines.size <= 1) return if (leftLineSize == 0) { leftLineSize = lineSize } ChapterProvider.run { val lastLine = textLines[leftLineSize - 1] if (lastLine.isImage) return@run val lastLineHeight = with(lastLine) { lineBottom - lineTop } val pageHeight = lastLine.lineBottom + contentPaintTextHeight * lineSpacingExtra if (visibleHeight - pageHeight >= lastLineHeight) return@run val surplus = (visibleBottom - lastLine.lineBottom) if (surplus == 0f) return@run height += surplus val tj = surplus / (leftLineSize - 1) for (i in 1 until leftLineSize) { val line = textLines[i] line.lineTop += tj * i line.lineBase += tj * i line.lineBottom += tj * i } } if (leftLineSize == lineSize) return ChapterProvider.run { val lastLine = textLines.last() if (lastLine.isImage) return@run val lastLineHeight = with(lastLine) { lineBottom - lineTop } val pageHeight = lastLine.lineBottom + contentPaintTextHeight * lineSpacingExtra if (visibleHeight - pageHeight >= lastLineHeight) return@run val surplus = (visibleBottom - lastLine.lineBottom) if (surplus == 0f) return@run val tj = surplus / (textLines.size - leftLineSize - 1) for (i in leftLineSize + 1 until textLines.size) { val line = textLines[i] val surplusIndex = i - leftLineSize line.lineTop += tj * surplusIndex line.lineBase += tj * surplusIndex line.lineBottom += tj * surplusIndex } } } /** * 计算文字位置,只用作单页面内容 */ @Suppress("DEPRECATION") fun format(): TextPage { if (textLines.isEmpty()) isMsgPage = true if (isMsgPage && ChapterProvider.viewWidth > 0) { textLines.clear() val visibleWidth = ChapterProvider.visibleRight - ChapterProvider.paddingLeft val paint = ChapterProvider.contentPaint val layout = StaticLayout( text, paint, visibleWidth, Layout.Alignment.ALIGN_NORMAL, 1f, 0f, false ) val letterSpacing = paint.letterSpacing * paint.textSize var y = (ChapterProvider.visibleHeight - layout.height) / 2f if (y < 0) y = 0f for (lineIndex in 0 until layout.lineCount) { val textLine = TextLine() textLine.lineTop = ChapterProvider.paddingTop + y + layout.getLineTop(lineIndex) textLine.lineBase = ChapterProvider.paddingTop + y + layout.getLineBaseline(lineIndex) textLine.lineBottom = ChapterProvider.paddingTop + y + layout.getLineBottom(lineIndex) var x = ChapterProvider.paddingLeft + (visibleWidth - layout.getLineMax(lineIndex)) / 2 textLine.text = text.substring(layout.getLineStart(lineIndex), layout.getLineEnd(lineIndex)) for (i in textLine.text.indices) { val char = textLine.text[i].toString() var cw = StaticLayout.getDesiredWidth(char, paint) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { cw += letterSpacing } val x1 = x + cw textLine.addColumn( TextColumn(start = x, end = x1, char) ) x = x1 } addLine(textLine) } height = ChapterProvider.visibleHeight.toFloat() upRenderHeight() invalidate() isCompleted = true } return this } /** * 移除朗读标志 */ fun removePageAloudSpan(): TextPage { if (!hasReadAloudSpan) { return this } hasReadAloudSpan = false for (i in textLines.indices) { textLines[i].isReadAloud = false } return this } /** * 更新朗读标志 * @param aloudSpanStart 朗读文字开始位置 */ fun upPageAloudSpan(aloudSpanStart: Int) { removePageAloudSpan() var lineStart = 0 for (index in textLines.indices) { val textLine = textLines[index] val lineLength = textLine.text.length + if (textLine.isParagraphEnd) 1 else 0 if (aloudSpanStart >= lineStart && aloudSpanStart < lineStart + lineLength) { for (i in index - 1 downTo 0) { if (textLines[i].isParagraphEnd) { break } else { textLines[i].isReadAloud = true } } for (i in index until textLines.size) { if (textLines[i].isParagraphEnd) { textLines[i].isReadAloud = true break } else { textLines[i].isReadAloud = true } } break } lineStart += lineLength } } /** * 阅读进度 */ val readProgress: String get() { val df = readProgressFormatter if (chapterSize == 0 || pageSize == 0 && chapterIndex == 0) { return "0.0%" } else if (pageSize == 0) { return df.format((chapterIndex + 1.0f) / chapterSize.toDouble()) } var percent = df.format(chapterIndex * 1.0f / chapterSize + 1.0f / chapterSize * (index + 1) / pageSize.toDouble()) if (percent == "100.0%" && (chapterIndex + 1 != chapterSize || index + 1 != pageSize)) { percent = "99.9%" } return percent } /** * 根据行和列返回字符在本页的位置 * @param lineIndex 字符在第几行 * @param columnIndex 字符在第几列 * @return 字符在本页位置 */ fun getPosByLineColumn(lineIndex: Int, columnIndex: Int): Int { var length = 0 val maxIndex = min(lineIndex, lineSize - 1) for (index in 0 until maxIndex) { length += textLines[index].charSize if (textLines[index].isParagraphEnd) { length++ } } val columns = textLines[maxIndex].columns for (index in 0 until columnIndex) { val column = columns[index] if (column is TextColumn) { length += column.charData.length } } return length } /** * @return 页面所在章节 */ fun getTextChapter(): TextChapter { return textChapter } /** * 判断章节字符位置是否在这一页中 * * @param chapterPos 章节字符位置 * @return */ fun containPos(chapterPos: Int): Boolean { val line = lines.first() val startPos = line.chapterPosition val endPos = startPos + charSize return chapterPos in startPos.. 0 && leftLineSize != lines.size) { val leftHeight = ceil(lines[leftLineSize - 1].lineBottom).toInt() renderHeight = max(renderHeight, leftHeight) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/entities/TextParagraph.kt ================================================ package io.legado.app.ui.book.read.page.entities @Suppress("unused", "MemberVisibilityCanBePrivate") data class TextParagraph( var num: Int, val textLines: ArrayList = arrayListOf(), ) { val text: String get() = textLines.joinToString("") { it.text } val length: Int get() = text.length val firstLine: TextLine get() = textLines.first() val lastLine: TextLine get() = textLines.last() val chapterIndices: IntRange get() = firstLine.chapterPosition..lastLine.chapterPosition + lastLine.charSize val chapterPosition: Int get() = firstLine.chapterPosition val realNum: Int get() = firstLine.paragraphNum val isParagraphEnd: Boolean get() = lastLine.isParagraphEnd } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/entities/TextPos.kt ================================================ package io.legado.app.ui.book.read.page.entities import androidx.annotation.Keep /** * 位置信息 */ @Keep @Suppress("unused") data class TextPos( var relativePagePos: Int, var lineIndex: Int, var columnIndex: Int, ) { fun upData( relativePos: Int, lineIndex: Int, charIndex: Int, ) { this.relativePagePos = relativePos this.lineIndex = lineIndex this.columnIndex = charIndex } fun upData(pos: TextPos) { relativePagePos = pos.relativePagePos lineIndex = pos.lineIndex columnIndex = pos.columnIndex } fun compare(pos: TextPos): Int { return when { relativePagePos < pos.relativePagePos -> -3 relativePagePos > pos.relativePagePos -> 3 lineIndex < pos.lineIndex -> -2 lineIndex > pos.lineIndex -> 2 columnIndex < pos.columnIndex -> -1 columnIndex > pos.columnIndex -> 1 else -> 0 } } fun compare(relativePos: Int, lineIndex: Int, charIndex: Int): Int { return when { this.relativePagePos < relativePos -> -3 this.relativePagePos > relativePos -> 3 this.lineIndex < lineIndex -> -2 this.lineIndex > lineIndex -> 2 this.columnIndex < charIndex -> -1 this.columnIndex > charIndex -> 1 else -> 0 } } fun reset() { relativePagePos = 0 lineIndex = -1 columnIndex = -1 } fun isSelected(): Boolean { return lineIndex >= 0 && columnIndex >= 0 } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/entities/column/BaseColumn.kt ================================================ package io.legado.app.ui.book.read.page.entities.column import android.graphics.Canvas import io.legado.app.ui.book.read.page.ContentTextView import io.legado.app.ui.book.read.page.entities.TextLine /** * 列基类 */ interface BaseColumn { var start: Float var end: Float var textLine: TextLine fun draw(view: ContentTextView, canvas: Canvas) fun isTouch(x: Float): Boolean { return x > start && x < end } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/entities/column/ButtonColumn.kt ================================================ package io.legado.app.ui.book.read.page.entities.column import android.graphics.Canvas import androidx.annotation.Keep 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 /** * 按钮列 */ @Keep data class ButtonColumn( override var start: Float, override var end: Float, ) : BaseColumn { override var textLine: TextLine = emptyTextLine override fun draw(view: ContentTextView, canvas: Canvas) { } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/entities/column/ImageColumn.kt ================================================ package io.legado.app.ui.book.read.page.entities.column import android.graphics.Canvas import android.graphics.RectF import androidx.annotation.Keep import io.legado.app.model.ImageProvider 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.toastOnUi import splitties.init.appCtx /** * 图片列 */ @Keep data class ImageColumn( override var start: Float, override var end: Float, var src: String ) : BaseColumn { override var textLine: TextLine = emptyTextLine override fun draw(view: ContentTextView, canvas: Canvas) { val book = ReadBook.book ?: return val height = textLine.height val bitmap = ImageProvider.getImage( book, src, (end - start).toInt(), height.toInt() ) val rectF = if (textLine.isImage) { RectF(start, 0f, end, height) } else { /*以宽度为基准保持图片的原始比例叠加,当div为负数时,允许高度比字符更高*/ val h = (end - start) / bitmap.width * bitmap.height val div = (height - h) / 2 RectF(start, div, end, height - div) } kotlin.runCatching { canvas.drawBitmap(bitmap, null, rectF, view.imagePaint) }.onFailure { e -> appCtx.toastOnUi(e.localizedMessage) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/entities/column/ReviewColumn.kt ================================================ package io.legado.app.ui.book.read.page.entities.column import android.graphics.Canvas import android.graphics.Paint import android.graphics.Path import androidx.annotation.Keep 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.ui.book.read.page.provider.ChapterProvider /** * 评论按钮列 */ @Keep data class ReviewColumn( override var start: Float, override var end: Float, val count: Int = 0 ) : BaseColumn { override var textLine: TextLine = emptyTextLine override fun draw(view: ContentTextView, canvas: Canvas) { val textPaint = if (textLine.isTitle) { ChapterProvider.titlePaint } else { ChapterProvider.contentPaint } drawToCanvas(canvas, textLine.lineBase, textPaint.textSize) } val countText by lazy { if (count > 999) { return@lazy "999" } return@lazy count.toString() } val path by lazy { Path() } fun drawToCanvas(canvas: Canvas, baseLine: Float, height: Float) { if (count == 0) return path.reset() path.moveTo(start + 1, baseLine - height * 2 / 5) path.lineTo(start + height / 6, baseLine - height * 0.55f) path.lineTo(start + height / 6, baseLine - height * 0.8f) path.lineTo(end - 1, baseLine - height * 0.8f) path.lineTo(end - 1, baseLine) path.lineTo(start + height / 6, baseLine) path.lineTo(start + height / 6, baseLine - height / 4) path.close() val reviewPaint = ChapterProvider.reviewPaint reviewPaint.style = Paint.Style.STROKE canvas.drawPath(path, reviewPaint) reviewPaint.style = Paint.Style.FILL canvas.drawText( countText, (start + height / 9 + end) / 2, baseLine - height * 0.23f, reviewPaint ) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextColumn.kt ================================================ package io.legado.app.ui.book.read.page.entities.column import android.graphics.Canvas import android.os.Build import androidx.annotation.Keep import io.legado.app.help.config.ReadBookConfig import io.legado.app.lib.theme.ThemeStore 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.ui.book.read.page.provider.ChapterProvider /** * 文字列 */ @Keep data class TextColumn( override var start: Float, override var end: Float, val charData: String, ) : BaseColumn { override var textLine: TextLine = emptyTextLine var selected: Boolean = false set(value) { if (field != value) { textLine.invalidate() } field = value } var isSearchResult: Boolean = false set(value) { if (field != value) { textLine.invalidate() if (value) { textLine.searchResultColumnCount++ } else { textLine.searchResultColumnCount-- } } field = value } override fun draw(view: ContentTextView, canvas: Canvas) { val textPaint = if (textLine.isTitle) { ChapterProvider.titlePaint } else { ChapterProvider.contentPaint } val textColor = if (textLine.isReadAloud || isSearchResult) { ThemeStore.accentColor } else { ReadBookConfig.textColor } if (textPaint.color != textColor) { textPaint.color = textColor } val y = textLine.lineBase - textLine.lineTop if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { val letterSpacing = textPaint.letterSpacing * textPaint.textSize val letterSpacingHalf = letterSpacing * 0.5f canvas.drawText(charData, start + letterSpacingHalf, y, textPaint) } else { canvas.drawText(charData, start, y, textPaint) } if (selected) { canvas.drawRect(start, 0f, end, textLine.height, view.selectedPaint) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/provider/ChapterProvider.kt ================================================ package io.legado.app.ui.book.read.page.provider import android.graphics.Paint.FontMetrics import android.graphics.RectF import android.graphics.Typeface import android.net.Uri import android.os.Build import android.text.Layout import android.text.StaticLayout import android.text.TextPaint import androidx.core.os.postDelayed import io.legado.app.constant.AppLog import io.legado.app.constant.AppPattern import io.legado.app.constant.EventBus import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.help.book.BookContent import io.legado.app.help.config.AppConfig import io.legado.app.help.config.ReadBookConfig import io.legado.app.model.ImageProvider import io.legado.app.model.ReadBook 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 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.utils.RealPathUtil import io.legado.app.utils.buildMainHandler import io.legado.app.utils.dpToPx import io.legado.app.utils.fastSum import io.legado.app.utils.isContentScheme import io.legado.app.utils.isPad import io.legado.app.utils.postEvent import io.legado.app.utils.spToPx import io.legado.app.utils.splitNotBlank import io.legado.app.utils.textHeight import kotlinx.coroutines.CoroutineScope import splitties.init.appCtx import java.util.LinkedList import java.util.Locale /** * 解析内容生成章节和页面 */ @Suppress("DEPRECATION", "ConstPropertyName") object ChapterProvider { //用于图片字的替换 const val srcReplaceChar = "▩" //用于评论按钮的替换 const val reviewChar = "▨" const val indentChar = " " @JvmStatic var viewWidth = 0 private set @JvmStatic var viewHeight = 0 private set @JvmStatic var paddingLeft = 0 private set @JvmStatic var paddingTop = 0 private set @JvmStatic var paddingRight = 0 private set @JvmStatic var paddingBottom = 0 private set @JvmStatic var visibleWidth = 0 private set @JvmStatic var visibleHeight = 0 private set @JvmStatic var visibleRight = 0 private set @JvmStatic var visibleBottom = 0 private set @JvmStatic var lineSpacingExtra = 0f private set @JvmStatic var paragraphSpacing = 0 private set @JvmStatic var titleTopSpacing = 0 private set @JvmStatic var titleBottomSpacing = 0 private set @JvmStatic var indentCharWidth = 0f private set @JvmStatic var titlePaintTextHeight = 0f private set @JvmStatic var contentPaintTextHeight = 0f private set @JvmStatic var titlePaintFontMetrics = FontMetrics() @JvmStatic var contentPaintFontMetrics = FontMetrics() @JvmStatic var typeface: Typeface? = Typeface.DEFAULT private set @JvmStatic var titlePaint: TextPaint = TextPaint() @JvmStatic var contentPaint: TextPaint = TextPaint() @JvmStatic var reviewPaint: TextPaint = TextPaint() @JvmStatic var doublePage = false private set @JvmStatic var visibleRect = RectF() private val handler by lazy { buildMainHandler() } private var upViewSizeRunnable: Runnable? = null init { upStyle() } /** * 获取拆分完的章节数据 */ suspend fun getTextChapter( book: Book, bookChapter: BookChapter, displayTitle: String, bookContent: BookContent, chapterSize: Int, ): TextChapter { val contents = bookContent.textList val textPages = arrayListOf() val stringBuilder = StringBuilder() var absStartX = paddingLeft var durY = 0f textPages.add(TextPage()) if (ReadBookConfig.titleMode != 2 || bookChapter.isVolume) { //标题非隐藏 displayTitle.splitNotBlank("\n").forEach { text -> setTypeText( book, absStartX, durY, if (AppConfig.enableReview) text + reviewChar else text, textPages, stringBuilder, titlePaint, titlePaintTextHeight, titlePaintFontMetrics, isTitle = true, emptyContent = contents.isEmpty(), isVolumeTitle = bookChapter.isVolume ).let { absStartX = it.first durY = it.second } } textPages.last().lines.last().isParagraphEnd = true stringBuilder.append("\n") durY += titleBottomSpacing } contents.forEach { content -> if (book.getImageStyle().equals(Book.imgStyleText, true)) { //图片样式为文字嵌入类型 var text = content.replace(srcReplaceChar, "▣") val srcList = LinkedList() val sb = StringBuffer() val matcher = AppPattern.imgPattern.matcher(text) while (matcher.find()) { matcher.group(1)?.let { src -> srcList.add(src) matcher.appendReplacement(sb, srcReplaceChar) } } matcher.appendTail(sb) text = sb.toString() setTypeText( book, absStartX, durY, text, textPages, stringBuilder, contentPaint, contentPaintTextHeight, contentPaintFontMetrics, srcList = srcList ).let { absStartX = it.first durY = it.second } } else { val matcher = AppPattern.imgPattern.matcher(content) var start = 0 while (matcher.find()) { val text = content.substring(start, matcher.start()) if (text.isNotBlank()) { setTypeText( book, absStartX, durY, text, textPages, stringBuilder, contentPaint, contentPaintTextHeight, contentPaintFontMetrics ).let { absStartX = it.first durY = it.second } } setTypeImage( book, matcher.group(1)!!, absStartX, durY, textPages, contentPaintTextHeight, stringBuilder, book.getImageStyle() ).let { absStartX = it.first durY = it.second } start = matcher.end() } if (start < content.length) { val text = content.substring(start, content.length) if (text.isNotBlank()) { setTypeText( book, absStartX, durY, if (AppConfig.enableReview) text + reviewChar else text, textPages, stringBuilder, contentPaint, contentPaintTextHeight, contentPaintFontMetrics ).let { absStartX = it.first durY = it.second } } } } textPages.last().lines.last().isParagraphEnd = true stringBuilder.append("\n") } val textPage = textPages.last() val endPadding = 20.dpToPx() val durYPadding = durY + endPadding if (textPage.height < durYPadding) { textPage.height = durYPadding } else { textPage.height += endPadding } textPage.text = stringBuilder.toString() textPages.forEachIndexed { index, item -> item.index = index //item.pageSize = textPages.size item.chapterIndex = bookChapter.index item.chapterSize = chapterSize item.title = displayTitle item.doublePage = doublePage item.paddingTop = paddingTop item.upLinesPosition() } return TextChapter( bookChapter, bookChapter.index, displayTitle, //textPages, chapterSize, bookContent.sameTitleRemoved, bookChapter.isVip, bookChapter.isPay, bookContent.effectiveReplaceRules ) } fun getTextChapterAsync( scope: CoroutineScope, book: Book, bookChapter: BookChapter, displayTitle: String, bookContent: BookContent, chapterSize: Int, ): TextChapter { val textChapter = TextChapter( bookChapter, bookChapter.index, displayTitle, chapterSize, bookContent.sameTitleRemoved, bookChapter.isVip, bookChapter.isPay, bookContent.effectiveReplaceRules ).apply { createLayout(scope, book, bookContent) } return textChapter } /** * 排版图片 */ private suspend fun setTypeImage( book: Book, src: String, x: Int, y: Float, textPages: ArrayList, textHeight: Float, stringBuilder: StringBuilder, imageStyle: String?, ): Pair { var absStartX = x var durY = y val size = ImageProvider.getImageSize(book, src, ReadBook.bookSource) if (size.width > 0 && size.height > 0) { if (durY > visibleHeight) { val textPage = textPages.last() if (textPage.height < durY) { textPage.height = durY } textPage.text = stringBuilder.toString().ifEmpty { "本页无文字内容" } stringBuilder.clear() textPages.add(TextPage()) durY = 0f } var height = size.height var width = size.width when (imageStyle?.uppercase(Locale.ROOT)) { Book.imgStyleFull -> { width = visibleWidth height = size.height * visibleWidth / size.width } Book.imgStyleSingle -> { width = visibleWidth height = size.height * visibleWidth / size.width if (height > visibleHeight) { width = width * visibleHeight / height height = visibleHeight } if (durY > 0f) { val textPage = textPages.last() if (doublePage && absStartX < viewWidth / 2) { //当前页面左列结束 textPage.leftLineSize = textPage.lineSize absStartX = viewWidth / 2 + paddingLeft } else { //当前页面结束 if (textPage.leftLineSize == 0) { textPage.leftLineSize = textPage.lineSize } textPage.text = stringBuilder.toString().ifEmpty { "本页无文字内容" } stringBuilder.clear() textPages.add(TextPage()) } // 双页的 durY 不正确,可能会小于实际高度 if (textPage.height < durY) { textPage.height = durY } durY = 0f } // 图片竖直方向居中:调整 Y 坐标 if (height < visibleHeight) { val adjustHeight = (visibleHeight - height) / 2f durY = adjustHeight // 将 Y 坐标设置为居中位置 } } else -> { if (size.width > visibleWidth) { height = size.height * visibleWidth / size.width width = visibleWidth } if (height > visibleHeight) { width = width * visibleHeight / height height = visibleHeight } if (durY + height > visibleHeight) { val textPage = textPages.last() if (doublePage && absStartX < viewWidth / 2) { //当前页面左列结束 textPage.leftLineSize = textPage.lineSize absStartX = viewWidth / 2 + paddingLeft } else { //当前页面结束 if (textPage.leftLineSize == 0) { textPage.leftLineSize = textPage.lineSize } textPage.text = stringBuilder.toString().ifEmpty { "本页无文字内容" } stringBuilder.clear() textPages.add(TextPage()) } // 双页的 durY 不正确,可能会小于实际高度 if (textPage.height < durY) { textPage.height = durY } durY = 0f } } } val textLine = TextLine(isImage = true) textLine.lineTop = durY + paddingTop durY += height textLine.lineBottom = durY + paddingTop val (start, end) = if (visibleWidth > width) { val adjustWidth = (visibleWidth - width) / 2f Pair(adjustWidth, adjustWidth + width) } else { Pair(0f, width.toFloat()) } textLine.addColumn( ImageColumn(start = x + start, end = x + end, src = src) ) calcTextLinePosition(textPages, textLine, stringBuilder.length) stringBuilder.append(" ") // 确保翻页时索引计算正确 textPages.last().addLine(textLine) } return absStartX to durY + textHeight * paragraphSpacing / 10f } /** * 排版文字 */ private suspend fun setTypeText( book: Book, x: Int, y: Float, text: String, textPages: ArrayList, stringBuilder: StringBuilder, textPaint: TextPaint, textHeight: Float, fontMetrics: FontMetrics, isTitle: Boolean = false, emptyContent: Boolean = false, isVolumeTitle: Boolean = false, srcList: LinkedList? = null ): Pair { var absStartX = x val layout = if (ReadBookConfig.useZhLayout) { ZhLayout( text, textPaint, visibleWidth, emptyList(), emptyList(), ReadBookConfig.paragraphIndent.length ) } else { StaticLayout(text, textPaint, visibleWidth, Layout.Alignment.ALIGN_NORMAL, 0f, 0f, true) } var durY = when { //标题y轴居中 emptyContent && textPages.size == 1 -> { val textPage = textPages.last() if (textPage.lineSize == 0) { val ty = (visibleHeight - layout.lineCount * textHeight) / 2 if (ty > titleTopSpacing) ty else titleTopSpacing.toFloat() } else { var textLayoutHeight = layout.lineCount * textHeight val fistLine = textPage.getLine(0) if (fistLine.lineTop < textLayoutHeight + titleTopSpacing) { textLayoutHeight = fistLine.lineTop - titleTopSpacing } textPage.lines.forEach { it.lineTop -= textLayoutHeight it.lineBase -= textLayoutHeight it.lineBottom -= textLayoutHeight } y - textLayoutHeight } } isTitle && textPages.size == 1 && textPages.last().lines.isEmpty() -> y + titleTopSpacing else -> y } for (lineIndex in 0 until layout.lineCount) { val textLine = TextLine(isTitle = isTitle) if (durY + textHeight > visibleHeight) { val textPage = textPages.last() if (doublePage && absStartX < viewWidth / 2) { //当前页面左列结束 textPage.leftLineSize = textPage.lineSize absStartX = viewWidth / 2 + paddingLeft } else { //当前页面结束,设置各种值 if (textPage.leftLineSize == 0) { textPage.leftLineSize = textPage.lineSize } textPage.text = stringBuilder.toString() //新建页面 textPages.add(TextPage()) stringBuilder.clear() absStartX = paddingLeft } if (textPage.height < durY) { textPage.height = durY } durY = 0f } val lineStart = layout.getLineStart(lineIndex) val lineEnd = layout.getLineEnd(lineIndex) val lineText = text.substring(lineStart, lineEnd) val (words, widths) = measureTextSplit(lineText, textPaint) val desiredWidth = widths.fastSum() when { lineIndex == 0 && layout.lineCount > 1 && !isTitle -> { //第一行 非标题 textLine.text = lineText addCharsToLineFirst( book, absStartX, textLine, words, desiredWidth, widths, srcList ) } lineIndex == layout.lineCount - 1 -> { //最后一行 textLine.text = lineText //标题x轴居中 val startX = if ( isTitle && (ReadBookConfig.isMiddleTitle || emptyContent || isVolumeTitle) ) { (visibleWidth - desiredWidth) / 2 } else { 0f } addCharsToLineNatural( book, absStartX, textLine, words, startX, !isTitle && lineIndex == 0, widths, srcList ) } else -> { if ( isTitle && (ReadBookConfig.isMiddleTitle || emptyContent || isVolumeTitle) ) { //标题居中 val startX = (visibleWidth - desiredWidth) / 2 addCharsToLineNatural( book, absStartX, textLine, words, startX, false, widths, srcList ) } else { //中间行 textLine.text = lineText addCharsToLineMiddle( book, absStartX, textLine, words, desiredWidth, 0f, widths, srcList ) } } } if (doublePage) { textLine.isLeftLine = absStartX < viewWidth / 2 } calcTextLinePosition(textPages, textLine, stringBuilder.length) stringBuilder.append(lineText) textLine.upTopBottom(durY, textHeight, fontMetrics) val textPage = textPages.last() textPage.addLine(textLine) durY += textHeight * lineSpacingExtra if (textPage.height < durY) { textPage.height = durY } } durY += textHeight * paragraphSpacing / 10f return Pair(absStartX, durY) } private fun calcTextLinePosition( textPages: ArrayList, textLine: TextLine, sbLength: Int ) { val lastLine = textPages.last().lines.lastOrNull { it.paragraphNum > 0 } ?: textPages.getOrNull( textPages.lastIndex - 1 )?.lines?.lastOrNull { it.paragraphNum > 0 } val paragraphNum = when { lastLine == null -> 1 lastLine.isParagraphEnd -> lastLine.paragraphNum + 1 else -> lastLine.paragraphNum } textLine.paragraphNum = paragraphNum textLine.chapterPosition = (textPages.getOrNull(textPages.lastIndex - 1)?.lines?.lastOrNull()?.run { chapterPosition + charSize + if (isParagraphEnd) 1 else 0 } ?: 0) + sbLength textLine.pagePosition = sbLength } /** * 有缩进,两端对齐 */ private suspend fun addCharsToLineFirst( book: Book, absStartX: Int, textLine: TextLine, words: List, /**自然排版长度**/ desiredWidth: Float, textWidths: List, srcList: LinkedList? ) { var x = 0f if (!ReadBookConfig.textFullJustify) { addCharsToLineNatural( book, absStartX, textLine, words, x, true, textWidths, srcList ) return } val bodyIndent = ReadBookConfig.paragraphIndent for (i in bodyIndent.indices) { val x1 = x + indentCharWidth textLine.addColumn( TextColumn( charData = indentChar, start = absStartX + x, end = absStartX + x1 ) ) x = x1 textLine.indentWidth = x } if (words.size > bodyIndent.length) { val text1 = words.subList(bodyIndent.length, words.size) val textWidths1 = textWidths.subList(bodyIndent.length, textWidths.size) addCharsToLineMiddle( book, absStartX, textLine, text1, desiredWidth, x, textWidths1, srcList ) } } /** * 无缩进,两端对齐 */ private suspend fun addCharsToLineMiddle( book: Book, absStartX: Int, textLine: TextLine, words: List, /**自然排版长度**/ desiredWidth: Float, /**起始x坐标**/ startX: Float, textWidths: List, srcList: LinkedList? ) { if (!ReadBookConfig.textFullJustify) { addCharsToLineNatural( book, absStartX, textLine, words, startX, false, textWidths, srcList ) return } val residualWidth = visibleWidth - desiredWidth val spaceSize = words.count { it == " " } if (spaceSize > 1) { val d = residualWidth / spaceSize var x = startX for (index in words.indices) { val char = words[index] val cw = textWidths[index] val x1 = if (char == " ") { if (index != words.lastIndex) (x + cw + d) else (x + cw) } else { (x + cw) } addCharToLine( book, absStartX, textLine, char, x, x1, index + 1 == words.size, srcList ) x = x1 } } else { val gapCount: Int = words.lastIndex val d = residualWidth / gapCount var x = startX for (index in words.indices) { val char = words[index] val cw = textWidths[index] 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 } } exceed(absStartX, textLine, words) } /** * 自然排列 */ private suspend fun addCharsToLineNatural( book: Book, absStartX: Int, textLine: TextLine, words: List, startX: Float, hasIndent: Boolean, textWidths: List, srcList: LinkedList? ) { val indentLength = ReadBookConfig.paragraphIndent.length var x = startX for (index in words.indices) { val char = words[index] val cw = textWidths[index] val x1 = x + cw addCharToLine(book, absStartX, textLine, char, x, x1, index + 1 == words.size, srcList) x = x1 if (hasIndent && index == indentLength - 1) { textLine.indentWidth = x } } exceed(absStartX, textLine, words) } /** * 添加字符 */ private suspend fun addCharToLine( book: Book, absStartX: Int, textLine: TextLine, char: String, xStart: Float, xEnd: Float, isLineEnd: Boolean, srcList: LinkedList? ) { val column = when { srcList != null && char == srcReplaceChar -> { val src = srcList.removeFirst() ImageProvider.cacheImage(book, src, ReadBook.bookSource) ImageColumn( start = absStartX + xStart, end = absStartX + xEnd, src = src ) } isLineEnd && char == reviewChar -> { ReviewColumn( start = absStartX + xStart, end = absStartX + xEnd, count = 100 ) } else -> { TextColumn( start = absStartX + xStart, end = absStartX + xEnd, charData = char ) } } textLine.addColumn(column) } /** * 超出边界处理 */ private fun exceed(absStartX: Int, textLine: TextLine, words: List) { val visibleEnd = absStartX + visibleWidth val endX = textLine.columns.lastOrNull()?.end ?: return if (endX > visibleEnd) { val cc = (endX - visibleEnd) / words.size for (i in 0..words.lastIndex) { textLine.getColumnReverseAt(i).let { val py = cc * (words.size - i) it.start -= py it.end -= py } } } } fun measureTextSplit( text: String, paint: TextPaint ): Pair, ArrayList> { val length = text.length val widthsArray = FloatArray(length) paint.getTextWidths(text, widthsArray) val clusterCount = widthsArray.count { it > 0f } val widths = ArrayList(clusterCount) val stringList = ArrayList(clusterCount) var i = 0 while (i < length) { val clusterBaseIndex = i++ widths.add(widthsArray[clusterBaseIndex]) while (i < length && widthsArray[i] == 0f) { i++ } stringList.add(text.substring(clusterBaseIndex, i)) } return stringList to widths } /** * 更新样式 */ fun upStyle() { typeface = getTypeface(ReadBookConfig.textFont) getPaints(typeface).let { titlePaint = it.first contentPaint = it.second // reviewPaint.color = contentPaint.color // reviewPaint.textSize = contentPaint.textSize * 0.45f // reviewPaint.textAlign = Paint.Align.CENTER } //间距 lineSpacingExtra = ReadBookConfig.lineSpacingExtra / 10f paragraphSpacing = ReadBookConfig.paragraphSpacing titleTopSpacing = ReadBookConfig.titleTopSpacing.dpToPx() titleBottomSpacing = ReadBookConfig.titleBottomSpacing.dpToPx() val bodyIndent = ReadBookConfig.paragraphIndent indentCharWidth = if (bodyIndent.isNotEmpty()) { var indentWidth = StaticLayout.getDesiredWidth(bodyIndent, contentPaint) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { indentWidth += contentPaint.letterSpacing * contentPaint.textSize } indentWidth / bodyIndent.length } else { 0f } titlePaintTextHeight = titlePaint.textHeight contentPaintTextHeight = contentPaint.textHeight titlePaintFontMetrics = titlePaint.fontMetrics contentPaintFontMetrics = contentPaint.fontMetrics upLayout() } private fun getTypeface(fontPath: String): Typeface? { return kotlin.runCatching { when { fontPath.isContentScheme() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> { appCtx.contentResolver .openFileDescriptor(Uri.parse(fontPath), "r")!! .use { Typeface.Builder(it.fileDescriptor).build() } } fontPath.isContentScheme() -> { Typeface.createFromFile(RealPathUtil.getPath(appCtx, Uri.parse(fontPath))) } fontPath.isNotEmpty() -> Typeface.createFromFile(fontPath) else -> when (AppConfig.systemTypefaces) { 1 -> Typeface.SERIF 2 -> Typeface.MONOSPACE else -> Typeface.SANS_SERIF } } }.getOrElse { ReadBookConfig.textFont = "" ReadBookConfig.save() Typeface.SANS_SERIF } ?: Typeface.DEFAULT } private fun getPaints(typeface: Typeface?): Pair { // 字体统一处理 val bold = Typeface.create(typeface, Typeface.BOLD) val normal = Typeface.create(typeface, Typeface.NORMAL) val (titleFont, textFont) = when (ReadBookConfig.textBold) { 1 -> { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) Pair(Typeface.create(typeface, 900, false), bold) else Pair(bold, bold) } 2 -> { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) Pair(normal, Typeface.create(typeface, 300, false)) else Pair(normal, normal) } else -> Pair(bold, normal) } //标题 val tPaint = TextPaint() tPaint.color = ReadBookConfig.textColor tPaint.letterSpacing = ReadBookConfig.letterSpacing tPaint.typeface = titleFont tPaint.textSize = with(ReadBookConfig) { textSize + titleSize }.toFloat().spToPx() tPaint.isAntiAlias = true if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q && AppConfig.optimizeRender) { tPaint.isLinearText = true } //正文 val cPaint = TextPaint() cPaint.color = ReadBookConfig.textColor cPaint.letterSpacing = ReadBookConfig.letterSpacing cPaint.typeface = textFont cPaint.textSize = ReadBookConfig.textSize.toFloat().spToPx() cPaint.isAntiAlias = true if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q && AppConfig.optimizeRender) { cPaint.isLinearText = true } return Pair(tPaint, cPaint) } /** * 更新View尺寸 */ fun upViewSize(width: Int, height: Int) { if (width <= 0 || height <= 0) { return } if (width != viewWidth || height != viewHeight) { if (width == viewWidth) { upViewSizeRunnable = handler.postDelayed(300) { upViewSizeRunnable = null notifyViewSizeChange(width, height) } } else { notifyViewSizeChange(width, height) } } else if (upViewSizeRunnable != null) { handler.removeCallbacks(upViewSizeRunnable!!) upViewSizeRunnable = null } } private fun notifyViewSizeChange(width: Int, height: Int) { viewWidth = width viewHeight = height upLayout() postEvent(EventBus.UP_CONFIG, arrayListOf(5)) } /** * 更新绘制尺寸 */ fun upLayout() { when (AppConfig.doublePageHorizontal) { "0" -> doublePage = false "1" -> doublePage = true "2" -> { doublePage = (viewWidth > viewHeight) && ReadBook.pageAnim() != 3 } "3" -> { doublePage = (viewWidth > viewHeight || appCtx.isPad) && ReadBook.pageAnim() != 3 } } if (viewWidth <= 0 || viewHeight <= 0) { return } paddingLeft = ReadBookConfig.paddingLeft.dpToPx() paddingTop = ReadBookConfig.paddingTop.dpToPx() paddingRight = ReadBookConfig.paddingRight.dpToPx() paddingBottom = ReadBookConfig.paddingBottom.dpToPx() visibleWidth = if (doublePage) { viewWidth / 2 - paddingLeft - paddingRight } else { viewWidth - paddingLeft - paddingRight } //留1dp画最后一行下划线 visibleHeight = viewHeight - paddingTop - paddingBottom visibleRight = viewWidth - paddingRight visibleBottom = paddingTop + visibleHeight if (paddingLeft >= visibleRight || paddingTop >= visibleBottom) { AppLog.put("边距设置过大,请重新设置", toast = true) setFallbackLayout() } visibleRect.set( paddingLeft.toFloat(), paddingTop.toFloat(), visibleRight.toFloat(), visibleBottom.toFloat() ) } private fun setFallbackLayout() { paddingLeft = 20.dpToPx() paddingTop = 5.dpToPx() paddingRight = 20.dpToPx() paddingBottom = 5.dpToPx() visibleWidth = if (doublePage) { viewWidth / 2 - paddingLeft - paddingRight } else { viewWidth - paddingLeft - paddingRight } //留1dp画最后一行下划线 visibleHeight = viewHeight - paddingTop - paddingBottom visibleRight = viewWidth - paddingRight visibleBottom = paddingTop + visibleHeight } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/provider/LayoutProgressListener.kt ================================================ package io.legado.app.ui.book.read.page.provider import io.legado.app.ui.book.read.page.entities.TextPage interface LayoutProgressListener { /** * 单页排版完成 */ fun onLayoutPageCompleted(index: Int, page: TextPage) {} /** * 全部排版完成 */ fun onLayoutCompleted() {} /** * 排版出现异常 */ fun onLayoutException(e: Throwable) {} } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt ================================================ package io.legado.app.ui.book.read.page.provider import android.graphics.Paint import android.text.Layout import android.text.StaticLayout import android.text.TextPaint import io.legado.app.constant.AppLog import io.legado.app.constant.AppPattern import io.legado.app.constant.PageAnim import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.help.book.BookContent import io.legado.app.help.book.BookHelp import io.legado.app.help.book.getBookSource import io.legado.app.help.config.AppConfig 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.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 import io.legado.app.ui.book.read.page.entities.column.ImageColumn import io.legado.app.ui.book.read.page.entities.column.TextColumn import io.legado.app.utils.dpToPx import io.legado.app.utils.fastSum import io.legado.app.utils.getTextWidthsCompat import io.legado.app.utils.splitNotBlank import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import java.util.LinkedList import kotlin.math.roundToInt class TextChapterLayout( scope: CoroutineScope, private val textChapter: TextChapter, private val textPages: ArrayList, private val book: Book, private val bookContent: BookContent, ) { @Volatile private var listener: LayoutProgressListener? = textChapter private val paddingLeft = ChapterProvider.paddingLeft private val paddingTop = ChapterProvider.paddingTop private val titlePaint = ChapterProvider.titlePaint private val titlePaintTextHeight = ChapterProvider.titlePaintTextHeight private val titlePaintFontMetrics = ChapterProvider.titlePaintFontMetrics private val contentPaint = ChapterProvider.contentPaint private val contentPaintTextHeight = ChapterProvider.contentPaintTextHeight private val contentPaintFontMetrics = ChapterProvider.contentPaintFontMetrics private val titleTopSpacing = ChapterProvider.titleTopSpacing private val titleBottomSpacing = ChapterProvider.titleBottomSpacing private val lineSpacingExtra = ChapterProvider.lineSpacingExtra private val paragraphSpacing = ChapterProvider.paragraphSpacing private val visibleHeight = ChapterProvider.visibleHeight private val visibleWidth = ChapterProvider.visibleWidth private val viewWidth = ChapterProvider.viewWidth private val doublePage = ChapterProvider.doublePage private val indentCharWidth = ChapterProvider.indentCharWidth private val stringBuilder = StringBuilder() private val paragraphIndent = ReadBookConfig.paragraphIndent private val titleMode = ReadBookConfig.titleMode private val useZhLayout = ReadBookConfig.useZhLayout private val isMiddleTitle = ReadBookConfig.isMiddleTitle private val textFullJustify = ReadBookConfig.textFullJustify private val pageAnim = book.getPageAnim() private var pendingTextPage = TextPage() private val bookChapter inline get() = textChapter.chapter private val displayTitle inline get() = textChapter.title private val chaptersSize inline get() = textChapter.chaptersSize private var durY = 0f private var absStartX = paddingLeft private var floatArray = FloatArray(128) private var isCompleted = false private val job: Coroutine<*> var exception: Throwable? = null var channel = Channel(Channel.UNLIMITED) init { job = Coroutine.async( scope, start = CoroutineStart.LAZY, executeContext = IO ) { launch { val bookSource = book.getBookSource() ?: return@launch BookHelp.saveImages(bookSource, book, bookChapter, bookContent.toString()) } getTextChapter(book, bookChapter, displayTitle, bookContent) }.onError { exception = it onException(it) }.onCancel { channel.cancel() }.onFinally { isCompleted = true } job.start() } fun setProgressListener(l: LayoutProgressListener?) { try { if (isCompleted) { // no op } else if (exception != null) { l?.onLayoutException(exception!!) } else { listener = l } } catch (e: Exception) { e.printStackTrace() AppLog.put("调用布局进度监听回调出错\n${e.localizedMessage}", e) } } fun cancel() { job.cancel() listener = null } private fun onPageCompleted() { val textPage = pendingTextPage textPage.index = textPages.size textPage.chapterIndex = bookChapter.index textPage.chapterSize = chaptersSize textPage.title = displayTitle textPage.doublePage = doublePage textPage.paddingTop = paddingTop textPage.isCompleted = true textPage.textChapter = textChapter textPage.upLinesPosition() textPage.upRenderHeight() textPages.add(textPage) channel.trySend(textPage) try { listener?.onLayoutPageCompleted(textPages.lastIndex, textPage) } catch (e: Exception) { e.printStackTrace() AppLog.put("调用布局进度监听回调出错\n${e.localizedMessage}", e) } } private fun onCompleted() { channel.close() try { listener?.onLayoutCompleted() } catch (e: Exception) { e.printStackTrace() AppLog.put("调用布局进度监听回调出错\n${e.localizedMessage}", e) } finally { listener = null } } private fun onException(e: Throwable) { channel.close(e) if (e is CancellationException) { listener = null return } try { listener?.onLayoutException(e) } catch (e: Exception) { e.printStackTrace() AppLog.put("调用布局进度监听回调出错\n${e.localizedMessage}", e) } finally { listener = null } } /** * 获取拆分完的章节数据 */ private suspend fun getTextChapter( book: Book, bookChapter: BookChapter, displayTitle: String, bookContent: BookContent, ) { val contents = bookContent.textList val imageStyle = book.getImageStyle() val isSingleImageStyle = imageStyle.equals(Book.imgStyleSingle, true) val isTextImageStyle = imageStyle.equals(Book.imgStyleText, true) if (titleMode != 2 || bookChapter.isVolume || contents.isEmpty()) { //标题非隐藏 displayTitle.splitNotBlank("\n").forEach { text -> setTypeText( book, if (AppConfig.enableReview) text + ChapterProvider.reviewChar else text, titlePaint, titlePaintTextHeight, titlePaintFontMetrics, imageStyle, isTitle = true, emptyContent = contents.isEmpty(), isVolumeTitle = bookChapter.isVolume ) pendingTextPage.lines.last().isParagraphEnd = true stringBuilder.append("\n") } durY += titleBottomSpacing // 如果是单图模式且当前页有内容,强制分页 if (isSingleImageStyle && pendingTextPage.lines.isNotEmpty() && contents.isNotEmpty()) { prepareNextPageIfNeed() } } val sb = StringBuffer() var isSetTypedImage = false contents.forEach { content -> currentCoroutineContext().ensureActive() if (isTextImageStyle) { //图片样式为文字嵌入类型 var text = content.replace(ChapterProvider.srcReplaceChar, "▣") val srcList = LinkedList() sb.setLength(0) val matcher = AppPattern.imgPattern.matcher(text) while (matcher.find()) { matcher.group(1)?.let { src -> srcList.add(src) matcher.appendReplacement(sb, ChapterProvider.srcReplaceChar) } } matcher.appendTail(sb) text = sb.toString() setTypeText( book, text, contentPaint, contentPaintTextHeight, contentPaintFontMetrics, imageStyle, srcList = srcList ) } else { if (isSingleImageStyle && isSetTypedImage) { isSetTypedImage = false prepareNextPageIfNeed() } var start = 0 if (content.contains(" 0 && size.height > 0) { prepareNextPageIfNeed(durY) var height = size.height var width = size.width when (imageStyle?.uppercase()) { Book.imgStyleFull -> { width = visibleWidth height = size.height * visibleWidth / size.width if (pageAnim != PageAnim.scrollPageAnim && height > visibleHeight - durY) { if (height > visibleHeight) { width = width * visibleHeight / height height = visibleHeight } prepareNextPageIfNeed(durY + height) } } Book.imgStyleSingle -> { width = visibleWidth height = size.height * visibleWidth / size.width if (height > visibleHeight) { width = width * visibleHeight / height height = visibleHeight } if (durY > 0f) { prepareNextPageIfNeed() } // 图片竖直方向居中:调整 Y 坐标 if (height < visibleHeight) { val adjustHeight = (visibleHeight - height) / 2f durY = adjustHeight // 将 Y 坐标设置为居中位置 } } else -> { if (size.width > visibleWidth) { height = size.height * visibleWidth / size.width width = visibleWidth } if (height > visibleHeight) { width = width * visibleHeight / height height = visibleHeight } prepareNextPageIfNeed(durY + height) } } val textLine = TextLine(isImage = true) textLine.text = " " textLine.lineTop = durY + paddingTop durY += height textLine.lineBottom = durY + paddingTop val (start, end) = if (visibleWidth > width) { val adjustWidth = (visibleWidth - width) / 2f Pair(adjustWidth, adjustWidth + width) } else { Pair(0f, width.toFloat()) } textLine.addColumn( ImageColumn(start = absStartX + start, end = absStartX + end, src = src) ) calcTextLinePosition(textPages, textLine, stringBuilder.length) stringBuilder.append(" ") // 确保翻页时索引计算正确 pendingTextPage.addLine(textLine) } durY += textHeight * paragraphSpacing / 10f } /** * 排版文字 */ @Suppress("DEPRECATION") private suspend fun setTypeText( book: Book, text: String, textPaint: TextPaint, textHeight: Float, fontMetrics: Paint.FontMetrics, imageStyle: String?, isTitle: Boolean = false, isFirstLine: Boolean = true, emptyContent: Boolean = false, isVolumeTitle: Boolean = false, srcList: LinkedList? = null ) { val widthsArray = allocateFloatArray(text.length) textPaint.getTextWidthsCompat(text, widthsArray) val layout = if (useZhLayout) { val (words, widths) = measureTextSplit(text, widthsArray) val indentSize = if (isFirstLine) paragraphIndent.length else 0 ZhLayout(text, textPaint, visibleWidth, words, widths, indentSize) } else { StaticLayout(text, textPaint, visibleWidth, Layout.Alignment.ALIGN_NORMAL, 0f, 0f, true) } durY = when { //标题y轴居中 emptyContent && textPages.isEmpty() -> { val textPage = pendingTextPage if (textPage.lineSize == 0) { val ty = (visibleHeight - layout.lineCount * textHeight) / 2 if (ty > titleTopSpacing) ty else titleTopSpacing.toFloat() } else { var textLayoutHeight = layout.lineCount * textHeight val fistLine = textPage.getLine(0) if (fistLine.lineTop < textLayoutHeight + titleTopSpacing) { textLayoutHeight = fistLine.lineTop - titleTopSpacing } textPage.lines.forEach { it.lineTop -= textLayoutHeight it.lineBase -= textLayoutHeight it.lineBottom -= textLayoutHeight } durY - textLayoutHeight } } isTitle && textPages.isEmpty() && pendingTextPage.lines.isEmpty() -> { when (imageStyle?.uppercase()) { Book.imgStyleSingle -> { val ty = (visibleHeight - layout.lineCount * textHeight) / 2 if (ty > titleTopSpacing) ty else titleTopSpacing.toFloat() } else -> durY + titleTopSpacing } } else -> durY } for (lineIndex in 0 until layout.lineCount) { val textLine = TextLine(isTitle = isTitle) prepareNextPageIfNeed(durY + textHeight) val lineStart = layout.getLineStart(lineIndex) val lineEnd = layout.getLineEnd(lineIndex) val lineText = text.substring(lineStart, lineEnd) val (words, widths) = measureTextSplit(lineText, widthsArray, lineStart) val desiredWidth = widths.fastSum() textLine.text = lineText when { lineIndex == 0 && layout.lineCount > 1 && !isTitle && isFirstLine -> { //多行的第一行 非标题 addCharsToLineFirst( book, absStartX, textLine, words, textPaint, desiredWidth, widths, srcList ) } lineIndex == layout.lineCount - 1 -> { //最后一行、单行 //标题x轴居中 val startX = if ( isTitle && (isMiddleTitle || emptyContent || isVolumeTitle || imageStyle?.uppercase() == Book.imgStyleSingle) ) { (visibleWidth - desiredWidth) / 2 } else { 0f } addCharsToLineNatural( book, absStartX, textLine, words, startX, !isTitle && lineIndex == 0, widths, srcList ) } else -> { if ( isTitle && (isMiddleTitle || emptyContent || isVolumeTitle || imageStyle?.uppercase() == Book.imgStyleSingle) ) { //标题居中 val startX = (visibleWidth - desiredWidth) / 2 addCharsToLineNatural( book, absStartX, textLine, words, startX, false, widths, srcList ) } else { //中间行 addCharsToLineMiddle( book, absStartX, textLine, words, textPaint, desiredWidth, 0f, widths, srcList ) } } } if (doublePage) { textLine.isLeftLine = absStartX < viewWidth / 2 } calcTextLinePosition(textPages, textLine, stringBuilder.length) stringBuilder.append(lineText) textLine.upTopBottom(durY, textHeight, fontMetrics) val textPage = pendingTextPage textPage.addLine(textLine) durY += textHeight * lineSpacingExtra if (textPage.height < durY) { textPage.height = durY } } durY += textHeight * paragraphSpacing / 10f } private fun calcTextLinePosition( textPages: ArrayList, textLine: TextLine, sbLength: Int ) { val lastLine = pendingTextPage.lines.lastOrNull { it.paragraphNum > 0 } ?: textPages.lastOrNull()?.lines?.lastOrNull { it.paragraphNum > 0 } val paragraphNum = when { lastLine == null -> 1 lastLine.isParagraphEnd -> lastLine.paragraphNum + 1 else -> lastLine.paragraphNum } textLine.paragraphNum = paragraphNum textLine.chapterPosition = (textPages.lastOrNull()?.lines?.lastOrNull()?.run { chapterPosition + charSize + if (isParagraphEnd) 1 else 0 } ?: 0) + sbLength textLine.pagePosition = sbLength } /** * 有缩进,两端对齐 */ private suspend fun addCharsToLineFirst( book: Book, absStartX: Int, textLine: TextLine, words: List, textPaint: TextPaint, /**自然排版长度**/ desiredWidth: Float, textWidths: List, srcList: LinkedList? ) { var x = 0f if (!textFullJustify) { addCharsToLineNatural( book, absStartX, textLine, words, x, true, textWidths, srcList ) return } val bodyIndent = paragraphIndent repeat(bodyIndent.length) { val x1 = x + indentCharWidth textLine.addColumn( TextColumn( charData = ChapterProvider.indentChar, start = absStartX + x, end = absStartX + x1 ) ) x = x1 textLine.indentWidth = x } textLine.indentSize = bodyIndent.length if (words.size > bodyIndent.length) { val text1 = words.subList(bodyIndent.length, words.size) val textWidths1 = textWidths.subList(bodyIndent.length, textWidths.size) addCharsToLineMiddle( book, absStartX, textLine, text1, textPaint, desiredWidth, x, textWidths1, srcList ) } } /** * 无缩进,两端对齐 */ private suspend fun addCharsToLineMiddle( book: Book, absStartX: Int, textLine: TextLine, words: List, textPaint: TextPaint, /**自然排版长度**/ desiredWidth: Float, /**起始x坐标**/ startX: Float, textWidths: List, srcList: LinkedList? ) { if (!textFullJustify) { addCharsToLineNatural( book, absStartX, textLine, words, startX, false, textWidths, srcList ) return } val residualWidth = visibleWidth - desiredWidth val spaceSize = words.count { it == " " } textLine.startX = absStartX + startX if (spaceSize > 1) { val d = residualWidth / spaceSize textLine.wordSpacing = d var x = startX for (index in words.indices) { val char = words[index] val cw = textWidths[index] val x1 = if (char == " ") { if (index != words.lastIndex) (x + cw + d) else (x + cw) } else { (x + cw) } addCharToLine( book, absStartX, textLine, char, x, x1, index + 1 == words.size, srcList ) x = x1 } } else { val gapCount: Int = words.lastIndex val d = if (gapCount > 0) residualWidth / gapCount else 0f textLine.extraLetterSpacingOffsetX = -d / 2 textLine.extraLetterSpacing = d / textPaint.textSize var x = startX for (index in words.indices) { val char = words[index] val cw = textWidths[index] 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 } } exceed(absStartX, textLine, words) } /** * 自然排列 */ private suspend fun addCharsToLineNatural( book: Book, absStartX: Int, textLine: TextLine, words: List, startX: Float, hasIndent: Boolean, textWidths: List, srcList: LinkedList? ) { val indentLength = paragraphIndent.length var x = startX textLine.startX = absStartX + startX for (index in words.indices) { val char = words[index] val cw = textWidths[index] val x1 = x + cw addCharToLine(book, absStartX, textLine, char, x, x1, index + 1 == words.size, srcList) x = x1 if (hasIndent && index == indentLength - 1) { textLine.indentWidth = x } } exceed(absStartX, textLine, words) } /** * 添加字符 */ private suspend fun addCharToLine( book: Book, absStartX: Int, textLine: TextLine, char: String, xStart: Float, xEnd: Float, isLineEnd: Boolean, srcList: LinkedList? ) { val column = when { srcList != null && char == ChapterProvider.srcReplaceChar -> { val src = srcList.removeFirst() ImageProvider.cacheImage(book, src, ReadBook.bookSource) ImageColumn( start = absStartX + xStart, end = absStartX + xEnd, src = src ) } // isLineEnd && char == ChapterProvider.reviewChar -> { // ReviewColumn( // start = absStartX + xStart, // end = absStartX + xEnd, // count = 100 // ) // } else -> { TextColumn( start = absStartX + xStart, end = absStartX + xEnd, charData = char ) } } textLine.addColumn(column) } /** * 超出边界处理 */ private fun exceed(absStartX: Int, textLine: TextLine, words: List) { var size = words.size if (size < 2) return val visibleEnd = absStartX + visibleWidth val columns = textLine.columns var offset = 0 val endColumn = if (words.last() == " ") { size-- offset++ columns[columns.lastIndex - 1] } else { columns.last() } val endX = endColumn.end.roundToInt() if (endX > visibleEnd) { textLine.exceed = true val cc = (endX - visibleEnd) / size for (i in 0.. visibleHeight || requestHeight == -1f) { val textPage = pendingTextPage // 双页的 durY 不正确,可能会小于实际高度 if (textPage.height < durY) { textPage.height = durY } if (doublePage && absStartX < viewWidth / 2) { //当前页面左列结束 textPage.leftLineSize = textPage.lineSize absStartX = viewWidth / 2 + paddingLeft } else { //当前页面结束,设置各种值 if (textPage.leftLineSize == 0) { textPage.leftLineSize = textPage.lineSize } textPage.text = stringBuilder.toString() currentCoroutineContext().ensureActive() onPageCompleted() //新建页面 pendingTextPage = TextPage() stringBuilder.clear() absStartX = paddingLeft } durY = 0f } } private fun allocateFloatArray(size: Int): FloatArray { if (size > floatArray.size) { floatArray = FloatArray(size) } return floatArray } private fun measureTextSplit( text: String, widthsArray: FloatArray, start: Int = 0 ): Pair, ArrayList> { val length = text.length var clusterCount = 0 for (i in start.. 0) clusterCount++ } val widths = ArrayList(clusterCount) val stringList = ArrayList(clusterCount) var i = 0 while (i < length) { val clusterBaseIndex = i++ widths.add(widthsArray[start + clusterBaseIndex]) while (i < length && widthsArray[start + i] == 0f && !isZeroWidthChar(text[i])) { i++ } stringList.add(text.substring(clusterBaseIndex, i)) } return stringList to widths } private fun isZeroWidthChar(char: Char): Boolean { val code = char.code return code == 8203 || code == 8204 || code == 8205 || code == 8288 } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/provider/TextMeasure.kt ================================================ package io.legado.app.ui.book.read.page.provider import android.text.TextPaint import android.util.SparseArray import androidx.core.util.getOrDefault import kotlin.math.ceil class TextMeasure(private var paint: TextPaint) { private var chineseCommonWidth = paint.measureText("一") private val asciiWidths = FloatArray(128) { -1f } private val codePointWidths = SparseArray() private fun measureCodePoint(codePoint: Int): Float { if (codePoint < 128) { return asciiWidths[codePoint] } // 中文 Unicode 范围 U+4E00 - U+9FA5 if (codePoint in 19968 .. 40869) { return chineseCommonWidth } return codePointWidths.getOrDefault(codePoint, -1f) } private fun measureCodePoints(codePoints: List) { val charArray = String(codePoints.toIntArray(), 0, codePoints.size).toCharArray() val widths = FloatArray(charArray.size) paint.getTextWidths(charArray, 0, charArray.size, widths) val widthsList = ArrayList(charArray.size) val buf = IntArray(1) for (i in charArray.indices) { if (charArray[i].isLowSurrogate()) continue val width = ceil(widths[i]) widthsList.add(width) // 可能需要检查是否不可见字符 if (width == 0f && widthsList.size > 1) { val lastIndex = widthsList.lastIndex buf[0] = codePoints[lastIndex - 1] widthsList[lastIndex - 1] = paint.measureText(String(buf, 0, 1)) buf[0] = codePoints[lastIndex] widthsList[lastIndex] = paint.measureText(String(buf, 0, 1)) } } for (i in codePoints.indices) { val codePoint = codePoints[i] val width = widthsList[i] if (codePoint < 128) { asciiWidths[codePoint] = width } else { codePointWidths[codePoint] = width } } } fun measureTextSplit(text: String): Pair, ArrayList> { var needMeasureCodePoints: HashSet? = null val codePoints = text.toCodePoints() val size = codePoints.size val widths = ArrayList(size) val stringList = ArrayList(size) val buf = IntArray(1) for (i in codePoints.indices) { val codePoint = codePoints[i] val width = measureCodePoint(codePoint) widths.add(width) if (width == -1f) { if (needMeasureCodePoints == null) { needMeasureCodePoints = hashSetOf() } needMeasureCodePoints.add(codePoint) } buf[0] = codePoint stringList.add(String(buf, 0, 1)) } if (!needMeasureCodePoints.isNullOrEmpty()) { measureCodePoints(needMeasureCodePoints.toList()) for (i in codePoints.indices) { if (widths[i] == -1f) { widths[i] = measureCodePoint(codePoints[i]) } } } return stringList to widths } fun measureText(text: String): Float { var textWidth = 0f var needMeasureCodePoints: ArrayList? = null val codePoints = text.toCodePoints() for (i in codePoints.indices) { val codePoint = codePoints[i] val width = measureCodePoint(codePoint) if (width == -1f) { if (needMeasureCodePoints == null) { needMeasureCodePoints = ArrayList() } needMeasureCodePoints.add(codePoint) continue } textWidth += width } if (!needMeasureCodePoints.isNullOrEmpty()) { measureCodePoints(needMeasureCodePoints.toHashSet().toList()) for (i in needMeasureCodePoints.indices) { textWidth += measureCodePoint(needMeasureCodePoints[i]) } } return textWidth } private fun String.toCodePoints(): List { val codePoints = ArrayList(length) val charArray = toCharArray() val size = length var i = 0 while (i < size) { val c1 = charArray[i++] var cp = c1.code if (c1.isHighSurrogate() && i < size) { val c2 = charArray[i] if (c2.isLowSurrogate()) { i++ cp = Character.toCodePoint(c1, c2) } } codePoints.add(cp) } return codePoints } fun setPaint(paint: TextPaint) { this.paint = paint invalidate() } private fun invalidate() { chineseCommonWidth = paint.measureText("一") codePointWidths.clear() asciiWidths.fill(-1f) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/provider/TextPageFactory.kt ================================================ package io.legado.app.ui.book.read.page.provider import io.legado.app.R import io.legado.app.model.ReadBook import io.legado.app.ui.book.read.page.api.DataSource import io.legado.app.ui.book.read.page.api.PageFactory import io.legado.app.ui.book.read.page.entities.TextPage import splitties.init.appCtx class TextPageFactory(dataSource: DataSource) : PageFactory(dataSource) { private val keepSwipeTip = appCtx.getString(R.string.keep_swipe_tip) override fun hasPrev(): Boolean = with(dataSource) { return hasPrevChapter() || pageIndex > 0 } override fun hasNext(): Boolean = with(dataSource) { return hasNextChapter() || (currentChapter != null && currentChapter?.isLastIndex(pageIndex) != true) } override fun hasNextPlus(): Boolean = with(dataSource) { return hasNextChapter() || pageIndex < (currentChapter?.pageSize ?: 1) - 2 } override fun moveToFirst() { ReadBook.setPageIndex(0) } override fun moveToLast() = with(dataSource) { currentChapter?.let { if (it.pageSize == 0) { ReadBook.setPageIndex(0) } else { ReadBook.setPageIndex(it.pageSize.minus(1)) } } ?: ReadBook.setPageIndex(0) } override fun moveToNext(upContent: Boolean): Boolean = with(dataSource) { return if (hasNext()) { val pageIndex = pageIndex if (currentChapter == null || currentChapter?.isLastIndex(pageIndex) == true) { if ((currentChapter == null || isScroll) && nextChapter == null) { return@with false } ReadBook.moveToNextChapter(upContent, false) } else { if (pageIndex < 0 || currentChapter?.isLastIndexCurrent(pageIndex) == true) { return@with false } ReadBook.setPageIndex(pageIndex.plus(1)) } if (upContent) upContent(resetPageOffset = false) true } else false } override fun moveToPrev(upContent: Boolean): Boolean = with(dataSource) { return if (hasPrev()) { if (pageIndex <= 0) { if (currentChapter == null && prevChapter == null) { return@with false } if (prevChapter != null && prevChapter?.isCompleted == false) { return@with false } ReadBook.moveToPrevChapter(upContent, upContentInPlace = false) } else { if (currentChapter == null) { return@with false } ReadBook.setPageIndex(pageIndex.minus(1)) } if (upContent) upContent(resetPageOffset = false) true } else false } override val curPage: TextPage get() = with(dataSource) { ReadBook.msg?.let { return@with TextPage(text = it).format() } currentChapter?.let { return@with it.getPage(pageIndex) ?: TextPage(title = it.title).apply { textChapter = it }.format() } return TextPage().format() } override val nextPage: TextPage get() = with(dataSource) { ReadBook.msg?.let { return@with TextPage(text = it).format() } currentChapter?.let { val pageIndex = pageIndex if (pageIndex < it.pageSize - 1) { return@with it.getPage(pageIndex + 1)?.removePageAloudSpan() ?: TextPage(title = it.title).format() } if (!it.isCompleted) { return@with TextPage(title = it.title).format() } } nextChapter?.let { return@with it.getPage(0)?.removePageAloudSpan() ?: TextPage(title = it.title).format() } return TextPage().format() } override val prevPage: TextPage get() = with(dataSource) { ReadBook.msg?.let { return@with TextPage(text = it).format() } currentChapter?.let { val pageIndex = pageIndex if (pageIndex > 0) { return@with it.getPage(pageIndex - 1)?.removePageAloudSpan() ?: TextPage(title = it.title).format() } if (!it.isCompleted) { return@with TextPage(title = it.title).format() } } prevChapter?.let { return@with it.lastPage?.removePageAloudSpan() ?: TextPage(title = it.title).format() } return TextPage().format() } override val nextPlusPage: TextPage get() = with(dataSource) { currentChapter?.let { val pageIndex = pageIndex if (pageIndex < it.pageSize - 2) { return@with it.getPage(pageIndex + 2)?.removePageAloudSpan() ?: TextPage(title = it.title).format() } if (!it.isCompleted) { return@with TextPage(title = it.title).format() } nextChapter?.let { nc -> if (pageIndex < it.pageSize - 1) { return@with nc.getPage(0)?.removePageAloudSpan() ?: TextPage(title = nc.title).format() } return@with nc.getPage(1)?.removePageAloudSpan() ?: TextPage(text = keepSwipeTip).format() } } return TextPage().format() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/read/page/provider/ZhLayout.kt ================================================ package io.legado.app.ui.book.read.page.provider import android.graphics.Paint import android.graphics.Rect import android.os.Build import android.text.Layout import android.text.TextPaint import java.util.WeakHashMap import kotlin.math.max /** * 针对中文的断行排版处理-by hoodie13 * 因为StaticLayout对标点处理不符合国人习惯,继承Layout * */ @Suppress("MemberVisibilityCanBePrivate", "unused") class ZhLayout( text: CharSequence, textPaint: TextPaint, width: Int, words: List, widths: List, indentSize: Int ) : Layout(text, textPaint, width, Alignment.ALIGN_NORMAL, 0f, 0f) { companion object { private val postPanc = hashSetOf( ",", "。", ":", "?", "!", "、", "”", "’", ")", "》", "}", "】", ")", ">", "]", "}", ",", ".", "?", "!", ":", "」", ";", ";" ) private val prePanc = hashSetOf("“", "(", "《", "【", "‘", "‘", "(", "<", "[", "{", "「") private val cnCharWidthCache = WeakHashMap() } private val defaultCapacity = 10 var lineStart = IntArray(defaultCapacity) var lineWidth = FloatArray(defaultCapacity) private var lineCount = 0 private val curPaint = textPaint private val cnCharWidth = cnCharWidthCache[textPaint] ?: getDesiredWidth("我", textPaint).also { cnCharWidthCache[textPaint] = it } enum class BreakMod { NORMAL, BREAK_ONE_CHAR, BREAK_MORE_CHAR, CPS_1, CPS_2, CPS_3, } class Locate { var start: Float = 0f var end: Float = 0f } class Interval { var total: Float = 0f var single: Float = 0f } init { var line = 0 var lineW = 0f var cwPre = 0f var length = 0 words.forEachIndexed { index, s -> val cw = widths[index] var breakMod: BreakMod var breakLine = false lineW += cw var offset = 0f var breakCharCnt = 0 if (lineW > width) { /*禁止在行尾的标点处理*/ breakMod = if (index >= 1 && isPrePanc(words[index - 1])) { if (index >= 2 && isPrePanc(words[index - 2])) BreakMod.CPS_2//如果后面还有一个禁首标点则异常 else BreakMod.BREAK_ONE_CHAR //无异常场景 } /*禁止在行首的标点处理*/ else if (isPostPanc(words[index])) { if (index >= 1 && isPostPanc(words[index - 1])) BreakMod.CPS_1//如果后面还有一个禁首标点则异常,不过三个连续行尾标点的用法不通用 else if (index >= 2 && isPrePanc(words[index - 2])) BreakMod.CPS_3//如果后面还有一个禁首标点则异常 else BreakMod.BREAK_ONE_CHAR //无异常场景 } else { BreakMod.NORMAL //无异常场景 } /*判断上述逻辑解决不了的特殊情况*/ var reCheck = false var breakIndex = 0 if (breakMod == BreakMod.CPS_1 && (inCompressible(widths[index]) || inCompressible(widths[index - 1])) ) reCheck = true if (breakMod == BreakMod.CPS_2 && (inCompressible(widths[index - 1]) || inCompressible(widths[index - 2])) ) reCheck = true if (breakMod == BreakMod.CPS_3 && (inCompressible(widths[index]) || inCompressible(widths[index - 2])) ) reCheck = true if (breakMod > BreakMod.BREAK_MORE_CHAR && index < words.lastIndex && isPostPanc(words[index + 1]) ) reCheck = true /*特殊标点使用难保证显示效果,所以不考虑间隔,直接查找到能满足条件的分割字*/ var breakLength = 0 if (reCheck && index > 2) { val startPos = if (line == 0) indentSize else getLineStart(line) breakMod = BreakMod.NORMAL for (i in (index) downTo 1 + startPos) { if (i == index) { breakIndex = 0 cwPre = 0f } else { breakIndex++ breakLength += words[i].length cwPre += widths[i] } if (!isPostPanc(words[i]) && !isPrePanc(words[i - 1])) { breakMod = BreakMod.BREAK_MORE_CHAR break } } } when (breakMod) { BreakMod.NORMAL -> {//模式0 正常断行 offset = cw lineStart[line + 1] = length breakCharCnt = 1 } BreakMod.BREAK_ONE_CHAR -> {//模式1 当前行下移一个字 offset = cw + cwPre lineStart[line + 1] = length - words[index - 1].length breakCharCnt = 2 } BreakMod.BREAK_MORE_CHAR -> {//模式2 当前行下移多个字 offset = cw + cwPre lineStart[line + 1] = length - breakLength breakCharCnt = breakIndex + 1 } BreakMod.CPS_1 -> {//模式3 两个后置标点压缩 offset = 0f lineStart[line + 1] = length + s.length breakCharCnt = 0 } BreakMod.CPS_2 -> { //模式4 前置标点压缩+前置标点压缩+字 offset = 0f lineStart[line + 1] = length + s.length breakCharCnt = 0 } BreakMod.CPS_3 -> {//模式5 前置标点压缩+字+后置标点压缩 offset = 0f lineStart[line + 1] = length + s.length breakCharCnt = 0 } } breakLine = true } /*当前行写满情况下的断行*/ if (breakLine) { lineWidth[line] = lineW - offset lineW = offset addLineArray(++line) } /*已到最后一个字符*/ if ((words.lastIndex) == index) { if (!breakLine) { offset = 0f lineStart[line + 1] = length + s.length lineWidth[line] = lineW - offset lineW = offset addLineArray(++line) } /*写满断行、段落末尾、且需要下移字符,这种特殊情况下要额外多一行*/ else if (breakCharCnt > 0) { lineStart[line + 1] = lineStart[line] + breakCharCnt lineWidth[line] = lineW addLineArray(++line) } } length += s.length cwPre = cw } lineCount = line } private fun addLineArray(line: Int) { if (lineStart.size <= line + 1) { lineStart = lineStart.copyOf(line + defaultCapacity) lineWidth = lineWidth.copyOf(line + defaultCapacity) } } private fun isPostPanc(string: String): Boolean { return postPanc.contains(string) } private fun isPrePanc(string: String): Boolean { return prePanc.contains(string) } private fun inCompressible(width: Float): Boolean { return width < cnCharWidth } private val gap = (cnCharWidth / 12.75).toFloat() private fun getPostPancOffset(string: String): Float { val textRect = Rect() curPaint.getTextBounds(string, 0, 1, textRect) return max(textRect.left.toFloat() - gap, 0f) } private fun getPrePancOffset(string: String): Float { val textRect = Rect() curPaint.getTextBounds(string, 0, 1, textRect) val d = max(cnCharWidth - textRect.right.toFloat() - gap, 0f) return cnCharWidth / 2 - d } fun getDesiredWidth(string: String, paint: TextPaint): Float { var width = paint.measureText(string) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { width += paint.letterSpacing * paint.textSize } return width } override fun getLineCount(): Int { return lineCount } override fun getLineTop(line: Int): Int { return 0 } override fun getLineDescent(line: Int): Int { return 0 } override fun getLineStart(line: Int): Int { return lineStart[line] } override fun getParagraphDirection(line: Int): Int { return 0 } override fun getLineContainsTab(line: Int): Boolean { return true } override fun getLineDirections(line: Int): Directions? { return null } override fun getTopPadding(): Int { return 0 } override fun getBottomPadding(): Int { return 0 } override fun getLineWidth(line: Int): Float { return lineWidth[line] } override fun getEllipsisStart(line: Int): Int { return 0 } override fun getEllipsisCount(line: Int): Int { return 0 } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/search/BookAdapter.kt ================================================ package io.legado.app.ui.book.search import android.content.Context import android.view.ViewGroup import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.data.entities.Book import io.legado.app.databinding.ItemFilletTextBinding class BookAdapter(context: Context, val callBack: CallBack) : RecyclerAdapter(context) { override fun getItemId(position: Int): Long { return position.toLong() } override fun getViewBinding(parent: ViewGroup): ItemFilletTextBinding { return ItemFilletTextBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemFilletTextBinding, item: Book, payloads: MutableList ) { binding.run { textView.text = item.name } } override fun registerListener(holder: ItemViewHolder, binding: ItemFilletTextBinding) { holder.itemView.apply { setOnClickListener { getItem(holder.layoutPosition)?.let { callBack.showBookInfo(it) } } } } interface CallBack { fun showBookInfo(book: Book) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/search/HistoryKeyAdapter.kt ================================================ package io.legado.app.ui.book.search import android.view.ViewGroup import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.data.entities.SearchKeyword import io.legado.app.databinding.ItemFilletTextBinding import io.legado.app.ui.widget.anima.explosion_field.ExplosionField import splitties.views.onLongClick class HistoryKeyAdapter(activity: SearchActivity, val callBack: CallBack) : RecyclerAdapter(activity) { private val explosionField = ExplosionField.attach2Window(activity) override fun getItemId(position: Int): Long { return position.toLong() } override fun getViewBinding(parent: ViewGroup): ItemFilletTextBinding { return ItemFilletTextBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemFilletTextBinding, item: SearchKeyword, payloads: MutableList ) { binding.run { textView.text = item.word } } override fun registerListener(holder: ItemViewHolder, binding: ItemFilletTextBinding) { holder.itemView.apply { setOnClickListener { getItemByLayoutPosition(holder.layoutPosition)?.let { callBack.searchHistory(it.word) } } onLongClick { explosionField.explode(this, true) getItemByLayoutPosition(holder.layoutPosition)?.let { callBack.deleteHistory(it) } } } } interface CallBack { fun searchHistory(key: String) fun deleteHistory(searchKeyword: SearchKeyword) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt ================================================ package io.legado.app.ui.book.search import android.content.Context import android.content.Intent import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.View.GONE import android.view.View.VISIBLE import android.widget.TextView import androidx.activity.viewModels import androidx.appcompat.widget.SearchView import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.flexbox.FlexboxLayoutManager import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.constant.AppLog import io.legado.app.constant.PreferKey import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.SearchBook import io.legado.app.data.entities.SearchKeyword import io.legado.app.databinding.ActivityBookSearchBinding import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.Selector import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.backgroundColor import io.legado.app.lib.theme.primaryColor import io.legado.app.lib.theme.primaryTextColor import io.legado.app.ui.about.AppLogDialog import io.legado.app.ui.book.info.BookInfoActivity import io.legado.app.ui.book.source.manage.BookSourceActivity import io.legado.app.utils.ColorUtils import io.legado.app.utils.applyNavigationBarMargin import io.legado.app.utils.applyNavigationBarPadding import io.legado.app.utils.applyTint import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.gone import io.legado.app.utils.invisible import io.legado.app.utils.putPrefBoolean import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.showDialogFragment import io.legado.app.utils.startActivity import io.legado.app.utils.transaction import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Job import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import splitties.init.appCtx import kotlin.math.abs class SearchActivity : VMBaseActivity(), BookAdapter.CallBack, HistoryKeyAdapter.CallBack, SearchScopeDialog.Callback, SearchAdapter.CallBack { override val binding by viewBinding(ActivityBookSearchBinding::inflate) override val viewModel by viewModels() private val adapter by lazy { SearchAdapter(this, this) } private val bookAdapter by lazy { BookAdapter(this, this).apply { setHasStableIds(true) } } private val historyKeyAdapter by lazy { HistoryKeyAdapter(this, this).apply { setHasStableIds(true) } } private val searchView: SearchView by lazy { binding.titleBar.findViewById(R.id.search_view) } private var menu: Menu? = null private var groups: List? = null private var historyFlowJob: Job? = null private var booksFlowJob: Job? = null private var precisionSearchMenuItem: MenuItem? = null private var isManualStopSearch = false override fun onActivityCreated(savedInstanceState: Bundle?) { binding.llInputHelp.setBackgroundColor(backgroundColor) initRecyclerView() initSearchView() initOtherView() initData() receiptIntent(intent) } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) receiptIntent(intent) } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.book_search, menu) this.menu = menu precisionSearchMenuItem = menu.findItem(R.id.menu_precision_search) precisionSearchMenuItem?.isChecked = getPrefBoolean(PreferKey.precisionSearch) return super.onCompatCreateOptionsMenu(menu) } override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { menu.transaction { menu.removeGroup(R.id.menu_group_1) menu.removeGroup(R.id.menu_group_2) var hasChecked = false val searchScopeNames = viewModel.searchScope.displayNames if (viewModel.searchScope.isSource()) { menu.add(R.id.menu_group_1, Menu.NONE, Menu.NONE, searchScopeNames.first()).apply { isChecked = true hasChecked = true } } val allSourceMenu = menu.add(R.id.menu_group_2, R.id.menu_1, Menu.NONE, getString(R.string.all_source)) .apply { if (searchScopeNames.isEmpty()) { isChecked = true hasChecked = true } } groups?.forEach { if (searchScopeNames.contains(it)) { menu.add(R.id.menu_group_1, Menu.NONE, Menu.NONE, it).apply { isChecked = true hasChecked = true } } else { menu.add(R.id.menu_group_2, Menu.NONE, Menu.NONE, it) } } if (!hasChecked) { viewModel.searchScope.update("") allSourceMenu.isChecked = true } menu.setGroupCheckable(R.id.menu_group_1, true, false) menu.setGroupCheckable(R.id.menu_group_2, true, true) } return super.onMenuOpened(featureId, menu) } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_precision_search -> { putPrefBoolean( PreferKey.precisionSearch, !getPrefBoolean(PreferKey.precisionSearch) ) precisionSearchMenuItem?.isChecked = getPrefBoolean(PreferKey.precisionSearch) searchView.query?.toString()?.trim()?.let { searchView.setQuery(it, true) } } R.id.menu_search_scope -> alertSearchScope() R.id.menu_source_manage -> startActivity() R.id.menu_log -> showDialogFragment(AppLogDialog()) R.id.menu_1 -> viewModel.searchScope.update("") else -> { if (item.groupId == R.id.menu_group_1) { viewModel.searchScope.remove(item.title.toString()) } else if (item.groupId == R.id.menu_group_2) { viewModel.searchScope.update(item.title.toString()) } } } return super.onCompatOptionsItemSelected(item) } private fun initSearchView() { searchView.applyTint(primaryTextColor) searchView.isSubmitButtonEnabled = true searchView.queryHint = getString(R.string.search_book_key) searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean { searchView.clearFocus() query.trim().let { searchKey -> isManualStopSearch = false viewModel.saveSearchKey(searchKey) viewModel.searchKey = "" viewModel.search(searchKey) } visibleInputHelp(false) return true } override fun onQueryTextChange(newText: String): Boolean { viewModel.stop() binding.fbStartStop.invisible() upHistory(newText.trim()) return false } }) searchView.setOnQueryTextFocusChangeListener { _, hasFocus -> if (binding.refreshProgressBar.isAutoLoading || (!hasFocus && adapter.isNotEmpty() && searchView.query.isNotBlank())) { visibleInputHelp(false) } else { visibleInputHelp(true) } } visibleInputHelp(true) } private fun initRecyclerView() { binding.recyclerView.setEdgeEffectColor(primaryColor) binding.rvBookshelfSearch.setEdgeEffectColor(primaryColor) binding.rvHistoryKey.setEdgeEffectColor(primaryColor) binding.rvBookshelfSearch.layoutManager = FlexboxLayoutManager(this) binding.rvBookshelfSearch.adapter = bookAdapter binding.rvBookshelfSearch.applyNavigationBarMargin() binding.rvHistoryKey.layoutManager = FlexboxLayoutManager(this) binding.rvHistoryKey.adapter = historyKeyAdapter binding.rvHistoryKey.applyNavigationBarMargin() binding.recyclerView.layoutManager = LinearLayoutManager(this) binding.recyclerView.adapter = adapter binding.recyclerView.itemAnimator = null binding.recyclerView.applyNavigationBarPadding() adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { super.onItemRangeInserted(positionStart, itemCount) if (positionStart == 0) { binding.recyclerView.scrollToPosition(0) } } override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) { super.onItemRangeMoved(fromPosition, toPosition, itemCount) if (toPosition == 0) { binding.recyclerView.scrollToPosition(0) } } }) binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { super.onScrolled(recyclerView, dx, dy) if (!recyclerView.canScrollVertically(1)) { val layoutManager = recyclerView.layoutManager as LinearLayoutManager val lastPosition = layoutManager.findLastCompletelyVisibleItemPosition() if (lastPosition == RecyclerView.NO_POSITION) { return } val lastView = layoutManager.findViewByPosition(lastPosition) if (lastView == null) { scrollToBottom() return } val bottom = abs(lastView.bottom - recyclerView.height) - recyclerView.paddingBottom if (bottom <= 1) { scrollToBottom() } } } }) } private fun initOtherView() { binding.fbStartStop.backgroundTintList = Selector.colorBuild() .setDefaultColor(accentColor) .setPressedColor(ColorUtils.darkenColor(accentColor)) .create() binding.fbStartStop.setOnClickListener { if (viewModel.isSearchLiveData.value == true) { isManualStopSearch = true viewModel.stop() binding.refreshProgressBar.isAutoLoading = false } else { viewModel.search("") } } binding.fbStartStop.applyNavigationBarMargin(true) binding.tvClearHistory.setOnClickListener { alertClearHistory() } } private fun initData() { viewModel.searchScope.stateLiveData.observe(this) { if (!binding.llInputHelp.isVisible) { searchView.query?.toString()?.trim()?.let { searchView.setQuery(it, true) } } } viewModel.isSearchLiveData.observe(this) { if (it) { startSearch() } else { searchFinally() } } viewModel.searchBookLiveData.observe(this) { adapter.setItems(it) } lifecycleScope.launch { appDb.bookSourceDao.flowEnabledGroups().collect { groups = it } } lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.RESUMED) { viewModel.resume() try { awaitCancellation() } finally { viewModel.pause() } } } } /** * 处理传入数据 */ private fun receiptIntent(intent: Intent? = null) { val searchScope = intent?.getStringExtra("searchScope") searchScope?.let { viewModel.searchScope.update(searchScope, false) } val key = intent?.getStringExtra("key") if (key.isNullOrBlank()) { searchView.findViewById(androidx.appcompat.R.id.search_src_text) .requestFocus() } else { searchView.setQuery(key, true) } } /** * 滚动到底部事件 */ private fun scrollToBottom() { if (isManualStopSearch) { return } if (viewModel.isSearchLiveData.value == false && viewModel.searchKey.isNotEmpty() && viewModel.hasMore ) { viewModel.search("") } } /** * 打开关闭输入帮助 */ private fun visibleInputHelp(visible: Boolean) { if (visible) { upHistory(searchView.query.toString()) binding.llInputHelp.visibility = VISIBLE } else { binding.llInputHelp.visibility = GONE } } /** * 更新搜索历史 */ private fun upHistory(key: String? = null) { booksFlowJob?.cancel() booksFlowJob = lifecycleScope.launch { if (key.isNullOrBlank()) { binding.tvBookShow.gone() binding.rvBookshelfSearch.gone() } else { appDb.bookDao.flowSearch(key).conflate().collect { if (it.isEmpty()) { binding.tvBookShow.gone() binding.rvBookshelfSearch.gone() } else { binding.tvBookShow.visible() binding.rvBookshelfSearch.visible() } bookAdapter.setItems(it) } } } historyFlowJob?.cancel() historyFlowJob = lifecycleScope.launch { when { key.isNullOrBlank() -> appDb.searchKeywordDao.flowByTime() else -> appDb.searchKeywordDao.flowSearch(key) }.catch { AppLog.put("搜索界面获取搜索历史数据失败\n${it.localizedMessage}", it) }.flowOn(IO).conflate().collect { historyKeyAdapter.setItems(it) if (it.isEmpty()) { binding.tvClearHistory.invisible() } else { binding.tvClearHistory.visible() } } } } /** * 开始搜索 */ private fun startSearch() { binding.refreshProgressBar.visible() binding.refreshProgressBar.isAutoLoading = true binding.fbStartStop.setImageResource(R.drawable.ic_stop_black_24dp) binding.fbStartStop.visible() } /** * 搜索结束 */ private fun searchFinally() { binding.refreshProgressBar.isAutoLoading = false binding.refreshProgressBar.gone() if (!isManualStopSearch && viewModel.hasMore) { binding.fbStartStop.setImageResource(R.drawable.ic_play_24dp) } else { binding.fbStartStop.invisible() } } override fun observeLiveBus() { viewModel.upAdapterLiveData.observe(this) { adapter.notifyItemRangeChanged(0, adapter.itemCount, bundleOf(it to null)) } viewModel.searchFinishLiveData.observe(this) { isEmpty -> if (!isEmpty || viewModel.searchScope.isAll()) return@observe alert("搜索结果为空") { val precisionSearch = appCtx.getPrefBoolean(PreferKey.precisionSearch) val displayScope = viewModel.searchScope.display if (precisionSearch) { setMessage("${displayScope}分组搜索结果为空,是否关闭精准搜索?") yesButton { appCtx.putPrefBoolean(PreferKey.precisionSearch, false) precisionSearchMenuItem?.isChecked = false viewModel.searchKey = "" viewModel.search(searchView.query.toString()) } } else { setMessage("${displayScope}分组搜索结果为空,是否切换到全部分组?") yesButton { viewModel.searchScope.update("") } } noButton() } } } /** * 显示书籍详情 */ override fun showBookInfo(name: String, author: String, bookUrl: String) { startActivity { putExtra("name", name) putExtra("author", author) putExtra("bookUrl", bookUrl) } } /** * 是否已经加入书架 */ override fun isInBookshelf(book: SearchBook): Boolean { return viewModel.isInBookShelf(book) } /** * 显示书籍详情 */ override fun showBookInfo(book: Book) { showBookInfo(book.name, book.author, book.bookUrl) } /** * 点击历史关键字 */ override fun searchHistory(key: String) { lifecycleScope.launch { when { searchView.query.toString() == key -> { searchView.setQuery(key, true) } withContext(IO) { appDb.bookDao.findByName(key).isEmpty() } -> { searchView.setQuery(key, true) } else -> { searchView.setQuery(key, false) } } } } /** * 删除搜索记录 */ override fun deleteHistory(searchKeyword: SearchKeyword) { viewModel.deleteHistory(searchKeyword) } override fun onSearchScopeOk(searchScope: SearchScope) { viewModel.searchScope.update(searchScope.toString()) } private fun alertSearchScope() { showDialogFragment() } private fun alertClearHistory() { alert(R.string.draw) { setMessage(R.string.sure_clear_search_history) yesButton { viewModel.clearHistory() } noButton() } } override fun finish() { if (searchView.hasFocus()) { searchView.clearFocus() return } super.finish() } companion object { fun start(context: Context, key: String?) { context.startActivity { putExtra("key", key) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/search/SearchAdapter.kt ================================================ package io.legado.app.ui.book.search import android.content.Context import android.os.Bundle import android.view.ViewGroup import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil import io.legado.app.R import io.legado.app.base.adapter.DiffRecyclerAdapter import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.data.entities.SearchBook import io.legado.app.databinding.ItemSearchBinding import io.legado.app.help.config.AppConfig import io.legado.app.utils.gone import io.legado.app.utils.visible class SearchAdapter(context: Context, val callBack: CallBack) : DiffRecyclerAdapter(context) { override val keepScrollPosition = true override val diffItemCallback: DiffUtil.ItemCallback get() = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: SearchBook, newItem: SearchBook): Boolean { return when { oldItem.name != newItem.name -> false oldItem.author != newItem.author -> false else -> true } } override fun areContentsTheSame(oldItem: SearchBook, newItem: SearchBook): Boolean { return false } override fun getChangePayload(oldItem: SearchBook, newItem: SearchBook): Any { val payload = Bundle() payload.putInt("origins", newItem.origins.size) if (oldItem.coverUrl != newItem.coverUrl) payload.putString("cover", newItem.coverUrl) if (oldItem.kind != newItem.kind) payload.putString("kind", newItem.kind) if (oldItem.latestChapterTitle != newItem.latestChapterTitle) payload.putString("last", newItem.latestChapterTitle) if (oldItem.intro != newItem.intro) payload.putString("intro", newItem.intro) return payload } } override fun getViewBinding(parent: ViewGroup): ItemSearchBinding { return ItemSearchBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemSearchBinding, item: SearchBook, payloads: MutableList ) { if (payloads.isEmpty()) { bind(binding, item) } else { for (i in payloads.indices) { val bundle = payloads[i] as Bundle bindChange(binding, item, bundle) } } } override fun registerListener(holder: ItemViewHolder, binding: ItemSearchBinding) { binding.root.setOnClickListener { getItem(holder.layoutPosition)?.let { callBack.showBookInfo(it.name, it.author, it.bookUrl) } } } private fun bind(binding: ItemSearchBinding, searchBook: SearchBook) { binding.run { tvName.text = searchBook.name tvAuthor.text = context.getString(R.string.author_show, searchBook.author) ivInBookshelf.isVisible = callBack.isInBookshelf(searchBook) bvOriginCount.setBadgeCount(searchBook.origins.size) upLasted(binding, searchBook.latestChapterTitle) tvIntroduce.text = searchBook.trimIntro(context) upKind(binding, searchBook.getKindList()) ivCover.load( searchBook.coverUrl, searchBook.name, searchBook.author, AppConfig.loadCoverOnlyWifi, searchBook.origin ) } } private fun bindChange(binding: ItemSearchBinding, searchBook: SearchBook, bundle: Bundle) { binding.run { bundle.keySet().forEach { when (it) { "origins" -> bvOriginCount.setBadgeCount(searchBook.origins.size) "last" -> upLasted(binding, searchBook.latestChapterTitle) "intro" -> tvIntroduce.text = searchBook.trimIntro(context) "kind" -> upKind(binding, searchBook.getKindList()) "isInBookshelf" -> ivInBookshelf.isVisible = callBack.isInBookshelf(searchBook) "cover" -> ivCover.load( searchBook.coverUrl, searchBook.name, searchBook.author, false, searchBook.origin ) } } } } private fun upLasted(binding: ItemSearchBinding, latestChapterTitle: String?) { binding.run { if (latestChapterTitle.isNullOrEmpty()) { tvLasted.gone() } else { tvLasted.text = context.getString(R.string.lasted_show, latestChapterTitle) tvLasted.visible() } } } private fun upKind(binding: ItemSearchBinding, kinds: List) = binding.run { if (kinds.isEmpty()) { llKind.gone() } else { llKind.visible() llKind.setLabels(kinds) } } interface CallBack { /** * 是否已经加入书架 */ fun isInBookshelf(book: SearchBook): Boolean /** * 显示书籍详情 */ fun showBookInfo(name: String, author: String, bookUrl: String) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/search/SearchScope.kt ================================================ package io.legado.app.ui.book.search import androidx.lifecycle.MutableLiveData import io.legado.app.R import io.legado.app.data.appDb import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSourcePart import io.legado.app.help.config.AppConfig import io.legado.app.utils.splitNotBlank import splitties.init.appCtx /** * 搜索范围 */ @Suppress("unused") data class SearchScope(private var scope: String) { constructor(groups: List) : this(groups.joinToString(",")) constructor(source: BookSource) : this( "${source.bookSourceName.replace(":", "")}::${source.bookSourceUrl}" ) constructor(source: BookSourcePart) : this( "${source.bookSourceName.replace(":", "")}::${source.bookSourceUrl}" ) override fun toString(): String { return scope } val stateLiveData = MutableLiveData(scope) fun update(scope: String, postValue: Boolean = true) { this.scope = scope if (postValue) stateLiveData.postValue(scope) save() } fun update(groups: List) { scope = groups.joinToString(",") stateLiveData.postValue(scope) save() } fun update(source: BookSource) { scope = "${source.bookSourceName}::${source.bookSourceUrl}" stateLiveData.postValue(scope) save() } fun isSource(): Boolean { return scope.contains("::") } val display: String get() { if (scope.contains("::")) { return scope.substringBefore("::") } if (scope.isEmpty()) { return appCtx.getString(R.string.all_source) } return scope } /** * 搜索范围显示 */ val displayNames: List get() { val list = arrayListOf() if (scope.contains("::")) { list.add(scope.substringBefore("::")) } else { scope.splitNotBlank(",").forEach { list.add(it) } } return list } fun remove(scope: String) { if (isSource()) { this.scope = "" } else { val stringBuilder = StringBuilder() this.scope.split(",").forEach { if (it != scope) { if (stringBuilder.isNotEmpty()) { stringBuilder.append(",") } stringBuilder.append(it) } } this.scope = stringBuilder.toString() } stateLiveData.postValue(this.scope) } /** * 搜索范围书源 */ fun getBookSourceParts(): List { val list = hashSetOf() if (scope.isEmpty()) { list.addAll(appDb.bookSourceDao.allEnabledPart) } else { if (scope.contains("::")) { scope.substringAfter("::").let { appDb.bookSourceDao.getBookSourcePart(it)?.let { source -> list.add(source) } } } else { val oldScope = scope.splitNotBlank(",") val newScope = oldScope.filter { val bookSources = appDb.bookSourceDao.getEnabledPartByGroup(it) list.addAll(bookSources) bookSources.isNotEmpty() } if (oldScope.size != newScope.size) { update(newScope) stateLiveData.postValue(scope) } } if (list.isEmpty()) { scope = "" appDb.bookSourceDao.allEnabledPart.let { if (it.isNotEmpty()) { stateLiveData.postValue(scope) list.addAll(it) } } } } return list.sortedBy { it.customOrder } } fun isAll(): Boolean { return scope.isEmpty() } fun save() { AppConfig.searchScope = scope if (isAll() || isSource() || scope.contains(",")) { AppConfig.searchGroup = "" } else { AppConfig.searchGroup = scope } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/search/SearchScopeDialog.kt ================================================ package io.legado.app.ui.book.search import android.annotation.SuppressLint import android.os.Bundle import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.SearchView import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.constant.AppLog import io.legado.app.data.AppDatabase import io.legado.app.data.appDb import io.legado.app.data.entities.BookSourcePart import io.legado.app.databinding.DialogSearchScopeBinding import io.legado.app.databinding.ItemCheckBoxBinding import io.legado.app.databinding.ItemRadioButtonBinding import io.legado.app.lib.theme.primaryColor import io.legado.app.utils.applyTint import io.legado.app.utils.flowWithLifecycleAndDatabaseChange import io.legado.app.utils.setLayout import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class SearchScopeDialog : BaseDialogFragment(R.layout.dialog_search_scope) { private val binding by viewBinding(DialogSearchScopeBinding::bind) private var sourceFlowJob: Job? = null val callback: Callback get() = parentFragment as? Callback ?: activity as Callback var groups: List = emptyList() val screenSources = arrayListOf() var screenText: String? = null val adapter by lazy { RecyclerAdapter() } override fun onStart() { super.onStart() setLayout(0.9f, 0.8f) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) binding.recyclerView.adapter = adapter initMenu() initSearchView() initOtherView() initData() } private fun initMenu() { binding.toolBar.inflateMenu(R.menu.book_search_scope) binding.toolBar.menu.applyTint(requireContext()) } private fun initSearchView() { val searchView = binding.toolBar.menu.findItem(R.id.menu_screen).actionView as SearchView searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { return false } override fun onQueryTextChange(newText: String?): Boolean { screenText = newText upData() return false } }) } private fun initOtherView() { binding.rgScope.setOnCheckedChangeListener { _, checkedId -> binding.toolBar.menu.findItem(R.id.menu_screen)?.isVisible = checkedId == R.id.rb_source upData() } binding.tvCancel.setOnClickListener { dismiss() } binding.tvAllSource.setOnClickListener { callback.onSearchScopeOk(SearchScope("")) dismiss() } binding.tvOk.setOnClickListener { if (binding.rbGroup.isChecked) { callback.onSearchScopeOk(SearchScope(adapter.selectGroups)) } else { val selectSource = adapter.selectSource if (selectSource != null) { callback.onSearchScopeOk(SearchScope(selectSource)) } else { callback.onSearchScopeOk(SearchScope("")) } } dismiss() } } private fun initData() { lifecycleScope.launch { groups = withContext(IO) { appDb.bookSourceDao.allEnabledGroups() } upData() } } @SuppressLint("NotifyDataSetChanged") private fun upData() { if (binding.rbSource.isChecked) { upBookSource(screenText) } else { adapter.notifyDataSetChanged() } } @SuppressLint("NotifyDataSetChanged") private fun upBookSource(searchKey: String? = null) { sourceFlowJob?.cancel() sourceFlowJob = lifecycleScope.launch { when { searchKey.isNullOrEmpty() -> { appDb.bookSourceDao.flowAll() } else -> { appDb.bookSourceDao.flowSearch(searchKey) } }.flowWithLifecycleAndDatabaseChange( lifecycle, table = AppDatabase.BOOK_SOURCE_TABLE_NAME ).catch { AppLog.put("多分组/书源界面更新书源出错", it) }.flowOn(IO).conflate().collect { data -> screenSources.clear() screenSources.addAll(data) adapter.notifyDataSetChanged() delay(500) } } } inner class RecyclerAdapter : RecyclerView.Adapter() { val selectGroups = arrayListOf() var selectSource: BookSourcePart? = null override fun getItemViewType(position: Int): Int { return if (binding.rbSource.isChecked) { 1 } else { 0 } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder { return if (viewType == 1) { ItemViewHolder(ItemRadioButtonBinding.inflate(layoutInflater, parent, false)) } else { ItemViewHolder(ItemCheckBoxBinding.inflate(layoutInflater, parent, false)) } } override fun onBindViewHolder( holder: ItemViewHolder, position: Int, payloads: MutableList ) { if (payloads.isEmpty()) { super.onBindViewHolder(holder, position, payloads) } else { when (holder.binding) { is ItemCheckBoxBinding -> { groups.getOrNull(position)?.let { holder.binding.checkBox.isChecked = selectGroups.contains(it) holder.binding.checkBox.text = it } } is ItemRadioButtonBinding -> { screenSources.getOrNull(position)?.let { holder.binding.radioButton.isChecked = selectSource == it holder.binding.radioButton.text = it.bookSourceName } } } } } override fun onBindViewHolder(holder: ItemViewHolder, position: Int) { when (holder.binding) { is ItemCheckBoxBinding -> { groups.getOrNull(position)?.let { holder.binding.checkBox.isChecked = selectGroups.contains(it) holder.binding.checkBox.text = it holder.binding.checkBox.setOnUserCheckedChangeListener { isChecked -> if (isChecked) { selectGroups.add(it) } else { selectGroups.remove(it) } holder.itemView.post { notifyItemRangeChanged(0, itemCount, "up") } } } } is ItemRadioButtonBinding -> { screenSources.getOrNull(position)?.let { holder.binding.radioButton.isChecked = selectSource == it holder.binding.radioButton.text = it.bookSourceName holder.binding.radioButton.setOnUserCheckedChangeListener { isChecked -> if (isChecked) { selectSource = it } holder.itemView.post { notifyItemRangeChanged(0, itemCount, "up") } } } } } } override fun getItemCount(): Int { return if (binding.rbSource.isChecked) { screenSources.size } else { groups.size } } } interface Callback { /** * 搜索范围确认 */ fun onSearchScopeOk(searchScope: SearchScope) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt ================================================ package io.legado.app.ui.book.search import android.app.Application import android.os.Handler import android.os.Looper import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.SearchBook import io.legado.app.data.entities.SearchKeyword import io.legado.app.help.book.isNotShelf import io.legado.app.help.config.AppConfig import io.legado.app.model.webBook.SearchModel import io.legado.app.utils.ConflateLiveData import io.legado.app.utils.toastOnUi import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.mapLatest import java.util.concurrent.ConcurrentHashMap @OptIn(ExperimentalCoroutinesApi::class) class SearchViewModel(application: Application) : BaseViewModel(application) { val handler = Handler(Looper.getMainLooper()) val bookshelf: MutableSet = ConcurrentHashMap.newKeySet() val upAdapterLiveData = MutableLiveData() var searchBookLiveData = ConflateLiveData>(1000) val searchScope: SearchScope = SearchScope(AppConfig.searchScope) var searchFinishLiveData = MutableLiveData() var isSearchLiveData = MutableLiveData() var searchKey: String = "" var hasMore = true private var searchID = 0L private val searchModel = SearchModel(viewModelScope, object : SearchModel.CallBack { override fun getSearchScope(): SearchScope { return searchScope } override fun onSearchStart() { isSearchLiveData.postValue(true) } override fun onSearchSuccess(searchBooks: List) { searchBookLiveData.postValue(searchBooks) } override fun onSearchFinish(isEmpty: Boolean, hasMore: Boolean) { this@SearchViewModel.hasMore = hasMore isSearchLiveData.postValue(false) searchFinishLiveData.postValue(isEmpty) } override fun onSearchCancel(exception: Throwable?) { isSearchLiveData.postValue(false) exception?.let { context.toastOnUi(it.localizedMessage) } } }) init { execute { appDb.bookDao.flowAll().mapLatest { books -> val keys = arrayListOf() books.filterNot { it.isNotShelf } .forEach { keys.add("${it.name}-${it.author}") keys.add(it.name) keys.add(it.bookUrl) } keys }.catch { AppLog.put("搜索界面获取书籍列表失败\n${it.localizedMessage}", it) }.collect { bookshelf.clear() bookshelf.addAll(it) upAdapterLiveData.postValue("isInBookshelf") } }.onError { AppLog.put("加载书架数据失败", it) } } fun isInBookShelf(book: SearchBook): Boolean { val name = book.name val author = book.author val bookUrl = book.bookUrl val key = if (author.isNotBlank()) "$name-$author" else name return bookshelf.contains(key) || bookshelf.contains(bookUrl) } /** * 开始搜索 */ fun search(key: String) { execute { if ((searchKey == key) || key.isNotEmpty()) { searchModel.cancelSearch() searchID = System.currentTimeMillis() searchBookLiveData.postValue(emptyList()) searchKey = key hasMore = true } if (searchKey.isEmpty()) { return@execute } searchModel.search(searchID, searchKey) } } /** * 停止搜索 */ fun stop() { searchModel.cancelSearch() } fun pause() { searchModel.pause() } fun resume() { searchModel.resume() } /** * 保存搜索关键字 */ fun saveSearchKey(key: String) { execute { appDb.searchKeywordDao.get(key)?.let { it.usage += 1 it.lastUseTime = System.currentTimeMillis() appDb.searchKeywordDao.update(it) } ?: appDb.searchKeywordDao.insert(SearchKeyword(key, 1)) } } /** * 清楚搜索关键字 */ fun clearHistory() { execute { appDb.searchKeywordDao.deleteAll() } } fun deleteHistory(searchKeyword: SearchKeyword) { execute { appDb.searchKeywordDao.delete(searchKeyword) } } override fun onCleared() { super.onCleared() searchModel.close() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentActivity.kt ================================================ package io.legado.app.ui.book.searchContent import android.annotation.SuppressLint import android.content.Intent import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.widget.EditText import androidx.activity.viewModels import androidx.appcompat.widget.SearchView import androidx.core.view.allViews import androidx.lifecycle.lifecycleScope import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.constant.AppLog import io.legado.app.constant.EventBus import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.databinding.ActivitySearchContentBinding import io.legado.app.help.IntentData import io.legado.app.help.book.BookHelp import io.legado.app.help.book.isLocal import io.legado.app.lib.theme.bottomBackground import io.legado.app.lib.theme.getPrimaryTextColor import io.legado.app.lib.theme.primaryTextColor import io.legado.app.ui.widget.recycler.UpLinearLayoutManager import io.legado.app.ui.widget.recycler.VerticalDivider import io.legado.app.utils.ColorUtils import io.legado.app.utils.applyNavigationBarMargin import io.legado.app.utils.applyTint import io.legado.app.utils.invisible import io.legado.app.utils.observeEvent import io.legado.app.utils.postEvent import io.legado.app.utils.showSoftInput import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Job import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class SearchContentActivity : VMBaseActivity(), SearchContentAdapter.Callback { override val binding by viewBinding(ActivitySearchContentBinding::inflate) override val viewModel by viewModels() private val adapter by lazy { SearchContentAdapter(this, this) } private val mLayoutManager by lazy { UpLinearLayoutManager(this) } private val searchView: SearchView by lazy { binding.titleBar.findViewById(R.id.search_view) } private var durChapterIndex = 0 private var searchJob: Job? = null private var initJob: Job? = null override fun onActivityCreated(savedInstanceState: Bundle?) { val bbg = bottomBackground val btc = getPrimaryTextColor(ColorUtils.isColorLight(bbg)) binding.llSearchBaseInfo.setBackgroundColor(bbg) binding.llSearchBaseInfo.applyNavigationBarMargin() binding.tvCurrentSearchInfo.setTextColor(btc) binding.ivSearchContentTop.setColorFilter(btc) binding.ivSearchContentBottom.setColorFilter(btc) val searchResultList = IntentData.get>("searchResultList") val position = intent.getIntExtra("searchResultIndex", 0) val noSearchResult = searchResultList == null initSearchView(noSearchResult) initRecyclerView() initView() val bookUrl = intent.getStringExtra("bookUrl") ?: return viewModel.initBook(bookUrl) { initSearchResultList(searchResultList, position) initBook(noSearchResult) } } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.content_search, menu) return super.onCompatCreateOptionsMenu(menu) } override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { menu.findItem(R.id.menu_enable_replace)?.isChecked = viewModel.replaceEnabled return super.onMenuOpened(featureId, menu) } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_enable_replace -> { viewModel.replaceEnabled = !viewModel.replaceEnabled item.isChecked = viewModel.replaceEnabled } } return super.onCompatOptionsItemSelected(item) } private fun initSearchResultList(list: List?, position: Int) { list ?: return viewModel.searchResultList.addAll(list) viewModel.searchResultCounts = list.size adapter.setItems(list) binding.recyclerView.scrollToPosition(position) } private fun initSearchView(requestFocus: Boolean) { searchView.applyTint(primaryTextColor) searchView.isSubmitButtonEnabled = true searchView.queryHint = getString(R.string.search) if (requestFocus) searchView.isIconified = false searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean { startContentSearch(query.trim()) searchView.clearFocus() return false } override fun onQueryTextChange(newText: String): Boolean { return false } }) } private fun initRecyclerView() { binding.recyclerView.layoutManager = mLayoutManager binding.recyclerView.addItemDecoration(VerticalDivider(this)) binding.recyclerView.adapter = adapter } private fun initView() { binding.ivSearchContentTop.setOnClickListener { mLayoutManager.scrollToPositionWithOffset(0, 0) } binding.ivSearchContentBottom.setOnClickListener { if (adapter.itemCount > 0) { mLayoutManager.scrollToPositionWithOffset(adapter.itemCount - 1, 0) } } binding.tvCurrentSearchInfo.setOnClickListener { searchView.allViews.forEach { view -> if (view is EditText) { view.showSoftInput() return@setOnClickListener } } } binding.fbStop.setOnClickListener { searchJob?.cancel() } } @SuppressLint("SetTextI18n") private fun initBook(submit: Boolean = true) { binding.tvCurrentSearchInfo.text = this.getString(R.string.search_content_size) + ": ${viewModel.searchResultCounts}" viewModel.book?.let { initCacheFileNames(it) durChapterIndex = it.durChapterIndex intent.getStringExtra("searchWord")?.let { searchWord -> searchView.setQuery(searchWord, submit) } } } private fun initCacheFileNames(book: Book) { initJob = lifecycleScope.launch { withContext(IO) { viewModel.cacheChapterNames.addAll(BookHelp.getChapterFiles(book)) } adapter.notifyItemRangeChanged(0, adapter.itemCount, true) } } override fun observeLiveBus() { observeEvent>(EventBus.SAVE_CONTENT) { (book, chapter) -> viewModel.book?.bookUrl?.let { bookUrl -> if (book.bookUrl == bookUrl) { viewModel.cacheChapterNames.add(chapter.getFileName()) adapter.notifyItemChanged(chapter.index, true) } } } } @SuppressLint("SetTextI18n") fun startContentSearch(query: String) { // 按章节搜索内容 if (query.isBlank()) return searchJob?.cancel() adapter.clearItems() viewModel.searchResultList.clear() viewModel.searchResultCounts = 0 viewModel.lastQuery = query binding.refreshProgressBar.isAutoLoading = true binding.fbStop.visible() searchJob = lifecycleScope.launch(IO) { initJob?.join() kotlin.runCatching { appDb.bookChapterDao.getChapterList(viewModel.bookUrl).forEach { bookChapter -> ensureActive() val searchResults = if (isLocalBook || viewModel.cacheChapterNames.contains(bookChapter.getFileName()) ) { viewModel.searchChapter(query, bookChapter) } else { return@forEach } ensureActive() if (searchResults.isNotEmpty()) { viewModel.searchResultList.addAll(searchResults) binding.tvCurrentSearchInfo.post { binding.tvCurrentSearchInfo.text = this@SearchContentActivity.getString(R.string.search_content_size) + ": ${viewModel.searchResultCounts}" adapter.addItems(searchResults) } } } if (viewModel.searchResultCounts == 0) { val noSearchResult = SearchResult(resultText = getString(R.string.search_content_empty)) binding.tvCurrentSearchInfo.post { adapter.addItem(noSearchResult) } } }.onFailure { AppLog.put("全文搜索出错\n${it.localizedMessage}", it) } binding.tvCurrentSearchInfo.post { binding.fbStop.invisible() binding.refreshProgressBar.isAutoLoading = false } } } private val isLocalBook: Boolean get() = viewModel.book?.isLocal == true override fun openSearchResult(searchResult: SearchResult, index: Int) { searchJob?.cancel() postEvent(EventBus.SEARCH_RESULT, viewModel.searchResultList as List) val searchData = Intent() val key = System.currentTimeMillis() IntentData.put("searchResult$key", searchResult) IntentData.put("searchResultList$key", viewModel.searchResultList) searchData.putExtra("key", key) searchData.putExtra("index", index) setResult(RESULT_OK, searchData) finish() } override fun durChapterIndex(): Int { return durChapterIndex } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentAdapter.kt ================================================ package io.legado.app.ui.book.searchContent import android.content.Context import android.view.ViewGroup import io.legado.app.R import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.databinding.ItemSearchListBinding import io.legado.app.lib.theme.accentColor import io.legado.app.utils.getCompatColor import io.legado.app.utils.hexString class SearchContentAdapter(context: Context, val callback: Callback) : RecyclerAdapter(context) { val textColor = context.getCompatColor(R.color.primaryText).hexString.substring(2) val accentColor = context.accentColor.hexString.substring(2) override fun getViewBinding(parent: ViewGroup): ItemSearchListBinding { return ItemSearchListBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemSearchListBinding, item: SearchResult, payloads: MutableList ) { binding.run { val isDur = callback.durChapterIndex() == item.chapterIndex if (payloads.isEmpty()) { tvSearchResult.text = item.getHtmlCompat(textColor, accentColor) tvSearchResult.paint.isFakeBoldText = isDur } } } override fun registerListener(holder: ItemViewHolder, binding: ItemSearchListBinding) { holder.itemView.setOnClickListener { getItem(holder.layoutPosition)?.let { if (it.query.isNotBlank()) callback.openSearchResult(it, holder.layoutPosition) } } } interface Callback { fun openSearchResult(searchResult: SearchResult, index: Int) fun durChapterIndex(): Int } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentViewModel.kt ================================================ package io.legado.app.ui.book.searchContent import android.app.Application import io.legado.app.base.BaseViewModel import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.help.book.BookHelp import io.legado.app.help.book.ContentProcessor import io.legado.app.help.config.AppConfig import io.legado.app.utils.ChineseUtils import kotlinx.coroutines.ensureActive import kotlin.coroutines.coroutineContext class SearchContentViewModel(application: Application) : BaseViewModel(application) { var bookUrl: String = "" var book: Book? = null private var contentProcessor: ContentProcessor? = null var lastQuery: String = "" var searchResultCounts = 0 val cacheChapterNames = hashSetOf() val searchResultList: MutableList = mutableListOf() var replaceEnabled = false fun initBook(bookUrl: String, success: () -> Unit) { this.bookUrl = bookUrl execute { book = appDb.bookDao.getBook(bookUrl) book?.let { contentProcessor = ContentProcessor.get(it.name, it.origin) } }.onSuccess { success.invoke() } } suspend fun searchChapter( query: String, chapter: BookChapter ): List { val searchResultsWithinChapter: MutableList = mutableListOf() val book = book ?: return searchResultsWithinChapter val chapterContent = BookHelp.getContent(book, chapter) ?: return searchResultsWithinChapter coroutineContext.ensureActive() chapter.title = when (AppConfig.chineseConverterType) { 1 -> ChineseUtils.t2s(chapter.title) 2 -> ChineseUtils.s2t(chapter.title) else -> chapter.title } coroutineContext.ensureActive() val mContent = contentProcessor!!.getContent( book, chapter, chapterContent, useReplace = replaceEnabled ).toString() val positions = searchPosition(mContent, query) positions.forEachIndexed { index, position -> coroutineContext.ensureActive() val construct = getResultAndQueryIndex(mContent, position, query) val result = SearchResult( resultCountWithinChapter = index, resultText = construct.second, chapterTitle = chapter.title, query = query, chapterIndex = chapter.index, queryIndexInResult = construct.first, queryIndexInChapter = position ) searchResultsWithinChapter.add(result) } searchResultCounts += searchResultsWithinChapter.size return searchResultsWithinChapter } private suspend fun searchPosition(content: String, pattern: String): List { val position: MutableList = mutableListOf() var index = content.indexOf(pattern) while (index >= 0) { coroutineContext.ensureActive() position.add(index) index = content.indexOf(pattern, index + pattern.length) } return position } private fun getResultAndQueryIndex( content: String, queryIndexInContent: Int, query: String ): Pair { // 左右移动20个字符,构建关键词周边文字,在搜索结果里显示 // 判断段落,只在关键词所在段落内分割 // 利用标点符号分割完整的句 // length和设置结合,自由调整周边文字长度 val length = 20 var po1 = queryIndexInContent - length var po2 = queryIndexInContent + query.length + length if (po1 < 0) { po1 = 0 } if (po2 > content.length) { po2 = content.length } val queryIndexInResult = queryIndexInContent - po1 val newText = content.substring(po1, po2) return queryIndexInResult to newText } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/searchContent/SearchResult.kt ================================================ package io.legado.app.ui.book.searchContent import android.text.Spanned import androidx.core.text.HtmlCompat import io.legado.app.help.config.AppConfig data class SearchResult( val resultCount: Int = 0, val resultCountWithinChapter: Int = 0, val resultText: String = "", val chapterTitle: String = "", val query: String = "", val pageSize: Int = 0, val chapterIndex: Int = 0, val pageIndex: Int = 0, val queryIndexInResult: Int = 0, val queryIndexInChapter: Int = 0 ) { fun getHtmlCompat(textColor: String, accentColor: String): Spanned { return if (query.isNotBlank()) { val queryIndexInSurrounding = resultText.indexOf(query) val leftString = resultText.substring(0, queryIndexInSurrounding) val rightString = resultText.substring(queryIndexInSurrounding + query.length, resultText.length) // 检查是否为墨水屏模式 val html = if (AppConfig.isEInkMode) { // 墨水屏模式:使用下划线 buildString { append("${chapterTitle}") append("
") append(leftString) append("${query}") append(rightString) } } else { // 普通模式:使用颜色 buildString { append(chapterTitle.colorTextForHtml(accentColor)) append("
") append(leftString.colorTextForHtml(textColor)) append(query.colorTextForHtml(accentColor)) append(rightString.colorTextForHtml(textColor)) } } HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY) } else { val html = if (AppConfig.isEInkMode) { resultText } else { resultText.colorTextForHtml(textColor) } HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY) } } private fun String.colorTextForHtml(textColor: String) = "$this" } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugActivity.kt ================================================ package io.legado.app.ui.book.source.debug import android.annotation.SuppressLint import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.View import androidx.activity.viewModels import androidx.appcompat.widget.SearchView import androidx.lifecycle.lifecycleScope import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.databinding.ActivitySourceDebugBinding import io.legado.app.help.source.clearExploreKindsCache import io.legado.app.help.source.exploreKinds import io.legado.app.lib.dialogs.selector import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.qrcode.QrCodeResult import io.legado.app.ui.widget.dialog.TextDialog import io.legado.app.utils.applyNavigationBarPadding import io.legado.app.utils.launch import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.showDialogFragment import io.legado.app.utils.showHelp import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.launch import splitties.views.onClick import splitties.views.onLongClick class BookSourceDebugActivity : VMBaseActivity() { override val binding by viewBinding(ActivitySourceDebugBinding::inflate) override val viewModel by viewModels() private val adapter by lazy { BookSourceDebugAdapter(this) } private val searchView: SearchView by lazy { binding.titleBar.findViewById(R.id.search_view) } private val qrCodeResult = registerForActivityResult(QrCodeResult()) { it?.let { startSearch(it) } } override fun onActivityCreated(savedInstanceState: Bundle?) { initRecyclerView() initSearchView() viewModel.init(intent.getStringExtra("key")) { initHelpView() } viewModel.observe { state, msg -> lifecycleScope.launch { adapter.addItem(msg) if (state == -1 || state == 1000) { binding.rotateLoading.gone() } } } } private fun initRecyclerView() { binding.recyclerView.setEdgeEffectColor(primaryColor) binding.recyclerView.adapter = adapter binding.recyclerView.applyNavigationBarPadding() binding.rotateLoading.loadingColor = accentColor } private fun initSearchView() { searchView.onActionViewExpanded() searchView.isSubmitButtonEnabled = true searchView.queryHint = getString(R.string.search_book_key) searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { searchView.clearFocus() openOrCloseHelp(false) startSearch(query ?: "我的") return true } override fun onQueryTextChange(newText: String?): Boolean { return false } }) searchView.setOnQueryTextFocusChangeListener { _, hasFocus -> openOrCloseHelp(hasFocus) } openOrCloseHelp(true) } @SuppressLint("SetTextI18n") private fun initHelpView() { viewModel.bookSource?.ruleSearch?.checkKeyWord?.let { if (it.isNotBlank()) { binding.textMy.text = it } } binding.textMy.onClick { searchView.setQuery(binding.textMy.text, true) } binding.textXt.onClick { searchView.setQuery(binding.textXt.text, true) } binding.textFx.onClick { if (!binding.textFx.text.startsWith("ERROR:")) { searchView.setQuery(binding.textFx.text, true) } } binding.textInfo.onClick { if (!searchView.query.isNullOrBlank()) { searchView.setQuery(searchView.query, true) } } binding.textToc.onClick { prefixAutoComplete("++") } binding.textContent.onClick { prefixAutoComplete("--") } initExploreKinds() } @SuppressLint("SetTextI18n") private fun initExploreKinds() { lifecycleScope.launch { try { val exploreKinds = viewModel.bookSource?.exploreKinds()?.filter { !it.url.isNullOrBlank() } exploreKinds?.firstOrNull()?.let { binding.textFx.text = "${it.title}::${it.url}" if (it.title.startsWith("ERROR:")) { adapter.addItem("获取发现出错\n${it.url}") openOrCloseHelp(false) searchView.clearFocus() return@launch } } @Suppress("USELESS_ELVIS") exploreKinds?.map { it.title ?: "" }?.let { exploreKindTitles -> binding.textFx.onLongClick { selector("选择发现", exploreKindTitles) { _, index -> val explore = exploreKinds[index] binding.textFx.text = "${explore.title}::${explore.url}" searchView.setQuery(binding.textFx.text, true) } } } } catch (e: NullPointerException) { adapter.addItem("获取发现出错 JSON 数据错误\n$e") openOrCloseHelp(false) searchView.clearFocus() } } } private fun prefixAutoComplete(prefix: String) { val query = searchView.query if (query.isNullOrBlank() || query.length <= 2) { searchView.setQuery(prefix, false) } else { if (!query.startsWith(prefix)) { searchView.setQuery("$prefix$query", true) } else { searchView.setQuery(query, true) } } } /** * 打开关闭历史界面 */ private fun openOrCloseHelp(open: Boolean) { if (open) { binding.help.visibility = View.VISIBLE } else { binding.help.visibility = View.GONE } } private fun startSearch(key: String) { adapter.clearItems() viewModel.startDebug(key, { binding.rotateLoading.visible() }, { toastOnUi("未获取到书源") }) } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.book_source_debug, menu) return super.onCompatCreateOptionsMenu(menu) } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_scan -> qrCodeResult.launch() R.id.menu_search_src -> showDialogFragment(TextDialog("html", viewModel.searchSrc)) R.id.menu_book_src -> showDialogFragment(TextDialog("html", viewModel.bookSrc)) R.id.menu_toc_src -> showDialogFragment(TextDialog("html", viewModel.tocSrc)) R.id.menu_content_src -> showDialogFragment(TextDialog("html", viewModel.contentSrc)) R.id.menu_refresh_explore -> lifecycleScope.launch { viewModel.bookSource?.clearExploreKindsCache() adapter.clearItems() openOrCloseHelp(true) initExploreKinds() } R.id.menu_help -> showHelp("debugHelp") } return super.onCompatOptionsItemSelected(item) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugAdapter.kt ================================================ package io.legado.app.ui.book.source.debug import android.content.Context import android.view.View import android.view.ViewGroup import io.legado.app.R import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.databinding.ItemLogBinding class BookSourceDebugAdapter(context: Context) : RecyclerAdapter(context) { override fun getViewBinding(parent: ViewGroup): ItemLogBinding { return ItemLogBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemLogBinding, item: String, payloads: MutableList ) { binding.apply { if (textView.getTag(R.id.tag1) == null) { val listener = object : View.OnAttachStateChangeListener { override fun onViewAttachedToWindow(v: View) { textView.isCursorVisible = false textView.isCursorVisible = true } override fun onViewDetachedFromWindow(v: View) {} } textView.addOnAttachStateChangeListener(listener) textView.setTag(R.id.tag1, listener) } textView.text = item } } override fun registerListener(holder: ItemViewHolder, binding: ItemLogBinding) { //nothing } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugModel.kt ================================================ package io.legado.app.ui.book.source.debug import android.app.Application import io.legado.app.base.BaseViewModel import io.legado.app.data.appDb import io.legado.app.data.entities.BookSource import io.legado.app.model.Debug class BookSourceDebugModel(application: Application) : BaseViewModel(application), Debug.Callback { var bookSource: BookSource? = null private var callback: ((Int, String) -> Unit)? = null var searchSrc: String? = null var bookSrc: String? = null var tocSrc: String? = null var contentSrc: String? = null fun init(sourceUrl: String?, finally: () -> Unit) { sourceUrl?.let { //优先使用这个,不会抛出异常 execute { bookSource = appDb.bookSourceDao.getBookSource(sourceUrl) }.onFinally { finally.invoke() } } } fun observe(callback: (Int, String) -> Unit) { this.callback = callback } fun startDebug(key: String, start: (() -> Unit)? = null, error: (() -> Unit)? = null) { execute { Debug.callback = this@BookSourceDebugModel Debug.startDebug(this, bookSource!!, key) }.onStart { start?.invoke() }.onError { error?.invoke() } } override fun printLog(state: Int, msg: String) { when (state) { 10 -> searchSrc = msg 20 -> bookSrc = msg 30 -> tocSrc = msg 40 -> contentSrc = msg else -> callback?.invoke(state, msg) } } override fun onCleared() { super.onCleared() Debug.cancelDebug(true) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt ================================================ package io.legado.app.ui.book.source.edit import android.content.Intent import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.widget.EditText import androidx.activity.viewModels import androidx.lifecycle.lifecycleScope import com.google.android.material.tabs.TabLayout import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.constant.BookSourceType import io.legado.app.data.appDb import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.rule.BookInfoRule import io.legado.app.data.entities.rule.ContentRule import io.legado.app.data.entities.rule.ExploreRule import io.legado.app.data.entities.rule.SearchRule import io.legado.app.data.entities.rule.TocRule import io.legado.app.databinding.ActivityBookSourceEditBinding import io.legado.app.help.config.LocalConfig import io.legado.app.lib.dialogs.SelectItem import io.legado.app.lib.dialogs.alert import io.legado.app.lib.dialogs.selector import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.backgroundColor import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.book.search.SearchActivity import io.legado.app.ui.book.search.SearchScope import io.legado.app.ui.book.source.debug.BookSourceDebugActivity import io.legado.app.ui.file.HandleFileContract import io.legado.app.ui.login.SourceLoginActivity import io.legado.app.ui.qrcode.QrCodeResult import io.legado.app.ui.widget.dialog.UrlOptionDialog import io.legado.app.ui.widget.dialog.VariableDialog import io.legado.app.ui.widget.keyboard.KeyboardToolPop import io.legado.app.ui.widget.recycler.NoChildScrollLinearLayoutManager import io.legado.app.ui.widget.text.EditEntity import io.legado.app.utils.GSON import io.legado.app.utils.imeHeight import io.legado.app.utils.isContentScheme import io.legado.app.utils.launch import io.legado.app.utils.navigationBarHeight import io.legado.app.utils.sendToClip import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat import io.legado.app.utils.share import io.legado.app.utils.shareWithQr import io.legado.app.utils.showDialogFragment import io.legado.app.utils.showHelp import io.legado.app.utils.startActivity import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import splitties.views.bottomPadding class BookSourceEditActivity : VMBaseActivity(), KeyboardToolPop.CallBack, VariableDialog.Callback { override val binding by viewBinding(ActivityBookSourceEditBinding::inflate) override val viewModel by viewModels() private val adapter by lazy { BookSourceEditAdapter() } private val sourceEntities: ArrayList = ArrayList() private val searchEntities: ArrayList = ArrayList() private val exploreEntities: ArrayList = ArrayList() private val infoEntities: ArrayList = ArrayList() private val tocEntities: ArrayList = ArrayList() private val contentEntities: ArrayList = ArrayList() // private val reviewEntities: ArrayList = ArrayList() private val qrCodeResult = registerForActivityResult(QrCodeResult()) { it ?: return@registerForActivityResult viewModel.importSource(it) { source -> upSourceView(source) } } private val selectDoc = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> if (uri.isContentScheme()) { sendText(uri.toString()) } else { sendText(uri.path.toString()) } } } private val softKeyboardTool by lazy { KeyboardToolPop(this, lifecycleScope, binding.root, this) } override fun onActivityCreated(savedInstanceState: Bundle?) { softKeyboardTool.attachToWindow(window) initView() viewModel.initData(intent) { upSourceView(viewModel.bookSource) } } override fun onPostCreate(savedInstanceState: Bundle?) { super.onPostCreate(savedInstanceState) if (!LocalConfig.ruleHelpVersionIsLast) { showHelp("ruleHelp") } } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.source_edit, menu) return super.onCompatCreateOptionsMenu(menu) } override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { menu.findItem(R.id.menu_login)?.isVisible = !getSource().loginUrl.isNullOrBlank() menu.findItem(R.id.menu_auto_complete)?.isChecked = viewModel.autoComplete return super.onMenuOpened(featureId, menu) } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_save -> viewModel.save(getSource()) { setResult(RESULT_OK, Intent().putExtra("origin", it.bookSourceUrl)) finish() } R.id.menu_debug_source -> viewModel.save(getSource()) { source -> startActivity { putExtra("key", source.bookSourceUrl) } } R.id.menu_clear_cookie -> viewModel.clearCookie(getSource().bookSourceUrl) R.id.menu_auto_complete -> viewModel.autoComplete = !viewModel.autoComplete R.id.menu_copy_source -> sendToClip(GSON.toJson(getSource())) R.id.menu_paste_source -> viewModel.pasteSource { upSourceView(it) } R.id.menu_qr_code_camera -> qrCodeResult.launch() R.id.menu_share_str -> share(GSON.toJson(getSource())) R.id.menu_share_qr -> shareWithQr( GSON.toJson(getSource()), getString(R.string.share_book_source), ErrorCorrectionLevel.L ) R.id.menu_help -> showHelp("ruleHelp") R.id.menu_login -> viewModel.save(getSource()) { source -> startActivity { putExtra("type", "bookSource") putExtra("key", source.bookSourceUrl) } } R.id.menu_set_source_variable -> setSourceVariable() R.id.menu_search -> viewModel.save(getSource()) { source -> startActivity { putExtra("searchScope", SearchScope(source).toString()) } } } return super.onCompatOptionsItemSelected(item) } private fun initView() { binding.tabLayout.addTab(binding.tabLayout.newTab().apply { setText(R.string.source_tab_base) }) binding.tabLayout.addTab(binding.tabLayout.newTab().apply { setText(R.string.source_tab_search) }) binding.tabLayout.addTab(binding.tabLayout.newTab().apply { setText(R.string.source_tab_find) }) binding.tabLayout.addTab(binding.tabLayout.newTab().apply { setText(R.string.source_tab_info) }) binding.tabLayout.addTab(binding.tabLayout.newTab().apply { setText(R.string.source_tab_toc) }) binding.tabLayout.addTab(binding.tabLayout.newTab().apply { setText(R.string.source_tab_content) }) binding.recyclerView.setEdgeEffectColor(primaryColor) binding.recyclerView.layoutManager = NoChildScrollLinearLayoutManager(this) binding.recyclerView.adapter = adapter binding.tabLayout.setBackgroundColor(backgroundColor) binding.tabLayout.setSelectedTabIndicatorColor(accentColor) binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { override fun onTabReselected(tab: TabLayout.Tab?) { } override fun onTabUnselected(tab: TabLayout.Tab?) { } override fun onTabSelected(tab: TabLayout.Tab?) { setEditEntities(tab?.position) } }) binding.recyclerView.setOnApplyWindowInsetsListenerCompat { view, windowInsets -> val navigationBarHeight = windowInsets.navigationBarHeight val imeHeight = windowInsets.imeHeight view.bottomPadding = if (imeHeight == 0) navigationBarHeight else 0 softKeyboardTool.initialPadding = imeHeight windowInsets } } override fun finish() { val source = getSource() if (!source.equal(viewModel.bookSource ?: BookSource())) { alert(R.string.exit) { setMessage(R.string.exit_no_save) positiveButton(R.string.yes) negativeButton(R.string.no) { super.finish() } } } else { super.finish() } } override fun onDestroy() { super.onDestroy() softKeyboardTool.dismiss() } private fun setEditEntities(tabPosition: Int?) { adapter.editEntities = when (tabPosition) { 1 -> searchEntities 2 -> exploreEntities 3 -> infoEntities 4 -> tocEntities 5 -> contentEntities // 6 -> reviewEntities else -> sourceEntities } binding.recyclerView.scrollToPosition(0) } private fun upSourceView(bookSource: BookSource?) { val bs = bookSource ?: BookSource() bs.let { binding.cbIsEnable.isChecked = it.enabled binding.cbIsEnableExplore.isChecked = it.enabledExplore binding.cbIsEnableCookie.isChecked = it.enabledCookieJar ?: false binding.spType.setSelection( when (it.bookSourceType) { BookSourceType.file -> 3 BookSourceType.image -> 2 BookSourceType.audio -> 1 else -> 0 } ) } // 基本信息 sourceEntities.clear() sourceEntities.apply { add(EditEntity("bookSourceUrl", bs.bookSourceUrl, R.string.source_url)) add(EditEntity("bookSourceName", bs.bookSourceName, R.string.source_name)) add(EditEntity("bookSourceGroup", bs.bookSourceGroup, R.string.source_group)) add(EditEntity("bookSourceComment", bs.bookSourceComment, R.string.comment)) add(EditEntity("loginUrl", bs.loginUrl, R.string.login_url)) add(EditEntity("loginUi", bs.loginUi, R.string.login_ui)) add(EditEntity("loginCheckJs", bs.loginCheckJs, R.string.login_check_js)) add(EditEntity("coverDecodeJs", bs.coverDecodeJs, R.string.cover_decode_js)) add(EditEntity("bookUrlPattern", bs.bookUrlPattern, R.string.book_url_pattern)) add(EditEntity("header", bs.header, R.string.source_http_header)) add(EditEntity("variableComment", bs.variableComment, R.string.variable_comment)) add(EditEntity("concurrentRate", bs.concurrentRate, R.string.concurrent_rate)) add(EditEntity("jsLib", bs.jsLib, "jsLib")) } // 搜索 val sr = bs.getSearchRule() searchEntities.clear() searchEntities.apply { add(EditEntity("searchUrl", bs.searchUrl, R.string.r_search_url)) add(EditEntity("checkKeyWord", sr.checkKeyWord, R.string.check_key_word)) add(EditEntity("bookList", sr.bookList, R.string.r_book_list)) add(EditEntity("name", sr.name, R.string.r_book_name)) add(EditEntity("author", sr.author, R.string.r_author)) add(EditEntity("kind", sr.kind, R.string.rule_book_kind)) add(EditEntity("wordCount", sr.wordCount, R.string.rule_word_count)) add(EditEntity("lastChapter", sr.lastChapter, R.string.rule_last_chapter)) add(EditEntity("intro", sr.intro, R.string.rule_book_intro)) add(EditEntity("coverUrl", sr.coverUrl, R.string.rule_cover_url)) add(EditEntity("bookUrl", sr.bookUrl, R.string.r_book_url)) } // 发现 val er = bs.getExploreRule() exploreEntities.clear() exploreEntities.apply { add(EditEntity("exploreUrl", bs.exploreUrl, R.string.r_find_url)) add(EditEntity("bookList", er.bookList, R.string.r_book_list)) add(EditEntity("name", er.name, R.string.r_book_name)) add(EditEntity("author", er.author, R.string.r_author)) add(EditEntity("kind", er.kind, R.string.rule_book_kind)) add(EditEntity("wordCount", er.wordCount, R.string.rule_word_count)) add(EditEntity("lastChapter", er.lastChapter, R.string.rule_last_chapter)) add(EditEntity("intro", er.intro, R.string.rule_book_intro)) add(EditEntity("coverUrl", er.coverUrl, R.string.rule_cover_url)) add(EditEntity("bookUrl", er.bookUrl, R.string.r_book_url)) } // 详情页 val ir = bs.getBookInfoRule() infoEntities.clear() infoEntities.apply { add(EditEntity("init", ir.init, R.string.rule_book_info_init)) add(EditEntity("name", ir.name, R.string.r_book_name)) add(EditEntity("author", ir.author, R.string.r_author)) add(EditEntity("kind", ir.kind, R.string.rule_book_kind)) add(EditEntity("wordCount", ir.wordCount, R.string.rule_word_count)) add(EditEntity("lastChapter", ir.lastChapter, R.string.rule_last_chapter)) add(EditEntity("intro", ir.intro, R.string.rule_book_intro)) add(EditEntity("coverUrl", ir.coverUrl, R.string.rule_cover_url)) add(EditEntity("tocUrl", ir.tocUrl, R.string.rule_toc_url)) add(EditEntity("canReName", ir.canReName, R.string.rule_can_re_name)) add(EditEntity("downloadUrls", ir.downloadUrls, R.string.download_url_rule)) } // 目录页 val tr = bs.getTocRule() tocEntities.clear() tocEntities.apply { add(EditEntity("preUpdateJs", tr.preUpdateJs, R.string.pre_update_js)) add(EditEntity("chapterList", tr.chapterList, R.string.rule_chapter_list)) add(EditEntity("chapterName", tr.chapterName, R.string.rule_chapter_name)) add(EditEntity("chapterUrl", tr.chapterUrl, R.string.rule_chapter_url)) add(EditEntity("formatJs", tr.formatJs, R.string.format_js_rule)) add(EditEntity("isVolume", tr.isVolume, R.string.rule_is_volume)) add(EditEntity("updateTime", tr.updateTime, R.string.rule_update_time)) add(EditEntity("isVip", tr.isVip, R.string.rule_is_vip)) add(EditEntity("isPay", tr.isPay, R.string.rule_is_pay)) add(EditEntity("nextTocUrl", tr.nextTocUrl, R.string.rule_next_toc_url)) } // 正文页 val cr = bs.getContentRule() contentEntities.clear() contentEntities.apply { add(EditEntity("content", cr.content, R.string.rule_book_content)) add(EditEntity("title", cr.title, R.string.rule_chapter_name)) add(EditEntity("nextContentUrl", cr.nextContentUrl, R.string.rule_next_content)) add(EditEntity("webJs", cr.webJs, R.string.rule_web_js)) add(EditEntity("sourceRegex", cr.sourceRegex, R.string.rule_source_regex)) add(EditEntity("replaceRegex", cr.replaceRegex, R.string.rule_replace_regex)) add(EditEntity("imageStyle", cr.imageStyle, R.string.rule_image_style)) add(EditEntity("imageDecode", cr.imageDecode, R.string.rule_image_decode)) add(EditEntity("payAction", cr.payAction, R.string.rule_pay_action)) } // 段评 // val rr = bs.getReviewRule() // reviewEntities.clear() // reviewEntities.apply { // add(EditEntity("reviewUrl", rr.reviewUrl, R.string.rule_review_url)) // add(EditEntity("avatarRule", rr.avatarRule, R.string.rule_avatar)) // add(EditEntity("contentRule", rr.contentRule, R.string.rule_review_content)) // add(EditEntity("postTimeRule", rr.postTimeRule, R.string.rule_post_time)) // add(EditEntity("reviewQuoteUrl", rr.reviewQuoteUrl, R.string.rule_review_quote)) // add(EditEntity("voteUpUrl", rr.voteUpUrl, R.string.review_vote_up)) // add(EditEntity("voteDownUrl", rr.voteDownUrl, R.string.review_vote_down)) // add(EditEntity("postReviewUrl", rr.postReviewUrl, R.string.post_review_url)) // add(EditEntity("postQuoteUrl", rr.postQuoteUrl, R.string.post_quote_url)) // add(EditEntity("deleteUrl", rr.deleteUrl, R.string.delete_review_url)) // } binding.tabLayout.selectTab(binding.tabLayout.getTabAt(0)) setEditEntities(0) } private fun getSource(): BookSource { val source = viewModel.bookSource?.copy() ?: BookSource() source.enabled = binding.cbIsEnable.isChecked source.enabledExplore = binding.cbIsEnableExplore.isChecked source.enabledCookieJar = binding.cbIsEnableCookie.isChecked source.bookSourceType = when (binding.spType.selectedItemPosition) { 3 -> BookSourceType.file 2 -> BookSourceType.image 1 -> BookSourceType.audio else -> BookSourceType.default } val searchRule = SearchRule() val exploreRule = ExploreRule() val bookInfoRule = BookInfoRule() val tocRule = TocRule() val contentRule = ContentRule() // val reviewRule = ReviewRule() sourceEntities.forEach { it.value = it.value?.takeIf { s -> s.isNotBlank() } when (it.key) { "bookSourceUrl" -> source.bookSourceUrl = it.value ?: "" "bookSourceName" -> source.bookSourceName = it.value ?: "" "bookSourceGroup" -> source.bookSourceGroup = it.value "loginUrl" -> source.loginUrl = it.value "loginUi" -> source.loginUi = it.value "loginCheckJs" -> source.loginCheckJs = it.value "coverDecodeJs" -> source.coverDecodeJs = it.value "bookUrlPattern" -> source.bookUrlPattern = it.value "header" -> source.header = it.value "bookSourceComment" -> source.bookSourceComment = it.value "concurrentRate" -> source.concurrentRate = it.value "variableComment" -> source.variableComment = it.value "jsLib" -> source.jsLib = it.value } } searchEntities.forEach { it.value = it.value?.takeIf { s -> s.isNotBlank() } when (it.key) { "searchUrl" -> source.searchUrl = it.value "checkKeyWord" -> searchRule.checkKeyWord = it.value "bookList" -> searchRule.bookList = it.value "name" -> searchRule.name = viewModel.ruleComplete(it.value, searchRule.bookList) "author" -> searchRule.author = viewModel.ruleComplete(it.value, searchRule.bookList) "kind" -> searchRule.kind = viewModel.ruleComplete(it.value, searchRule.bookList) "intro" -> searchRule.intro = viewModel.ruleComplete(it.value, searchRule.bookList) // "updateTime" -> searchRule.updateTime = // viewModel.ruleComplete(it.value, searchRule.bookList) "wordCount" -> searchRule.wordCount = viewModel.ruleComplete(it.value, searchRule.bookList) "lastChapter" -> searchRule.lastChapter = viewModel.ruleComplete(it.value, searchRule.bookList) "coverUrl" -> searchRule.coverUrl = viewModel.ruleComplete(it.value, searchRule.bookList, 3) "bookUrl" -> searchRule.bookUrl = viewModel.ruleComplete(it.value, searchRule.bookList, 2) } } exploreEntities.forEach { it.value = it.value?.takeIf { s -> s.isNotBlank() } when (it.key) { "exploreUrl" -> source.exploreUrl = it.value "bookList" -> exploreRule.bookList = it.value "name" -> exploreRule.name = viewModel.ruleComplete(it.value, exploreRule.bookList) "author" -> exploreRule.author = viewModel.ruleComplete(it.value, exploreRule.bookList) "kind" -> exploreRule.kind = viewModel.ruleComplete(it.value, exploreRule.bookList) "intro" -> exploreRule.intro = viewModel.ruleComplete(it.value, exploreRule.bookList) // "updateTime" -> exploreRule.updateTime = // viewModel.ruleComplete(it.value, exploreRule.bookList) "wordCount" -> exploreRule.wordCount = viewModel.ruleComplete(it.value, exploreRule.bookList) "lastChapter" -> exploreRule.lastChapter = viewModel.ruleComplete(it.value, exploreRule.bookList) "coverUrl" -> exploreRule.coverUrl = viewModel.ruleComplete(it.value, exploreRule.bookList, 3) "bookUrl" -> exploreRule.bookUrl = viewModel.ruleComplete(it.value, exploreRule.bookList, 2) } } infoEntities.forEach { it.value = it.value?.takeIf { s -> s.isNotBlank() } when (it.key) { "init" -> bookInfoRule.init = it.value "name" -> bookInfoRule.name = viewModel.ruleComplete(it.value, bookInfoRule.init) "author" -> bookInfoRule.author = viewModel.ruleComplete(it.value, bookInfoRule.init) "kind" -> bookInfoRule.kind = viewModel.ruleComplete(it.value, bookInfoRule.init) "intro" -> bookInfoRule.intro = viewModel.ruleComplete(it.value, bookInfoRule.init) // "updateTime" -> bookInfoRule.updateTime = // viewModel.ruleComplete(it.value, bookInfoRule.init) "wordCount" -> bookInfoRule.wordCount = viewModel.ruleComplete(it.value, bookInfoRule.init) "lastChapter" -> bookInfoRule.lastChapter = viewModel.ruleComplete(it.value, bookInfoRule.init) "coverUrl" -> bookInfoRule.coverUrl = viewModel.ruleComplete(it.value, bookInfoRule.init, 3) "tocUrl" -> bookInfoRule.tocUrl = viewModel.ruleComplete(it.value, bookInfoRule.init, 2) "canReName" -> bookInfoRule.canReName = it.value "downloadUrls" -> bookInfoRule.downloadUrls = viewModel.ruleComplete(it.value, bookInfoRule.init) } } tocEntities.forEach { it.value = it.value?.takeIf { s -> s.isNotBlank() } when (it.key) { "preUpdateJs" -> tocRule.preUpdateJs = it.value "chapterList" -> tocRule.chapterList = it.value "chapterName" -> tocRule.chapterName = viewModel.ruleComplete(it.value, tocRule.chapterList) "chapterUrl" -> tocRule.chapterUrl = viewModel.ruleComplete(it.value, tocRule.chapterList, 2) "formatJs" -> tocRule.formatJs = it.value "isVolume" -> tocRule.isVolume = it.value "updateTime" -> tocRule.updateTime = it.value "isVip" -> tocRule.isVip = it.value "isPay" -> tocRule.isPay = it.value "nextTocUrl" -> tocRule.nextTocUrl = viewModel.ruleComplete(it.value, tocRule.chapterList, 2) } } contentEntities.forEach { it.value = it.value?.takeIf { s -> s.isNotBlank() } when (it.key) { "content" -> contentRule.content = viewModel.ruleComplete(it.value) "title" -> contentRule.title = viewModel.ruleComplete(it.value) "nextContentUrl" -> contentRule.nextContentUrl = viewModel.ruleComplete(it.value, type = 2) "webJs" -> contentRule.webJs = it.value "sourceRegex" -> contentRule.sourceRegex = it.value "replaceRegex" -> contentRule.replaceRegex = it.value "imageStyle" -> contentRule.imageStyle = it.value "imageDecode" -> contentRule.imageDecode = it.value "payAction" -> contentRule.payAction = it.value } } // reviewEntities.forEach { // when (it.key) { // "reviewUrl" -> reviewRule.reviewUrl = it.value // "avatarRule" -> reviewRule.avatarRule = // viewModel.ruleComplete(it.value, reviewRule.reviewUrl, 3) // // "contentRule" -> reviewRule.contentRule = // viewModel.ruleComplete(it.value, reviewRule.reviewUrl) // // "postTimeRule" -> reviewRule.postTimeRule = // viewModel.ruleComplete(it.value, reviewRule.reviewUrl) // // "reviewQuoteUrl" -> reviewRule.reviewQuoteUrl = // viewModel.ruleComplete(it.value, reviewRule.reviewUrl, 2) // // "voteUpUrl" -> reviewRule.voteUpUrl = it.value // "voteDownUrl" -> reviewRule.voteDownUrl = it.value // "postReviewUrl" -> reviewRule.postReviewUrl = it.value // "postQuoteUrl" -> reviewRule.postQuoteUrl = it.value // "deleteUrl" -> reviewRule.deleteUrl = it.value // } // } source.ruleSearch = searchRule source.ruleExplore = exploreRule source.ruleBookInfo = bookInfoRule source.ruleToc = tocRule source.ruleContent = contentRule // source.ruleReview = reviewRule return source } private fun alertGroups() { lifecycleScope.launch { val groups = withContext(IO) { appDb.bookSourceDao.allGroups() } selector(groups) { _, s, _ -> sendText(s) } } } override fun helpActions(): List> { val helpActions = arrayListOf( SelectItem("插入URL参数", "urlOption"), SelectItem("书源教程", "ruleHelp"), SelectItem("js教程", "jsHelp"), SelectItem("正则教程", "regexHelp"), ) val view = window.decorView.findFocus() if (view is EditText) { when (view.getTag(R.id.tag)) { "bookSourceGroup" -> { helpActions.add( SelectItem("插入分组", "addGroup") ) } else -> { helpActions.add( SelectItem("选择文件", "selectFile") ) } } } return helpActions } override fun onHelpActionSelect(action: String) { when (action) { "addGroup" -> alertGroups() "urlOption" -> UrlOptionDialog(this) { sendText(it) }.show() "ruleHelp" -> showHelp("ruleHelp") "jsHelp" -> showHelp("jsHelp") "regexHelp" -> showHelp("regexHelp") "selectFile" -> selectDoc.launch { mode = HandleFileContract.FILE } } } override fun sendText(text: String) { if (text.isBlank()) return val view = window.decorView.findFocus() if (view is EditText) { val start = view.selectionStart val end = view.selectionEnd val edit = view.editableText//获取EditText的文字 if (start < 0 || start >= edit.length) { edit.append(text) } else if (start > end) { edit.replace(end, start, text) } else { edit.replace(start, end, text)//光标所在位置插入文字 } } } private fun setSourceVariable() { viewModel.save(getSource()) { source -> lifecycleScope.launch { val comment = source.getDisplayVariableComment("源变量可在js中通过source.getVariable()获取") val variable = withContext(IO) { source.getVariable() } showDialogFragment( VariableDialog( getString(R.string.set_source_variable), source.getKey(), variable, comment ) ) } } } override fun setVariable(key: String, variable: String?) { viewModel.bookSource?.setVariable(variable) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditAdapter.kt ================================================ package io.legado.app.ui.book.source.edit import android.annotation.SuppressLint import android.text.Editable import android.text.TextWatcher import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import io.legado.app.R import io.legado.app.databinding.ItemSourceEditBinding import io.legado.app.help.config.AppConfig import io.legado.app.ui.widget.code.addJsPattern import io.legado.app.ui.widget.code.addJsonPattern import io.legado.app.ui.widget.code.addLegadoPattern import io.legado.app.ui.widget.text.EditEntity class BookSourceEditAdapter : RecyclerView.Adapter() { val editEntityMaxLine = AppConfig.sourceEditMaxLine var editEntities: ArrayList = ArrayList() @SuppressLint("NotifyDataSetChanged") set(value) { field = value notifyDataSetChanged() } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder { val binding = ItemSourceEditBinding .inflate(LayoutInflater.from(parent.context), parent, false) binding.editText.addLegadoPattern() binding.editText.addJsonPattern() binding.editText.addJsPattern() return MyViewHolder(binding) } override fun onBindViewHolder(holder: MyViewHolder, position: Int) { holder.bind(editEntities[position]) } override fun getItemCount(): Int { return editEntities.size } inner class MyViewHolder(val binding: ItemSourceEditBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(editEntity: EditEntity) = binding.run { editText.setTag(R.id.tag, editEntity.key) editText.maxLines = editEntityMaxLine if (editText.getTag(R.id.tag1) == null) { val listener = object : View.OnAttachStateChangeListener { override fun onViewAttachedToWindow(v: View) { editText.isCursorVisible = false editText.isCursorVisible = true editText.isFocusable = true editText.isFocusableInTouchMode = true } override fun onViewDetachedFromWindow(v: View) { } } editText.addOnAttachStateChangeListener(listener) editText.setTag(R.id.tag1, listener) } editText.getTag(R.id.tag2)?.let { if (it is TextWatcher) { editText.removeTextChangedListener(it) } } editText.setText(editEntity.value) textInputLayout.hint = editEntity.hint val textWatcher = object : TextWatcher { override fun beforeTextChanged( s: CharSequence, start: Int, count: Int, after: Int ) { } override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { } override fun afterTextChanged(s: Editable?) { editEntity.value = (s?.toString()) } } editText.addTextChangedListener(textWatcher) editText.setTag(R.id.tag2, textWatcher) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditViewModel.kt ================================================ package io.legado.app.ui.book.source.edit import android.app.Application import android.content.Intent import io.legado.app.R import io.legado.app.base.BaseViewModel import io.legado.app.data.appDb import io.legado.app.data.entities.BookSource import io.legado.app.exception.NoStackTraceException import io.legado.app.help.RuleComplete import io.legado.app.help.config.SourceConfig import io.legado.app.help.http.CookieStore import io.legado.app.help.http.newCallStrResponse import io.legado.app.help.http.okHttpClient import io.legado.app.help.source.SourceHelp import io.legado.app.help.source.clearExploreKindsCache import io.legado.app.help.storage.ImportOldData import io.legado.app.model.SharedJsScope import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonArray import io.legado.app.utils.fromJsonObject import io.legado.app.utils.getClipText import io.legado.app.utils.isAbsUrl import io.legado.app.utils.isJsonArray import io.legado.app.utils.isJsonObject import io.legado.app.utils.jsonPath import io.legado.app.utils.printOnDebug import io.legado.app.utils.toastOnUi import kotlinx.coroutines.Dispatchers class BookSourceEditViewModel(application: Application) : BaseViewModel(application) { var autoComplete = false var bookSource: BookSource? = null fun initData(intent: Intent, onFinally: () -> Unit) { execute { val sourceUrl = intent.getStringExtra("sourceUrl") var source: BookSource? = null if (sourceUrl != null) { source = appDb.bookSourceDao.getBookSource(sourceUrl) } source?.let { bookSource = it } }.onFinally { onFinally() } } fun save(source: BookSource, success: ((BookSource) -> Unit)? = null) { execute { if (source.bookSourceUrl.isBlank() || source.bookSourceName.isBlank()) { throw NoStackTraceException(context.getString(R.string.non_null_name_url)) } val oldSource = bookSource ?: BookSource() if (!source.equal(oldSource)) { source.lastUpdateTime = System.currentTimeMillis() if (oldSource.exploreUrl != source.exploreUrl) { oldSource.clearExploreKindsCache() } if (oldSource.jsLib != source.jsLib) { SharedJsScope.remove(oldSource.jsLib) } } bookSource?.let { if (it.bookSourceUrl != source.bookSourceUrl) { SourceHelp.deleteBookSource(it.bookSourceUrl) } else { appDb.bookSourceDao.delete(it) SourceConfig.removeSource(it.bookSourceUrl) } } appDb.bookSourceDao.insert(source) bookSource = source source }.onSuccess { success?.invoke(it) }.onError { context.toastOnUi(it.localizedMessage) it.printOnDebug() } } fun pasteSource(onSuccess: (source: BookSource) -> Unit) { execute(context = Dispatchers.Main) { val text = context.getClipText() if (text.isNullOrBlank()) { throw NoStackTraceException("剪贴板为空") } else { importSource(text, onSuccess) } }.onError { context.toastOnUi(it.localizedMessage ?: "Error") it.printOnDebug() } } fun importSource(text: String, finally: (source: BookSource) -> Unit) { execute { importSource(text) }.onSuccess { finally.invoke(it) }.onError { context.toastOnUi(it.localizedMessage ?: "Error") it.printOnDebug() } } suspend fun importSource(text: String): BookSource { return when { text.isAbsUrl() -> { val text1 = okHttpClient.newCallStrResponse { url(text) }.body importSource(text1!!) } text.isJsonArray() -> { if (text.contains("ruleSearchUrl") || text.contains("ruleFindUrl")) { val items: List> = jsonPath.parse(text).read("$") val jsonItem = jsonPath.parse(items[0]) ImportOldData.fromOldBookSource(jsonItem) } else { GSON.fromJsonArray(text).getOrThrow()[0] } } text.isJsonObject() -> { if (text.contains("ruleSearchUrl") || text.contains("ruleFindUrl")) { val jsonItem = jsonPath.parse(text) ImportOldData.fromOldBookSource(jsonItem) } else { GSON.fromJsonObject(text).getOrThrow() } } else -> throw NoStackTraceException("格式不对") } } fun clearCookie(url: String) { execute { CookieStore.removeCookie(url) } } fun ruleComplete(rule: String?, preRule: String? = null, type: Int = 1): String? { if (autoComplete) { return RuleComplete.autoComplete(rule, preRule, type) } return rule } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceActivity.kt ================================================ package io.legado.app.ui.book.source.manage import android.annotation.SuppressLint import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.SubMenu import android.view.WindowManager import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.SearchView import androidx.core.os.bundleOf import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.ItemTouchHelper import com.google.android.material.snackbar.Snackbar import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.constant.AppLog import io.legado.app.constant.EventBus import io.legado.app.data.AppDatabase import io.legado.app.data.appDb import io.legado.app.data.entities.BookSourcePart import io.legado.app.databinding.ActivityBookSourceBinding import io.legado.app.databinding.DialogEditTextBinding import io.legado.app.help.DirectLinkUpload import io.legado.app.help.config.LocalConfig import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.primaryColor import io.legado.app.lib.theme.primaryTextColor import io.legado.app.model.CheckSource import io.legado.app.model.Debug import io.legado.app.ui.association.ImportBookSourceDialog import io.legado.app.ui.book.search.SearchActivity import io.legado.app.ui.book.search.SearchScope import io.legado.app.ui.book.source.debug.BookSourceDebugActivity import io.legado.app.ui.book.source.edit.BookSourceEditActivity import io.legado.app.ui.config.CheckSourceConfig import io.legado.app.ui.file.HandleFileContract import io.legado.app.ui.qrcode.QrCodeResult import io.legado.app.ui.widget.SelectActionBar import io.legado.app.ui.widget.recycler.DragSelectTouchHelper import io.legado.app.ui.widget.recycler.ItemTouchCallback import io.legado.app.ui.widget.recycler.VerticalDivider import io.legado.app.utils.ACache import io.legado.app.utils.NetworkUtils import io.legado.app.utils.applyTint import io.legado.app.utils.cnCompare import io.legado.app.utils.dpToPx import io.legado.app.utils.flowWithLifecycleAndDatabaseChange import io.legado.app.utils.flowWithLifecycleAndDatabaseChangeFirst import io.legado.app.utils.isAbsUrl import io.legado.app.utils.launch import io.legado.app.utils.observeEvent import io.legado.app.utils.sendToClip import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.share import io.legado.app.utils.showDialogFragment import io.legado.app.utils.showHelp import io.legado.app.utils.splitNotBlank import io.legado.app.utils.startActivity import io.legado.app.utils.toastOnUi import io.legado.app.utils.transaction import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.isActive import kotlinx.coroutines.launch /** * 书源管理界面 */ class BookSourceActivity : VMBaseActivity(), PopupMenu.OnMenuItemClickListener, BookSourceAdapter.CallBack, SelectActionBar.CallBack, SearchView.OnQueryTextListener { override val binding by viewBinding(ActivityBookSourceBinding::inflate) override val viewModel by viewModels() private val importRecordKey = "bookSourceRecordKey" private val adapter by lazy { BookSourceAdapter(this, this, binding.recyclerView) } private val itemTouchCallback by lazy { ItemTouchCallback(adapter) } private val searchView: SearchView by lazy { binding.titleBar.findViewById(R.id.search_view) } private var sourceFlowJob: Job? = null private var checkMessageRefreshJob: Job? = null private val groups = linkedSetOf() private var groupMenu: SubMenu? = null override var sort = BookSourceSort.Default private set override var sortAscending = true private set private var snackBar: Snackbar? = null private var groupSourcesByDomain = false private val hostMap = hashMapOf() private val qrResult = registerForActivityResult(QrCodeResult()) { it ?: return@registerForActivityResult showDialogFragment(ImportBookSourceDialog(it)) } private val importDoc = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> showDialogFragment(ImportBookSourceDialog(uri.toString())) } } private val exportDir = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> alert(R.string.export_success) { if (uri.toString().isAbsUrl()) { setMessage(DirectLinkUpload.getSummary()) } val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = getString(R.string.path) editView.setText(uri.toString()) } customView { alertBinding.root } okButton { sendToClip(uri.toString()) } } } } private val groupMenuLifecycleOwner = object : LifecycleOwner { private val registry = LifecycleRegistry(this) override val lifecycle: Lifecycle get() = registry fun onMenuOpened() { registry.handleLifecycleEvent(Lifecycle.Event.ON_START) } fun onMenuClosed() { registry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) } } override fun onActivityCreated(savedInstanceState: Bundle?) { initRecyclerView() initSearchView() upBookSource() initLiveDataGroup() initSelectActionBar() resumeCheckSource() if (!LocalConfig.bookSourcesHelpVersionIsLast) { showHelp("SourceMBookHelp") } } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.book_source, menu) return super.onCompatCreateOptionsMenu(menu) } override fun onPrepareOptionsMenu(menu: Menu): Boolean { groupMenu = menu.findItem(R.id.menu_group).subMenu val sortSubMenu = menu.findItem(R.id.action_sort).subMenu!! sortSubMenu.findItem(R.id.menu_sort_desc).isChecked = !sortAscending sortSubMenu.setGroupCheckable(R.id.menu_group_sort, true, true) upGroupMenu() return super.onPrepareOptionsMenu(menu) } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_add_book_source -> startActivity() R.id.menu_import_qr -> qrResult.launch() R.id.menu_group_manage -> showDialogFragment() R.id.menu_import_local -> importDoc.launch { mode = HandleFileContract.FILE allowExtensions = arrayOf("txt", "json") } R.id.menu_import_onLine -> showImportDialog() R.id.menu_sort_desc -> { sortAscending = !sortAscending item.isChecked = !sortAscending upBookSource(searchView.query?.toString()) } R.id.menu_sort_manual -> { item.isChecked = true sort = BookSourceSort.Default upBookSource(searchView.query?.toString()) } R.id.menu_sort_auto -> { item.isChecked = true sort = BookSourceSort.Weight upBookSource(searchView.query?.toString()) } R.id.menu_sort_name -> { item.isChecked = true sort = BookSourceSort.Name upBookSource(searchView.query?.toString()) } R.id.menu_sort_url -> { item.isChecked = true sort = BookSourceSort.Url upBookSource(searchView.query?.toString()) } R.id.menu_sort_time -> { item.isChecked = true sort = BookSourceSort.Update upBookSource(searchView.query?.toString()) } R.id.menu_sort_respondTime -> { item.isChecked = true sort = BookSourceSort.Respond upBookSource(searchView.query?.toString()) } R.id.menu_sort_enable -> { item.isChecked = true sort = BookSourceSort.Enable upBookSource(searchView.query?.toString()) } R.id.menu_enabled_group -> { searchView.setQuery(getString(R.string.enabled), true) } R.id.menu_disabled_group -> { searchView.setQuery(getString(R.string.disabled), true) } R.id.menu_group_login -> { searchView.setQuery(getString(R.string.need_login), true) } R.id.menu_group_null -> { searchView.setQuery(getString(R.string.no_group), true) } R.id.menu_enabled_explore_group -> { searchView.setQuery(getString(R.string.enabled_explore), true) } R.id.menu_disabled_explore_group -> { searchView.setQuery(getString(R.string.disabled_explore), true) } R.id.menu_group_sources_by_domain -> { item.isChecked = !item.isChecked groupSourcesByDomain = item.isChecked adapter.showSourceHost = item.isChecked upBookSource(searchView.query?.toString()) } R.id.menu_help -> showHelp("SourceMBookHelp") } if (item.groupId == R.id.source_group) { searchView.setQuery("group:${item.title}", true) } return super.onCompatOptionsItemSelected(item) } private fun initRecyclerView() { binding.recyclerView.setEdgeEffectColor(primaryColor) binding.recyclerView.addItemDecoration(VerticalDivider(this)) binding.recyclerView.adapter = adapter binding.recyclerView.recycledViewPool.setMaxRecycledViews(0, 15) // When this page is opened, it is in selection mode val dragSelectTouchHelper = DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50) dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView) dragSelectTouchHelper.activeSlideSelect() // Note: need judge selection first, so add ItemTouchHelper after it. ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView) } private fun initSearchView() { searchView.applyTint(primaryTextColor) searchView.queryHint = getString(R.string.search_book_source) searchView.setOnQueryTextListener(this) } private fun upBookSource(searchKey: String? = null) { sourceFlowJob?.cancel() sourceFlowJob = lifecycleScope.launch { when { searchKey.isNullOrEmpty() -> { appDb.bookSourceDao.flowAll() } searchKey == getString(R.string.enabled) -> { appDb.bookSourceDao.flowEnabled() } searchKey == getString(R.string.disabled) -> { appDb.bookSourceDao.flowDisabled() } searchKey == getString(R.string.need_login) -> { appDb.bookSourceDao.flowLogin() } searchKey == getString(R.string.no_group) -> { appDb.bookSourceDao.flowNoGroup() } searchKey == getString(R.string.enabled_explore) -> { appDb.bookSourceDao.flowEnabledExplore() } searchKey == getString(R.string.disabled_explore) -> { appDb.bookSourceDao.flowDisabledExplore() } searchKey.startsWith("group:") -> { val key = searchKey.substringAfter("group:") appDb.bookSourceDao.flowGroupSearch(key) } else -> { appDb.bookSourceDao.flowSearch(searchKey) } }.map { data -> hostMap.clear() if (groupSourcesByDomain) { data.sortedWith( compareBy { getSourceHost(it.bookSourceUrl) == "#" } .thenBy { getSourceHost(it.bookSourceUrl) } .thenByDescending { it.lastUpdateTime }) } else if (sortAscending) { when (sort) { BookSourceSort.Weight -> data.sortedBy { it.weight } BookSourceSort.Name -> data.sortedWith { o1, o2 -> o1.bookSourceName.cnCompare(o2.bookSourceName) } BookSourceSort.Url -> data.sortedBy { it.bookSourceUrl } BookSourceSort.Update -> data.sortedByDescending { it.lastUpdateTime } BookSourceSort.Respond -> data.sortedBy { it.respondTime } BookSourceSort.Enable -> data.sortedWith { o1, o2 -> var sort = -o1.enabled.compareTo(o2.enabled) if (sort == 0) { sort = o1.bookSourceName.cnCompare(o2.bookSourceName) } sort } else -> data } } else { when (sort) { BookSourceSort.Weight -> data.sortedByDescending { it.weight } BookSourceSort.Name -> data.sortedWith { o1, o2 -> o2.bookSourceName.cnCompare(o1.bookSourceName) } BookSourceSort.Url -> data.sortedByDescending { it.bookSourceUrl } BookSourceSort.Update -> data.sortedBy { it.lastUpdateTime } BookSourceSort.Respond -> data.sortedByDescending { it.respondTime } BookSourceSort.Enable -> data.sortedWith { o1, o2 -> var sort = o1.enabled.compareTo(o2.enabled) if (sort == 0) { sort = o1.bookSourceName.cnCompare(o2.bookSourceName) } sort } else -> data.reversed() } } }.flowWithLifecycleAndDatabaseChange( lifecycle, table = AppDatabase.BOOK_SOURCE_TABLE_NAME ).catch { AppLog.put("书源界面更新书源出错", it) }.flowOn(IO).conflate().collect { data -> adapter.setItems(data, adapter.diffItemCallback, !Debug.isChecking) itemTouchCallback.isCanDrag = sort == BookSourceSort.Default && !groupSourcesByDomain delay(500) } } } private fun initLiveDataGroup() { lifecycleScope.launch { appDb.bookSourceDao.flowGroups() .flowWithLifecycleAndDatabaseChange( lifecycle, table = AppDatabase.BOOK_SOURCE_TABLE_NAME ) .flowWithLifecycleAndDatabaseChangeFirst( groupMenuLifecycleOwner.lifecycle, table = AppDatabase.BOOK_SOURCE_TABLE_NAME ) .conflate() .distinctUntilChanged() .collect { groups.clear() groups.addAll(it) upGroupMenu() delay(500) } } } override fun selectAll(selectAll: Boolean) { if (selectAll) { adapter.selectAll() } else { adapter.revertSelection() } } override fun revertSelection() { adapter.revertSelection() } override fun onClickSelectBarMainAction() { alert(titleResource = R.string.draw, messageResource = R.string.sure_del) { yesButton { viewModel.del(adapter.selection) } noButton() } } override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { if (menu === groupMenu) { groupMenuLifecycleOwner.onMenuOpened() } return super.onMenuOpened(featureId, menu) } override fun onPanelClosed(featureId: Int, menu: Menu) { super.onPanelClosed(featureId, menu) if (menu === groupMenu) { groupMenuLifecycleOwner.onMenuClosed() } } private fun initSelectActionBar() { binding.selectActionBar.setMainActionText(R.string.delete) binding.selectActionBar.inflateMenu(R.menu.book_source_sel) binding.selectActionBar.setOnMenuItemClickListener(this) binding.selectActionBar.setCallBack(this) } override fun onMenuItemClick(item: MenuItem?): Boolean { when (item?.itemId) { R.id.menu_enable_selection -> viewModel.enableSelection(adapter.selection) R.id.menu_disable_selection -> viewModel.disableSelection(adapter.selection) R.id.menu_enable_explore -> viewModel.enableSelectExplore(adapter.selection) R.id.menu_disable_explore -> viewModel.disableSelectExplore(adapter.selection) R.id.menu_check_source -> checkSource() R.id.menu_top_sel -> viewModel.topSource(*adapter.selection.toTypedArray()) R.id.menu_bottom_sel -> viewModel.bottomSource(*adapter.selection.toTypedArray()) R.id.menu_add_group -> selectionAddToGroups() R.id.menu_remove_group -> selectionRemoveFromGroups() R.id.menu_export_selection -> viewModel.saveToFile( adapter, searchView.query?.toString(), sortAscending, sort ) { file -> exportDir.launch { mode = HandleFileContract.EXPORT fileData = HandleFileContract.FileData( "bookSource.json", file, "application/json" ) } } R.id.menu_share_source -> viewModel.saveToFile( adapter, searchView.query?.toString(), sortAscending, sort ) { share(it) } R.id.menu_check_selected_interval -> adapter.checkSelectedInterval() } return true } @SuppressLint("InflateParams") private fun checkSource() { val dialog = alert(titleResource = R.string.search_book_key) { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = "search word" editView.setText(CheckSource.keyword) } customView { alertBinding.root } okButton { keepScreenOn(true) alertBinding.editView.text?.toString()?.let { if (it.isNotEmpty()) { CheckSource.keyword = it } } val selectItems = adapter.selection CheckSource.start(this@BookSourceActivity, selectItems) val adapterItems = adapter.getItems() val firstItem = adapterItems.indexOf(selectItems.firstOrNull()) val lastItem = adapterItems.indexOf(selectItems.lastOrNull()) Debug.isChecking = firstItem >= 0 && lastItem >= 0 startCheckMessageRefreshJob(firstItem, lastItem) } neutralButton(R.string.check_source_config) cancelButton() } //手动设置监听 避免点击打开校验设置后对话框关闭 dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener { showDialogFragment() } } private fun resumeCheckSource() { if (!Debug.isChecking) { return } keepScreenOn(true) CheckSource.resume(this) startCheckMessageRefreshJob(0, 0) } @SuppressLint("InflateParams") private fun selectionAddToGroups() { alert(titleResource = R.string.add_group) { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.setHint(R.string.group_name) editView.setFilterValues(groups.toList()) editView.dropDownHeight = 180.dpToPx() } customView { alertBinding.root } okButton { alertBinding.editView.text?.toString()?.let { if (it.isNotEmpty()) { viewModel.selectionAddToGroups(adapter.selection, it) } } } cancelButton() } } @SuppressLint("InflateParams") private fun selectionRemoveFromGroups() { alert(titleResource = R.string.remove_group) { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.setHint(R.string.group_name) editView.setFilterValues(groups.toList()) editView.dropDownHeight = 180.dpToPx() } customView { alertBinding.root } okButton { alertBinding.editView.text?.toString()?.let { if (it.isNotEmpty()) { viewModel.selectionRemoveFromGroups(adapter.selection, it) } } } cancelButton() } } private fun upGroupMenu() = groupMenu?.transaction { menu -> menu.removeGroup(R.id.source_group) groups.forEach { menu.add(R.id.source_group, Menu.NONE, Menu.NONE, it) } } @SuppressLint("InflateParams") private fun showImportDialog() { val aCache = ACache.get(cacheDir = false) val cacheUrls: MutableList = aCache .getAsString(importRecordKey) ?.splitNotBlank(",") ?.toMutableList() ?: mutableListOf() alert(titleResource = R.string.import_on_line) { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = "url" editView.setFilterValues(cacheUrls) editView.delCallBack = { cacheUrls.remove(it) aCache.put(importRecordKey, cacheUrls.joinToString(",")) } } customView { alertBinding.root } okButton { val text = alertBinding.editView.text?.toString() text?.let { if (it.isAbsUrl() && !cacheUrls.contains(it)) { cacheUrls.add(0, it) aCache.put(importRecordKey, cacheUrls.joinToString(",")) } showDialogFragment(ImportBookSourceDialog(it)) } } cancelButton() } } override fun observeLiveBus() { observeEvent(EventBus.CHECK_SOURCE) { msg -> snackBar?.setText(msg) ?: let { snackBar = Snackbar .make(binding.root, msg, Snackbar.LENGTH_INDEFINITE) .setAction(R.string.cancel) { CheckSource.stop(this) Debug.finishChecking() }.apply { show() } } } observeEvent(EventBus.CHECK_SOURCE_DONE) { keepScreenOn(false) snackBar?.dismiss() snackBar = null adapter.notifyItemRangeChanged( 0, adapter.itemCount, bundleOf(Pair("checkSourceMessage", null)) ) groups.forEach { group -> if (group.contains("失效") && searchView.query.isEmpty()) { searchView.setQuery("失效", true) toastOnUi("发现有失效书源,已为您自动筛选!") } } } } private fun startCheckMessageRefreshJob(firstItem: Int, lastItem: Int) { checkMessageRefreshJob?.cancel() checkMessageRefreshJob = lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { while (isActive) { if (lastItem == 0) { adapter.notifyItemRangeChanged( 0, adapter.itemCount, bundleOf(Pair("checkSourceMessage", null)) ) } else { adapter.notifyItemRangeChanged( firstItem, lastItem + 1, bundleOf(Pair("checkSourceMessage", null)) ) } if (!Debug.isChecking) { checkMessageRefreshJob?.cancel() } delay(300L) } } } } /** * 保持亮屏 */ private fun keepScreenOn(on: Boolean) { val isScreenOn = (window.attributes.flags and WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) != 0 if (on == isScreenOn) return if (on) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } else { window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } } override fun upCountView() { binding.selectActionBar .upCountView(adapter.selection.size, adapter.itemCount) } override fun getSourceHost(origin: String): String { return hostMap.getOrPut(origin) { NetworkUtils.getSubDomainOrNull(origin) ?: "#" } } override fun onQueryTextChange(newText: String?): Boolean { newText?.let { upBookSource(it) } return false } override fun onQueryTextSubmit(query: String?): Boolean { return false } override fun del(bookSource: BookSourcePart) { alert(R.string.draw) { setMessage(getString(R.string.sure_del) + "\n" + bookSource.bookSourceName) noButton() yesButton { viewModel.del(listOf(bookSource)) } } } override fun edit(bookSource: BookSourcePart) { startActivity { putExtra("sourceUrl", bookSource.bookSourceUrl) } } override fun upOrder(items: List) { viewModel.upOrder(items) } override fun enable(enable: Boolean, bookSource: BookSourcePart) { viewModel.enable(enable, listOf(bookSource)) } override fun enableExplore(enable: Boolean, bookSource: BookSourcePart) { viewModel.enableExplore(enable, listOf(bookSource)) } override fun toTop(bookSource: BookSourcePart) { if (sortAscending) { viewModel.topSource(bookSource) } else { viewModel.bottomSource(bookSource) } } override fun toBottom(bookSource: BookSourcePart) { if (sortAscending) { viewModel.bottomSource(bookSource) } else { viewModel.topSource(bookSource) } } override fun searchBook(bookSource: BookSourcePart) { startActivity { putExtra("searchScope", SearchScope(bookSource).toString()) } } override fun debug(bookSource: BookSourcePart) { startActivity { putExtra("key", bookSource.bookSourceUrl) } } override fun finish() { if (searchView.query.isNullOrEmpty()) { super.finish() } else { searchView.setQuery("", true) } } override fun onDestroy() { super.onDestroy() if (!Debug.isChecking) { Debug.debugMessageMap.clear() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceAdapter.kt ================================================ package io.legado.app.ui.book.source.manage import android.content.Context import android.graphics.Color import android.os.Bundle import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.PopupMenu import androidx.core.os.bundleOf import androidx.core.view.doOnLayout import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import io.legado.app.R import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.data.entities.BookSourcePart import io.legado.app.databinding.ItemBookSourceBinding import io.legado.app.lib.theme.backgroundColor import io.legado.app.model.Debug import io.legado.app.ui.login.SourceLoginActivity import io.legado.app.ui.widget.recycler.DragSelectTouchHelper import io.legado.app.ui.widget.recycler.ItemTouchCallback import io.legado.app.utils.ColorUtils import io.legado.app.utils.buildMainHandler import io.legado.app.utils.gone import io.legado.app.utils.invisible import io.legado.app.utils.startActivity import io.legado.app.utils.visible import java.util.Collections class BookSourceAdapter( context: Context, private val callBack: CallBack, private val recyclerView: RecyclerView ) : RecyclerAdapter(context), ItemTouchCallback.Callback { private val selected = linkedSetOf() private val finalMessageRegex = Regex("成功|失败") private val handler = buildMainHandler() var showSourceHost = false val selection: List get() { return getItems().filter { selected.contains(it) } } val diffItemCallback = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: BookSourcePart, newItem: BookSourcePart): Boolean { return oldItem.bookSourceUrl == newItem.bookSourceUrl } override fun areContentsTheSame(oldItem: BookSourcePart, newItem: BookSourcePart): Boolean { return oldItem.bookSourceName == newItem.bookSourceName && oldItem.bookSourceGroup == newItem.bookSourceGroup && oldItem.enabled == newItem.enabled && oldItem.enabledExplore == newItem.enabledExplore && oldItem.hasExploreUrl == newItem.hasExploreUrl } override fun getChangePayload(oldItem: BookSourcePart, newItem: BookSourcePart): Any? { val payload = Bundle() if (oldItem.bookSourceName != newItem.bookSourceName || oldItem.bookSourceGroup != newItem.bookSourceGroup ) { payload.putBoolean("upName", true) } if (oldItem.enabled != newItem.enabled) { payload.putBoolean("enabled", newItem.enabled) } if (oldItem.enabledExplore != newItem.enabledExplore || oldItem.hasExploreUrl != newItem.hasExploreUrl ) { payload.putBoolean("upExplore", true) } if (payload.isEmpty) { return null } return payload } } override fun getViewBinding(parent: ViewGroup): ItemBookSourceBinding { return ItemBookSourceBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemBookSourceBinding, item: BookSourcePart, payloads: MutableList ) { binding.run { if (payloads.isEmpty()) { root.setBackgroundColor(ColorUtils.withAlpha(context.backgroundColor, 0.5f)) cbBookSource.text = item.getDisPlayNameGroup() swtEnabled.isChecked = item.enabled cbBookSource.isChecked = selected.contains(item) upCheckSourceMessage(binding, item) upShowExplore(ivExplore, item) upSourceHost(binding, holder.layoutPosition) } else { for (i in payloads.indices) { val bundle = payloads[i] as Bundle bundle.keySet().forEach { when (it) { "enabled" -> swtEnabled.isChecked = bundle.getBoolean("enabled") "upName" -> cbBookSource.text = item.getDisPlayNameGroup() "upExplore" -> upShowExplore(ivExplore, item) "selected" -> cbBookSource.isChecked = selected.contains(item) "checkSourceMessage" -> upCheckSourceMessage(binding, item) "upSourceHost" -> upSourceHost(binding, holder.layoutPosition) } } } } } } override fun registerListener(holder: ItemViewHolder, binding: ItemBookSourceBinding) { binding.apply { swtEnabled.setOnUserCheckedChangeListener { checked -> getItem(holder.layoutPosition)?.let { it.enabled = checked callBack.enable(checked, it) } } cbBookSource.setOnUserCheckedChangeListener { checked -> getItem(holder.layoutPosition)?.let { if (checked) { selected.add(it) } else { selected.remove(it) } callBack.upCountView() } } ivEdit.setOnClickListener { getItem(holder.layoutPosition)?.let { callBack.edit(it) } } ivMenuMore.setOnClickListener { showMenu(ivMenuMore, holder.layoutPosition) } } } override fun onCurrentListChanged() { callBack.upCountView() recyclerView.doOnLayout { handler.post { notifyItemRangeChanged(0, itemCount, bundleOf("upSourceHost" to null)) } } } private fun showMenu(view: View, position: Int) { val source = getItem(position) ?: return val popupMenu = PopupMenu(context, view) popupMenu.inflate(R.menu.book_source_item) popupMenu.menu.findItem(R.id.menu_top).isVisible = callBack.sort == BookSourceSort.Default popupMenu.menu.findItem(R.id.menu_bottom).isVisible = callBack.sort == BookSourceSort.Default val qyMenu = popupMenu.menu.findItem(R.id.menu_enable_explore) if (!source.hasExploreUrl) { qyMenu.isVisible = false } else { if (source.enabledExplore) { qyMenu.setTitle(R.string.disable_explore) } else { qyMenu.setTitle(R.string.enable_explore) } } val loginMenu = popupMenu.menu.findItem(R.id.menu_login) loginMenu.isVisible = source.hasLoginUrl popupMenu.setOnMenuItemClickListener { menuItem -> when (menuItem.itemId) { R.id.menu_top -> callBack.toTop(source) R.id.menu_bottom -> callBack.toBottom(source) R.id.menu_login -> context.startActivity { putExtra("type", "bookSource") putExtra("key", source.bookSourceUrl) } R.id.menu_search -> callBack.searchBook(source) R.id.menu_debug_source -> callBack.debug(source) R.id.menu_del -> { callBack.del(source) selected.remove(source) } R.id.menu_enable_explore -> { callBack.enableExplore(!source.enabledExplore, source) } } true } popupMenu.show() } private fun upShowExplore(iv: ImageView, source: BookSourcePart) { when { !source.hasExploreUrl -> { iv.invisible() } source.enabledExplore -> { iv.setColorFilter(Color.GREEN) iv.visible() iv.contentDescription = context.getString(R.string.tag_explore_enabled) } else -> { iv.setColorFilter(Color.RED) iv.visible() iv.contentDescription = context.getString(R.string.tag_explore_disabled) } } } private fun upCheckSourceMessage( binding: ItemBookSourceBinding, item: BookSourcePart ) = binding.run { val msg = Debug.debugMessageMap[item.bookSourceUrl] ?: "" ivDebugText.text = msg val isEmpty = msg.isEmpty() var isFinalMessage = msg.contains(finalMessageRegex) if (!Debug.isChecking && !isFinalMessage) { Debug.updateFinalMessage(item.bookSourceUrl, "校验失败") ivDebugText.text = Debug.debugMessageMap[item.bookSourceUrl] ?: "" isFinalMessage = true } ivDebugText.visibility = if (!isEmpty) View.VISIBLE else View.GONE ivProgressBar.visibility = if (isFinalMessage || isEmpty || !Debug.isChecking) View.GONE else View.VISIBLE } private fun upSourceHost(binding: ItemBookSourceBinding, position: Int) = binding.run { if (showSourceHost && isItemHeader(position)) { tvHostText.text = getHeaderText(position) tvHostText.visible() } else { tvHostText.gone() } } fun selectAll() { getItems().forEach { selected.add(it) } notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null))) callBack.upCountView() } fun revertSelection() { getItems().forEach { if (selected.contains(it)) { selected.remove(it) } else { selected.add(it) } } notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null))) callBack.upCountView() } fun checkSelectedInterval() { val selectedPosition = linkedSetOf() getItems().forEachIndexed { index, it -> if (selected.contains(it)) { selectedPosition.add(index) } } val minPosition = Collections.min(selectedPosition) val maxPosition = Collections.max(selectedPosition) val itemCount = maxPosition - minPosition + 1 for (i in minPosition..maxPosition) { getItem(i)?.let { selected.add(it) } } notifyItemRangeChanged(minPosition, itemCount, bundleOf(Pair("selected", null))) callBack.upCountView() } fun getHeaderText(position: Int): String { val source = getItem(position)!! return callBack.getSourceHost(source.bookSourceUrl) } fun isItemHeader(position: Int): Boolean { if (position == 0) return true val lastHost = getHeaderText(position - 1) val curHost = getHeaderText(position) return lastHost != curHost } override fun swap(srcPosition: Int, targetPosition: Int): Boolean { val srcItem = getItem(srcPosition) val targetItem = getItem(targetPosition) if (srcItem != null && targetItem != null) { val srcOrder = srcItem.customOrder srcItem.customOrder = targetItem.customOrder targetItem.customOrder = srcOrder movedItems.add(srcItem) movedItems.add(targetItem) } swapItem(srcPosition, targetPosition) return true } private val movedItems = hashSetOf() override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { if (movedItems.isNotEmpty()) { val sortNumberSet = hashSetOf() movedItems.forEach { sortNumberSet.add(it.customOrder) } if (movedItems.size > sortNumberSet.size) { callBack.upOrder(getItems().mapIndexed { index, bookSourcePart -> bookSourcePart.customOrder = if (callBack.sortAscending) index else -index bookSourcePart }) } else { callBack.upOrder(movedItems.toList()) } movedItems.clear() } } val dragSelectCallback: DragSelectTouchHelper.Callback = object : DragSelectTouchHelper.AdvanceCallback(Mode.ToggleAndReverse) { override fun currentSelectedId(): MutableSet { return selected } override fun getItemId(position: Int): BookSourcePart { return getItem(position)!! } override fun updateSelectState(position: Int, isSelected: Boolean): Boolean { getItem(position)?.let { if (isSelected) { selected.add(it) } else { selected.remove(it) } notifyItemChanged(position, bundleOf(Pair("selected", null))) callBack.upCountView() return true } return false } } interface CallBack { val sort: BookSourceSort val sortAscending: Boolean fun del(bookSource: BookSourcePart) fun edit(bookSource: BookSourcePart) fun toTop(bookSource: BookSourcePart) fun toBottom(bookSource: BookSourcePart) fun searchBook(bookSource: BookSourcePart) fun debug(bookSource: BookSourcePart) fun upOrder(items: List) fun enable(enable: Boolean, bookSource: BookSourcePart) fun enableExplore(enable: Boolean, bookSource: BookSourcePart) fun upCountView() fun getSourceHost(origin: String): String } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceSort.kt ================================================ package io.legado.app.ui.book.source.manage enum class BookSourceSort { Default, Name, Url, Weight, Update, Enable, Respond } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceViewModel.kt ================================================ package io.legado.app.ui.book.source.manage import android.app.Application import android.text.TextUtils import io.legado.app.R import io.legado.app.base.BaseViewModel import io.legado.app.data.appDb import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.toBookSource import io.legado.app.help.source.SourceHelp import io.legado.app.utils.FileUtils import io.legado.app.utils.GSON import io.legado.app.utils.cnCompare import io.legado.app.utils.outputStream import io.legado.app.utils.splitNotBlank import io.legado.app.utils.stackTraceStr import io.legado.app.utils.toastOnUi import io.legado.app.utils.writeToOutputStream import splitties.init.appCtx import java.io.File /** * 书源管理数据修改 * 修改数据要copy,直接修改会导致界面不刷新 */ class BookSourceViewModel(application: Application) : BaseViewModel(application) { fun topSource(vararg sources: BookSourcePart) { execute { sources.sortBy { it.customOrder } val minOrder = appDb.bookSourceDao.minOrder - 1 val array = sources.mapIndexed { index, it -> it.copy(customOrder = minOrder - index) } appDb.bookSourceDao.upOrder(array) } } fun bottomSource(vararg sources: BookSourcePart) { execute { sources.sortBy { it.customOrder } val maxOrder = appDb.bookSourceDao.maxOrder + 1 val array = sources.mapIndexed { index, it -> it.copy(customOrder = maxOrder + index) } appDb.bookSourceDao.upOrder(array) } } fun del(sources: List) { execute { SourceHelp.deleteBookSourceParts(sources) } } fun update(vararg bookSource: BookSource) { execute { appDb.bookSourceDao.update(*bookSource) } } fun upOrder(items: List) { if (items.isEmpty()) return execute { appDb.bookSourceDao.upOrder(items) } } fun enable(enable: Boolean, items: List) { execute { appDb.bookSourceDao.enable(enable, items) } } fun enableSelection(sources: List) { execute { appDb.bookSourceDao.enable(true, sources) } } fun disableSelection(sources: List) { execute { appDb.bookSourceDao.enable(false, sources) } } fun enableExplore(enable: Boolean, items: List) { execute { appDb.bookSourceDao.enableExplore(enable, items) } } fun enableSelectExplore(sources: List) { execute { appDb.bookSourceDao.enableExplore(true, sources) } } fun disableSelectExplore(sources: List) { execute { appDb.bookSourceDao.enableExplore(false, sources) } } fun selectionAddToGroups(sources: List, groups: String) { execute { val array = sources.map { it.copy().apply { addGroup(groups) } } appDb.bookSourceDao.upGroup(array) } } fun selectionRemoveFromGroups(sources: List, groups: String) { execute { val array = sources.map { it.copy().apply { removeGroup(groups) } } appDb.bookSourceDao.upGroup(array) } } private fun saveToFile(sources: List, success: (file: File) -> Unit) { execute { val path = "${context.filesDir}/shareBookSource.json" FileUtils.delete(path) val file = FileUtils.createFileWithReplace(path) file.outputStream().buffered().use { GSON.writeToOutputStream(it, sources) } file }.onSuccess { success.invoke(it) }.onError { context.toastOnUi(it.stackTraceStr) } } fun saveToFile( adapter: BookSourceAdapter, searchKey: String?, sortAscending: Boolean, sort: BookSourceSort, success: (file: File) -> Unit ) { execute { val selection = adapter.selection val selectedRate = selection.size.toFloat() / adapter.itemCount.toFloat() val sources = if (selectedRate == 1f) { getBookSources(searchKey, sortAscending, sort) } else if (selectedRate < 0.3) { selection.toBookSource() } else { val keys = selection.map { it.bookSourceUrl }.toHashSet() val bookSources = getBookSources(searchKey, sortAscending, sort) bookSources.filter { keys.contains(it.bookSourceUrl) } } saveToFile(sources, success) } } private fun getBookSources( searchKey: String?, sortAscending: Boolean, sort: BookSourceSort ): List { return when { searchKey.isNullOrEmpty() -> { appDb.bookSourceDao.all } searchKey == appCtx.getString(R.string.enabled) -> { appDb.bookSourceDao.allEnabled } searchKey == appCtx.getString(R.string.disabled) -> { appDb.bookSourceDao.allDisabled } searchKey == appCtx.getString(R.string.need_login) -> { appDb.bookSourceDao.allLogin } searchKey == appCtx.getString(R.string.no_group) -> { appDb.bookSourceDao.allNoGroup } searchKey == appCtx.getString(R.string.enabled_explore) -> { appDb.bookSourceDao.allEnabledExplore } searchKey == appCtx.getString(R.string.disabled_explore) -> { appDb.bookSourceDao.allDisabledExplore } searchKey.startsWith("group:") -> { val key = searchKey.substringAfter("group:") appDb.bookSourceDao.groupSearch(key) } else -> { appDb.bookSourceDao.search(searchKey) } }.let { data -> if (sortAscending) when (sort) { BookSourceSort.Weight -> data.sortedBy { it.weight } BookSourceSort.Name -> data.sortedWith { o1, o2 -> o1.bookSourceName.cnCompare(o2.bookSourceName) } BookSourceSort.Url -> data.sortedBy { it.bookSourceUrl } BookSourceSort.Update -> data.sortedByDescending { it.lastUpdateTime } BookSourceSort.Respond -> data.sortedBy { it.respondTime } BookSourceSort.Enable -> data.sortedWith { o1, o2 -> var sortNum = -o1.enabled.compareTo(o2.enabled) if (sortNum == 0) { sortNum = o1.bookSourceName.cnCompare(o2.bookSourceName) } sortNum } else -> data } else when (sort) { BookSourceSort.Weight -> data.sortedByDescending { it.weight } BookSourceSort.Name -> data.sortedWith { o1, o2 -> o2.bookSourceName.cnCompare(o1.bookSourceName) } BookSourceSort.Url -> data.sortedByDescending { it.bookSourceUrl } BookSourceSort.Update -> data.sortedBy { it.lastUpdateTime } BookSourceSort.Respond -> data.sortedByDescending { it.respondTime } BookSourceSort.Enable -> data.sortedWith { o1, o2 -> var sortNum = o1.enabled.compareTo(o2.enabled) if (sortNum == 0) { sortNum = o1.bookSourceName.cnCompare(o2.bookSourceName) } sortNum } else -> data.reversed() } } } fun addGroup(group: String) { execute { val sources = appDb.bookSourceDao.noGroup sources.forEach { source -> source.bookSourceGroup = group } appDb.bookSourceDao.update(*sources.toTypedArray()) } } fun upGroup(oldGroup: String, newGroup: String?) { execute { val sources = appDb.bookSourceDao.getByGroup(oldGroup) sources.forEach { source -> source.bookSourceGroup?.splitNotBlank(",")?.toHashSet()?.let { it.remove(oldGroup) if (!newGroup.isNullOrEmpty()) it.add(newGroup) source.bookSourceGroup = TextUtils.join(",", it) } } appDb.bookSourceDao.update(*sources.toTypedArray()) } } fun delGroup(group: String) { execute { execute { val sources = appDb.bookSourceDao.getByGroup(group) sources.forEach { source -> source.removeGroup(group) } appDb.bookSourceDao.update(*sources.toTypedArray()) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/source/manage/GroupManageDialog.kt ================================================ package io.legado.app.ui.book.source.manage import android.annotation.SuppressLint import android.content.Context import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.data.appDb import io.legado.app.databinding.DialogEditTextBinding import io.legado.app.databinding.DialogRecyclerViewBinding import io.legado.app.databinding.ItemGroupManageBinding import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.backgroundColor import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.widget.recycler.VerticalDivider import io.legado.app.utils.applyTint import io.legado.app.utils.requestInputMethod import io.legado.app.utils.setLayout import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.launch class GroupManageDialog : BaseDialogFragment(R.layout.dialog_recycler_view), Toolbar.OnMenuItemClickListener { private val viewModel: BookSourceViewModel by activityViewModels() private val binding by viewBinding(DialogRecyclerViewBinding::bind) private val adapter by lazy { GroupAdapter(requireContext()) } override fun onStart() { super.onStart() setLayout(0.9f, 0.9f) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { view.setBackgroundColor(backgroundColor) binding.toolBar.setBackgroundColor(primaryColor) binding.toolBar.title = getString(R.string.group_manage) binding.toolBar.inflateMenu(R.menu.group_manage) binding.toolBar.menu.applyTint(requireContext()) binding.toolBar.setOnMenuItemClickListener(this) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.addItemDecoration(VerticalDivider(requireContext())) binding.recyclerView.adapter = adapter initData() } private fun initData() { lifecycleScope.launch { appDb.bookSourceDao.flowGroups().collect { adapter.setItems(it) } } } override fun onMenuItemClick(item: MenuItem?): Boolean { when (item?.itemId) { R.id.menu_add -> addGroup() } return true } @SuppressLint("InflateParams") private fun addGroup() { alert(title = getString(R.string.add_group)) { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.setHint(R.string.group_name) } customView { alertBinding.root } okButton { alertBinding.editView.text?.toString()?.let { if (it.isNotBlank()) { viewModel.addGroup(it) } } } cancelButton() }.requestInputMethod() } @SuppressLint("InflateParams") private fun editGroup(group: String) { alert(title = getString(R.string.group_edit)) { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.setHint(R.string.group_name) editView.setText(group) } customView { alertBinding.root } okButton { viewModel.upGroup(group, alertBinding.editView.text?.toString()) } cancelButton() }.requestInputMethod() } private inner class GroupAdapter(context: Context) : RecyclerAdapter(context) { override fun getViewBinding(parent: ViewGroup): ItemGroupManageBinding { return ItemGroupManageBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemGroupManageBinding, item: String, payloads: MutableList ) { binding.run { root.setBackgroundColor(context.backgroundColor) tvGroup.text = item } } override fun registerListener(holder: ItemViewHolder, binding: ItemGroupManageBinding) { binding.apply { tvEdit.setOnClickListener { getItem(holder.layoutPosition)?.let { editGroup(it) } } tvDel.setOnClickListener { getItem(holder.layoutPosition)?.let { viewModel.delGroup(it) } } } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/toc/BookmarkAdapter.kt ================================================ package io.legado.app.ui.book.toc import android.content.Context import android.view.ViewGroup import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.data.entities.Bookmark import io.legado.app.databinding.ItemBookmarkBinding import io.legado.app.utils.gone import splitties.views.onLongClick class BookmarkAdapter(context: Context, val callback: Callback) : RecyclerAdapter(context) { override fun getViewBinding(parent: ViewGroup): ItemBookmarkBinding { return ItemBookmarkBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemBookmarkBinding, item: Bookmark, payloads: MutableList ) { binding.tvChapterName.text = item.chapterName binding.tvBookText.gone(item.bookText.isEmpty()) binding.tvBookText.text = item.bookText binding.tvContent.gone(item.content.isEmpty()) binding.tvContent.text = item.content } override fun registerListener(holder: ItemViewHolder, binding: ItemBookmarkBinding) { binding.root.setOnClickListener { getItem(holder.layoutPosition)?.let { bookmark -> callback.onClick(bookmark) } } binding.root.onLongClick { getItem(holder.layoutPosition)?.let { bookmark -> callback.onLongClick(bookmark, holder.layoutPosition) } } } interface Callback { fun onClick(bookmark: Bookmark) fun onLongClick(bookmark: Bookmark, pos: Int) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/toc/BookmarkFragment.kt ================================================ package io.legado.app.ui.book.toc import android.app.Activity import android.content.Intent import android.os.Bundle import android.view.View import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import io.legado.app.R import io.legado.app.base.VMBaseFragment import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.Bookmark import io.legado.app.databinding.FragmentBookmarkBinding import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.book.bookmark.BookmarkDialog import io.legado.app.ui.widget.recycler.UpLinearLayoutManager import io.legado.app.ui.widget.recycler.VerticalDivider import io.legado.app.utils.applyNavigationBarPadding import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.showDialogFragment import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class BookmarkFragment : VMBaseFragment(R.layout.fragment_bookmark), BookmarkAdapter.Callback, TocViewModel.BookmarkCallBack { override val viewModel by activityViewModels() private val binding by viewBinding(FragmentBookmarkBinding::bind) private val mLayoutManager by lazy { UpLinearLayoutManager(requireContext()) } private val adapter by lazy { BookmarkAdapter(requireContext(), this) } private var durChapterIndex = 0 override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { viewModel.bookMarkCallBack = this initRecyclerView() viewModel.bookData.observe(this) { durChapterIndex = it.durChapterIndex upBookmark(null) } } private fun initRecyclerView() { binding.recyclerView.setEdgeEffectColor(primaryColor) binding.recyclerView.layoutManager = mLayoutManager binding.recyclerView.addItemDecoration(VerticalDivider(requireContext())) binding.recyclerView.adapter = adapter binding.recyclerView.applyNavigationBarPadding() } override fun upBookmark(searchKey: String?) { val book = viewModel.bookData.value ?: return lifecycleScope.launch { when { searchKey.isNullOrBlank() -> appDb.bookmarkDao.flowByBook(book.name, book.author) else -> appDb.bookmarkDao.flowSearch(book.name, book.author, searchKey) }.catch { AppLog.put("目录界面获取书签数据失败\n${it.localizedMessage}", it) }.flowOn(IO).collect { adapter.setItems(it) var scrollPos = 0 withContext(Dispatchers.Default) { adapter.getItems().forEachIndexed { index, bookmark -> if (bookmark.chapterIndex >= durChapterIndex) { return@withContext } scrollPos = index } } mLayoutManager.scrollToPositionWithOffset(scrollPos, 0) } } } override fun onClick(bookmark: Bookmark) { activity?.run { setResult(Activity.RESULT_OK, Intent().apply { putExtra("index", bookmark.chapterIndex) putExtra("chapterPos", bookmark.chapterPos) }) finish() } } override fun onLongClick(bookmark: Bookmark, pos: Int) { showDialogFragment(BookmarkDialog(bookmark, pos)) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/toc/ChapterListAdapter.kt ================================================ package io.legado.app.ui.book.toc import android.content.Context import android.os.Handler import android.os.Looper import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import io.legado.app.R import io.legado.app.base.adapter.DiffRecyclerAdapter import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.databinding.ItemChapterListBinding import io.legado.app.help.book.ContentProcessor import io.legado.app.help.config.AppConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.lib.theme.ThemeUtils import io.legado.app.lib.theme.accentColor import io.legado.app.utils.getCompatColor import io.legado.app.utils.gone import io.legado.app.utils.longToastOnUi import io.legado.app.utils.visible import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap class ChapterListAdapter(context: Context, val callback: Callback) : DiffRecyclerAdapter(context) { val cacheFileNames = hashSetOf() private val displayTitleMap = ConcurrentHashMap() private val handler = Handler(Looper.getMainLooper()) override val diffItemCallback: DiffUtil.ItemCallback get() = object : DiffUtil.ItemCallback() { override fun areItemsTheSame( oldItem: BookChapter, newItem: BookChapter ): Boolean { return oldItem.index == newItem.index } override fun areContentsTheSame( oldItem: BookChapter, newItem: BookChapter ): Boolean { return oldItem.bookUrl == newItem.bookUrl && oldItem.url == newItem.url && oldItem.isVip == newItem.isVip && oldItem.isPay == newItem.isPay && oldItem.title == newItem.title && oldItem.tag == newItem.tag && oldItem.wordCount == newItem.wordCount && oldItem.isVolume == newItem.isVolume } } private var upDisplayTileJob: Coroutine<*>? = null override fun onCurrentListChanged() { super.onCurrentListChanged() callback.onListChanged() } fun clearDisplayTitle() { upDisplayTileJob?.cancel() displayTitleMap.clear() } fun upDisplayTitles(startIndex: Int) { upDisplayTileJob?.cancel() upDisplayTileJob = Coroutine.async(callback.scope) { val book = callback.book ?: return@async val replaceRules = ContentProcessor.get(book.name, book.origin).getTitleReplaceRules() val useReplace = AppConfig.tocUiUseReplace && book.getUseReplaceRule() val items = getItems() launch { for (i in startIndex until items.size) { val item = items[i] if (displayTitleMap[item.title] == null) { ensureActive() val displayTitle = item.getDisplayTitle(replaceRules, useReplace) ensureActive() displayTitleMap[item.title] = displayTitle handler.post { notifyItemChanged(i, true) } } } } launch { for (i in startIndex downTo 0) { val item = items[i] if (displayTitleMap[item.title] == null) { ensureActive() val displayTitle = item.getDisplayTitle(replaceRules, useReplace) ensureActive() displayTitleMap[item.title] = displayTitle handler.post { notifyItemChanged(i, true) } } } } } } private fun getDisplayTitle(chapter: BookChapter): String { return displayTitleMap[chapter.title] ?: chapter.title } override fun getViewBinding(parent: ViewGroup): ItemChapterListBinding { return ItemChapterListBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemChapterListBinding, item: BookChapter, payloads: MutableList ) { binding.run { val isDur = callback.durChapterIndex() == item.index val cached = callback.isLocalBook || item.isVolume || cacheFileNames.contains(item.getFileName()) if (payloads.isEmpty()) { if (isDur) { tvChapterName.setTextColor(context.accentColor) } else { tvChapterName.setTextColor(context.getCompatColor(R.color.primaryText)) } tvChapterName.text = getDisplayTitle(item) if (item.isVolume) { //卷名,如第一卷 突出显示 tvChapterItem.setBackgroundColor(context.getCompatColor(R.color.btn_bg_press)) } else { //普通章节 保持不变 tvChapterItem.background = ThemeUtils.resolveDrawable(context, android.R.attr.selectableItemBackground) } //卷名不显示 if (!item.tag.isNullOrEmpty() && !item.isVolume) { //更新时间规则 tvTag.text = item.tag tvTag.visible() } else { tvTag.gone() } if (AppConfig.tocCountWords && !item.wordCount.isNullOrEmpty() && !item.isVolume) { //章节字数 tvWordCount.text = item.wordCount tvWordCount.visible() } else { tvWordCount.gone() } if (item.isVip && !item.isPay) { ivLocked.visible() } else { ivLocked.gone() } upHasCache(binding, isDur, cached) } else { tvChapterName.text = getDisplayTitle(item) upHasCache(binding, isDur, cached) } } } override fun registerListener(holder: ItemViewHolder, binding: ItemChapterListBinding) { holder.itemView.setOnClickListener { getItem(holder.layoutPosition)?.let { callback.openChapter(it) } } holder.itemView.setOnLongClickListener { getItem(holder.layoutPosition)?.let { item -> context.longToastOnUi(getDisplayTitle(item)) } true } } private fun upHasCache(binding: ItemChapterListBinding, isDur: Boolean, cached: Boolean) = binding.apply { ivChecked.setImageResource(R.drawable.ic_outline_cloud_24) ivChecked.visible(!cached) if (isDur) { ivChecked.setImageResource(R.drawable.ic_check) ivChecked.visible() } } interface Callback { val scope: CoroutineScope val book: Book? val isLocalBook: Boolean fun openChapter(bookChapter: BookChapter) fun durChapterIndex(): Int fun onListChanged() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/toc/ChapterListFragment.kt ================================================ package io.legado.app.ui.book.toc import android.annotation.SuppressLint import android.app.Activity.RESULT_OK import android.content.Intent import android.graphics.PorterDuff import android.os.Bundle import android.view.View import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import io.legado.app.R import io.legado.app.base.VMBaseFragment import io.legado.app.constant.EventBus import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.databinding.FragmentChapterListBinding import io.legado.app.help.book.BookHelp import io.legado.app.help.book.isLocal import io.legado.app.help.book.simulatedTotalChapterNum import io.legado.app.lib.theme.bottomBackground import io.legado.app.lib.theme.getPrimaryTextColor import io.legado.app.ui.widget.recycler.UpLinearLayoutManager import io.legado.app.ui.widget.recycler.VerticalDivider import io.legado.app.utils.ColorUtils import io.legado.app.utils.applyNavigationBarPadding import io.legado.app.utils.observeEvent import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.Default import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class ChapterListFragment : VMBaseFragment(R.layout.fragment_chapter_list), ChapterListAdapter.Callback, TocViewModel.ChapterListCallBack { override val viewModel by activityViewModels() private val binding by viewBinding(FragmentChapterListBinding::bind) private val mLayoutManager by lazy { UpLinearLayoutManager(requireContext()) } private val adapter by lazy { ChapterListAdapter(requireContext(), this) } private var durChapterIndex = 0 override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) = binding.run { viewModel.chapterListCallBack = this@ChapterListFragment val bbg = bottomBackground val btc = requireContext().getPrimaryTextColor(ColorUtils.isColorLight(bbg)) llChapterBaseInfo.setBackgroundColor(bbg) tvCurrentChapterInfo.setTextColor(btc) ivChapterTop.setColorFilter(btc, PorterDuff.Mode.SRC_IN) ivChapterBottom.setColorFilter(btc, PorterDuff.Mode.SRC_IN) initRecyclerView() initView() viewModel.bookData.observe(this@ChapterListFragment) { initBook(it) } } private fun initRecyclerView() { binding.recyclerView.layoutManager = mLayoutManager binding.recyclerView.addItemDecoration(VerticalDivider(requireContext())) binding.recyclerView.adapter = adapter } private fun initView() = binding.run { ivChapterTop.setOnClickListener { mLayoutManager.scrollToPositionWithOffset(0, 0) } ivChapterBottom.setOnClickListener { if (adapter.itemCount > 0) { mLayoutManager.scrollToPositionWithOffset(adapter.itemCount - 1, 0) } } tvCurrentChapterInfo.setOnClickListener { mLayoutManager.scrollToPositionWithOffset(durChapterIndex, 0) } binding.llChapterBaseInfo.applyNavigationBarPadding() } @SuppressLint("SetTextI18n") private fun initBook(book: Book) { lifecycleScope.launch { upChapterList(null) durChapterIndex = book.durChapterIndex binding.tvCurrentChapterInfo.text = "${book.durChapterTitle}(${book.durChapterIndex + 1}/${book.simulatedTotalChapterNum()})" initCacheFileNames(book) } } private fun initCacheFileNames(book: Book) { lifecycleScope.launch(IO) { adapter.cacheFileNames.addAll(BookHelp.getChapterFiles(book)) withContext(Main) { adapter.notifyItemRangeChanged(0, adapter.itemCount, true) } } } override fun observeLiveBus() { observeEvent>(EventBus.SAVE_CONTENT) { (book, chapter) -> viewModel.bookData.value?.bookUrl?.let { bookUrl -> if (book.bookUrl == bookUrl) { adapter.cacheFileNames.add(chapter.getFileName()) if (viewModel.searchKey.isNullOrEmpty()) { adapter.notifyItemChanged(chapter.index, true) } else { adapter.getItems().forEachIndexed { index, bookChapter -> if (bookChapter.index == chapter.index) { adapter.notifyItemChanged(index, true) } } } } } } } override fun upChapterList(searchKey: String?) { lifecycleScope.launch { withContext(IO) { val end = (book?.simulatedTotalChapterNum() ?: Int.MAX_VALUE) - 1 when { searchKey.isNullOrBlank() -> appDb.bookChapterDao.getChapterList(viewModel.bookUrl, 0, end) else -> appDb.bookChapterDao.search(viewModel.bookUrl, searchKey, 0, end) } }.let { adapter.setItems(it) } } } override fun onListChanged() { lifecycleScope.launch { var scrollPos = 0 withContext(Default) { adapter.getItems().forEachIndexed { index, bookChapter -> if (bookChapter.index >= durChapterIndex) { return@withContext } scrollPos = index } } mLayoutManager.scrollToPositionWithOffset(scrollPos, 0) adapter.upDisplayTitles(scrollPos) } } override fun clearDisplayTitle() { adapter.clearDisplayTitle() adapter.upDisplayTitles(mLayoutManager.findFirstVisibleItemPosition()) } override fun upAdapter() { adapter.notifyItemRangeChanged(0, adapter.itemCount) } override val scope: CoroutineScope get() = lifecycleScope override val book: Book? get() = viewModel.bookData.value override val isLocalBook: Boolean get() = viewModel.bookData.value?.isLocal == true override fun durChapterIndex(): Int { return durChapterIndex } override fun openChapter(bookChapter: BookChapter) { activity?.run { setResult( RESULT_OK, Intent() .putExtra("index", bookChapter.index) .putExtra("chapterChanged", bookChapter.index != durChapterIndex) ) finish() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/toc/TocActivity.kt ================================================ @file:Suppress("DEPRECATION") package io.legado.app.ui.book.toc import android.content.Intent import android.os.Bundle import android.view.Menu import android.view.MenuItem import androidx.activity.viewModels import androidx.appcompat.widget.SearchView import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentPagerAdapter import com.google.android.material.tabs.TabLayout import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.data.entities.Book import io.legado.app.databinding.ActivityChapterListBinding import io.legado.app.help.book.isLocalTxt import io.legado.app.help.config.AppConfig import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.primaryTextColor import io.legado.app.model.ReadBook import io.legado.app.ui.about.AppLogDialog import io.legado.app.ui.book.toc.rule.TxtTocRuleDialog import io.legado.app.ui.file.HandleFileContract import io.legado.app.ui.widget.dialog.WaitDialog import io.legado.app.utils.applyTint import io.legado.app.utils.gone import io.legado.app.utils.showDialogFragment import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible /** * 目录 */ class TocActivity : VMBaseActivity(), TxtTocRuleDialog.CallBack { override val binding by viewBinding(ActivityChapterListBinding::inflate) override val viewModel by viewModels() private lateinit var tabLayout: TabLayout private var menu: Menu? = null private var searchView: SearchView? = null private val waitDialog by lazy { WaitDialog(this) } private val exportDir = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> when (it.requestCode) { 1 -> viewModel.saveBookmark(uri) 2 -> viewModel.saveBookmarkMd(uri) } } } override fun onActivityCreated(savedInstanceState: Bundle?) { tabLayout = binding.titleBar.findViewById(R.id.tab_layout) tabLayout.isTabIndicatorFullWidth = false tabLayout.setSelectedTabIndicatorColor(accentColor) binding.viewPager.adapter = TabFragmentPageAdapter() tabLayout.setupWithViewPager(binding.viewPager) tabLayout.tabGravity = TabLayout.GRAVITY_CENTER viewModel.bookData.observe(this) { menu?.setGroupVisible(R.id.menu_group_text, it.isLocalTxt) } intent.getStringExtra("bookUrl")?.let { viewModel.initBook(it) } } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.book_toc, menu) this.menu = menu val search = menu.findItem(R.id.menu_search) searchView = (search.actionView as SearchView).apply { applyTint(primaryTextColor) maxWidth = resources.displayMetrics.widthPixels onActionViewCollapsed() setOnCloseListener { tabLayout.visible() false } setOnSearchClickListener { tabLayout.gone() } setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean { viewModel.searchKey = query return false } override fun onQueryTextChange(newText: String): Boolean { viewModel.searchKey = newText if (tabLayout.selectedTabPosition == 1) { viewModel.startBookmarkSearch(newText) } else { viewModel.startChapterListSearch(newText) } return false } }) setOnQueryTextFocusChangeListener { _, hasFocus -> if (!hasFocus) { searchView?.isIconified = true } } } return super.onCompatCreateOptionsMenu(menu) } override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { if (tabLayout.selectedTabPosition == 1) { menu.setGroupVisible(R.id.menu_group_bookmark, true) menu.setGroupVisible(R.id.menu_group_toc, false) menu.setGroupVisible(R.id.menu_group_text, false) } else { menu.setGroupVisible(R.id.menu_group_bookmark, false) menu.setGroupVisible(R.id.menu_group_toc, true) menu.setGroupVisible(R.id.menu_group_text, viewModel.bookData.value?.isLocalTxt == true) } menu.findItem(R.id.menu_use_replace)?.isChecked = AppConfig.tocUiUseReplace menu.findItem(R.id.menu_load_word_count)?.isChecked = AppConfig.tocCountWords menu.findItem(R.id.menu_split_long_chapter)?.isChecked = viewModel.bookData.value?.getSplitLongChapter() == true return super.onMenuOpened(featureId, menu) } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_toc_regex -> showDialogFragment( TxtTocRuleDialog(viewModel.bookData.value?.tocUrl) ) R.id.menu_split_long_chapter -> { viewModel.bookData.value?.let { book -> item.isChecked = !item.isChecked book.setSplitLongChapter(item.isChecked) upBookAndToc(book) } } R.id.menu_reverse_toc -> viewModel.reverseToc { viewModel.chapterListCallBack?.upChapterList(searchView?.query?.toString()) setResult(RESULT_OK, Intent().apply { putExtra("index", it.durChapterIndex) putExtra("chapterPos", 0) }) } R.id.menu_use_replace -> { AppConfig.tocUiUseReplace = !item.isChecked viewModel.chapterListCallBack?.clearDisplayTitle() viewModel.chapterListCallBack?.upChapterList(searchView?.query?.toString()) } R.id.menu_load_word_count -> { AppConfig.tocCountWords = !item.isChecked viewModel.upChapterListAdapter() } R.id.menu_export_bookmark -> exportDir.launch { requestCode = 1 } R.id.menu_export_md -> exportDir.launch { requestCode = 2 } R.id.menu_log -> showDialogFragment() } return super.onCompatOptionsItemSelected(item) } override fun onTocRegexDialogResult(tocRegex: String) { viewModel.bookData.value?.let { book -> book.tocUrl = tocRegex upBookAndToc(book) } } private fun upBookAndToc(book: Book) { waitDialog.show() viewModel.upBookTocRule(book) { waitDialog.dismiss() if (ReadBook.book == book) { if (it == null) { ReadBook.upMsg(null) } else { ReadBook.upMsg("LoadTocError:${it.localizedMessage}") } } } } @Suppress("DEPRECATION") private inner class TabFragmentPageAdapter : FragmentPagerAdapter(supportFragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { override fun getItem(position: Int): Fragment { return when (position) { 1 -> BookmarkFragment() else -> ChapterListFragment() } } override fun getCount(): Int { return 2 } override fun getPageTitle(position: Int): CharSequence { return when (position) { 1 -> getString(R.string.bookmark) else -> getString(R.string.chapter_list) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/toc/TocActivityResult.kt ================================================ package io.legado.app.ui.book.toc import android.app.Activity.RESULT_OK import android.content.Context import android.content.Intent import androidx.activity.result.contract.ActivityResultContract class TocActivityResult : ActivityResultContract?>() { override fun createIntent(context: Context, input: String): Intent { return Intent(context, TocActivity::class.java) .putExtra("bookUrl", input) } override fun parseResult(resultCode: Int, intent: Intent?): Triple? { if (resultCode == RESULT_OK) { intent?.let { return Triple( it.getIntExtra("index", 0), it.getIntExtra("chapterPos", 0), it.getBooleanExtra("chapterChanged", false) ) } } return null } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt ================================================ package io.legado.app.ui.book.toc import android.app.Application import android.net.Uri import androidx.lifecycle.MutableLiveData import io.legado.app.R import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.exception.NoStackTraceException import io.legado.app.model.ReadBook import io.legado.app.model.localBook.LocalBook import io.legado.app.utils.FileDoc import io.legado.app.utils.GSON import io.legado.app.utils.createFileIfNotExist import io.legado.app.utils.openOutputStream import io.legado.app.utils.toastOnUi import io.legado.app.utils.writeText class TocViewModel(application: Application) : BaseViewModel(application) { var bookUrl: String = "" var bookData = MutableLiveData() var chapterListCallBack: ChapterListCallBack? = null var bookMarkCallBack: BookmarkCallBack? = null var searchKey: String? = null fun initBook(bookUrl: String) { this.bookUrl = bookUrl execute { appDb.bookDao.getBook(bookUrl)?.let { bookData.postValue(it) } } } fun upBookTocRule(book: Book, complete: (Throwable?) -> Unit) { execute { appDb.bookDao.update(book) LocalBook.getChapterList(book).let { appDb.bookChapterDao.delByBook(book.bookUrl) appDb.bookChapterDao.insert(*it.toTypedArray()) appDb.bookDao.update(book) ReadBook.onChapterListUpdated(book) bookData.postValue(book) } }.onSuccess { complete.invoke(null) }.onError { complete.invoke(it) } } fun reverseToc(success: (book: Book) -> Unit) { execute { bookData.value?.apply { setReverseToc(!getReverseToc()) val toc = appDb.bookChapterDao.getChapterList(bookUrl) val newToc = toc.reversed() newToc.forEachIndexed { index, bookChapter -> bookChapter.index = index } appDb.bookChapterDao.insert(*newToc.toTypedArray()) } }.onSuccess { it?.let(success) } } fun startChapterListSearch(newText: String?) { chapterListCallBack?.upChapterList(newText) } fun startBookmarkSearch(newText: String?) { bookMarkCallBack?.upBookmark(newText) } fun upChapterListAdapter() { chapterListCallBack?.upAdapter() } fun saveBookmark(treeUri: Uri) { execute { val book = bookData.value ?: throw NoStackTraceException(context.getString(R.string.no_book)) val fileName = "bookmark-${book.name} ${book.author}.json" val doc = FileDoc.fromUri(treeUri, true) doc.createFileIfNotExist(fileName).writeText( GSON.toJson( appDb.bookmarkDao.getByBook(book.name, book.author) ) ) }.onError { AppLog.put("导出失败\n${it.localizedMessage}", it, true) }.onSuccess { context.toastOnUi("导出成功") } } fun saveBookmarkMd(treeUri: Uri) { execute { val book = bookData.value ?: throw NoStackTraceException(context.getString(R.string.no_book)) val fileName = "bookmark-${book.name} ${book.author}.md" val treeDoc = FileDoc.fromUri(treeUri, true) val fileDoc = treeDoc.createFileIfNotExist(fileName) .openOutputStream() .getOrThrow() fileDoc.use { outputStream -> outputStream.write("## ${book.name} ${book.author}\n\n".toByteArray()) appDb.bookmarkDao.getByBook(book.name, book.author).forEach { outputStream.write("#### ${it.chapterName}\n\n".toByteArray()) outputStream.write("###### 原文\n ${it.bookText}\n\n".toByteArray()) outputStream.write("###### 摘要\n ${it.content}\n\n".toByteArray()) } } }.onError { AppLog.put("导出失败\n${it.localizedMessage}", it, true) }.onSuccess { context.toastOnUi("导出成功") } } interface ChapterListCallBack { fun upChapterList(searchKey: String?) fun clearDisplayTitle() fun upAdapter() } interface BookmarkCallBack { fun upBookmark(searchKey: String?) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleActivity.kt ================================================ package io.legado.app.ui.book.toc.rule import android.annotation.SuppressLint import android.os.Bundle import android.view.Menu import android.view.MenuItem import androidx.activity.viewModels import androidx.appcompat.widget.PopupMenu import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.ItemTouchHelper import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.TxtTocRule import io.legado.app.databinding.ActivityTxtTocRuleBinding import io.legado.app.databinding.DialogEditTextBinding import io.legado.app.help.DirectLinkUpload import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.association.ImportTxtTocRuleDialog import io.legado.app.ui.file.HandleFileContract import io.legado.app.ui.qrcode.QrCodeResult import io.legado.app.ui.widget.SelectActionBar import io.legado.app.ui.widget.recycler.DragSelectTouchHelper import io.legado.app.ui.widget.recycler.ItemTouchCallback import io.legado.app.ui.widget.recycler.VerticalDivider import io.legado.app.utils.ACache import io.legado.app.utils.GSON import io.legado.app.utils.isAbsUrl import io.legado.app.utils.launch import io.legado.app.utils.sendToClip import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.showDialogFragment import io.legado.app.utils.showHelp import io.legado.app.utils.splitNotBlank import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch class TxtTocRuleActivity : VMBaseActivity(), TxtTocRuleAdapter.CallBack, SelectActionBar.CallBack, TxtTocRuleEditDialog.Callback, PopupMenu.OnMenuItemClickListener { override val viewModel by viewModels() override val binding by viewBinding(ActivityTxtTocRuleBinding::inflate) private val adapter: TxtTocRuleAdapter by lazy { TxtTocRuleAdapter(this, this) } private val importTocRuleKey = "tocRuleUrl" private val qrCodeResult = registerForActivityResult(QrCodeResult()) { it ?: return@registerForActivityResult showDialogFragment(ImportTxtTocRuleDialog(it)) } private val importDoc = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> showDialogFragment(ImportTxtTocRuleDialog(uri.toString())) } } private val exportResult = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> alert(R.string.export_success) { if (uri.toString().isAbsUrl()) { setMessage(DirectLinkUpload.getSummary()) } val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = getString(R.string.path) editView.setText(uri.toString()) } customView { alertBinding.root } okButton { sendToClip(uri.toString()) } } } } override fun onActivityCreated(savedInstanceState: Bundle?) { initView() initBottomActionBar() initData() } private fun initView() = binding.run { recyclerView.setEdgeEffectColor(primaryColor) recyclerView.addItemDecoration(VerticalDivider(this@TxtTocRuleActivity)) recyclerView.adapter = adapter // When this page is opened, it is in selection mode val dragSelectTouchHelper = DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50) dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView) dragSelectTouchHelper.activeSlideSelect() // Note: need judge selection first, so add ItemTouchHelper after it. val itemTouchCallback = ItemTouchCallback(adapter) itemTouchCallback.isCanDrag = true ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView) } private fun initBottomActionBar() { binding.selectActionBar.setMainActionText(R.string.delete) binding.selectActionBar.inflateMenu(R.menu.txt_toc_rule_sel) binding.selectActionBar.setOnMenuItemClickListener(this) binding.selectActionBar.setCallBack(this) } private fun initData() { lifecycleScope.launch { appDb.txtTocRuleDao.observeAll().catch { AppLog.put("TXT目录规则界面获取数据失败\n${it.localizedMessage}", it) }.flowOn(IO).conflate().collect { tocRules -> adapter.setItems(tocRules, adapter.diffItemCallBack) } } } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.txt_toc_rule, menu) return super.onCompatCreateOptionsMenu(menu) } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_add -> showDialogFragment(TxtTocRuleEditDialog()) R.id.menu_import_local -> importDoc.launch { mode = HandleFileContract.FILE allowExtensions = arrayOf("txt", "json") } R.id.menu_import_onLine -> showImportDialog() R.id.menu_import_qr -> qrCodeResult.launch() R.id.menu_import_default -> viewModel.importDefault() R.id.menu_help -> showHelp("txtTocRuleHelp") } return super.onCompatOptionsItemSelected(item) } override fun del(source: TxtTocRule) { alert(R.string.draw) { setMessage(getString(R.string.sure_del) + "\n" + source.name) noButton() yesButton { viewModel.del(source) } } } override fun edit(source: TxtTocRule) { showDialogFragment(TxtTocRuleEditDialog(source.id)) } override fun onClickSelectBarMainAction() { delSourceDialog() } override fun revertSelection() { adapter.revertSelection() } override fun selectAll(selectAll: Boolean) { if (selectAll) { adapter.selectAll() } else { adapter.revertSelection() } } override fun saveTxtTocRule(txtTocRule: TxtTocRule) { viewModel.save(txtTocRule) } override fun update(vararg source: TxtTocRule) { viewModel.update(*source) } override fun toTop(source: TxtTocRule) { viewModel.toTop(source) } override fun toBottom(source: TxtTocRule) { viewModel.toBottom(source) } override fun upOrder() { viewModel.upOrder() } override fun upCountView() { binding.selectActionBar .upCountView(adapter.selection.size, adapter.itemCount) } private fun delSourceDialog() { alert(titleResource = R.string.draw, messageResource = R.string.sure_del) { yesButton { viewModel.del(*adapter.selection.toTypedArray()) } noButton() } } @SuppressLint("InflateParams") private fun showImportDialog() { val aCache = ACache.get(cacheDir = false) val defaultUrl = "https://gitee.com/fisher52/YueDuJson/raw/master/myTxtChapterRule.json" val cacheUrls: MutableList = aCache .getAsString(importTocRuleKey) ?.splitNotBlank(",") ?.toMutableList() ?: mutableListOf() if (!cacheUrls.contains(defaultUrl)) { cacheUrls.add(0, defaultUrl) } alert(titleResource = R.string.import_on_line) { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = "url" editView.setFilterValues(cacheUrls) editView.delCallBack = { cacheUrls.remove(it) aCache.put(importTocRuleKey, cacheUrls.joinToString(",")) } } customView { alertBinding.root } okButton { val text = alertBinding.editView.text?.toString() text?.let { if (it.isAbsUrl() && !cacheUrls.contains(it)) { cacheUrls.add(0, it) aCache.put(importTocRuleKey, cacheUrls.joinToString(",")) } showDialogFragment(ImportTxtTocRuleDialog(it)) } } cancelButton() } } override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_enable_selection -> viewModel.enableSelection( *adapter.selection.toTypedArray() ) R.id.menu_disable_selection -> viewModel.disableSelection( *adapter.selection.toTypedArray() ) R.id.menu_export_selection -> exportResult.launch { mode = HandleFileContract.EXPORT fileData = HandleFileContract.FileData( "exportTxtTocRule.json", GSON.toJson(adapter.selection).toByteArray(), "application/json" ) } } return true } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleAdapter.kt ================================================ package io.legado.app.ui.book.toc.rule import android.content.Context import android.os.Bundle import android.view.View import android.view.ViewGroup import android.widget.PopupMenu import androidx.core.os.bundleOf import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import io.legado.app.R import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.data.entities.TxtTocRule import io.legado.app.databinding.ItemTxtTocRuleBinding import io.legado.app.lib.theme.backgroundColor import io.legado.app.ui.widget.recycler.DragSelectTouchHelper import io.legado.app.ui.widget.recycler.ItemTouchCallback import io.legado.app.utils.ColorUtils class TxtTocRuleAdapter(context: Context, private val callBack: CallBack) : RecyclerAdapter(context), ItemTouchCallback.Callback { private val selected = linkedSetOf() val selection: List get() = getItems().filter { selected.contains(it) } val diffItemCallBack = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: TxtTocRule, newItem: TxtTocRule): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame(oldItem: TxtTocRule, newItem: TxtTocRule): Boolean { if (oldItem.name != newItem.name) { return false } if (oldItem.enable != newItem.enable) { return false } if (oldItem.example != newItem.example) { return false } return true } override fun getChangePayload(oldItem: TxtTocRule, newItem: TxtTocRule): Any? { val payload = Bundle() if (oldItem.name != newItem.name) { payload.putBoolean("upName", true) } if (oldItem.enable != newItem.enable) { payload.putBoolean("enabled", newItem.enable) } if (oldItem.example != newItem.example) { payload.putBoolean("upExample", true) } if (payload.isEmpty) { return null } return payload } } override fun getViewBinding(parent: ViewGroup): ItemTxtTocRuleBinding { return ItemTxtTocRuleBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemTxtTocRuleBinding, item: TxtTocRule, payloads: MutableList ) { binding.run { if (payloads.isEmpty()) { root.setBackgroundColor(ColorUtils.withAlpha(context.backgroundColor, 0.5f)) cbSource.text = item.name swtEnabled.isChecked = item.enable cbSource.isChecked = selected.contains(item) titleExample.text = item.example } else { for (i in payloads.indices) { val bundle = payloads[i] as Bundle bundle.keySet().forEach { when (it) { "selected" -> cbSource.isChecked = selected.contains(item) "upName" -> cbSource.text = item.name "upExample" -> titleExample.text = item.example "enabled" -> swtEnabled.isChecked = item.enable } } } } } } override fun registerListener(holder: ItemViewHolder, binding: ItemTxtTocRuleBinding) { binding.cbSource.setOnUserCheckedChangeListener { isChecked -> getItem(holder.layoutPosition)?.let { if (isChecked) { selected.add(it) } else { selected.remove(it) } callBack.upCountView() } } binding.swtEnabled.setOnUserCheckedChangeListener { isChecked -> getItem(holder.layoutPosition)?.let { it.enable = isChecked callBack.update(it) } } binding.ivEdit.setOnClickListener { getItem(holder.layoutPosition)?.let { callBack.edit(it) } } binding.ivMenuMore.setOnClickListener { showMenu(it, holder.layoutPosition) } } override fun onCurrentListChanged() { callBack.upCountView() } private fun showMenu(view: View, position: Int) { val source = getItem(position) ?: return val popupMenu = PopupMenu(context, view) popupMenu.inflate(R.menu.txt_toc_rule_item) popupMenu.setOnMenuItemClickListener { menuItem -> when (menuItem.itemId) { R.id.menu_top -> callBack.toTop(source) R.id.menu_bottom -> callBack.toBottom(source) R.id.menu_del -> { callBack.del(source) selected.remove(source) } } true } popupMenu.show() } fun selectAll() { getItems().forEach { selected.add(it) } notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null))) callBack.upCountView() } fun revertSelection() { getItems().forEach { if (selected.contains(it)) { selected.remove(it) } else { selected.add(it) } } notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null))) callBack.upCountView() } override fun swap(srcPosition: Int, targetPosition: Int): Boolean { val srcItem = getItem(srcPosition) val targetItem = getItem(targetPosition) if (srcItem != null && targetItem != null) { if (srcItem.serialNumber == targetItem.serialNumber) { callBack.upOrder() } else { val srcOrder = srcItem.serialNumber srcItem.serialNumber = targetItem.serialNumber targetItem.serialNumber = srcOrder movedItems.add(srcItem) movedItems.add(targetItem) } } swapItem(srcPosition, targetPosition) return true } private val movedItems = hashSetOf() override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { if (movedItems.isNotEmpty()) { callBack.update(*movedItems.toTypedArray()) movedItems.clear() } } val dragSelectCallback: DragSelectTouchHelper.Callback = object : DragSelectTouchHelper.AdvanceCallback(Mode.ToggleAndReverse) { override fun currentSelectedId(): MutableSet { return selected } override fun getItemId(position: Int): TxtTocRule { return getItem(position)!! } override fun updateSelectState(position: Int, isSelected: Boolean): Boolean { getItem(position)?.let { if (isSelected) { selected.add(it) } else { selected.remove(it) } notifyItemChanged(position, bundleOf(Pair("selected", null))) callBack.upCountView() return true } return false } } interface CallBack { fun del(source: TxtTocRule) fun edit(source: TxtTocRule) fun update(vararg source: TxtTocRule) fun toTop(source: TxtTocRule) fun toBottom(source: TxtTocRule) fun upOrder() fun upCountView() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleDialog.kt ================================================ package io.legado.app.ui.book.toc.rule import android.annotation.SuppressLint import android.content.Context import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.core.os.bundleOf import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.TxtTocRule import io.legado.app.databinding.DialogEditTextBinding import io.legado.app.databinding.DialogTocRegexBinding import io.legado.app.databinding.ItemTocRegexBinding import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.backgroundColor import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.association.ImportTxtTocRuleDialog import io.legado.app.ui.file.HandleFileContract import io.legado.app.ui.qrcode.QrCodeResult import io.legado.app.ui.widget.recycler.ItemTouchCallback import io.legado.app.ui.widget.recycler.VerticalDivider import io.legado.app.utils.ACache import io.legado.app.utils.applyTint import io.legado.app.utils.isAbsUrl import io.legado.app.utils.launch import io.legado.app.utils.setLayout import io.legado.app.utils.showDialogFragment import io.legado.app.utils.showHelp import io.legado.app.utils.splitNotBlank import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch /** * txt目录规则 */ class TxtTocRuleDialog() : BaseDialogFragment(R.layout.dialog_toc_regex), Toolbar.OnMenuItemClickListener, TxtTocRuleEditDialog.Callback { constructor(tocRegex: String?) : this() { arguments = Bundle().apply { putString("tocRegex", tocRegex) } } private val importTocRuleKey = "tocRuleUrl" private val viewModel: TxtTocRuleViewModel by viewModels() private val binding by viewBinding(DialogTocRegexBinding::bind) private val adapter by lazy { TocRegexAdapter(requireContext()) } var selectedName: String? = null private var durRegex: String? = null private val qrCodeResult = registerForActivityResult(QrCodeResult()) { it ?: return@registerForActivityResult showDialogFragment(ImportTxtTocRuleDialog(it)) } private val importDoc = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> showDialogFragment(ImportTxtTocRuleDialog(uri.toString())) } } override fun onStart() { super.onStart() setLayout(0.9f, 0.8f) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) durRegex = arguments?.getString("tocRegex") binding.toolBar.setTitle(R.string.txt_toc_rule) binding.toolBar.inflateMenu(R.menu.txt_toc_rule) binding.toolBar.menu.applyTint(requireContext()) binding.toolBar.setOnMenuItemClickListener(this) initView() initData() } private fun initView() = binding.run { recyclerView.addItemDecoration(VerticalDivider(requireContext())) recyclerView.adapter = adapter val itemTouchCallback = ItemTouchCallback(adapter) itemTouchCallback.isCanDrag = true ItemTouchHelper(itemTouchCallback).attachToRecyclerView(recyclerView) tvCancel.setOnClickListener { dismissAllowingStateLoss() } tvOk.setOnClickListener { adapter.getItems().forEach { tocRule -> if (selectedName == tocRule.name) { val callBack = activity as? CallBack callBack?.onTocRegexDialogResult(tocRule.rule) dismissAllowingStateLoss() return@setOnClickListener } } } } private fun initData() { lifecycleScope.launch { appDb.txtTocRuleDao.observeAll().catch { AppLog.put("TXT目录规则对话框获取数据失败\n${it.localizedMessage}", it) }.flowOn(IO).conflate().collect { tocRules -> initSelectedName(tocRules) adapter.setItems(tocRules, adapter.diffItemCallBack) } } } private fun initSelectedName(tocRules: List) { if (selectedName == null && durRegex != null) { tocRules.forEach { if (durRegex == it.rule) { selectedName = it.name return@forEach } } if (selectedName == null) { selectedName = "" } } } override fun onMenuItemClick(item: MenuItem?): Boolean { when (item?.itemId) { R.id.menu_add -> showDialogFragment(TxtTocRuleEditDialog()) R.id.menu_import_local -> importDoc.launch { mode = HandleFileContract.FILE allowExtensions = arrayOf("txt", "json") } R.id.menu_import_onLine -> showImportDialog() R.id.menu_import_qr -> qrCodeResult.launch() R.id.menu_import_default -> viewModel.importDefault() R.id.menu_help -> showHelp("txtTocRuleHelp") } return false } override fun saveTxtTocRule(txtTocRule: TxtTocRule) { viewModel.save(txtTocRule) } @SuppressLint("InflateParams") private fun showImportDialog() { val aCache = ACache.get(cacheDir = false) val defaultUrl = "https://gitee.com/fisher52/YueDuJson/raw/master/myTxtChapterRule.json" val cacheUrls: MutableList = aCache .getAsString(importTocRuleKey) ?.splitNotBlank(",") ?.toMutableList() ?: mutableListOf() if (!cacheUrls.contains(defaultUrl)) { cacheUrls.add(0, defaultUrl) } requireContext().alert(titleResource = R.string.import_on_line) { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = "url" editView.setFilterValues(cacheUrls) editView.delCallBack = { cacheUrls.remove(it) aCache.put(importTocRuleKey, cacheUrls.joinToString(",")) } } customView { alertBinding.root } okButton { val text = alertBinding.editView.text?.toString() text?.let { if (it.isAbsUrl() && !cacheUrls.contains(it)) { cacheUrls.add(0, it) aCache.put(importTocRuleKey, cacheUrls.joinToString(",")) } showDialogFragment(ImportTxtTocRuleDialog(it)) } } cancelButton() } } inner class TocRegexAdapter(context: Context) : RecyclerAdapter(context), ItemTouchCallback.Callback { val diffItemCallBack = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: TxtTocRule, newItem: TxtTocRule): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame(oldItem: TxtTocRule, newItem: TxtTocRule): Boolean { if (oldItem.name != newItem.name) { return false } if (oldItem.enable != newItem.enable) { return false } if (oldItem.example != newItem.example) { return false } return true } override fun getChangePayload(oldItem: TxtTocRule, newItem: TxtTocRule): Any? { val payload = Bundle() if (oldItem.name != newItem.name) { payload.putBoolean("upName", true) } if (oldItem.enable != newItem.enable) { payload.putBoolean("enabled", newItem.enable) } if (oldItem.example != newItem.example) { payload.putBoolean("upExample", true) } if (payload.isEmpty) { return null } return payload } } override fun getViewBinding(parent: ViewGroup): ItemTocRegexBinding { return ItemTocRegexBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemTocRegexBinding, item: TxtTocRule, payloads: MutableList ) { binding.apply { if (payloads.isEmpty()) { root.setBackgroundColor(context.backgroundColor) rbRegexName.text = item.name titleExample.text = item.example rbRegexName.isChecked = item.name == selectedName swtEnabled.isChecked = item.enable } else { for (i in payloads.indices) { val bundle = payloads[i] as Bundle bundle.keySet().forEach { when (it) { "upName" -> rbRegexName.text = item.name "upExample" -> titleExample.text = item.example "enabled" -> swtEnabled.isChecked = item.enable "upSelect" -> rbRegexName.isChecked = item.name == selectedName } } } } } } override fun registerListener(holder: ItemViewHolder, binding: ItemTocRegexBinding) { binding.apply { rbRegexName.setOnUserCheckedChangeListener { isChecked -> if (isChecked) { selectedName = getItem(holder.layoutPosition)?.name updateItems(0, itemCount - 1, bundleOf("upSelect" to null)) } } swtEnabled.setOnUserCheckedChangeListener { isChecked -> getItem(holder.layoutPosition)?.let { it.enable = isChecked viewModel.update(it) } } ivEdit.setOnClickListener { showDialogFragment(TxtTocRuleEditDialog(getItem(holder.layoutPosition)?.id)) } ivDelete.setOnClickListener { getItem(holder.layoutPosition)?.let { item -> alert(R.string.draw) { setMessage(getString(R.string.sure_del) + "\n" + item.name) noButton() yesButton { viewModel.del(item) } } } } } } private var isMoved = false override fun swap(srcPosition: Int, targetPosition: Int): Boolean { swapItem(srcPosition, targetPosition) isMoved = true return super.swap(srcPosition, targetPosition) } override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { super.onClearView(recyclerView, viewHolder) if (isMoved) { for ((index, item) in getItems().withIndex()) { item.serialNumber = index + 1 } viewModel.update(*getItems().toTypedArray()) } isMoved = false } } interface CallBack { fun onTocRegexDialogResult(tocRegex: String) {} } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleEditDialog.kt ================================================ package io.legado.app.ui.book.toc.rule import android.app.Application import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.fragment.app.viewModels import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.TxtTocRule import io.legado.app.databinding.DialogTocRegexEditBinding import io.legado.app.exception.NoStackTraceException import io.legado.app.lib.theme.primaryColor import io.legado.app.utils.* import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers import java.util.regex.Pattern import java.util.regex.PatternSyntaxException class TxtTocRuleEditDialog() : BaseDialogFragment(R.layout.dialog_toc_regex_edit, true), Toolbar.OnMenuItemClickListener { constructor(id: Long?) : this() { id ?: return arguments = Bundle().apply { putLong("id", id) } } private val binding by viewBinding(DialogTocRegexEditBinding::bind) private val viewModel by viewModels() private val callback get() = (parentFragment as? Callback) ?: activity as? Callback override fun onStart() { super.onStart() setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) initMenu() viewModel.initData(arguments?.getLong("id")) { upRuleView(it) } } private fun initMenu() { binding.toolBar.inflateMenu(R.menu.txt_toc_rule_edit) binding.toolBar.menu.applyTint(requireContext()) binding.toolBar.setOnMenuItemClickListener(this) } override fun onMenuItemClick(item: MenuItem?): Boolean { when (item?.itemId) { R.id.menu_save -> { val tocRule = getRuleFromView() if (checkValid(tocRule)) { callback?.saveTxtTocRule(getRuleFromView()) dismissAllowingStateLoss() } } R.id.menu_copy_rule -> context?.sendToClip(GSON.toJson(getRuleFromView())) R.id.menu_paste_rule -> viewModel.pasteRule { upRuleView(it) } } return true } private fun checkValid(tocRule: TxtTocRule): Boolean { if (tocRule.name.isEmpty()) { toastOnUi("名称不能为空") return false } try { Pattern.compile(tocRule.rule, Pattern.MULTILINE) } catch (ex: PatternSyntaxException) { AppLog.put("正则语法错误或不支持(txt):${ex.localizedMessage}", ex, true) return false } return true } private fun upRuleView(tocRule: TxtTocRule?) { binding.tvRuleName.setText(tocRule?.name) binding.tvRuleRegex.setText(tocRule?.rule) binding.tvRuleExample.setText(tocRule?.example) } private fun getRuleFromView(): TxtTocRule { val tocRule = viewModel.tocRule ?: TxtTocRule().apply { viewModel.tocRule = this } binding.run { tocRule.name = tvRuleName.text.toString() tocRule.rule = tvRuleRegex.text.toString() tocRule.example = tvRuleExample.text.toString() } return tocRule } class ViewModel(application: Application) : BaseViewModel(application) { var tocRule: TxtTocRule? = null fun initData(id: Long?, finally: (tocRule: TxtTocRule?) -> Unit) { if (tocRule != null) return execute { if (id == null) return@execute tocRule = appDb.txtTocRuleDao.get(id) }.onFinally { finally.invoke(tocRule) } } fun pasteRule(success: (TxtTocRule) -> Unit) { execute(context = Dispatchers.Main) { val text = context.getClipText() if (text.isNullOrBlank()) { throw NoStackTraceException("剪贴板为空") } GSON.fromJsonObject(text).getOrNull() ?: throw NoStackTraceException("格式不对") }.onSuccess { success.invoke(it) }.onError { context.toastOnUi(it.localizedMessage ?: "Error") it.printOnDebug() } } } interface Callback { fun saveTxtTocRule(txtTocRule: TxtTocRule) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleViewModel.kt ================================================ package io.legado.app.ui.book.toc.rule import android.app.Application import io.legado.app.base.BaseViewModel import io.legado.app.data.appDb import io.legado.app.data.entities.TxtTocRule import io.legado.app.help.DefaultData class TxtTocRuleViewModel(app: Application) : BaseViewModel(app) { fun save(txtTocRule: TxtTocRule) { execute { appDb.txtTocRuleDao.insert(txtTocRule) } } fun del(vararg txtTocRule: TxtTocRule) { execute { appDb.txtTocRuleDao.delete(*txtTocRule) } } fun update(vararg txtTocRule: TxtTocRule) { execute { appDb.txtTocRuleDao.update(*txtTocRule) } } fun importDefault() { execute { DefaultData.importDefaultTocRules() } } fun toTop(vararg rules: TxtTocRule) { execute { val minOrder = appDb.txtTocRuleDao.minOrder - 1 rules.forEachIndexed { index, source -> source.serialNumber = minOrder - index } appDb.txtTocRuleDao.update(*rules) } } fun toBottom(vararg sources: TxtTocRule) { execute { val maxOrder = appDb.txtTocRuleDao.maxOrder + 1 sources.forEachIndexed { index, source -> source.serialNumber = maxOrder + index } appDb.txtTocRuleDao.update(*sources) } } fun upOrder() { execute { val sources = appDb.txtTocRuleDao.all for ((index: Int, source: TxtTocRule) in sources.withIndex()) { source.serialNumber = index + 1 } appDb.txtTocRuleDao.update(*sources.toTypedArray()) } } fun enableSelection(vararg txtTocRule: TxtTocRule) { execute { val array = txtTocRule.map { it.copy(enable = true) }.toTypedArray() appDb.txtTocRuleDao.insert(*array) } } fun disableSelection(vararg txtTocRule: TxtTocRule) { execute { val array = txtTocRule.map { it.copy(enable = false) }.toTypedArray() appDb.txtTocRuleDao.insert(*array) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/browser/WebViewActivity.kt ================================================ package io.legado.app.ui.browser import android.annotation.SuppressLint import android.content.pm.ActivityInfo import android.net.Uri import android.net.http.SslError import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.View import android.webkit.CookieManager import android.webkit.SslErrorHandler import android.webkit.URLUtil import android.webkit.WebChromeClient import android.webkit.WebResourceRequest import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient import androidx.activity.addCallback import androidx.activity.viewModels import androidx.core.view.size import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.constant.AppConst import io.legado.app.constant.AppConst.imagePathKey import io.legado.app.databinding.ActivityWebViewBinding import io.legado.app.help.config.AppConfig import io.legado.app.help.http.CookieStore import io.legado.app.help.source.SourceVerificationHelp import io.legado.app.lib.dialogs.SelectItem import io.legado.app.lib.dialogs.alert import io.legado.app.lib.dialogs.selector import io.legado.app.lib.theme.accentColor import io.legado.app.model.Download import io.legado.app.ui.association.OnLineImportActivity import io.legado.app.ui.file.HandleFileContract import io.legado.app.utils.ACache import io.legado.app.utils.gone import io.legado.app.utils.invisible import io.legado.app.utils.keepScreenOn import io.legado.app.utils.longSnackbar import io.legado.app.utils.openUrl import io.legado.app.utils.sendToClip import io.legado.app.utils.setDarkeningAllowed import io.legado.app.utils.startActivity import io.legado.app.utils.toggleSystemBar import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible import java.net.URLDecoder import io.legado.app.help.http.CookieManager as AppCookieManager class WebViewActivity : VMBaseActivity() { override val binding by viewBinding(ActivityWebViewBinding::inflate) override val viewModel by viewModels() private var customWebViewCallback: WebChromeClient.CustomViewCallback? = null private var webPic: String? = null private var isCloudflareChallenge = false private var isFullScreen = false private val saveImage = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> ACache.get().put(imagePathKey, uri.toString()) viewModel.saveImage(webPic, uri.toString()) } } override fun onActivityCreated(savedInstanceState: Bundle?) { binding.titleBar.title = intent.getStringExtra("title") ?: getString(R.string.loading) binding.titleBar.subtitle = intent.getStringExtra("sourceName") viewModel.initData(intent) { val url = viewModel.baseUrl val headerMap = viewModel.headerMap initWebView(url, headerMap) val html = viewModel.html if (html.isNullOrEmpty()) { binding.webView.loadUrl(url, headerMap) } else { binding.webView.loadDataWithBaseURL(url, html, "text/html", "utf-8", url) } } onBackPressedDispatcher.addCallback(this) { if (binding.customWebView.size > 0) { customWebViewCallback?.onCustomViewHidden() return@addCallback } else if (binding.webView.canGoBack() && binding.webView.copyBackForwardList().size > 1 ) { binding.webView.goBack() return@addCallback } if (isFullScreen) { toggleFullScreen() return@addCallback } finish() } } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.web_view, menu) return super.onCompatCreateOptionsMenu(menu) } override fun onPrepareOptionsMenu(menu: Menu): Boolean { if (viewModel.sourceOrigin.isNotEmpty()) { menu.findItem(R.id.menu_disable_source)?.isVisible = true menu.findItem(R.id.menu_delete_source)?.isVisible = true } return super.onPrepareOptionsMenu(menu) } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_open_in_browser -> openUrl(viewModel.baseUrl) R.id.menu_copy_url -> sendToClip(viewModel.baseUrl) R.id.menu_ok -> { if (viewModel.sourceVerificationEnable) { viewModel.saveVerificationResult(binding.webView) { finish() } } else { finish() } } R.id.menu_full_screen -> toggleFullScreen() R.id.menu_disable_source -> { viewModel.disableSource { finish() } } R.id.menu_delete_source -> { alert(R.string.draw) { setMessage(getString(R.string.sure_del) + "\n" + viewModel.sourceName) noButton() yesButton { viewModel.deleteSource { finish() } } } } } return super.onCompatOptionsItemSelected(item) } //实现starBrowser调起页面全屏 private fun toggleFullScreen() { isFullScreen = !isFullScreen toggleSystemBar(!isFullScreen) if (isFullScreen) { supportActionBar?.hide() } else { supportActionBar?.show() } } @SuppressLint("SetJavaScriptEnabled") private fun initWebView(url: String, headerMap: HashMap) { binding.progressBar.fontColor = accentColor binding.webView.webChromeClient = CustomWebChromeClient() binding.webView.webViewClient = CustomWebViewClient() binding.webView.settings.apply { setDarkeningAllowed(AppConfig.isNightTheme) mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW domStorageEnabled = true allowContentAccess = true useWideViewPort = true loadWithOverviewMode = true javaScriptEnabled = true builtInZoomControls = true displayZoomControls = false headerMap[AppConst.UA_NAME]?.let { userAgentString = it } } AppCookieManager.applyToWebView(url) binding.webView.setOnLongClickListener { val hitTestResult = binding.webView.hitTestResult if (hitTestResult.type == WebView.HitTestResult.IMAGE_TYPE || hitTestResult.type == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE ) { hitTestResult.extra?.let { webPic -> selector( arrayListOf( SelectItem(getString(R.string.action_save), "save"), SelectItem(getString(R.string.select_folder), "selectFolder") ) ) { _, charSequence, _ -> when (charSequence.value) { "save" -> saveImage(webPic) "selectFolder" -> selectSaveFolder() } } return@setOnLongClickListener true } } return@setOnLongClickListener false } binding.webView.setDownloadListener { downloadUrl, _, contentDisposition, _, _ -> var fileName = URLUtil.guessFileName(downloadUrl, contentDisposition, null) fileName = URLDecoder.decode(fileName, "UTF-8") binding.llView.longSnackbar(fileName, getString(R.string.action_download)) { Download.start(this, downloadUrl, fileName) } } } private fun saveImage(webPic: String) { this.webPic = webPic val path = ACache.get().getAsString(imagePathKey) if (path.isNullOrEmpty()) { selectSaveFolder() } else { viewModel.saveImage(webPic, path) } } private fun selectSaveFolder() { val default = arrayListOf>() val path = ACache.get().getAsString(imagePathKey) if (!path.isNullOrEmpty()) { default.add(SelectItem(path, -1)) } saveImage.launch { otherActions = default } } override fun finish() { SourceVerificationHelp.checkResult(viewModel.sourceOrigin) super.finish() } override fun onDestroy() { super.onDestroy() binding.webView.destroy() } inner class CustomWebChromeClient : WebChromeClient() { override fun onProgressChanged(view: WebView?, newProgress: Int) { super.onProgressChanged(view, newProgress) binding.progressBar.setDurProgress(newProgress) binding.progressBar.gone(newProgress == 100) } override fun onShowCustomView(view: View?, callback: CustomViewCallback?) { requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR binding.llView.invisible() binding.customWebView.addView(view) customWebViewCallback = callback keepScreenOn(true) toggleSystemBar(false) } override fun onHideCustomView() { binding.customWebView.removeAllViews() binding.llView.visible() requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED keepScreenOn(false) toggleSystemBar(true) } } inner class CustomWebViewClient : WebViewClient() { override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest? ): Boolean { request?.let { return shouldOverrideUrlLoading(it.url) } return true } @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION", "KotlinRedundantDiagnosticSuppress") override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean { url?.let { return shouldOverrideUrlLoading(Uri.parse(it)) } return true } override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url) val cookieManager = CookieManager.getInstance() url?.let { CookieStore.setCookie(it, cookieManager.getCookie(it)) } view?.title?.let { title -> if (title != url && title != view.url && title.isNotBlank()) { binding.titleBar.title = title } else { binding.titleBar.title = intent.getStringExtra("title") } view.evaluateJavascript("!!window._cf_chl_opt") { if (it == "true") { isCloudflareChallenge = true } else if (isCloudflareChallenge && viewModel.sourceVerificationEnable) { viewModel.saveVerificationResult(binding.webView) { finish() } } } } } private fun shouldOverrideUrlLoading(url: Uri): Boolean { when (url.scheme) { "http", "https" -> { return false } "legado", "yuedu" -> { startActivity { data = url } return true } else -> { binding.root.longSnackbar(R.string.jump_to_another_app, R.string.confirm) { openUrl(url) } return true } } } @SuppressLint("WebViewClientOnReceivedSslError") override fun onReceivedSslError( view: WebView?, handler: SslErrorHandler?, error: SslError? ) { handler?.proceed() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/browser/WebViewModel.kt ================================================ package io.legado.app.ui.browser import android.app.Application import android.content.Intent import android.util.Base64 import android.webkit.URLUtil import android.webkit.WebView import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppConst import io.legado.app.constant.AppConst.imagePathKey import io.legado.app.constant.SourceType import io.legado.app.data.appDb import io.legado.app.exception.NoStackTraceException import io.legado.app.help.http.newCallResponseBody import io.legado.app.help.http.okHttpClient import io.legado.app.help.source.SourceHelp import io.legado.app.help.source.SourceVerificationHelp import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.utils.ACache import io.legado.app.utils.FileDoc import io.legado.app.utils.createFileIfNotExist import io.legado.app.utils.openOutputStream import io.legado.app.utils.printOnDebug import io.legado.app.utils.toastOnUi import org.apache.commons.text.StringEscapeUtils import java.util.Date class WebViewModel(application: Application) : BaseViewModel(application) { var intent: Intent? = null var baseUrl: String = "" var html: String? = null val headerMap: HashMap = hashMapOf() var sourceVerificationEnable: Boolean = false var refetchAfterSuccess: Boolean = true var sourceName: String = "" var sourceOrigin: String = "" var sourceType = SourceType.book fun initData( intent: Intent, success: () -> Unit ) { execute { this@WebViewModel.intent = intent val url = intent.getStringExtra("url") ?: throw NoStackTraceException("url不能为空") sourceName = intent.getStringExtra("sourceName") ?: "" sourceOrigin = intent.getStringExtra("sourceOrigin") ?: "" sourceType = intent.getIntExtra("sourceType", SourceType.book) sourceVerificationEnable = intent.getBooleanExtra("sourceVerificationEnable", false) refetchAfterSuccess = intent.getBooleanExtra("refetchAfterSuccess", true) val source = SourceHelp.getSource(sourceOrigin, sourceType) val analyzeUrl = AnalyzeUrl(url, source = source, coroutineContext = coroutineContext) baseUrl = analyzeUrl.url headerMap.putAll(analyzeUrl.headerMap) if (analyzeUrl.isPost()) { html = analyzeUrl.getStrResponseAwait(useWebView = false).body } }.onSuccess { success.invoke() }.onError { context.toastOnUi("error\n${it.localizedMessage}") it.printOnDebug() } } fun saveImage(webPic: String?, path: String) { webPic ?: return execute { val fileName = "${AppConst.fileNameFormat.format(Date(System.currentTimeMillis()))}.jpg" webData2bitmap(webPic)?.let { byteArray -> val fileDoc = FileDoc.fromDir(path) val picFile = fileDoc.createFileIfNotExist(fileName) picFile.openOutputStream().getOrThrow().use { it.write(byteArray) } } ?: throw Throwable("NULL") }.onError { ACache.get().remove(imagePathKey) context.toastOnUi("保存图片失败:${it.localizedMessage}") }.onSuccess { context.toastOnUi("保存成功") } } private suspend fun webData2bitmap(data: String): ByteArray? { return if (URLUtil.isValidUrl(data)) { okHttpClient.newCallResponseBody { url(data) }.bytes() } else { Base64.decode(data.split(",").toTypedArray()[1], Base64.DEFAULT) } } fun saveVerificationResult(webView: WebView, success: () -> Unit) { if (!sourceVerificationEnable) { return success.invoke() } if (refetchAfterSuccess) { execute { val url = intent!!.getStringExtra("url")!! val source = appDb.bookSourceDao.getBookSource(sourceOrigin) html = AnalyzeUrl( url, headerMapF = headerMap, source = source, coroutineContext = coroutineContext ).getStrResponseAwait(useWebView = false).body SourceVerificationHelp.setResult(sourceOrigin, html ?: "") }.onSuccess { success.invoke() } } else { webView.evaluateJavascript("document.documentElement.outerHTML") { execute { html = StringEscapeUtils.unescapeJson(it).trim('"') SourceVerificationHelp.setResult(sourceOrigin, html ?: "") }.onSuccess { success.invoke() } } } } fun disableSource(block: () -> Unit) { execute { SourceHelp.enableSource(sourceOrigin, sourceType, false) }.onSuccess { block.invoke() } } fun deleteSource(block: () -> Unit) { execute { SourceHelp.deleteSource(sourceOrigin, sourceType) }.onSuccess { block.invoke() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/config/BackupConfigFragment.kt ================================================ package io.legado.app.ui.config import android.content.Context import android.content.SharedPreferences import android.os.Bundle import android.text.InputType import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.core.view.MenuProvider import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.preference.EditTextPreference import androidx.preference.ListPreference import androidx.preference.Preference import io.legado.app.R import io.legado.app.constant.AppLog import io.legado.app.constant.PreferKey import io.legado.app.exception.NoStackTraceException import io.legado.app.help.AppWebDav import io.legado.app.help.config.AppConfig import io.legado.app.help.config.LocalConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.storage.Backup import io.legado.app.help.storage.BackupConfig import io.legado.app.help.storage.ImportOldData import io.legado.app.help.storage.Restore import io.legado.app.lib.dialogs.alert import io.legado.app.lib.dialogs.selector import io.legado.app.lib.permission.Permissions import io.legado.app.lib.permission.PermissionsCompat import io.legado.app.lib.prefs.fragment.PreferenceFragment import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.about.AppLogDialog import io.legado.app.ui.file.HandleFileContract import io.legado.app.ui.widget.dialog.WaitDialog import io.legado.app.utils.FileDoc import io.legado.app.utils.applyTint import io.legado.app.utils.checkWrite import io.legado.app.utils.getPrefString import io.legado.app.utils.isContentScheme import io.legado.app.utils.launch import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.showDialogFragment import io.legado.app.utils.showHelp import io.legado.app.utils.toEditable import io.legado.app.utils.toastOnUi import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Job import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import splitties.init.appCtx class BackupConfigFragment : PreferenceFragment(), SharedPreferences.OnSharedPreferenceChangeListener, MenuProvider { private val viewModel by activityViewModels() private val waitDialog by lazy { WaitDialog(requireContext()) } private var backupJob: Job? = null private var restoreJob: Job? = null private val selectBackupPath = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> if (uri.isContentScheme()) { AppConfig.backupPath = uri.toString() } else { AppConfig.backupPath = uri.path } } } private val backupDir = registerForActivityResult(HandleFileContract()) { result -> result.uri?.let { uri -> if (uri.isContentScheme()) { AppConfig.backupPath = uri.toString() backup(uri.toString()) } else { uri.path?.let { path -> AppConfig.backupPath = path backup(path) } } } } private val restoreDoc = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> waitDialog.setText("恢复中…") waitDialog.show() val task = Coroutine.async { Restore.restore(appCtx, uri) }.onFinally { waitDialog.dismiss() } waitDialog.setOnCancelListener { task.cancel() } } } private val restoreOld = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> ImportOldData.importUri(appCtx, uri) } } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_config_backup) findPreference(PreferKey.webDavPassword)?.let { it.setOnBindEditTextListener { editText -> editText.inputType = InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_CLASS_TEXT editText.setSelection(editText.text.length) } } findPreference(PreferKey.webDavDir)?.let { it.setOnBindEditTextListener { editText -> editText.text = AppConfig.webDavDir?.toEditable() editText.setSelection(editText.text.length) } } findPreference(PreferKey.webDavDeviceName)?.let { it.setOnBindEditTextListener { editText -> editText.text = AppConfig.webDavDeviceName?.toEditable() editText.setSelection(editText.text.length) } } upPreferenceSummary(PreferKey.webDavUrl, getPrefString(PreferKey.webDavUrl)) upPreferenceSummary(PreferKey.webDavAccount, getPrefString(PreferKey.webDavAccount)) upPreferenceSummary(PreferKey.webDavPassword, getPrefString(PreferKey.webDavPassword)) upPreferenceSummary(PreferKey.webDavDir, AppConfig.webDavDir) upPreferenceSummary(PreferKey.webDavDeviceName, AppConfig.webDavDeviceName) upPreferenceSummary(PreferKey.backupPath, getPrefString(PreferKey.backupPath)) findPreference("web_dav_restore") ?.onLongClick { restoreFromLocal() true } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) activity?.setTitle(R.string.backup_restore) preferenceManager.sharedPreferences?.registerOnSharedPreferenceChangeListener(this) listView.setEdgeEffectColor(primaryColor) activity?.addMenuProvider(this, viewLifecycleOwner) if (!LocalConfig.backupHelpVersionIsLast) { showHelp("webDavHelp") } } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.backup_restore, menu) menu.applyTint(requireContext()) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { when (menuItem.itemId) { R.id.menu_help -> { showHelp("webDavHelp") return true } R.id.menu_log -> showDialogFragment() } return false } override fun onDestroy() { super.onDestroy() preferenceManager.sharedPreferences?.unregisterOnSharedPreferenceChangeListener(this) } override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { when (key) { PreferKey.backupPath -> upPreferenceSummary(key, getPrefString(key)) PreferKey.webDavUrl, PreferKey.webDavAccount, PreferKey.webDavPassword, PreferKey.webDavDir -> listView.post { upPreferenceSummary(key, appCtx.getPrefString(key)) viewModel.upWebDavConfig() } PreferKey.webDavDeviceName -> upPreferenceSummary(key, getPrefString(key)) } } private fun upPreferenceSummary(preferenceKey: String, value: String?) { val preference = findPreference(preferenceKey) ?: return when (preferenceKey) { PreferKey.webDavUrl -> if (value.isNullOrBlank()) { preference.summary = getString(R.string.web_dav_url_s) } else { preference.summary = value } PreferKey.webDavAccount -> if (value.isNullOrBlank()) { preference.summary = getString(R.string.web_dav_account_s) } else { preference.summary = value } PreferKey.webDavPassword -> if (value.isNullOrEmpty()) { preference.summary = getString(R.string.web_dav_pw_s) } else { preference.summary = "*".repeat(value.length) } PreferKey.webDavDir -> preference.summary = when (value) { null -> "legado" else -> value } else -> { if (preference is ListPreference) { val index = preference.findIndexOfValue(value) // Set the summary to reflect the new value. preference.summary = if (index >= 0) preference.entries[index] else null } else { preference.summary = value } } } } override fun onPreferenceTreeClick(preference: Preference): Boolean { when (preference.key) { PreferKey.backupPath -> selectBackupPath.launch() PreferKey.restoreIgnore -> backupIgnore() "web_dav_backup" -> backup() "web_dav_restore" -> restore() "import_old" -> restoreOld.launch() } return super.onPreferenceTreeClick(preference) } /** * 备份忽略设置 */ private fun backupIgnore() { val checkedItems = BooleanArray(BackupConfig.ignoreKeys.size) { BackupConfig.ignoreConfig[BackupConfig.ignoreKeys[it]] ?: false } alert(R.string.restore_ignore) { multiChoiceItems(BackupConfig.ignoreTitle, checkedItems) { _, which, isChecked -> BackupConfig.ignoreConfig[BackupConfig.ignoreKeys[which]] = isChecked } onDismiss { BackupConfig.saveIgnoreConfig() } } } fun backup() { val backupPath = AppConfig.backupPath if (backupPath.isNullOrEmpty()) { backupDir.launch() } else { if (backupPath.isContentScheme()) { lifecycleScope.launch { val canWrite = withContext(IO) { FileDoc.fromDir(backupPath).checkWrite() } if (canWrite) { backup(backupPath) } else { backupDir.launch() } } } else { backupUsePermission(backupPath) } } } private fun backup(backupPath: String) { waitDialog.setText("备份中…") waitDialog.setOnCancelListener { backupJob?.cancel() } waitDialog.show() backupJob?.cancel() backupJob = lifecycleScope.launch { try { Backup.backupLocked(requireContext(), backupPath) appCtx.toastOnUi(R.string.backup_success) } catch (e: Throwable) { ensureActive() AppLog.put("备份出错\n${e.localizedMessage}", e) appCtx.toastOnUi( appCtx.getString( R.string.backup_fail, e.localizedMessage ) ) } finally { ensureActive() waitDialog.dismiss() } } } private fun backupUsePermission(path: String) { PermissionsCompat.Builder() .addPermissions(*Permissions.Group.STORAGE) .rationale(R.string.tip_perm_request_storage) .onGranted { backup(path) } .request() } fun restore() { waitDialog.setText(R.string.loading) waitDialog.setOnCancelListener { restoreJob?.cancel() } waitDialog.show() Coroutine.async { restoreJob = coroutineContext[Job] showRestoreDialog(requireContext()) }.onError { AppLog.put("恢复备份出错WebDavError\n${it.localizedMessage}", it) if (context == null) { return@onError } alert { setTitle(R.string.restore) setMessage("WebDavError\n${it.localizedMessage}\n将从本地备份恢复。") okButton { restoreFromLocal() } cancelButton() } }.onFinally { waitDialog.dismiss() } } private suspend fun showRestoreDialog(context: Context) { val names = withContext(IO) { AppWebDav.getBackupNames() } if (AppWebDav.isJianGuoYun && names.size > 700) { context.toastOnUi("由于坚果云限制列出文件数量,部分备份可能未显示,请及时清理旧备份") } if (names.isNotEmpty()) { currentCoroutineContext().ensureActive() withContext(Main) { context.selector( title = context.getString(R.string.select_restore_file), items = names ) { _, index -> if (index in 0 until names.size) { listView.post { restoreWebDav(names[index]) } } } } } else { throw NoStackTraceException("Web dav no back up file") } } private fun restoreWebDav(name: String) { waitDialog.setText("恢复中…") waitDialog.show() val task = Coroutine.async { AppWebDav.restoreWebDav(name) }.onError { AppLog.put("WebDav恢复出错\n${it.localizedMessage}", it) appCtx.toastOnUi("WebDav恢复出错\n${it.localizedMessage}") }.onFinally { waitDialog.dismiss() } waitDialog.setOnCancelListener { task.cancel() } } private fun restoreFromLocal() { restoreDoc.launch { title = getString(R.string.select_restore_file) mode = HandleFileContract.FILE allowExtensions = arrayOf("zip") } } override fun onDestroyView() { super.onDestroyView() waitDialog.dismiss() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/config/CheckSourceConfig.kt ================================================ package io.legado.app.ui.config import android.os.Bundle import android.view.View import android.view.ViewGroup import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.constant.PreferKey import io.legado.app.databinding.DialogCheckSourceConfigBinding import io.legado.app.lib.theme.primaryColor import io.legado.app.model.CheckSource import io.legado.app.utils.putPrefString import io.legado.app.utils.setLayout import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding import splitties.views.onClick class CheckSourceConfig : BaseDialogFragment(R.layout.dialog_check_source_config) { private val binding by viewBinding(DialogCheckSourceConfigBinding::bind) //允许的最小超时时间,秒 private val minTimeout = 0L override fun onStart() { super.onStart() setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) binding.run { checkSearch.onClick { if (!checkSearch.isChecked && !checkDiscovery.isChecked) { checkDiscovery.isChecked = true } } checkDiscovery.onClick { if (!checkSearch.isChecked && !checkDiscovery.isChecked) { checkSearch.isChecked = true } } checkInfo.onClick { if (!checkInfo.isChecked) { checkCategory.isChecked = false checkContent.isChecked = false checkCategory.isEnabled = false checkContent.isEnabled = false } else { checkCategory.isEnabled = true } } checkCategory.onClick { if (!checkCategory.isChecked) { checkContent.isChecked = false checkContent.isEnabled = false } else { checkContent.isEnabled = true } } } CheckSource.run { binding.checkSourceTimeout.setText((timeout / 1000).toString()) binding.checkSearch.isChecked = checkSearch binding.checkDiscovery.isChecked = checkDiscovery binding.checkInfo.isChecked = checkInfo binding.checkCategory.isChecked = checkCategory binding.checkContent.isChecked = checkContent binding.checkCategory.isEnabled = checkInfo binding.checkContent.isEnabled = checkCategory binding.tvCancel.onClick { dismiss() } binding.tvOk.onClick { val text = binding.checkSourceTimeout.text.toString() when { text.isBlank() -> { toastOnUi("${getString(R.string.timeout)}${getString(R.string.cannot_empty)}") return@onClick } text.toLong() <= minTimeout -> { toastOnUi( "${getString(R.string.timeout)}${getString(R.string.less_than)}${minTimeout}${ getString( R.string.seconds ) }" ) return@onClick } else -> timeout = text.toLong() * 1000 } checkSearch = binding.checkSearch.isChecked checkDiscovery = binding.checkDiscovery.isChecked checkInfo = binding.checkInfo.isChecked checkCategory = binding.checkCategory.isChecked checkContent = binding.checkContent.isChecked putConfig() putPrefString(PreferKey.checkSource, summary) dismiss() } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/config/ConfigActivity.kt ================================================ package io.legado.app.ui.config import android.os.Bundle import androidx.activity.viewModels import androidx.fragment.app.Fragment import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.constant.EventBus import io.legado.app.databinding.ActivityConfigBinding import io.legado.app.utils.observeEvent import io.legado.app.utils.viewbindingdelegate.viewBinding class ConfigActivity : VMBaseActivity() { override val binding by viewBinding(ActivityConfigBinding::inflate) override val viewModel by viewModels() override fun onActivityCreated(savedInstanceState: Bundle?) { when (val configTag = intent.getStringExtra("configTag")) { ConfigTag.OTHER_CONFIG -> replaceFragment(configTag) ConfigTag.THEME_CONFIG -> replaceFragment(configTag) ConfigTag.BACKUP_CONFIG -> replaceFragment(configTag) ConfigTag.COVER_CONFIG -> replaceFragment(configTag) ConfigTag.WELCOME_CONFIG -> replaceFragment(configTag) else -> finish() } } override fun setTitle(resId: Int) { super.setTitle(resId) binding.titleBar.setTitle(resId) } inline fun replaceFragment(configTag: String) { intent.putExtra("configTag", configTag) @Suppress("DEPRECATION") val configFragment = supportFragmentManager.findFragmentByTag(configTag) ?: T::class.java.newInstance() supportFragmentManager.beginTransaction() .replace(R.id.configFrameLayout, configFragment, configTag) .commit() } override fun observeLiveBus() { super.observeLiveBus() observeEvent(EventBus.RECREATE) { recreate() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/config/ConfigTag.kt ================================================ package io.legado.app.ui.config object ConfigTag { const val OTHER_CONFIG = "otherConfig" const val THEME_CONFIG = "themeConfig" const val BACKUP_CONFIG = "backupConfig" const val COVER_CONFIG = "coverConfig" const val WELCOME_CONFIG = "welcomeConfig" } ================================================ FILE: app/src/main/java/io/legado/app/ui/config/ConfigViewModel.kt ================================================ package io.legado.app.ui.config import android.app.Application import android.content.Context import io.legado.app.R import io.legado.app.base.BaseViewModel import io.legado.app.data.appDb import io.legado.app.help.AppWebDav import io.legado.app.help.book.BookHelp import io.legado.app.utils.FileUtils import io.legado.app.utils.restart import io.legado.app.utils.toastOnUi import kotlinx.coroutines.delay import splitties.init.appCtx class ConfigViewModel(application: Application) : BaseViewModel(application) { fun upWebDavConfig() { execute { AppWebDav.upConfig() } } fun clearCache() { execute { BookHelp.clearCache() FileUtils.delete(context.cacheDir.absolutePath) }.onSuccess { context.toastOnUi(R.string.clear_cache_success) } } fun clearWebViewData() { execute { FileUtils.delete(context.getDir("webview", Context.MODE_PRIVATE)) FileUtils.delete(context.getDir("hws_webview", Context.MODE_PRIVATE), true) context.toastOnUi(R.string.clear_webview_data_success) delay(3000) appCtx.restart() } } fun shrinkDatabase() { execute { appDb.openHelper.writableDatabase.execSQL("VACUUM") }.onSuccess { context.toastOnUi(R.string.success) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/config/CoverConfigFragment.kt ================================================ package io.legado.app.ui.config import android.annotation.SuppressLint import android.content.SharedPreferences import android.net.Uri import android.os.Bundle import android.view.View import androidx.preference.Preference import io.legado.app.R import io.legado.app.constant.PreferKey import io.legado.app.lib.dialogs.selector import io.legado.app.lib.prefs.SwitchPreference import io.legado.app.lib.prefs.fragment.PreferenceFragment import io.legado.app.lib.theme.primaryColor import io.legado.app.model.BookCover import io.legado.app.ui.file.HandleFileContract import io.legado.app.utils.FileUtils import io.legado.app.utils.MD5Utils import io.legado.app.utils.externalFiles import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.getPrefString import io.legado.app.utils.inputStream import io.legado.app.utils.putPrefString import io.legado.app.utils.readUri import io.legado.app.utils.removePref import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.showDialogFragment import io.legado.app.utils.toastOnUi import splitties.init.appCtx import java.io.FileOutputStream class CoverConfigFragment : PreferenceFragment(), SharedPreferences.OnSharedPreferenceChangeListener { private val requestCodeCover = 111 private val requestCodeCoverDark = 112 private val selectImage = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> when (it.requestCode) { requestCodeCover -> setCoverFromUri(PreferKey.defaultCover, uri) requestCodeCoverDark -> setCoverFromUri(PreferKey.defaultCoverDark, uri) } } } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_config_cover) upPreferenceSummary(PreferKey.defaultCover, getPrefString(PreferKey.defaultCover)) upPreferenceSummary(PreferKey.defaultCoverDark, getPrefString(PreferKey.defaultCoverDark)) findPreference(PreferKey.coverShowAuthor) ?.isEnabled = getPrefBoolean(PreferKey.coverShowName) findPreference(PreferKey.coverShowAuthorN) ?.isEnabled = getPrefBoolean(PreferKey.coverShowNameN) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) activity?.setTitle(R.string.cover_config) listView.setEdgeEffectColor(primaryColor) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) preferenceManager.sharedPreferences?.registerOnSharedPreferenceChangeListener(this) } override fun onDestroy() { super.onDestroy() preferenceManager.sharedPreferences?.unregisterOnSharedPreferenceChangeListener(this) } override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { sharedPreferences ?: return when (key) { PreferKey.defaultCover, PreferKey.defaultCoverDark -> { upPreferenceSummary(key, getPrefString(key)) } PreferKey.coverShowName -> { findPreference(PreferKey.coverShowAuthor) ?.isEnabled = getPrefBoolean(key) BookCover.upDefaultCover() } PreferKey.coverShowNameN -> { findPreference(PreferKey.coverShowAuthorN) ?.isEnabled = getPrefBoolean(key) BookCover.upDefaultCover() } PreferKey.coverShowAuthor, PreferKey.coverShowAuthorN -> { BookCover.upDefaultCover() } } } @SuppressLint("PrivateResource") override fun onPreferenceTreeClick(preference: Preference): Boolean { when (preference.key) { "coverRule" -> showDialogFragment(CoverRuleConfigDialog()) PreferKey.defaultCover -> if (getPrefString(preference.key).isNullOrEmpty()) { selectImage.launch { requestCode = requestCodeCover mode = HandleFileContract.IMAGE } } else { context?.selector( items = arrayListOf( getString(R.string.delete), getString(R.string.select_image) ) ) { _, i -> if (i == 0) { removePref(preference.key) BookCover.upDefaultCover() } else { selectImage.launch { requestCode = requestCodeCover mode = HandleFileContract.IMAGE } } } } PreferKey.defaultCoverDark -> if (getPrefString(preference.key).isNullOrEmpty()) { selectImage.launch { requestCode = requestCodeCoverDark mode = HandleFileContract.IMAGE } } else { context?.selector( items = arrayListOf( getString(R.string.delete), getString(R.string.select_image) ) ) { _, i -> if (i == 0) { removePref(preference.key) BookCover.upDefaultCover() } else { selectImage.launch { requestCode = requestCodeCoverDark mode = HandleFileContract.IMAGE } } } } } return super.onPreferenceTreeClick(preference) } private fun upPreferenceSummary(preferenceKey: String, value: String?) { val preference = findPreference(preferenceKey) ?: return when (preferenceKey) { PreferKey.defaultCover, PreferKey.defaultCoverDark -> preference.summary = if (value.isNullOrBlank()) { getString(R.string.select_image) } else { value } else -> preference.summary = value } } private fun setCoverFromUri(preferenceKey: String, uri: Uri) { readUri(uri) { fileDoc, inputStream -> kotlin.runCatching { var file = requireContext().externalFiles val suffix = fileDoc.name.substringAfterLast(".") val fileName = uri.inputStream(requireContext()).getOrThrow().use { MD5Utils.md5Encode(it) + ".$suffix" } file = FileUtils.createFileIfNotExist(file, "covers", fileName) FileOutputStream(file).use { inputStream.copyTo(it) } putPrefString(preferenceKey, file.absolutePath) BookCover.upDefaultCover() }.onFailure { appCtx.toastOnUi(it.localizedMessage) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/config/CoverRuleConfigDialog.kt ================================================ package io.legado.app.ui.config import android.os.Bundle import android.util.Log import android.view.View import android.view.ViewGroup import androidx.lifecycle.lifecycleScope import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.databinding.DialogCoverRuleConfigBinding import io.legado.app.lib.theme.primaryColor import io.legado.app.model.BookCover import io.legado.app.utils.GSON import io.legado.app.utils.setLayout import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import splitties.views.onClick class CoverRuleConfigDialog : BaseDialogFragment(R.layout.dialog_cover_rule_config) { val binding by viewBinding(DialogCoverRuleConfigBinding::bind) override fun onStart() { super.onStart() setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) initData() binding.tvCancel.onClick { dismissAllowingStateLoss() } binding.tvOk.onClick { val enable = binding.cbEnable.isChecked val searchUrl = binding.editSearchUrl.text?.toString() val coverRule = binding.editCoverUrlRule.text?.toString() if (searchUrl.isNullOrBlank() || coverRule.isNullOrBlank()) { toastOnUi("搜索url和cover规则不能为空") } else { BookCover.CoverRule(enable, searchUrl, coverRule).let { config -> BookCover.saveCoverRule(config) } dismissAllowingStateLoss() } } binding.tvFooterLeft.onClick { BookCover.delCoverRule() dismissAllowingStateLoss() } } private fun initData() { lifecycleScope.launch { val rule = withContext(IO) { BookCover.getCoverRule() } Log.e("coverRule", GSON.toJson(rule)) binding.cbEnable.isChecked = rule.enable binding.editSearchUrl.setText(rule.searchUrl) binding.editCoverUrlRule.setText(rule.coverRule) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/config/DirectLinkUploadConfig.kt ================================================ package io.legado.app.ui.config import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.databinding.DialogDirectLinkUploadConfigBinding import io.legado.app.help.DirectLinkUpload import io.legado.app.lib.dialogs.alert import io.legado.app.lib.dialogs.selector import io.legado.app.lib.theme.primaryColor import io.legado.app.utils.GSON import io.legado.app.utils.applyTint import io.legado.app.utils.fromJsonObject import io.legado.app.utils.getClipText import io.legado.app.utils.sendToClip import io.legado.app.utils.setLayout import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding import splitties.init.appCtx import splitties.views.onClick class DirectLinkUploadConfig : BaseDialogFragment(R.layout.dialog_direct_link_upload_config), Toolbar.OnMenuItemClickListener { private val binding by viewBinding(DialogDirectLinkUploadConfigBinding::bind) override fun onStart() { super.onStart() setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) binding.toolBar.inflateMenu(R.menu.direct_link_upload_config) binding.toolBar.menu.applyTint(requireContext()) binding.toolBar.setOnMenuItemClickListener(this) upView(DirectLinkUpload.getRule()) binding.tvCancel.onClick { dismiss() } binding.tvFooterLeft.onClick { test() } binding.tvOk.onClick { getRule()?.let { rule -> DirectLinkUpload.putConfig(rule) dismiss() } } } override fun onMenuItemClick(item: MenuItem?): Boolean { when (item?.itemId) { R.id.menu_import_default -> importDefault() R.id.menu_copy_rule -> getRule()?.let { rule -> requireContext().sendToClip(GSON.toJson(rule)) } R.id.menu_paste_rule -> runCatching { requireContext().getClipText()!!.let { val rule = GSON.fromJsonObject(it).getOrThrow() upView(rule) } }.onFailure { toastOnUi("剪贴板为空或格式不对") } } return true } private fun upView(rule: DirectLinkUpload.Rule) { binding.editUploadUrl.setText(rule.uploadUrl) binding.editDownloadUrlRule.setText(rule.downloadUrlRule) binding.editSummary.setText(rule.summary) binding.cbCompress.isChecked = rule.compress } private fun getRule(): DirectLinkUpload.Rule? { val uploadUrl = binding.editUploadUrl.text?.toString() val downloadUrlRule = binding.editDownloadUrlRule.text?.toString() val summary = binding.editSummary.text?.toString() val compress = binding.cbCompress.isChecked if (uploadUrl.isNullOrBlank()) { toastOnUi("上传Url不能为空") return null } if (downloadUrlRule.isNullOrBlank()) { toastOnUi("下载Url规则不能为空") return null } if (summary.isNullOrBlank()) { toastOnUi("注释不能为空") return null } return DirectLinkUpload.Rule(uploadUrl, downloadUrlRule, summary, compress) } private fun importDefault() { requireContext().selector(DirectLinkUpload.defaultRules) { _, rule, _ -> upView(rule) } } private fun test() { val rule = getRule() ?: return execute { DirectLinkUpload.upLoad("test.json", "{}", "application/json", rule) }.onError { alertTestResult(it.localizedMessage ?: "ERROR") }.onSuccess { result -> alertTestResult(result) } } private fun alertTestResult(result: String) { alert { setTitle("result") setMessage(result) okButton() negativeButton(R.string.copy_text) { appCtx.sendToClip(result) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/config/OtherConfigFragment.kt ================================================ package io.legado.app.ui.config import android.annotation.SuppressLint import android.content.ComponentName import android.content.SharedPreferences import android.content.pm.PackageManager import android.os.Bundle import android.view.View import androidx.core.view.postDelayed import androidx.fragment.app.activityViewModels import androidx.preference.ListPreference import androidx.preference.Preference import com.jeremyliao.liveeventbus.LiveEventBus import io.legado.app.R import io.legado.app.constant.EventBus import io.legado.app.constant.PreferKey import io.legado.app.databinding.DialogEditTextBinding import io.legado.app.help.AppFreezeMonitor import io.legado.app.help.DispatchersMonitor import io.legado.app.help.config.AppConfig import io.legado.app.help.config.LocalConfig import io.legado.app.lib.dialogs.alert import io.legado.app.lib.prefs.fragment.PreferenceFragment import io.legado.app.lib.theme.primaryColor import io.legado.app.model.CheckSource import io.legado.app.model.ImageProvider import io.legado.app.receiver.SharedReceiverActivity import io.legado.app.service.WebService import io.legado.app.ui.file.HandleFileContract import io.legado.app.ui.widget.number.NumberPickerDialog import io.legado.app.utils.LogUtils import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.postEvent import io.legado.app.utils.putPrefBoolean import io.legado.app.utils.putPrefString import io.legado.app.utils.removePref import io.legado.app.utils.restart import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.showDialogFragment import splitties.init.appCtx /** * 其它设置 */ class OtherConfigFragment : PreferenceFragment(), SharedPreferences.OnSharedPreferenceChangeListener { private val viewModel by activityViewModels() private val packageManager = appCtx.packageManager private val componentName = ComponentName( appCtx, SharedReceiverActivity::class.java.name ) private val localBookTreeSelect = registerForActivityResult(HandleFileContract()) { it.uri?.let { treeUri -> AppConfig.defaultBookTreeUri = treeUri.toString() } } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { putPrefBoolean(PreferKey.processText, isProcessTextEnabled()) addPreferencesFromResource(R.xml.pref_config_other) upPreferenceSummary(PreferKey.userAgent, AppConfig.userAgent) upPreferenceSummary(PreferKey.preDownloadNum, AppConfig.preDownloadNum.toString()) upPreferenceSummary(PreferKey.threadCount, AppConfig.threadCount.toString()) upPreferenceSummary(PreferKey.webPort, AppConfig.webPort.toString()) AppConfig.defaultBookTreeUri?.let { upPreferenceSummary(PreferKey.defaultBookTreeUri, it) } upPreferenceSummary(PreferKey.checkSource, CheckSource.summary) upPreferenceSummary(PreferKey.bitmapCacheSize, AppConfig.bitmapCacheSize.toString()) upPreferenceSummary(PreferKey.imageRetainNum, AppConfig.imageRetainNum.toString()) upPreferenceSummary(PreferKey.sourceEditMaxLine, AppConfig.sourceEditMaxLine.toString()) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) activity?.setTitle(R.string.other_setting) preferenceManager.sharedPreferences?.registerOnSharedPreferenceChangeListener(this) listView.setEdgeEffectColor(primaryColor) } override fun onDestroy() { super.onDestroy() preferenceManager.sharedPreferences?.unregisterOnSharedPreferenceChangeListener(this) } override fun onPreferenceTreeClick(preference: Preference): Boolean { when (preference.key) { PreferKey.userAgent -> showUserAgentDialog() PreferKey.defaultBookTreeUri -> localBookTreeSelect.launch { title = getString(R.string.select_book_folder) mode = HandleFileContract.DIR_SYS } PreferKey.preDownloadNum -> NumberPickerDialog(requireContext()) .setTitle(getString(R.string.pre_download)) .setMaxValue(9999) .setMinValue(0) .setValue(AppConfig.preDownloadNum) .show { AppConfig.preDownloadNum = it } PreferKey.threadCount -> NumberPickerDialog(requireContext()) .setTitle(getString(R.string.threads_num_title)) .setMaxValue(999) .setMinValue(1) .setValue(AppConfig.threadCount) .show { AppConfig.threadCount = it } PreferKey.webPort -> NumberPickerDialog(requireContext()) .setTitle(getString(R.string.web_port_title)) .setMaxValue(60000) .setMinValue(1024) .setValue(AppConfig.webPort) .show { AppConfig.webPort = it } PreferKey.cleanCache -> clearCache() PreferKey.uploadRule -> showDialogFragment() PreferKey.checkSource -> showDialogFragment() PreferKey.bitmapCacheSize -> { NumberPickerDialog(requireContext()) .setTitle(getString(R.string.bitmap_cache_size)) .setMaxValue(1024) .setMinValue(1) .setValue(AppConfig.bitmapCacheSize) .show { AppConfig.bitmapCacheSize = it ImageProvider.bitmapLruCache.resize(ImageProvider.cacheSize) } } PreferKey.imageRetainNum -> NumberPickerDialog(requireContext()) .setTitle(getString(R.string.image_retain_number)) .setMaxValue(999) .setMinValue(0) .setValue(AppConfig.imageRetainNum) .show { AppConfig.imageRetainNum = it } PreferKey.sourceEditMaxLine -> { NumberPickerDialog(requireContext()) .setTitle(getString(R.string.source_edit_text_max_line)) .setMaxValue(Int.MAX_VALUE) .setMinValue(10) .setValue(AppConfig.sourceEditMaxLine) .show { AppConfig.sourceEditMaxLine = it } } PreferKey.clearWebViewData -> clearWebViewData() "localPassword" -> alertLocalPassword() PreferKey.shrinkDatabase -> shrinkDatabase() } return super.onPreferenceTreeClick(preference) } override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { when (key) { PreferKey.preDownloadNum -> { upPreferenceSummary(key, AppConfig.preDownloadNum.toString()) } PreferKey.threadCount -> { upPreferenceSummary(key, AppConfig.threadCount.toString()) postEvent(PreferKey.threadCount, "") } PreferKey.webPort -> { upPreferenceSummary(key, AppConfig.webPort.toString()) if (WebService.isRun) { WebService.stop(requireContext()) WebService.start(requireContext()) } } PreferKey.defaultBookTreeUri -> { upPreferenceSummary(key, AppConfig.defaultBookTreeUri) } PreferKey.recordLog -> { AppConfig.recordLog = appCtx.getPrefBoolean(PreferKey.recordLog) LogUtils.upLevel() LogUtils.logDeviceInfo() LiveEventBus.config().enableLogger(AppConfig.recordLog) AppFreezeMonitor.init(appCtx) DispatchersMonitor.init() } PreferKey.processText -> sharedPreferences?.let { setProcessTextEnable(it.getBoolean(key, true)) } PreferKey.showDiscovery, PreferKey.showRss -> postEvent(EventBus.NOTIFY_MAIN, true) PreferKey.language -> listView.postDelayed(1000) { appCtx.restart() } PreferKey.userAgent -> listView.post { upPreferenceSummary(PreferKey.userAgent, AppConfig.userAgent) } PreferKey.checkSource -> listView.post { upPreferenceSummary(PreferKey.checkSource, CheckSource.summary) } PreferKey.bitmapCacheSize -> { upPreferenceSummary(key, AppConfig.bitmapCacheSize.toString()) } PreferKey.imageRetainNum -> { upPreferenceSummary(key, AppConfig.imageRetainNum.toString()) } PreferKey.sourceEditMaxLine -> { upPreferenceSummary(key, AppConfig.sourceEditMaxLine.toString()) } } } private fun upPreferenceSummary(preferenceKey: String, value: String?) { val preference = findPreference(preferenceKey) ?: return when (preferenceKey) { PreferKey.preDownloadNum -> preference.summary = getString(R.string.pre_download_s, value) PreferKey.threadCount -> preference.summary = getString(R.string.threads_num, value) PreferKey.webPort -> preference.summary = getString(R.string.web_port_summary, value) PreferKey.bitmapCacheSize -> preference.summary = getString(R.string.bitmap_cache_size_summary, value) PreferKey.imageRetainNum -> preference.summary = getString(R.string.image_retain_number_summary, value) PreferKey.sourceEditMaxLine -> preference.summary = getString(R.string.source_edit_max_line_summary, value) else -> if (preference is ListPreference) { val index = preference.findIndexOfValue(value) // Set the summary to reflect the new value. preference.summary = if (index >= 0) preference.entries[index] else null } else { preference.summary = value } } } @SuppressLint("InflateParams") private fun showUserAgentDialog() { alert(getString(R.string.user_agent)) { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = getString(R.string.user_agent) editView.setText(AppConfig.userAgent) } customView { alertBinding.root } okButton { val userAgent = alertBinding.editView.text?.toString() if (userAgent.isNullOrBlank()) { removePref(PreferKey.userAgent) } else { putPrefString(PreferKey.userAgent, userAgent) } } cancelButton() } } private fun clearCache() { requireContext().alert( titleResource = R.string.clear_cache, messageResource = R.string.sure_del ) { okButton { viewModel.clearCache() } noButton() } } private fun shrinkDatabase() { alert(R.string.sure, R.string.shrink_database) { okButton { viewModel.shrinkDatabase() } noButton() } } private fun clearWebViewData() { alert(R.string.clear_webview_data, R.string.sure_del) { okButton { viewModel.clearWebViewData() } noButton() } } private fun isProcessTextEnabled(): Boolean { return packageManager.getComponentEnabledSetting(componentName) != PackageManager.COMPONENT_ENABLED_STATE_DISABLED } private fun setProcessTextEnable(enable: Boolean) { if (enable) { packageManager.setComponentEnabledSetting( componentName, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP ) } else { packageManager.setComponentEnabledSetting( componentName, PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP ) } } private fun alertLocalPassword() { context?.alert(R.string.set_local_password, R.string.set_local_password_summary) { val editTextBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = "password" } customView { editTextBinding.root } okButton { LocalConfig.password = editTextBinding.editView.text.toString() } cancelButton() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/config/ThemeConfigFragment.kt ================================================ package io.legado.app.ui.config import android.annotation.SuppressLint import android.content.SharedPreferences import android.net.Uri import android.os.Build import android.os.Bundle import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.widget.SeekBar import androidx.core.view.MenuProvider import androidx.preference.Preference import io.legado.app.R import io.legado.app.base.AppContextWrapper import io.legado.app.constant.AppConst import io.legado.app.constant.EventBus import io.legado.app.constant.PreferKey import io.legado.app.databinding.DialogEditTextBinding import io.legado.app.databinding.DialogImageBlurringBinding import io.legado.app.help.LauncherIconHelp import io.legado.app.help.config.AppConfig import io.legado.app.help.config.ThemeConfig import io.legado.app.lib.dialogs.alert import io.legado.app.lib.dialogs.selector import io.legado.app.lib.prefs.ColorPreference import io.legado.app.lib.prefs.fragment.PreferenceFragment import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.file.HandleFileContract import io.legado.app.ui.widget.number.NumberPickerDialog import io.legado.app.ui.widget.seekbar.SeekBarChangeListener import io.legado.app.utils.ColorUtils import io.legado.app.utils.FileUtils import io.legado.app.utils.MD5Utils import io.legado.app.utils.applyTint import io.legado.app.utils.externalFiles import io.legado.app.utils.getPrefInt import io.legado.app.utils.getPrefString import io.legado.app.utils.inputStream import io.legado.app.utils.postEvent import io.legado.app.utils.putPrefInt import io.legado.app.utils.putPrefString import io.legado.app.utils.readUri import io.legado.app.utils.removePref import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.startActivity import io.legado.app.utils.toastOnUi import splitties.init.appCtx import java.io.FileOutputStream @Suppress("SameParameterValue") class ThemeConfigFragment : PreferenceFragment(), SharedPreferences.OnSharedPreferenceChangeListener, MenuProvider { private val requestCodeBgLight = 121 private val requestCodeBgDark = 122 private val selectImage = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> when (it.requestCode) { requestCodeBgLight -> setBgFromUri(uri, PreferKey.bgImage) { upTheme(false) } requestCodeBgDark -> setBgFromUri(uri, PreferKey.bgImageN) { upTheme(true) } } } } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_config_theme) if (Build.VERSION.SDK_INT < 26) { preferenceScreen.removePreferenceRecursively(PreferKey.launcherIcon) } upPreferenceSummary(PreferKey.bgImage, getPrefString(PreferKey.bgImage)) upPreferenceSummary(PreferKey.bgImageN, getPrefString(PreferKey.bgImageN)) upPreferenceSummary(PreferKey.barElevation, AppConfig.elevation.toString()) upPreferenceSummary(PreferKey.fontScale) findPreference(PreferKey.cBackground)?.let { it.onSaveColor = { color -> if (!ColorUtils.isColorLight(color)) { toastOnUi(R.string.day_background_too_dark) true } else { false } } } findPreference(PreferKey.cNBackground)?.let { it.onSaveColor = { color -> if (ColorUtils.isColorLight(color)) { toastOnUi(R.string.night_background_too_light) true } else { false } } } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) activity?.setTitle(R.string.theme_setting) listView.setEdgeEffectColor(primaryColor) activity?.addMenuProvider(this, viewLifecycleOwner) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) preferenceManager.sharedPreferences?.registerOnSharedPreferenceChangeListener(this) } override fun onDestroy() { super.onDestroy() preferenceManager.sharedPreferences?.unregisterOnSharedPreferenceChangeListener(this) } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.theme_config, menu) menu.applyTint(requireContext()) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { when (menuItem.itemId) { R.id.menu_theme_mode -> { AppConfig.isNightTheme = !AppConfig.isNightTheme ThemeConfig.applyDayNight(requireContext()) return true } } return false } override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { sharedPreferences ?: return when (key) { PreferKey.launcherIcon -> LauncherIconHelp.changeIcon(getPrefString(key)) PreferKey.transparentStatusBar -> recreateActivities() PreferKey.immNavigationBar -> recreateActivities() PreferKey.cPrimary, PreferKey.cAccent, PreferKey.cBackground, PreferKey.cBBackground -> { upTheme(false) } PreferKey.cNPrimary, PreferKey.cNAccent, PreferKey.cNBackground, PreferKey.cNBBackground -> { upTheme(true) } PreferKey.bgImage, PreferKey.bgImageN -> { upPreferenceSummary(key, getPrefString(key)) } } } @SuppressLint("PrivateResource") override fun onPreferenceTreeClick(preference: Preference): Boolean { when (val key = preference.key) { PreferKey.barElevation -> NumberPickerDialog(requireContext()) .setTitle(getString(R.string.bar_elevation)) .setMaxValue(32) .setMinValue(0) .setValue(AppConfig.elevation) .setCustomButton((R.string.btn_default_s)) { AppConfig.elevation = AppConst.sysElevation recreateActivities() } .show { AppConfig.elevation = it recreateActivities() } PreferKey.fontScale -> NumberPickerDialog(requireContext()) .setTitle(getString(R.string.font_scale)) .setMaxValue(16) .setMinValue(8) .setValue(10) .setCustomButton((R.string.btn_default_s)) { putPrefInt(PreferKey.fontScale, 0) recreateActivities() } .show { putPrefInt(PreferKey.fontScale, it) recreateActivities() } PreferKey.bgImage -> selectBgAction(false) PreferKey.bgImageN -> selectBgAction(true) "themeList" -> ThemeListDialog().show(childFragmentManager, "themeList") "saveDayTheme", "saveNightTheme" -> alertSaveTheme(key) "coverConfig" -> startActivity { putExtra("configTag", ConfigTag.COVER_CONFIG) } "welcomeStyle" -> startActivity { putExtra("configTag", ConfigTag.WELCOME_CONFIG) } } return super.onPreferenceTreeClick(preference) } @SuppressLint("InflateParams") private fun alertSaveTheme(key: String) { alert(R.string.theme_name) { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = "name" } customView { alertBinding.root } okButton { alertBinding.editView.text?.toString()?.let { themeName -> when (key) { "saveDayTheme" -> { ThemeConfig.saveDayTheme(requireContext(), themeName) } "saveNightTheme" -> { ThemeConfig.saveNightTheme(requireContext(), themeName) } } } } cancelButton() } } private fun selectBgAction(isNight: Boolean) { val bgKey = if (isNight) PreferKey.bgImageN else PreferKey.bgImage val blurringKey = if (isNight) PreferKey.bgImageNBlurring else PreferKey.bgImageBlurring val actions = arrayListOf( getString(R.string.background_image_blurring), getString(R.string.select_image) ) if (!getPrefString(bgKey).isNullOrEmpty()) { actions.add(getString(R.string.delete)) } context?.selector(items = actions) { _, i -> when (i) { 0 -> alertImageBlurring(blurringKey) { upTheme(isNight) } 1 -> { if (isNight) { selectImage.launch { requestCode = requestCodeBgDark mode = HandleFileContract.IMAGE } } else { selectImage.launch { requestCode = requestCodeBgLight mode = HandleFileContract.IMAGE } } } 2 -> { removePref(bgKey) upTheme(isNight) } } } } private fun alertImageBlurring(preferKey: String, success: () -> Unit) { alert(R.string.background_image_blurring) { val alertBinding = DialogImageBlurringBinding.inflate(layoutInflater).apply { getPrefInt(preferKey, 0).let { seekBar.progress = it textViewValue.text = it.toString() } seekBar.setOnSeekBarChangeListener(object : SeekBarChangeListener { override fun onProgressChanged( seekBar: SeekBar, progress: Int, fromUser: Boolean ) { textViewValue.text = progress.toString() } }) } customView { alertBinding.root } okButton { alertBinding.seekBar.progress.let { putPrefInt(preferKey, it) success.invoke() } } cancelButton() } } private fun upTheme(isNightTheme: Boolean) { if (AppConfig.isNightTheme == isNightTheme) { listView.post { ThemeConfig.applyTheme(requireContext()) recreateActivities() } } } private fun recreateActivities() { postEvent(EventBus.RECREATE, "") } private fun upPreferenceSummary(preferenceKey: String, value: String? = null) { val preference = findPreference(preferenceKey) ?: return when (preferenceKey) { PreferKey.barElevation -> preference.summary = getString(R.string.bar_elevation_s, value) PreferKey.fontScale -> { val fontScale = AppContextWrapper.getFontScale(requireContext()) preference.summary = getString(R.string.font_scale_summary, fontScale) } PreferKey.bgImage, PreferKey.bgImageN -> preference.summary = if (value.isNullOrBlank()) { getString(R.string.select_image) } else { value } else -> preference.summary = value } } private fun setBgFromUri(uri: Uri, preferenceKey: String, success: () -> Unit) { readUri(uri) { fileDoc, inputStream -> kotlin.runCatching { var file = requireContext().externalFiles val suffix = fileDoc.name.substringAfterLast(".") val fileName = uri.inputStream(requireContext()).getOrThrow().use { MD5Utils.md5Encode(it) + ".$suffix" } file = FileUtils.createFileIfNotExist(file, preferenceKey, fileName) FileOutputStream(file).use { inputStream.copyTo(it) } putPrefString(preferenceKey, file.absolutePath) success() }.onFailure { appCtx.toastOnUi(it.localizedMessage) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/config/ThemeListDialog.kt ================================================ package io.legado.app.ui.config import android.content.Context import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.databinding.DialogRecyclerViewBinding import io.legado.app.databinding.ItemThemeConfigBinding import io.legado.app.help.config.ThemeConfig import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.widget.recycler.VerticalDivider import io.legado.app.utils.* import io.legado.app.utils.viewbindingdelegate.viewBinding class ThemeListDialog : BaseDialogFragment(R.layout.dialog_recycler_view), Toolbar.OnMenuItemClickListener { private val binding by viewBinding(DialogRecyclerViewBinding::bind) private val adapter by lazy { Adapter(requireContext()) } override fun onStart() { super.onStart() setLayout(0.9f, 0.9f) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) binding.toolBar.setTitle(R.string.theme_list) initView() initMenu() initData() } private fun initView() = binding.run { recyclerView.layoutManager = LinearLayoutManager(requireContext()) recyclerView.addItemDecoration(VerticalDivider(requireContext())) recyclerView.adapter = adapter } private fun initMenu() = binding.run { toolBar.setOnMenuItemClickListener(this@ThemeListDialog) toolBar.inflateMenu(R.menu.theme_list) toolBar.menu.applyTint(requireContext()) } fun initData() { adapter.setItems(ThemeConfig.configList) } override fun onMenuItemClick(item: MenuItem?): Boolean { when (item?.itemId) { R.id.menu_import -> { requireContext().getClipText()?.let { if (ThemeConfig.addConfig(it)) { initData() } else { toastOnUi("格式不对,添加失败") } } } } return true } fun delete(index: Int) { alert(R.string.delete, R.string.sure_del) { yesButton { ThemeConfig.delConfig(index) initData() } noButton() } } fun share(index: Int) { val json = GSON.toJson(ThemeConfig.configList[index]) requireContext().share(json, "主题分享") } inner class Adapter(context: Context) : RecyclerAdapter(context) { override fun getViewBinding(parent: ViewGroup): ItemThemeConfigBinding { return ItemThemeConfigBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemThemeConfigBinding, item: ThemeConfig.Config, payloads: MutableList ) { binding.apply { tvName.text = item.themeName } } override fun registerListener(holder: ItemViewHolder, binding: ItemThemeConfigBinding) { binding.apply { root.setOnClickListener { ThemeConfig.applyConfig(context, ThemeConfig.configList[holder.layoutPosition]) } ivShare.setOnClickListener { share(holder.layoutPosition) } ivDelete.setOnClickListener { delete(holder.layoutPosition) } } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/config/WelcomeConfigFragment.kt ================================================ package io.legado.app.ui.config import android.annotation.SuppressLint import android.content.SharedPreferences import android.net.Uri import android.os.Bundle import android.view.View import androidx.preference.Preference import io.legado.app.R import io.legado.app.constant.PreferKey import io.legado.app.help.config.AppConfig import io.legado.app.lib.dialogs.selector import io.legado.app.lib.prefs.SwitchPreference import io.legado.app.lib.prefs.fragment.PreferenceFragment import io.legado.app.lib.theme.primaryColor import io.legado.app.model.BookCover import io.legado.app.ui.file.HandleFileContract import io.legado.app.utils.FileUtils import io.legado.app.utils.MD5Utils import io.legado.app.utils.externalFiles import io.legado.app.utils.getPrefString import io.legado.app.utils.inputStream import io.legado.app.utils.putPrefString import io.legado.app.utils.readUri import io.legado.app.utils.removePref import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.toastOnUi import splitties.init.appCtx import java.io.FileOutputStream class WelcomeConfigFragment : PreferenceFragment(), SharedPreferences.OnSharedPreferenceChangeListener { private val requestWelcomeImage = 221 private val requestWelcomeImageDark = 222 private val selectImage = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> when (it.requestCode) { requestWelcomeImage -> setCoverFromUri(PreferKey.welcomeImage, uri) requestWelcomeImageDark -> setCoverFromUri(PreferKey.welcomeImageDark, uri) } } } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_config_welcome) val welcomeImage = AppConfig.welcomeImage val welcomeImageDark = AppConfig.welcomeImageDark upPreferenceSummary(PreferKey.welcomeImage, welcomeImage) upPreferenceSummary(PreferKey.welcomeImageDark, welcomeImageDark) findPreference(PreferKey.welcomeShowText)?.let { it.isEnabled = !welcomeImage.isNullOrEmpty() } findPreference(PreferKey.welcomeShowIcon)?.let { it.isEnabled = !welcomeImage.isNullOrEmpty() } findPreference(PreferKey.welcomeShowTextDark)?.let { it.isEnabled = !welcomeImageDark.isNullOrEmpty() } findPreference(PreferKey.welcomeShowIconDark)?.let { it.isEnabled = !welcomeImageDark.isNullOrEmpty() } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) activity?.setTitle(R.string.welcome_style) listView.setEdgeEffectColor(primaryColor) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) preferenceManager.sharedPreferences?.registerOnSharedPreferenceChangeListener(this) } override fun onDestroy() { super.onDestroy() preferenceManager.sharedPreferences?.unregisterOnSharedPreferenceChangeListener(this) } override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { sharedPreferences ?: return when (key) { PreferKey.welcomeImage -> { val welcomeImage = getPrefString(key) upPreferenceSummary(key, welcomeImage) findPreference(PreferKey.welcomeShowText)?.let { it.isEnabled = !welcomeImage.isNullOrEmpty() } findPreference(PreferKey.welcomeShowIcon)?.let { it.isEnabled = !welcomeImage.isNullOrEmpty() } } PreferKey.welcomeImageDark -> { val welcomeImageDark = getPrefString(key) upPreferenceSummary(key, welcomeImageDark) findPreference(PreferKey.welcomeShowTextDark)?.let { it.isEnabled = !welcomeImageDark.isNullOrEmpty() } findPreference(PreferKey.welcomeShowIconDark)?.let { it.isEnabled = !welcomeImageDark.isNullOrEmpty() } } } } @SuppressLint("PrivateResource") override fun onPreferenceTreeClick(preference: Preference): Boolean { when (preference.key) { PreferKey.welcomeImage -> if (getPrefString(preference.key).isNullOrEmpty()) { selectImage.launch { requestCode = requestWelcomeImage mode = HandleFileContract.IMAGE } } else { context?.selector( items = arrayListOf( getString(R.string.delete), getString(R.string.select_image) ) ) { _, i -> if (i == 0) { removePref(preference.key) AppConfig.welcomeShowText = true AppConfig.welcomeShowIcon = true findPreference(PreferKey.welcomeShowText)?.let { it.isChecked = true } findPreference(PreferKey.welcomeShowIcon)?.let { it.isChecked = true } BookCover.upDefaultCover() } else { selectImage.launch { requestCode = requestWelcomeImage mode = HandleFileContract.IMAGE } } } } PreferKey.welcomeImageDark -> if (getPrefString(preference.key).isNullOrEmpty()) { selectImage.launch { requestCode = requestWelcomeImageDark mode = HandleFileContract.IMAGE } } else { context?.selector( items = arrayListOf( getString(R.string.delete), getString(R.string.select_image) ) ) { _, i -> if (i == 0) { removePref(preference.key) AppConfig.welcomeShowTextDark = true AppConfig.welcomeShowIconDark = true findPreference(PreferKey.welcomeShowTextDark)?.let { it.isChecked = true } findPreference(PreferKey.welcomeShowIconDark)?.let { it.isChecked = true } BookCover.upDefaultCover() } else { selectImage.launch { requestCode = requestWelcomeImageDark mode = HandleFileContract.IMAGE } } } } } return super.onPreferenceTreeClick(preference) } private fun upPreferenceSummary(preferenceKey: String, value: String?) { val preference = findPreference(preferenceKey) ?: return when (preferenceKey) { PreferKey.welcomeImage, PreferKey.welcomeImageDark -> preference.summary = if (value.isNullOrBlank()) { getString(R.string.select_image) } else { value } else -> preference.summary = value } } private fun setCoverFromUri(preferenceKey: String, uri: Uri) { readUri(uri) { fileDoc, inputStream -> kotlin.runCatching { var file = requireContext().externalFiles val suffix = fileDoc.name.substringAfterLast(".") val fileName = uri.inputStream(requireContext()).getOrThrow().use { MD5Utils.md5Encode(it) + ".$suffix" } file = FileUtils.createFileIfNotExist(file, "covers", fileName) FileOutputStream(file).use { inputStream.copyTo(it) } putPrefString(preferenceKey, file.absolutePath) }.onFailure { appCtx.toastOnUi(it.localizedMessage) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/dict/DictDialog.kt ================================================ package io.legado.app.ui.dict import android.os.Bundle import android.text.method.LinkMovementMethod import android.view.View import android.view.ViewGroup import androidx.fragment.app.viewModels import com.google.android.material.tabs.TabLayout import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.data.entities.DictRule import io.legado.app.databinding.DialogDictBinding import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.backgroundColor import io.legado.app.utils.setHtml import io.legado.app.utils.setLayout import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding /** * 词典 */ class DictDialog() : BaseDialogFragment(R.layout.dialog_dict) { constructor(word: String) : this() { arguments = Bundle().apply { putString("word", word) } } private val viewModel by viewModels() private val binding by viewBinding(DialogDictBinding::bind) private var word: String? = null override fun onStart() { super.onStart() setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.tvDict.movementMethod = LinkMovementMethod() word = arguments?.getString("word") if (word.isNullOrEmpty()) { toastOnUi(R.string.cannot_empty) dismiss() return } binding.tabLayout.setBackgroundColor(backgroundColor) binding.tabLayout.setSelectedTabIndicatorColor(accentColor) binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { override fun onTabReselected(tab: TabLayout.Tab) { } override fun onTabUnselected(tab: TabLayout.Tab) { } override fun onTabSelected(tab: TabLayout.Tab) { val dictRule = tab.tag as DictRule binding.rotateLoading.visible() viewModel.dict(dictRule, word!!) { binding.rotateLoading.inVisible() binding.tvDict.setHtml(it) } } }) viewModel.initData { it.forEach { binding.tabLayout.addTab(binding.tabLayout.newTab().apply { text = it.name tag = it }) } setupTabLayoutMode(it.size) } } //根据已启用词典数动态选取布局 private fun setupTabLayoutMode(dictCount: Int) { if (dictCount <= 4) { binding.tabLayout.tabMode = TabLayout.MODE_FIXED binding.tabLayout.tabGravity = TabLayout.GRAVITY_FILL } else { binding.tabLayout.tabMode = TabLayout.MODE_SCROLLABLE binding.tabLayout.tabGravity = TabLayout.GRAVITY_CENTER } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/dict/DictViewModel.kt ================================================ package io.legado.app.ui.dict import android.app.Application import io.legado.app.base.BaseViewModel import io.legado.app.data.appDb import io.legado.app.data.entities.DictRule import io.legado.app.help.coroutine.Coroutine class DictViewModel(application: Application) : BaseViewModel(application) { private var dictJob: Coroutine? = null fun initData(onSuccess: (List) -> Unit) { execute { appDb.dictRuleDao.enabled }.onSuccess { onSuccess.invoke(it) } } fun dict( dictRule: DictRule, word: String, onFinally: (String) -> Unit ) { dictJob?.cancel() dictJob = execute { dictRule.search(word) }.onSuccess { onFinally.invoke(it) }.onError { onFinally.invoke(it.localizedMessage ?: "ERROR") } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/dict/rule/DictRuleActivity.kt ================================================ package io.legado.app.ui.dict.rule import android.annotation.SuppressLint import android.os.Bundle import android.view.Menu import android.view.MenuItem import androidx.activity.viewModels import androidx.appcompat.widget.PopupMenu import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.DictRule import io.legado.app.databinding.ActivityDictRuleBinding import io.legado.app.databinding.DialogEditTextBinding import io.legado.app.help.DirectLinkUpload import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.association.ImportDictRuleDialog import io.legado.app.ui.file.HandleFileContract import io.legado.app.ui.qrcode.QrCodeResult import io.legado.app.ui.widget.SelectActionBar import io.legado.app.ui.widget.recycler.DragSelectTouchHelper import io.legado.app.ui.widget.recycler.ItemTouchCallback import io.legado.app.ui.widget.recycler.VerticalDivider import io.legado.app.utils.ACache import io.legado.app.utils.GSON import io.legado.app.utils.isAbsUrl import io.legado.app.utils.launch import io.legado.app.utils.sendToClip import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.showDialogFragment import io.legado.app.utils.showHelp import io.legado.app.utils.splitNotBlank import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch class DictRuleActivity : VMBaseActivity(), PopupMenu.OnMenuItemClickListener, SelectActionBar.CallBack, DictRuleAdapter.CallBack { override val viewModel by viewModels() override val binding by viewBinding(ActivityDictRuleBinding::inflate) private val importRecordKey = "dictRuleUrls" private val adapter by lazy { DictRuleAdapter(this, this) } private val qrCodeResult = registerForActivityResult(QrCodeResult()) { it ?: return@registerForActivityResult showDialogFragment(ImportDictRuleDialog(it)) } private val importDoc = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> showDialogFragment(ImportDictRuleDialog(uri.toString())) } } private val exportResult = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> alert(R.string.export_success) { if (uri.toString().isAbsUrl()) { setMessage(DirectLinkUpload.getSummary()) } val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = getString(R.string.path) editView.setText(uri.toString()) } customView { alertBinding.root } okButton { sendToClip(uri.toString()) } } } } override fun onActivityCreated(savedInstanceState: Bundle?) { initRecyclerView() initSelectActionView() observeDictRuleData() } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.dict_rule, menu) return super.onCompatCreateOptionsMenu(menu) } private fun initRecyclerView() { binding.recyclerView.setEdgeEffectColor(primaryColor) binding.recyclerView.layoutManager = LinearLayoutManager(this) binding.recyclerView.adapter = adapter binding.recyclerView.addItemDecoration(VerticalDivider(this)) val itemTouchCallback = ItemTouchCallback(adapter) itemTouchCallback.isCanDrag = true val dragSelectTouchHelper: DragSelectTouchHelper = DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50) dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView) // When this page is opened, it is in selection mode dragSelectTouchHelper.activeSlideSelect() // Note: need judge selection first, so add ItemTouchHelper after it. ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView) } private fun initSelectActionView() { binding.selectActionBar.setMainActionText(R.string.delete) binding.selectActionBar.inflateMenu(R.menu.dict_rule_sel) binding.selectActionBar.setOnMenuItemClickListener(this) binding.selectActionBar.setCallBack(this) } private fun observeDictRuleData() { lifecycleScope.launch { appDb.dictRuleDao.flowAll().catch { AppLog.put("字典规则获取数据失败\n${it.localizedMessage}", it) }.flowOn(IO).collect { adapter.setItems(it, adapter.diffItemCallBack) } } } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_add -> showDialogFragment() R.id.menu_import_local -> importDoc.launch { mode = HandleFileContract.FILE allowExtensions = arrayOf("txt", "json") } R.id.menu_import_onLine -> showImportDialog() R.id.menu_import_qr -> qrCodeResult.launch() R.id.menu_import_default -> viewModel.importDefault() R.id.menu_help -> showHelp("dictRuleHelp") } return super.onCompatOptionsItemSelected(item) } override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_enable_selection -> { viewModel.enableSelection(*adapter.selection.toTypedArray()) } R.id.menu_disable_selection -> { viewModel.disableSelection(*adapter.selection.toTypedArray()) } R.id.menu_export_selection -> exportResult.launch { mode = HandleFileContract.EXPORT fileData = HandleFileContract.FileData( "exportDictRule.json", GSON.toJson(adapter.selection).toByteArray(), "application/json" ) } } return true } override fun onClickSelectBarMainAction() { viewModel.delete(*adapter.selection.toTypedArray()) } override fun selectAll(selectAll: Boolean) { if (selectAll) { adapter.selectAll() } else { adapter.revertSelection() } } override fun revertSelection() { adapter.revertSelection() } override fun update(vararg rule: DictRule) { viewModel.update(*rule) } override fun delete(rule: DictRule) { alert(R.string.draw) { setMessage(getString(R.string.sure_del) + "\n" + rule.name) noButton() yesButton { viewModel.delete(rule) } } } override fun edit(rule: DictRule) { showDialogFragment(DictRuleEditDialog(rule.name)) } override fun upOrder() { viewModel.upSortNumber() } override fun upCountView() { binding.selectActionBar.upCountView( adapter.selection.size, adapter.itemCount ) } @SuppressLint("InflateParams") private fun showImportDialog() { val aCache = ACache.get(cacheDir = false) val cacheUrls: MutableList = aCache .getAsString(importRecordKey) ?.splitNotBlank(",") ?.toMutableList() ?: mutableListOf() alert(titleResource = R.string.import_on_line) { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = "url" editView.setFilterValues(cacheUrls) editView.delCallBack = { cacheUrls.remove(it) aCache.put(importRecordKey, cacheUrls.joinToString(",")) } } customView { alertBinding.root } okButton { val text = alertBinding.editView.text?.toString() text?.let { if (it.isAbsUrl() && !cacheUrls.contains(it)) { cacheUrls.add(0, it) aCache.put(importRecordKey, cacheUrls.joinToString(",")) } showDialogFragment( ImportDictRuleDialog(it) ) } } cancelButton() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/dict/rule/DictRuleAdapter.kt ================================================ package io.legado.app.ui.dict.rule import android.content.Context import android.os.Bundle import android.view.ViewGroup import androidx.core.os.bundleOf import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.data.entities.DictRule import io.legado.app.databinding.ItemDictRuleBinding import io.legado.app.lib.theme.backgroundColor import io.legado.app.ui.widget.recycler.DragSelectTouchHelper import io.legado.app.ui.widget.recycler.ItemTouchCallback import io.legado.app.utils.ColorUtils class DictRuleAdapter(context: Context, var callBack: CallBack) : RecyclerAdapter(context), ItemTouchCallback.Callback { private val selected = linkedSetOf() val selection: List get() { return getItems().filter { selected.contains(it) } } val diffItemCallBack = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: DictRule, newItem: DictRule): Boolean { return oldItem.name == newItem.name } override fun areContentsTheSame(oldItem: DictRule, newItem: DictRule): Boolean { if (oldItem.name != newItem.name) { return false } if (oldItem.enabled != newItem.enabled) { return false } return true } override fun getChangePayload(oldItem: DictRule, newItem: DictRule): Any? { val payload = Bundle() if (oldItem.name != newItem.name) { payload.putBoolean("upName", true) } if (oldItem.enabled != newItem.enabled) { payload.putBoolean("enabled", newItem.enabled) } if (payload.isEmpty) { return null } return payload } } fun selectAll() { getItems().forEach { selected.add(it) } notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null))) callBack.upCountView() } fun revertSelection() { getItems().forEach { if (selected.contains(it)) { selected.remove(it) } else { selected.add(it) } } notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null))) callBack.upCountView() } override fun getViewBinding(parent: ViewGroup): ItemDictRuleBinding { return ItemDictRuleBinding.inflate(inflater, parent, false) } override fun onCurrentListChanged() { callBack.upCountView() } override fun convert( holder: ItemViewHolder, binding: ItemDictRuleBinding, item: DictRule, payloads: MutableList ) { binding.run { if (payloads.isEmpty()) { root.setBackgroundColor(ColorUtils.withAlpha(context.backgroundColor, 0.5f)) cbName.text = item.name swtEnabled.isChecked = item.enabled cbName.isChecked = selected.contains(item) } else { for (i in payloads.indices) { val bundle = payloads[i] as Bundle bundle.keySet().forEach { when (it) { "selected" -> cbName.isChecked = selected.contains(item) "upName" -> cbName.text = item.name "enabled" -> swtEnabled.isChecked = item.enabled } } } } } } override fun registerListener(holder: ItemViewHolder, binding: ItemDictRuleBinding) { binding.apply { swtEnabled.setOnUserCheckedChangeListener { isChecked -> getItem(holder.layoutPosition)?.let { it.enabled = isChecked callBack.update(it) } } cbName.setOnClickListener { getItem(holder.layoutPosition)?.let { if (cbName.isChecked) { selected.add(it) } else { selected.remove(it) } } callBack.upCountView() } ivEdit.setOnClickListener { getItem(holder.layoutPosition)?.let { callBack.edit(it) } } ivDelete.setOnClickListener { getItem(holder.layoutPosition)?.let { callBack.delete(it) } } } } override fun swap(srcPosition: Int, targetPosition: Int): Boolean { val srcItem = getItem(srcPosition) val targetItem = getItem(targetPosition) if (srcItem != null && targetItem != null) { if (srcItem.sortNumber == targetItem.sortNumber) { callBack.upOrder() } else { val srcOrder = srcItem.sortNumber srcItem.sortNumber = targetItem.sortNumber targetItem.sortNumber = srcOrder movedItems.add(srcItem) movedItems.add(targetItem) } } swapItem(srcPosition, targetPosition) return true } private val movedItems = linkedSetOf() override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { if (movedItems.isNotEmpty()) { callBack.update(*movedItems.toTypedArray()) movedItems.clear() } } val dragSelectCallback: DragSelectTouchHelper.Callback = object : DragSelectTouchHelper.AdvanceCallback(Mode.ToggleAndReverse) { override fun currentSelectedId(): MutableSet { return selected } override fun getItemId(position: Int): DictRule { return getItem(position)!! } override fun updateSelectState(position: Int, isSelected: Boolean): Boolean { getItem(position)?.let { if (isSelected) { selected.add(it) } else { selected.remove(it) } notifyItemChanged(position, bundleOf(Pair("selected", null))) callBack.upCountView() return true } return false } } interface CallBack { fun update(vararg rule: DictRule) fun delete(rule: DictRule) fun edit(rule: DictRule) fun upOrder() fun upCountView() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/dict/rule/DictRuleEditDialog.kt ================================================ package io.legado.app.ui.dict.rule import android.app.Application import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.fragment.app.viewModels import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.BaseViewModel import io.legado.app.data.appDb import io.legado.app.data.entities.DictRule import io.legado.app.databinding.DialogDictRuleEditBinding import io.legado.app.lib.theme.primaryColor import io.legado.app.utils.* import io.legado.app.utils.viewbindingdelegate.viewBinding class DictRuleEditDialog() : BaseDialogFragment(R.layout.dialog_dict_rule_edit, true), Toolbar.OnMenuItemClickListener { val viewModel by viewModels() val binding by viewBinding(DialogDictRuleEditBinding::bind) constructor(name: String) : this() { arguments = Bundle().apply { putString("name", name) } } override fun onStart() { super.onStart() setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) binding.toolBar.inflateMenu(R.menu.dict_rule_edit) binding.toolBar.menu.applyTint(requireContext()) binding.toolBar.setOnMenuItemClickListener(this) viewModel.initData(arguments?.getString("name")) { upRuleView(viewModel.dictRule) } } override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_save -> viewModel.save(getDictRule()) { dismissAllowingStateLoss() } R.id.menu_copy_rule -> viewModel.copyRule(getDictRule()) R.id.menu_paste_rule -> viewModel.pasteRule { upRuleView(it) } } return true } private fun upRuleView(dictRule: DictRule?) { binding.tvRuleName.setText(dictRule?.name) binding.tvUrlRule.setText(dictRule?.urlRule) binding.tvShowRule.setText(dictRule?.showRule) } private fun getDictRule(): DictRule { val dictRule = viewModel.dictRule?.copy() ?: DictRule() dictRule.name = binding.tvRuleName.text.toString() dictRule.urlRule = binding.tvUrlRule.text.toString() dictRule.showRule = binding.tvShowRule.text.toString() return dictRule } class DictRuleEditViewModel(application: Application) : BaseViewModel(application) { var dictRule: DictRule? = null fun initData(name: String?, onFinally: () -> Unit) { execute { if (dictRule == null && name != null) { dictRule = appDb.dictRuleDao.getByName(name) } }.onFinally { onFinally.invoke() } } fun save(newDictRule: DictRule, onFinally: () -> Unit) { execute { dictRule?.let { appDb.dictRuleDao.delete(it) } appDb.dictRuleDao.insert(newDictRule) dictRule = newDictRule }.onFinally { onFinally.invoke() } } fun copyRule(dictRule: DictRule) { context.sendToClip(GSON.toJson(dictRule)) } fun pasteRule(success: (DictRule) -> Unit) { val text = context.getClipText() if (text.isNullOrBlank()) { context.toastOnUi("剪贴板没有内容") return } execute { GSON.fromJsonObject(text).getOrThrow() }.onSuccess { success.invoke(it) }.onError { context.toastOnUi("格式不对") } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/dict/rule/DictRuleViewModel.kt ================================================ package io.legado.app.ui.dict.rule import android.app.Application import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.DictRule import io.legado.app.help.DefaultData import io.legado.app.utils.toastOnUi class DictRuleViewModel(application: Application) : BaseViewModel(application) { fun update(vararg dictRule: DictRule) { execute { appDb.dictRuleDao.update(*dictRule) }.onError { val msg = "更新字典规则出错\n${it.localizedMessage}" AppLog.put(msg, it) context.toastOnUi(msg) } } fun delete(vararg dictRule: DictRule) { execute { appDb.dictRuleDao.delete(*dictRule) }.onError { val msg = "删除字典规则出错\n${it.localizedMessage}" AppLog.put(msg, it) context.toastOnUi(msg) } } fun upSortNumber() { execute { val rules = appDb.dictRuleDao.all for ((index, rule) in rules.withIndex()) { rule.sortNumber = index + 1 } appDb.dictRuleDao.insert(*rules.toTypedArray()) } } fun enableSelection(vararg dictRule: DictRule) { execute { val array = dictRule.map { it.copy(enabled = true) }.toTypedArray() appDb.dictRuleDao.insert(*array) } } fun disableSelection(vararg dictRule: DictRule) { execute { val array = dictRule.map { it.copy(enabled = false) }.toTypedArray() appDb.dictRuleDao.insert(*array) } } fun importDefault() { execute { DefaultData.importDefaultDictRules() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/file/FileManageActivity.kt ================================================ package io.legado.app.ui.file import android.annotation.SuppressLint import android.os.Bundle import android.view.View import android.view.ViewGroup import android.widget.PopupMenu import androidx.activity.addCallback import androidx.activity.viewModels import androidx.appcompat.widget.SearchView import androidx.core.content.FileProvider import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.constant.AppConst import io.legado.app.databinding.ActivityFileManageBinding import io.legado.app.databinding.ItemFileBinding import io.legado.app.databinding.ItemPathPickerBinding import io.legado.app.lib.theme.primaryTextColor import io.legado.app.ui.file.utils.FilePickerIcon import io.legado.app.ui.widget.recycler.VerticalDivider import io.legado.app.utils.ConvertUtils import io.legado.app.utils.applyNavigationBarPadding import io.legado.app.utils.applyTint import io.legado.app.utils.openFileUri import io.legado.app.utils.viewbindingdelegate.viewBinding import java.io.File class FileManageActivity : VMBaseActivity() { override val binding by viewBinding(ActivityFileManageBinding::inflate) override val viewModel by viewModels() private val dirParent = ".." private val searchView: SearchView by lazy { binding.titleBar.findViewById(R.id.search_view) } private val pathAdapter by lazy { PathAdapter() } private val fileAdapter by lazy { FileAdapter() } private val currentFiles = arrayListOf() override fun onActivityCreated(savedInstanceState: Bundle?) { initView() initSearchView() viewModel.upFiles(viewModel.rootDoc) } private fun initView() { binding.rvPath.layoutManager = LinearLayoutManager(this, RecyclerView.HORIZONTAL, false) binding.rvPath.adapter = pathAdapter binding.recyclerView.layoutManager = LinearLayoutManager(this) binding.recyclerView.addItemDecoration(VerticalDivider(this)) binding.recyclerView.adapter = fileAdapter binding.recyclerView.applyNavigationBarPadding() onBackPressedDispatcher.addCallback(this) { if (viewModel.lastDir != viewModel.rootDoc) { gotoLastDir() return@addCallback } finish() } } private fun initSearchView() { searchView.applyTint(primaryTextColor) searchView.queryHint = getString(R.string.screen) + " • " + getString(R.string.file_manage) searchView.isSubmitButtonEnabled = true searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { return false } override fun onQueryTextChange(newText: String?): Boolean { updateFiles() return false } }) } private fun updateFiles() { if (searchView.query.isNotEmpty()) { currentFiles.filter { it.name == dirParent || it.name.contains(searchView.query) }.let { fileAdapter.setItems(it) } } else { fileAdapter.setItems(currentFiles) } } private fun gotoLastDir() { viewModel.subDocs.removeLastOrNull() pathAdapter.setItems(viewModel.subDocs) viewModel.upFiles(viewModel.lastDir) } override fun observeLiveBus() { viewModel.filesLiveData.observe(this) { searchView.setQuery("", false) currentFiles.clear() currentFiles.addAll(it) updateFiles() } } @SuppressLint("SetTextI18n") inner class PathAdapter : RecyclerAdapter(this@FileManageActivity) { private val arrowIcon = ConvertUtils.toDrawable(FilePickerIcon.getArrow()) init { addHeaderView { ItemPathPickerBinding.inflate(inflater, it, false).apply { textView.text = "root" imageView.setImageDrawable(arrowIcon) root.setOnClickListener { viewModel.subDocs.clear() setItems(viewModel.subDocs) viewModel.upFiles(viewModel.rootDoc) } } } } override fun getViewBinding(parent: ViewGroup): ItemPathPickerBinding { return ItemPathPickerBinding.inflate(inflater, parent, false).apply { imageView.setImageDrawable(arrowIcon) } } override fun registerListener(holder: ItemViewHolder, binding: ItemPathPickerBinding) { binding.root.setOnClickListener { viewModel.subDocs = viewModel.subDocs.subList(0, holder.layoutPosition) setItems(viewModel.subDocs) viewModel.upFiles(viewModel.subDocs.lastOrNull()) } } override fun convert( holder: ItemViewHolder, binding: ItemPathPickerBinding, item: File, payloads: MutableList ) { binding.textView.text = item.name } } inner class FileAdapter : RecyclerAdapter(this@FileManageActivity) { private val upIcon = ConvertUtils.toDrawable(FilePickerIcon.getUpDir())!! private val folderIcon = ConvertUtils.toDrawable(FilePickerIcon.getFolder())!! private val fileIcon = ConvertUtils.toDrawable(FilePickerIcon.getFile())!! override fun getViewBinding(parent: ViewGroup): ItemFileBinding { return ItemFileBinding.inflate(inflater, parent, false) } override fun registerListener(holder: ItemViewHolder, binding: ItemFileBinding) { binding.root.setOnClickListener { val item = getItemByLayoutPosition(holder.layoutPosition) item?.let { if (item == viewModel.lastDir) { gotoLastDir() } else if (item.isDirectory) { viewModel.subDocs.add(item) pathAdapter.setItems(viewModel.subDocs) viewModel.upFiles(item) } else { openFileUri( FileProvider.getUriForFile( this@FileManageActivity, AppConst.authority, item ) ) } } } binding.root.setOnLongClickListener { view -> val item = getItemByLayoutPosition(holder.layoutPosition) if (item == viewModel.lastDir) { return@setOnLongClickListener true } item?.let { showFileMenu(view, item) } return@setOnLongClickListener true } } override fun convert( holder: ItemViewHolder, binding: ItemFileBinding, item: File, payloads: MutableList ) { if (item == viewModel.lastDir) { binding.imageView.setImageDrawable(upIcon) binding.textView.text = dirParent } else if (item.isDirectory) { binding.imageView.setImageDrawable(folderIcon) binding.textView.text = item.name } else { binding.imageView.setImageDrawable(fileIcon) binding.textView.text = item.name } } private fun showFileMenu(view: View, file: File) { val popupMenu = PopupMenu(context, view) popupMenu.inflate(R.menu.file_long_click) popupMenu.setOnMenuItemClickListener { when (it.itemId) { R.id.menu_del -> viewModel.delFile(file) } true } popupMenu.show() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/file/FileManageViewModel.kt ================================================ package io.legado.app.ui.file import android.app.Application import androidx.lifecycle.MutableLiveData import io.legado.app.base.BaseViewModel import io.legado.app.utils.toastOnUi import java.io.File class FileManageViewModel(application: Application) : BaseViewModel(application) { val rootDoc = context.getExternalFilesDir(null)?.parentFile var subDocs = mutableListOf() val filesLiveData = MutableLiveData>() val lastDir: File? get() = subDocs.lastOrNull() ?: rootDoc fun upFiles(parentFile: File?) { execute { parentFile ?: return@execute emptyList() if (parentFile == rootDoc) { parentFile.listFiles()?.sortedWith( compareBy({ it.isFile }, { it.name }) ) } else { val list = arrayListOf(parentFile) parentFile.listFiles()?.sortedWith( compareBy({ it.isFile }, { it.name }) )?.let { list.addAll(it) } list } }.onStart { filesLiveData.postValue(emptyList()) }.onSuccess { filesLiveData.postValue(it ?: emptyList()) }.onError { context.toastOnUi(it.localizedMessage) } } fun delFile(file: File) { execute { file.delete() }.onSuccess { upFiles(lastDir) }.onError { context.toastOnUi(it.localizedMessage) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/file/FilePickerDialog.kt ================================================ package io.legado.app.ui.file import android.annotation.SuppressLint import android.content.DialogInterface import android.content.Intent import android.net.Uri import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.core.content.res.ResourcesCompat import androidx.core.graphics.drawable.DrawableCompat import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.databinding.DialogEditTextBinding import io.legado.app.databinding.DialogFileChooserBinding import io.legado.app.databinding.ItemFilePickerBinding import io.legado.app.databinding.ItemPathPickerBinding import io.legado.app.help.config.AppConfig import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.getPrimaryDisabledTextColor import io.legado.app.lib.theme.getPrimaryTextColor import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.file.HandleFileContract.Companion.FILE import io.legado.app.ui.file.utils.FilePickerIcon import io.legado.app.ui.widget.recycler.VerticalDivider import io.legado.app.utils.ConvertUtils import io.legado.app.utils.FileUtils import io.legado.app.utils.applyTint import io.legado.app.utils.getCompatColor import io.legado.app.utils.setLayout import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding import java.io.File class FilePickerDialog : BaseDialogFragment(R.layout.dialog_file_chooser), Toolbar.OnMenuItemClickListener { companion object { const val tag = "FileChooserDialog" fun show( manager: FragmentManager, mode: Int = FILE, title: String? = null, initPath: String? = null, isShowHideDir: Boolean = false, allowExtensions: Array? = null, ) { FilePickerDialog().apply { val bundle = Bundle() bundle.putInt("mode", mode) bundle.putString("title", title) bundle.putBoolean("isShowHideDir", isShowHideDir) bundle.putString("initPath", initPath) bundle.putStringArray("allowExtensions", allowExtensions) arguments = bundle }.show(manager, tag) } } private val binding by viewBinding(DialogFileChooserBinding::bind) private val viewModel by viewModels() private val dirParent = ".." private val pathAdapter by lazy { PathAdapter() } private val fileAdapter by lazy { FileAdapter() } override fun onStart() { super.onStart() setLayout(0.9f, 0.8f) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) view.setBackgroundResource(R.color.background_card) initMenu() initContentView() viewModel.filesLiveData.observe(viewLifecycleOwner) { fileAdapter.selectFile = null fileAdapter.setItems(it) } viewModel.initData(arguments) binding.toolBar.title = arguments?.getString("title") ?: let { if (viewModel.isSelectDir) { getString(R.string.folder_chooser) } else { getString(R.string.file_chooser) } } } private fun initMenu() { binding.toolBar.inflateMenu(R.menu.file_chooser) binding.toolBar.menu.applyTint(requireContext()) binding.toolBar.setOnMenuItemClickListener(this) } private fun initContentView() { binding.rvPath.layoutManager = LinearLayoutManager(activity, RecyclerView.HORIZONTAL, false) binding.rvPath.adapter = pathAdapter binding.rvFile.addItemDecoration(VerticalDivider(requireContext())) binding.rvFile.layoutManager = LinearLayoutManager(activity) binding.rvFile.adapter = fileAdapter binding.tvOk.setOnClickListener { if (viewModel.isSelectDir) { viewModel.lastDir?.let { setResultData(it.path) dismissAllowingStateLoss() } } else { val file = fileAdapter.selectFile if (file == null) { toastOnUi("请选择文件") } else { setResultData(file.path) dismissAllowingStateLoss() } } } } override fun onMenuItemClick(item: MenuItem?): Boolean { when (item?.itemId) { R.id.menu_create -> alert(R.string.create_folder) { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = "文件夹名" } customView { alertBinding.root } okButton { val text = alertBinding.editView.text?.toString() if (text.isNullOrBlank()) { toastOnUi("文件夹名不能为空") } else { viewModel.createFolder(text.trim()) } } cancelButton() } } return true } private fun setResultData(path: String) { val data = Intent().setData(Uri.fromFile(File(path))) (parentFragment as? CallBack)?.onResult(data) (activity as? CallBack)?.onResult(data) } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) activity?.finish() } @SuppressLint("SetTextI18n") inner class PathAdapter : RecyclerAdapter(requireContext()) { private val arrowIcon = ConvertUtils.toDrawable(FilePickerIcon.getArrow()) init { addHeaderView { ItemPathPickerBinding.inflate(inflater, it, false).apply { textView.text = "root" imageView.setImageDrawable(arrowIcon) root.setOnClickListener { viewModel.subDocs.clear() setItems(viewModel.subDocs) viewModel.upFiles(viewModel.rootDoc) } } } } override fun getViewBinding(parent: ViewGroup): ItemPathPickerBinding { return ItemPathPickerBinding.inflate(inflater, parent, false).apply { imageView.setImageDrawable(arrowIcon) } } override fun registerListener(holder: ItemViewHolder, binding: ItemPathPickerBinding) { binding.root.setOnClickListener { viewModel.subDocs = viewModel.subDocs.subList(0, holder.layoutPosition) setItems(viewModel.subDocs) viewModel.upFiles(viewModel.subDocs.lastOrNull()) } } override fun convert( holder: ItemViewHolder, binding: ItemPathPickerBinding, item: File, payloads: MutableList ) { binding.textView.text = item.name } } inner class FileAdapter : RecyclerAdapter(requireContext()) { private val primaryTextColor = context.getPrimaryTextColor(!AppConfig.isNightTheme) private val disabledTextColor = context.getPrimaryDisabledTextColor(!AppConfig.isNightTheme) private val upIcon = ConvertUtils.toDrawable(FilePickerIcon.getUpDir())!! private val folderIcon = ConvertUtils.toDrawable(FilePickerIcon.getFolder())!! private val fileIcon = ConvertUtils.toDrawable(FilePickerIcon.getFile())!! private val selectDrawable = ResourcesCompat.getDrawable(resources, R.drawable.shape_radius_1dp, null)!!.apply { DrawableCompat.setTint(this, primaryTextColor) } var selectFile: File? = null override fun getViewBinding(parent: ViewGroup): ItemFilePickerBinding { return ItemFilePickerBinding.inflate(inflater, parent, false) } override fun registerListener(holder: ItemViewHolder, binding: ItemFilePickerBinding) { binding.root.setOnClickListener { val item = getItemByLayoutPosition(holder.layoutPosition) item?.let { if (item == viewModel.lastDir) { viewModel.subDocs.removeLastOrNull() pathAdapter.setItems(viewModel.subDocs) viewModel.upFiles(viewModel.subDocs.lastOrNull() ?: viewModel.rootDoc) } else if (item.isDirectory) { viewModel.subDocs.add(item) pathAdapter.setItems(viewModel.subDocs) viewModel.upFiles(item) } else if (viewModel.isSelectFile) { viewModel.allowExtensions.let { if (it.isNullOrEmpty() || it.contains(FileUtils.getExtension(item.path))) { selectFile = item notifyItemRangeChanged(getHeaderCount(), itemCount, "selectFile") } } } } } } override fun convert( holder: ItemViewHolder, binding: ItemFilePickerBinding, item: File, payloads: MutableList ) { if (payloads.isEmpty()) { if (item == viewModel.lastDir) { binding.imageView.setImageDrawable(upIcon) binding.textView.text = dirParent } else if (item.isDirectory) { binding.imageView.setImageDrawable(folderIcon) binding.textView.text = item.name } else { binding.imageView.setImageDrawable(fileIcon) binding.textView.text = item.name } if (item.isDirectory) { binding.textView.setTextColor(primaryTextColor) } else { if (viewModel.isSelectDir) { binding.textView.setTextColor(disabledTextColor) } else { viewModel.allowExtensions?.let { if (it.isEmpty() || it.contains(FileUtils.getExtension(item.path))) { binding.textView.setTextColor(primaryTextColor) } else { binding.textView.setTextColor(disabledTextColor) } } ?: binding.textView.setTextColor(primaryTextColor) } } } binding.root.isSelected = item == selectFile if (item == selectFile) { binding.root.background = selectDrawable } else { binding.root.setBackgroundColor(getCompatColor(R.color.transparent)) } } } interface CallBack { fun onResult(data: Intent) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/file/FilePickerViewModel.kt ================================================ package io.legado.app.ui.file import android.app.Application import android.os.Bundle import android.os.Environment import androidx.lifecycle.MutableLiveData import io.legado.app.base.BaseViewModel import io.legado.app.exception.NoStackTraceException import io.legado.app.utils.toastOnUi import java.io.File class FilePickerViewModel(application: Application) : BaseViewModel(application) { var rootDoc: File? = Environment.getExternalStorageDirectory() var subDocs = mutableListOf() val filesLiveData = MutableLiveData>() var mode: Int = HandleFileContract.FILE var isShowHideDir: Boolean = false var allowExtensions: Array? = null val isSelectDir: Boolean get() = mode == HandleFileContract.DIR val isSelectFile: Boolean get() = mode == HandleFileContract.FILE val lastDir: File? get() = subDocs.lastOrNull() ?: rootDoc fun initData(arguments: Bundle?) { arguments?.let { mode = it.getInt("mode", HandleFileContract.FILE) isShowHideDir = it.getBoolean("isShowHideDir") it.getString("initPath")?.let { path -> rootDoc = File(path) } allowExtensions = it.getStringArray("allowExtensions") } upFiles(rootDoc) } fun upFiles(parentFile: File?) { execute { parentFile ?: return@execute emptyList() if (parentFile == rootDoc) { parentFile.listFiles()?.sortedWith( compareBy({ it.isFile }, { it.name }) ) } else { val list = arrayListOf(parentFile) parentFile.listFiles()?.sortedWith( compareBy({ it.isFile }, { it.name }) )?.let { list.addAll(it) } list } }.onStart { filesLiveData.postValue(emptyList()) }.onSuccess { filesLiveData.postValue(it ?: emptyList()) }.onError { context.toastOnUi(it.localizedMessage) } } fun createFolder(name: String) { execute { val dir = lastDir ?: throw NoStackTraceException("父文件夹不存在") val folder = File(dir, name) if (!folder.canonicalPath.contains(dir.canonicalPath)) { throw NoStackTraceException("非法文件名") } folder.mkdir() }.onSuccess { upFiles(lastDir) }.onError { context.toastOnUi(it.localizedMessage) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/file/HandleFileActivity.kt ================================================ package io.legado.app.ui.file import android.content.Intent import android.net.Uri import android.os.Bundle import android.os.Environment import android.webkit.MimeTypeMap import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.core.net.toUri import androidx.lifecycle.lifecycleScope import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.constant.AppLog import io.legado.app.databinding.ActivityTranslucenceBinding import io.legado.app.databinding.DialogEditTextBinding import io.legado.app.help.IntentData import io.legado.app.lib.dialogs.SelectItem import io.legado.app.lib.dialogs.alert import io.legado.app.lib.permission.Permissions import io.legado.app.lib.permission.PermissionsCompat import io.legado.app.utils.SelectImageContract import io.legado.app.utils.checkWrite import io.legado.app.utils.externalFiles import io.legado.app.utils.getJsonArray import io.legado.app.utils.isContentScheme import io.legado.app.utils.launch import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding import splitties.init.appCtx import java.io.File class HandleFileActivity : VMBaseActivity(), FilePickerDialog.CallBack { override val binding by viewBinding(ActivityTranslucenceBinding::inflate) override val viewModel by viewModels() private var mode = 0 private val selectDocTree = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> uri?.let { if (uri.isContentScheme()) { val modeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION contentResolver.takePersistableUriPermission(uri, modeFlags) } onResult(Intent().setData(uri)) } ?: finish() } private val selectDoc = registerForActivityResult(ActivityResultContracts.OpenDocument()) { it?.let { if (it.isContentScheme()) { val modeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION contentResolver.takePersistableUriPermission(it, modeFlags) } onResult(Intent().setData(it)) } ?: finish() } private val selectImage = registerForActivityResult(SelectImageContract()) { it.uri?.let { uri -> onResult(Intent().setData(uri)) } ?: finish() } override fun onActivityCreated(savedInstanceState: Bundle?) { mode = intent.getIntExtra("mode", 0) viewModel.errorLiveData.observe(this) { toastOnUi(it) finish() } val allowExtensions = intent.getStringArrayExtra("allowExtensions") val selectList: ArrayList> = when (mode) { HandleFileContract.DIR_SYS -> getDirActions(true) HandleFileContract.DIR -> getDirActions() HandleFileContract.FILE -> getFileActions() HandleFileContract.EXPORT -> arrayListOf( SelectItem(getString(R.string.upload_url), 111) ).apply { addAll(getDirActions()) } HandleFileContract.IMAGE -> getImageActions() else -> arrayListOf() } intent.getJsonArray>("otherActions")?.let { selectList.addAll(it) } val title = intent.getStringExtra("title") ?: let { when (mode) { HandleFileContract.EXPORT -> return@let getString(R.string.export) HandleFileContract.DIR -> return@let getString(R.string.select_folder) HandleFileContract.IMAGE -> return@let getString(R.string.select_image) else -> return@let getString(R.string.select_file) } } alert(title) { items(selectList) { _, item, _ -> when (item.value) { HandleFileContract.DIR -> kotlin.runCatching { selectDocTree.launch() }.onFailure { AppLog.put(getString(R.string.open_sys_dir_picker_error), it, true) checkPermissions { FilePickerDialog.show( supportFragmentManager, mode = HandleFileContract.DIR ) } } HandleFileContract.FILE -> kotlin.runCatching { selectDoc.launch(typesOfExtensions(allowExtensions)) }.onFailure { AppLog.put(getString(R.string.open_sys_dir_picker_error), it, true) checkPermissions { FilePickerDialog.show( supportFragmentManager, mode = HandleFileContract.FILE, allowExtensions = allowExtensions ) } } HandleFileContract.IMAGE -> { selectImage.launch() } 10 -> checkPermissions { @Suppress("DEPRECATION") lifecycleScope.launchWhenResumed { FilePickerDialog.show( supportFragmentManager, mode = HandleFileContract.DIR ) } } 11 -> checkPermissions { @Suppress("DEPRECATION") lifecycleScope.launchWhenResumed { FilePickerDialog.show( supportFragmentManager, mode = HandleFileContract.FILE, allowExtensions = allowExtensions ) } } 111 -> getFileData()?.let { viewModel.upload(it.first, it.second, it.third) { url -> val uri = url.toUri() setResult(RESULT_OK, Intent().setData(uri)) finish() } } 112 -> checkPermissions { // 手动输入目录路径 showInputDirectoryDialog() } else -> { val path = item.title val uri = if (path.isContentScheme()) { path.toUri() } else { Uri.fromFile(File(path)) } onResult(Intent().setData(uri)) } } } onCancelled { finish() } } } private fun showInputDirectoryDialog() { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = getString(R.string.enter_directory_path) } alert(getString(R.string.manual_input)) { customView { alertBinding.root } okButton { val inputPath = alertBinding.editView.text.toString() if (inputPath.isBlank()) { toastOnUi(getString(R.string.empty_directory_input)) return@okButton } val file = File(inputPath) if (file.exists() && file.isDirectory && isExternalStorage(file) && file.checkWrite() ) { onResult(Intent().setData(Uri.fromFile(file))) } else { toastOnUi(getString(R.string.invalid_directory)) } } onDismiss { finish() } cancelButton() } } private fun isExternalStorage(path: File): Boolean { if (path.canonicalPath.startsWith(appCtx.externalFiles.parent!!)) { return false } try { if (Environment.isExternalStorageEmulated(path)) { return true } } catch (_: IllegalArgumentException) { } try { if (Environment.isExternalStorageRemovable(path)) { return true } } catch (_: IllegalArgumentException) { } return false } private fun getFileData(): Triple? { val fileName = intent.getStringExtra("fileName") val file = intent.getStringExtra("fileKey")?.let { IntentData.get(it) } val contentType = intent.getStringExtra("contentType") if (fileName != null && file != null && contentType != null) { return Triple(fileName, file, contentType) } return null } private fun getDirActions(onlySys: Boolean = false): ArrayList> { return if (onlySys) { arrayListOf( SelectItem(getString(R.string.sys_folder_picker), HandleFileContract.DIR), SelectItem(getString(R.string.manual_input), 112) // 添加手动输入选项 ) } else { arrayListOf( SelectItem(getString(R.string.sys_folder_picker), HandleFileContract.DIR), SelectItem(getString(R.string.app_folder_picker), 10), SelectItem(getString(R.string.manual_input), 112) // 添加手动输入选项 ) } } private fun getFileActions(): ArrayList> { return arrayListOf( SelectItem(getString(R.string.sys_file_picker), HandleFileContract.FILE), SelectItem(getString(R.string.app_file_picker), 11) ) } private fun getImageActions(): ArrayList> { return arrayListOf( SelectItem(getString(R.string.sys_image_picker), HandleFileContract.IMAGE) ).apply { addAll(getFileActions()) } } private fun checkPermissions(success: (() -> Unit)? = null) { PermissionsCompat.Builder() .addPermissions(*Permissions.Group.STORAGE) .rationale(R.string.tip_perm_request_storage) .onGranted { success?.invoke() } .onDenied { finish() } .onError { finish() } .request() } private fun typesOfExtensions(allowExtensions: Array?): Array { val types = hashSetOf() if (allowExtensions.isNullOrEmpty()) { types.add("*/*") } else { allowExtensions.forEach { when (it) { "*" -> types.add("*/*") "txt", "xml" -> types.add("text/*") else -> { val mime = MimeTypeMap.getSingleton() .getMimeTypeFromExtension(it) ?: "application/octet-stream" types.add(mime) } } } } return types.toTypedArray() } override fun onResult(data: Intent) { val uri = data.data uri ?: let { finish() return } if (mode == HandleFileContract.EXPORT) { getFileData()?.let { fileData -> viewModel.saveToLocal(uri, fileData.first, fileData.second) { savedUri -> setResult(RESULT_OK, Intent().setData(savedUri)) finish() } } } else { data.putExtra("value", intent.getStringExtra("value")) setResult(RESULT_OK, data) finish() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/file/HandleFileContract.kt ================================================ package io.legado.app.ui.file import android.app.Activity.RESULT_OK import android.content.Context import android.content.Intent import android.net.Uri import androidx.activity.result.contract.ActivityResultContract import io.legado.app.help.IntentData import io.legado.app.lib.dialogs.SelectItem import io.legado.app.utils.RealPathUtil import io.legado.app.utils.externalFiles import io.legado.app.utils.putJson import splitties.init.appCtx @Suppress("unused") class HandleFileContract : ActivityResultContract<(HandleFileContract.HandleFileParam.() -> Unit)?, HandleFileContract.Result>() { private var requestCode: Int = 0 override fun createIntent(context: Context, input: (HandleFileParam.() -> Unit)?): Intent { val intent = Intent(context, HandleFileActivity::class.java) val handleFileParam = HandleFileParam() input?.let { handleFileParam.apply(input) } if (handleFileParam.mode == IMAGE) { handleFileParam.allowExtensions = arrayOf("jpg", "png", "bmp", "webp") } handleFileParam.let { requestCode = it.requestCode intent.putExtra("mode", it.mode) intent.putExtra("title", it.title) intent.putExtra("allowExtensions", it.allowExtensions) intent.putJson("otherActions", it.otherActions) it.fileData?.let { fileData -> intent.putExtra("fileName", fileData.name) intent.putExtra("fileKey", IntentData.put(fileData.data)) intent.putExtra("contentType", fileData.type) } intent.putExtra("value", it.value) } return intent } override fun parseResult(resultCode: Int, intent: Intent?): Result { val uri = if (resultCode != RESULT_OK || intent?.data == null || RealPathUtil.getTreePath(intent.data!!) ?.startsWith(appCtx.externalFiles.parent!!) == true ) { null } else { intent.data } return Result(uri, requestCode, intent?.getStringExtra("value")) } companion object { const val DIR = 0 const val FILE = 1 const val DIR_SYS = 2 const val EXPORT = 3 const val IMAGE = 4 } @Suppress("ArrayInDataClass") data class HandleFileParam( var mode: Int = DIR, var title: String? = null, var allowExtensions: Array = arrayOf(), var otherActions: ArrayList>? = null, var fileData: FileData? = null, var requestCode: Int = 0, var value: String? = null ) data class Result( val uri: Uri?, val requestCode: Int, val value: String? ) data class FileData( val name: String, val data: Any, val type: String ) } ================================================ FILE: app/src/main/java/io/legado/app/ui/file/HandleFileViewModel.kt ================================================ package io.legado.app.ui.file import android.app.Application import android.net.Uri import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.MutableLiveData import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppLog import io.legado.app.help.DirectLinkUpload import io.legado.app.utils.* import java.io.File class HandleFileViewModel(application: Application) : BaseViewModel(application) { val errorLiveData = MutableLiveData() fun upload( fileName: String, file: Any, contentType: String, success: (url: String) -> Unit ) { execute { DirectLinkUpload.upLoad(fileName, file, contentType) }.onSuccess { success.invoke(it) }.onError { AppLog.put("上传文件失败\n${it.localizedMessage}", it) it.printOnDebug() errorLiveData.postValue(it.localizedMessage) } } fun saveToLocal(uri: Uri, fileName: String, data: Any, success: (uri: Uri) -> Unit) { execute { val bytes = when (data) { is File -> data.readBytes() is ByteArray -> data is String -> data.toByteArray() else -> GSON.toJson(data).toByteArray() } return@execute if (uri.isContentScheme()) { val doc = DocumentFile.fromTreeUri(context, uri)!! doc.findFile(fileName)?.delete() val newDoc = doc.createFile("", fileName) newDoc!!.writeBytes(context, bytes) newDoc.uri } else { val file = File(uri.path ?: uri.toString()) val newFile = FileUtils.createFileIfNotExist(file, fileName) newFile.writeBytes(bytes) Uri.fromFile(newFile) } }.onError { it.printOnDebug() errorLiveData.postValue(it.localizedMessage) }.onSuccess { success.invoke(it) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/file/utils/FilePickerIcon.java ================================================ package io.legado.app.ui.file.utils; /** * Generated by https://github.com/gzu-liyujiang/Image2ByteVar * * @author 李玉江[QQ:1023694760] * @since 2017/01/04 06:03 */ public class FilePickerIcon { public static byte[] getFile() { return FILE; } public static byte[] getFolder() { return FOLDER; } public static byte[] getHome() { return HOME; } public static byte[] getUpDir() { return UPDIR; } public static byte[] getArrow() { return ARROW; } // fixed: 17-1-7 "static final" arrays should be "private" private static final byte[] FILE = { -119, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 42, 0, 0, 0, 40, 8, 6, 0, 0, 0, -120, 11, 104, 80, 0, 0, 0, 6, 98, 75, 71, 68, 0, -1, 0, -1, 0, -1, -96, -67, -89, -109, 0, 0, 0, 9, 112, 72, 89, 115, 0, 0, 14, -60, 0, 0, 14, -60, 1, -107, 43, 14, 27, 0, 0, 1, -73, 73, 68, 65, 84, 88, -123, -19, -106, 63, 75, 3, 49, 24, -121, 127, -74, 69, -37, 110, -30, -94, -109, -120, -126, -125, -101, 56, 72, 63, -125, -97, -64, -63, 77, 28, 69, 20, 81, 68, 113, 83, 113, -12, 91, -120, -96, -117, -120, -85, 31, 65, 29, 28, -100, -124, -94, -101, 46, -83, -105, 92, 114, 113, -24, 31, -38, -110, 59, -34, -9, 114, 87, 69, -14, 64, 41, -92, 73, -34, -89, -55, -17, 114, 1, 60, 30, -113, -25, 79, 50, 66, -19, 120, -7, 16, 26, 0, -112, 82, -61, 24, 3, 41, 35, 0, -128, 20, 10, 74, 69, -48, 58, -126, 82, -90, -37, -90, -75, -127, 82, 81, 119, 124, 16, 40, -100, 109, 79, -109, -21, 13, 82, -94, 118, -108, 82, 99, -91, 54, -58, 25, 2, 0, 120, -85, 7, -72, -71, 127, -57, -18, -58, 12, -102, -51, 87, 115, 113, 56, -105, 74, -74, 64, -19, 104, -116, 73, 51, 63, 0, -96, 88, 104, -107, 57, -34, -102, -59, -6, -63, 75, -86, -119, -56, -94, -99, -83, -26, -96, 123, -122, 20, -37, -107, 78, -9, -25, -79, -74, -13, -52, -106, 37, -117, 114, -23, 72, 42, 109, -96, -93, 8, 66, 0, -97, 95, -83, -49, -55, -34, 2, 86, 55, 31, 89, -78, -12, -116, 10, -59, 18, -20, 48, 90, -86, -96, -47, -48, -72, -66, -5, 64, -40, -98, -94, 90, -27, -81, 15, 89, -76, -9, 9, -114, 99, 80, 18, 0, -90, 38, -127, -38, -46, 4, -76, 78, -97, 113, -128, 33, 42, -124, 78, -4, -35, 38, -87, -62, -42, -9, -14, -30, 120, 127, -69, 6, -82, 110, -21, -44, -46, 0, -72, 103, 77, 12, 73, -110, 125, 109, -55, -1, 53, 17, 86, 70, 109, 66, 113, -72, 72, -39, 32, -89, -38, 53, 99, -82, 100, -14, 48, -39, -74, 57, 107, -100, -49, -47, -84, -77, 24, 7, 121, 69, -125, -64, 126, -114, 82, -91, -66, 3, 106, 37, 59, -12, 21, 85, -46, -87, 80, -91, -20, 52, -100, 46, -38, -108, -87, 111, 104, -103, -64, 58, 71, -121, -15, -48, -60, -63, -54, -24, -80, -14, 104, 35, -73, -37, 83, -42, -112, 69, 5, -15, -10, -108, 23, -12, 3, 63, -92, -65, 63, 43, 101, -5, -10, 7, 14, -111, 112, -66, -108, -28, -111, 71, 27, -1, 47, -93, -65, 77, 38, -9, 81, 27, 46, 121, -76, -63, 18, 29, 86, 30, 109, -80, 68, -113, -50, -97, -14, -14, -16, 120, 60, -98, 1, 126, 0, -110, 81, -78, -5, 36, 19, -64, 3, 0, 0, 0, 0, 73, 69, 78, 68, -82, 66, 96, -126}; private static final byte[] FOLDER = { -119, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 42, 0, 0, 0, 40, 8, 6, 0, 0, 0, -120, 11, 104, 80, 0, 0, 0, 6, 98, 75, 71, 68, 0, -1, 0, -1, 0, -1, -96, -67, -89, -109, 0, 0, 0, 9, 112, 72, 89, 115, 0, 0, 14, -60, 0, 0, 14, -60, 1, -107, 43, 14, 27, 0, 0, 2, -102, 73, 68, 65, 84, 88, -123, -19, -105, 61, -113, 19, 49, 16, -122, 95, 111, -110, 75, -124, -124, -60, -49, 65, 20, -48, 34, 81, 80, 32, 81, 83, 80, -94, 52, 72, 8, 78, 66, -94, 64, 20, -108, 124, 20, 39, -47, 64, -51, -11, -7, 87, -96, 11, -38, -75, 61, 99, 15, -59, 110, 54, 118, -42, -5, 113, -71, 77, 117, -5, 54, -55, -50, -38, -42, -77, -81, 61, 99, 27, -104, 52, 105, -46, -92, 73, -73, 66, 106, 104, -61, -51, 57, -92, -21, -3, -29, 79, -61, -57, 58, 70, -13, 33, -115, 54, -25, -112, 71, -17, 127, 0, 0, -106, -38, -105, 65, 93, 64, -56, 0, 108, 33, 76, -72, -28, -113, -14, -20, -77, 59, 25, 108, 54, 4, -14, -63, -6, 59, -112, 123, -84, -4, -84, -114, -117, 39, 96, -74, -17, -2, -12, -59, 91, 92, -66, -103, 117, -70, 126, 19, 117, 58, -80, 57, -121, 60, 92, 127, 5, 0, -84, -106, -53, -3, 11, 93, -108, -96, 0, 96, 52, -124, 9, 96, 6, 116, -127, -33, -65, -66, -44, -51, 56, -117, -121, -73, 62, 3, -7, 56, -74, 123, 126, -11, -83, -24, 100, -23, -99, -6, -43, -67, -5, -119, -96, -119, -66, 80, 1, -128, 49, 0, -128, -25, 31, -98, -108, 65, 103, -93, 46, -62, -36, 24, -58, -27, -37, -6, -65, -39, -66, -108, -41, 63, -13, 86, -40, 65, 107, 84, -7, 43, 64, -103, 70, 92, 108, 17, -45, 82, -94, -115, -47, 77, -40, -22, 35, -108, 53, 32, 34, 56, 34, 112, -49, -14, 30, 4, 90, 66, -2, -119, 99, -34, 2, -69, -23, -33, -119, -10, -32, 18, -66, -37, -63, 114, 16, 99, -122, 2, -64, 87, 127, 97, -56, -61, -53, 24, -96, -121, -14, -107, 35, 103, 11, -120, -91, 116, 27, -25, -45, -15, 96, 9, 80, -95, 97, -56, -61, 114, 127, 14, -10, 102, 125, 27, 36, -128, 86, -56, -70, 34, -52, 50, -128, 109, -78, 77, 40, 118, -82, -73, 77, -65, -93, 98, 26, -128, -111, -62, 41, 62, -101, 67, 116, -111, 110, -41, 34, -53, 2, -26, 22, -9, 3, 29, 53, -11, -111, -109, -39, -94, -4, -83, 0, 85, -74, -120, -41, -25, 72, -22, 4, -27, -86, -58, -119, 45, -102, -119, 3, 52, -36, -124, 61, 40, 65, 81, -58, 55, -5, -117, 99, -80, 115, -89, 115, 52, 9, 93, 65, -90, -36, 76, 65, 94, 87, -67, -96, 74, -52, -2, 52, -46, 54, -91, -95, -109, -119, 108, 87, -13, -59, 126, -9, 74, 40, -109, 27, 58, 106, 124, 6, -79, 40, -117, -7, 33, 100, 0, 23, -71, -72, -37, -1, -85, 105, 31, -61, 77, 96, -24, -44, -109, 1, 40, -19, 70, 50, 113, -126, -75, -39, -22, -26, 53, -85, -61, 113, 89, 127, 8, 23, 78, 119, 80, 55, -5, -36, -12, 117, 34, -11, 23, -4, 78, 80, -14, 10, 112, 22, -30, 92, -5, -6, 28, 2, 25, -70, -103, 112, -46, -75, -19, 98, 67, 65, 57, 60, -110, -11, 13, 118, 4, -92, -9, 2, 79, 4, -94, 17, 118, 38, 97, 6, -104, 64, -108, -34, -103, -124, -35, -63, 115, -20, -68, -46, 58, 124, 2, 0, 56, 91, -18, 118, -123, -74, -48, 122, 88, -78, 117, -126, 90, 95, 102, -80, 49, 5, -88, 26, 80, -101, -46, 33, 83, -24, -42, 126, 53, 116, 42, 97, -104, -22, 2, -97, 111, 115, 108, -13, 17, 64, 1, -64, 85, 103, 71, 50, 84, 1, -106, 110, 88, 106, 95, 10, 93, -5, -67, -81, 62, -44, 22, 26, 84, 1, -33, -67, -77, -24, 5, -19, -67, -116, 93, -84, 87, 2, 32, -70, 66, -104, -32, -112, -53, 94, -63, -113, 112, 1, 125, 119, -15, -17, -92, -73, -40, 73, -109, 38, 77, -70, -19, -6, 15, -2, -54, -98, -96, -19, -118, -95, -10, 0, 0, 0, 0, 73, 69, 78, 68, -82, 66, 96, -126}; private static final byte[] HOME = { -119, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 42, 0, 0, 0, 40, 8, 4, 0, 0, 0, 34, 2, -96, -37, 0, 0, 0, 1, 115, 82, 71, 66, 0, -82, -50, 28, -23, 0, 0, 0, 2, 98, 75, 71, 68, 0, -1, -121, -113, -52, -65, 0, 0, 0, 9, 112, 72, 89, 115, 0, 0, 46, 35, 0, 0, 46, 35, 1, 120, -91, 63, 118, 0, 0, 0, 7, 116, 73, 77, 69, 7, -37, 8, 4, 10, 36, 16, -22, -9, -18, -50, 0, 0, 2, -84, 73, 68, 65, 84, 72, -57, -19, -106, 91, 72, 20, 97, 20, -57, 127, -77, -18, -106, -41, 54, -14, -106, -23, -102, 4, 21, 97, 5, 5, 61, 70, 74, -76, -122, -94, -11, 18, -108, -92, 121, 41, 16, -124, 96, -95, 48, -118, -108, -94, 18, -70, 96, 33, 68, -81, -127, 15, 25, 68, 15, 21, 89, 80, 42, 25, 42, -91, 97, -106, -103, -122, 107, 41, -19, -86, -37, -22, 122, -39, 77, -73, -103, -81, 7, -105, 84, -36, -85, -26, -125, -44, -127, -31, 59, -52, -103, -7, 113, -50, -103, -17, -4, -65, -127, -1, -74, 44, 76, -102, 113, -29, 22, -119, 50, -3, -15, 84, 75, -111, -87, -38, -81, -89, 84, 24, -120, 1, -96, -106, -102, -65, 83, -66, -118, 59, -119, 39, 54, 97, 69, -31, -109, 115, -14, 8, 15, 124, -107, -17, 27, 42, 113, 123, 125, 81, 46, 102, 28, 8, -84, -68, 116, 78, -26, 80, 77, 5, -79, 104, -48, -48, 70, -39, 124, -88, -38, 103, 37, -107, -70, -94, 28, -6, 105, 7, 34, 89, 67, -86, -90, -74, 106, 82, -51, 65, 125, -110, 13, 27, -99, 43, 2, -17, -87, -60, -51, -124, -30, 99, -104, -88, -63, -12, 20, -93, -90, 96, 111, -80, -106, 20, 117, -3, -35, -97, -65, 54, -13, 29, 21, 8, -9, -3, -14, -122, -68, 17, 127, 50, 15, 51, -49, 49, 85, -109, 73, -79, 51, -81, 94, -103, 96, 21, 123, -126, 66, 86, 10, 64, 4, 12, -107, -72, -70, -50, -112, -57, 0, 47, -24, 123, 66, 46, 50, 80, -19, 40, 121, -59, 20, -31, -20, 102, 0, 121, 1, -48, -14, -72, 83, -7, 12, 81, -121, -79, -106, 67, 76, -71, -18, 94, -73, 85, 54, 34, 19, -122, 115, -102, 23, 16, -12, 114, 92, 73, 1, 22, 26, -24, 110, -26, 0, -114, 89, 17, -61, -32, -61, 22, 4, 2, -127, -30, -31, 125, -9, -48, -117, -79, 103, -13, -79, -48, 68, -57, 123, -46, 25, -101, 19, -109, 57, -38, -41, -40, -127, 64, 16, -127, 70, -113, -34, 63, 104, 105, -52, -7, 2, -84, -76, -48, -42, 69, 26, -42, 121, 113, 59, 89, 93, -35, -67, 8, -126, -39, -87, 81, -35, 103, -69, 111, -24, -71, -24, 11, -123, 12, -45, -58, -37, -81, -20, -61, -20, -74, 18, 11, -23, -19, -125, 102, 20, -76, 36, 107, 121, 76, -68, 119, -24, -103, -88, 75, -123, -116, -16, -111, 38, 51, 122, -66, 121, -4, -116, 95, 68, -42, 59, -5, 8, -126, 24, 54, -24, 120, 68, -124, 103, -24, -23, -88, -14, -29, -40, -24, -28, -75, 85, -92, -47, -27, 117, 48, -102, -27, -20, 86, 121, 28, 5, 29, 107, 119, 112, 111, -10, 24, 5, -51, -72, 17, -122, -32, 107, -39, -110, -99, 30, -22, -58, -108, -3, -76, -6, 20, -93, -49, -78, -59, -102, 17, -50, 40, -126, -47, -115, 66, 59, 94, -29, 78, 80, -86, -40, 74, -108, 20, -119, 34, 50, -88, -13, 83, 58, -81, -112, 73, 47, 70, -116, 52, -104, -34, 120, 86, 41, -119, 4, 10, 1, 24, -26, -106, 71, 88, 10, 41, 0, 60, -93, -47, 31, -107, 18, -124, -123, -106, -19, -30, 7, 31, 122, -68, 64, 83, 19, 75, -75, -12, 51, 108, -101, -127, -6, -40, -4, 97, -92, -110, -20, 67, 18, -109, -40, -58, 106, -113, -86, -66, 96, 83, -36, 15, -2, 34, -96, -110, 88, 8, 84, -8, 60, -37, 60, 67, -43, -18, -127, 2, 1, 26, -74, 120, -124, 70, 11, 20, -108, 64, -113, 104, -119, 88, -99, -24, 112, -105, -71, 112, 117, -44, 22, 88, -90, 32, 8, -27, 48, 33, 76, 119, 78, -52, 89, 101, 20, -100, -12, 49, 21, 24, 84, 97, 12, 59, 19, 46, -44, -36, 75, 65, 70, 70, 118, -83, -2, 67, 101, -21, 80, -123, -65, -69, -64, -79, -68, 127, -48, -106, 4, -6, -113, -37, 111, 38, -57, 11, 112, 71, 102, 113, -50, 0, 0, 0, 0, 73, 69, 78, 68, -82, 66, 96, -126}; private static final byte[] UPDIR = { -119, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 42, 0, 0, 0, 40, 8, 6, 0, 0, 0, -120, 11, 104, 80, 0, 0, 0, 6, 98, 75, 71, 68, 0, -1, 0, -1, 0, -1, -96, -67, -89, -109, 0, 0, 0, 9, 112, 72, 89, 115, 0, 0, 14, -60, 0, 0, 14, -60, 1, -107, 43, 14, 27, 0, 0, 2, -111, 73, 68, 65, 84, 88, -123, -19, -106, -51, 74, -21, 64, 20, -57, -1, -87, -47, 7, -15, 41, 92, 118, -47, 82, 10, -59, 110, 20, -95, 32, 77, -101, 44, -92, -37, -66, 80, -23, 3, 8, -126, -76, -48, -115, -120, 43, 117, -19, -109, -44, -113, -50, -57, -103, 115, 87, 103, 76, 106, -117, 77, 19, -17, -107, 75, -2, 48, -52, -112, 100, 38, -65, 57, -25, -52, 57, 3, 84, -86, 84, -87, -46, -1, -83, 36, 73, 56, 73, 18, -2, -87, -11, -61, 50, 22, -119, -29, -104, 47, 47, 47, 97, -116, -127, -75, -106, 39, -109, 73, 80, -58, -70, 105, 21, 94, 48, -114, 99, 78, -110, 4, 68, 4, -91, 20, -116, 49, 120, 121, 121, -63, 120, 60, 46, 21, -74, 86, 100, -14, 112, 56, -28, -85, -85, 43, 0, -128, -75, 22, -50, 57, 48, 51, -114, -113, -113, -47, -21, -11, 74, 13, -125, -67, 65, -121, -61, 33, -113, 70, 35, 4, 65, 0, 99, -116, 111, -42, 90, 16, 17, 58, -99, 14, 46, 46, 46, 74, -125, -35, 11, 84, 44, -23, -100, -61, -57, -57, 7, 86, -85, 21, -116, 49, 32, 34, 111, 89, 107, 45, 90, -83, 22, -50, -50, -50, 74, -127, -51, 125, -104, 6, -125, 1, -57, 113, -20, -83, -89, -108, -126, -75, 22, 0, -32, -100, -13, 45, 8, 2, 48, 51, -102, -51, 38, -100, 115, 124, 125, 125, 93, 40, 102, 115, -127, 14, 6, 3, -114, -94, -56, -69, 87, 107, 13, 34, 2, 0, -1, 44, 8, -78, 60, 68, -124, 70, -93, 1, 34, -30, -101, -101, -101, -67, 97, 119, 6, -115, -94, -120, 123, -67, -98, -121, 19, 43, 10, -88, -12, -58, 24, 56, -25, -4, 55, -78, -127, 122, -67, 14, 34, -30, -37, -37, -37, -67, 96, 119, -102, 20, 69, 17, 119, -69, 93, 15, 98, -83, -59, -63, -63, 1, 0, 32, 12, -61, 12, -88, -124, -61, -37, -37, 27, -34, -33, -33, -3, -122, 68, 15, 15, 15, -104, -49, -25, -71, 97, -65, -99, -48, -17, -9, -71, -35, 110, -5, 83, 45, 58, 58, 58, -14, -112, 34, -79, -30, -21, -21, 43, -106, -53, 37, -76, -42, 0, 0, -26, -49, -13, 20, -122, 33, -18, -17, -17, -79, 88, 44, 114, -63, 126, -21, 122, 102, -58, 108, 54, -13, 22, 19, 32, 0, 56, 61, 61, -3, -14, 76, 107, -115, -43, 106, 5, 34, -62, -45, -45, 83, -26, -35, -6, -72, 84, -48, -23, 116, -70, 113, -25, -25, -25, -25, -20, -100, -53, -4, 60, 29, -109, 114, -6, 103, -77, 89, 41, 21, 106, -17, 90, 47, -112, 2, 8, -64, 3, -118, -85, -45, -33, 20, -43, -34, -96, -52, -100, 57, -35, -64, 103, -116, -82, 3, -1, 83, 80, -87, 62, 2, -72, -98, 79, 5, -74, 44, -107, 2, 42, 112, 0, 50, -15, -7, 43, 64, -103, 121, 35, -24, -81, 118, -67, -28, -41, -76, -53, -91, -43, -21, 117, -106, 119, -23, 94, -58, -113, -113, -113, 59, 101, -123, -62, -96, -101, -54, -87, 88, -14, -28, -28, 4, -121, -121, -121, 25, 48, -71, 35, 40, -91, -16, -4, -4, -68, -13, -1, 10, -71, 62, 125, -75, 91, 7, -83, -43, 106, 30, 74, 42, -104, -28, 89, -83, 53, -76, -42, -71, 98, -72, -112, 69, -91, 68, 110, -69, -96, -92, 123, -7, 70, 41, 5, -91, 84, -18, 10, 85, 10, 104, 58, 70, -45, 125, -6, -80, 73, 47, 22, -51, -85, 66, -82, 87, 74, 1, -128, -17, 55, -127, 109, 2, -1, -85, -96, 98, -47, -76, 91, -73, 1, -82, -113, 1, -8, 107, -30, -113, -125, -34, -35, -35, 5, 68, -28, 19, -27, -74, -12, 83, 102, -46, -81, 84, -87, 82, -91, 95, -88, 63, 49, -122, -88, 68, 127, -55, -90, 73, 0, 0, 0, 0, 73, 69, 78, 68, -82, 66, 96, -126}; private static final byte[] ARROW = {-119, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 48, 0, 0, 0, 117, 8, 3, 0, 0, 0, 63, 73, -110, 106, 0, 0, 2, -9, 80, 76, 84, 69, -46, -46, -46, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -65, -65, -65, 0, 0, 0, -52, -52, -52, -49, -49, -49, -47, -47, -47, -47, -47, -47, -48, -48, -48, -54, -54, -54, -48, -48, -48, -48, -48, -48, -47, -47, -47, -47, -47, -47, -49, -49, -49, -86, -86, -86, -48, -48, -48, -48, -48, -48, -47, -47, -47, -52, -52, -52, -49, -49, -49, -48, -48, -48, -48, -48, -48, -1, -1, -1, -49, -49, -49, -48, -48, -48, -48, -48, -48, -51, -51, -51, -50, -50, -50, -48, -48, -48, -48, -48, -48, -50, -50, -50, -48, -48, -48, -48, -48, -48, -48, -48, -48, -52, -52, -52, -48, -48, -48, -48, -48, -48, -48, -48, -48, -46, -46, -46, -47, -47, -47, -52, -52, -52, -48, -48, -48, -48, -48, -48, -1, -1, -1, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -41, -41, -41, -48, -48, -48, -49, -49, -49, -43, -43, -43, -47, -47, -47, -49, -49, -49, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -49, -49, -49, -47, -47, -47, -49, -49, -49, -47, -47, -47, -48, -48, -48, -48, -48, -48, -48, -48, -48, -49, -49, -49, -47, -47, -47, -44, -44, -44, -48, -48, -48, -48, -48, -48, -1, -1, -1, -46, -46, -46, -48, -48, -48, -49, -49, -49, -47, -47, -47, -49, -49, -49, -35, -35, -35, -49, -49, -49, -49, -49, -49, -49, -49, -49, -47, -47, -47, -40, -40, -40, -49, -49, -49, -48, -48, -48, -48, -48, -48, -47, -47, -47, -47, -47, -47, -47, -47, -47, -48, -48, -48, -60, -60, -60, -53, -53, -53, -48, -48, -48, -46, -46, -46, -65, -65, -65, -48, -48, -48, -54, -54, -54, -45, -45, -45, -47, -47, -47, -49, -49, -49, -48, -48, -48, -49, -49, -49, -51, -51, -51, -48, -48, -48, -48, -48, -48, -46, -46, -46, -47, -47, -47, -47, -47, -47, -37, -37, -37, -47, -47, -47, -49, -49, -49, -50, -50, -50, -48, -48, -48, -128, -128, -128, -48, -48, -48, -48, -48, -48, -50, -50, -50, -48, -48, -48, -48, -48, -48, -43, -43, -43, -47, -47, -47, -48, -48, -48, -48, -48, -48, -48, -48, -48, -43, -43, -43, -47, -47, -47, -48, -48, -48, -50, -50, -50, -49, -49, -49, -48, -48, -48, -48, -48, -48, -33, -33, -33, -48, -48, -48, -52, -52, -52, -51, -51, -51, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -52, -52, -52, -48, -48, -48, -46, -46, -46, -48, -48, -48, -37, -37, -37, -29, -29, -29, -48, -48, -48, -48, -48, -48, -47, -47, -47, -48, -48, -48, -49, -49, -49, -46, -46, -46, -48, -48, -48, -49, -49, -49, -47, -47, -47, -58, -58, -58, -49, -49, -49, -47, -47, -47, -48, -48, -48, -50, -50, -50, -47, -47, -47, -50, -50, -50, -48, -48, -48, -48, -48, -48, -50, -50, -50, -48, -48, -48, -48, -48, -48, -48, -48, -48, -55, -55, -55, -45, -45, -45, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -46, -46, -46, -47, -47, -47, -48, -48, -48, -47, -47, -47, -51, -51, -51, -47, -47, -47, -51, -51, -51, -48, -48, -48, -47, -47, -47, -48, -48, -48, -65, -65, -65, -48, -48, -48, -50, -50, -50, -48, -48, -48, -47, -47, -47, -49, -49, -49, -47, -47, -47, -47, -47, -47, -48, -48, -48, -48, -48, -48, -50, -50, -50, -47, -47, -47, -49, -49, -49, -49, -49, -49, -47, -47, -47, -48, -48, -48, -47, -47, -47, -49, -49, -49, -39, -39, -39, -50, -50, -50, -47, -47, -47, -48, -48, -48, -42, -42, -42, -47, -47, -47, -46, -46, -46, -47, -47, -47, -47, -47, -47, -48, -48, -48, -49, -49, -49, -49, -49, -49, -47, -47, -47, -48, -48, -48, -47, -47, -47, -48, -48, -48, -50, -50, -50, -47, -47, -47, -48, -48, -48, -47, -47, -47, -49, -49, -49, -48, -48, -48, -49, -49, -49, -50, -50, -50, -47, -47, -47, -56, -56, -56, -48, -48, -48, -48, -48, -48, -40, -40, -40, -45, -45, -45, -47, -47, -47, -47, -47, -47, -49, -49, -49, -48, -48, -48, -43, -43, -43, -52, -52, -52, -49, -49, -49, -47, -47, -47, -47, -47, -47, -50, -50, -50, -49, -49, -49, -47, -47, -47, -49, -49, -49, -48, -48, -48, -49, -49, -49, -115, 52, -27, 40, 0, 0, 0, -3, 116, 82, 78, 83, 34, -37, -1, -7, 120, 4, 0, 5, 118, -9, -41, 38, 24, -46, -2, -100, 78, 69, 3, -98, -54, 28, 40, -37, -8, 115, 3, 122, -6, -43, 36, 31, -49, -109, 78, 70, -93, -50, 20, 43, -38, -10, 113, 123, 30, -45, -115, 2, 86, -5, -9, 65, -94, -55, 19, -29, 111, 6, 127, 32, -39, -3, -114, 92, -4, 58, -86, -63, 22, -32, 109, -126, -5, -51, 36, -44, -121, 1, 90, -11, 59, -79, -59, 15, 48, -11, 106, -123, 26, 37, -40, 98, -3, -14, -80, -61, 13, 44, -25, 103, 8, -120, 29, 35, -35, -4, -127, 101, 46, -76, -71, 17, -24, 99, 7, -118, -58, 42, 124, 2, 103, -13, 47, -67, -65, 12, 55, -27, -116, -57, 24, 44, -35, 119, 112, -21, 49, 8, -70, 10, 51, -19, -14, 93, -111, -62, 25, -30, 119, -78, 14, 9, -108, 113, 117, -18, -53, 63, -23, -15, 89, 9, -106, -2, -66, 21, 50, -125, -33, -28, 57, 97, -97, -73, 19, 69, -12, -17, 81, -87, 57, 94, -99, -73, 72, -18, 82, -88, -20, 27, 12, -100, 67, -16, 83, 16, -91, -122, 54, -26, 104, -101, 64, 85, 11, -82, 100, -101, 20, 84, -47, 125, 31, -102, 62, -57, -88, -125, 107, -102, -69, -15, -53, -77, 52, -31, -105, 66, -21, 87, 121, 110, -67, 23, -60, -84, 13, 46, 111, 88, -75, 119, 42, 50, -32, 106, -107, 63, 91, -78, 117, 114, -108, 41, -51, -28, -65, 0, 0, 3, -107, 73, 68, 65, 84, 88, -61, -107, -40, 105, 84, -108, 85, 24, 7, 112, -4, -113, -13, -112, -37, -101, -111, -96, 78, 67, -94, 38, -18, -66, 14, 46, 52, -38, -28, -96, 34, 90, -67, -31, -106, 75, -114, 70, 74, 42, 90, 86, -109, 4, 38, -88, -111, -90, 50, -126, -72, -46, -54, -102, 38, -102, -53, 104, -101, 123, -88, -71, 78, -18, -91, -90, 88, 82, 46, 89, 90, -71, -107, -27, 7, 95, 62, 120, -114, -100, -93, -121, -5, 127, 63, -65, -65, 15, -9, -36, 123, -97, -5, 127, -98, -96, -96, 106, -80, 84, -73, -118, -14, 23, 20, -4, 64, 13, -44, -84, 69, 0, -87, 93, 7, -38, -125, 12, -112, -70, 22, -32, 33, 6, -124, 60, 12, -44, 11, 37, -128, -124, -43, 71, -125, -122, 33, 4, -80, 61, 98, 71, -8, -93, 4, -112, 70, 17, -48, 26, 51, 64, -102, 0, 53, -102, 50, 64, 30, -45, -48, 44, -110, 1, -51, 91, -96, 101, 43, 43, 1, -84, -83, -19, -88, -39, -122, 0, 18, -38, 22, 104, -89, 19, 64, -38, 59, 16, -43, -127, 1, -42, -114, 64, -89, -50, 4, -112, -80, 104, 68, 61, -82, 19, -64, -39, -59, -126, -120, -82, 4, -112, -48, 39, -32, 122, -110, 1, -46, -51, 1, 119, 12, 3, -84, -35, -127, 30, 61, 9, 32, -51, 99, 17, -43, 75, 39, 64, 112, 92, 111, -12, 105, 68, 0, 121, -22, 105, 104, -49, 48, -64, 120, 22, -120, -17, 75, 0, -111, 126, 64, -1, 1, 12, 24, 24, -117, -25, 6, -39, 8, -32, 28, -20, 64, -77, 33, 4, -112, -95, -49, 3, 113, 6, 1, 100, -104, 7, -61, 99, 24, -32, 28, 1, -68, -112, 64, 0, 121, 113, 36, -30, 71, -23, 4, 72, 124, -55, -114, 78, -93, 9, 32, 99, -58, 34, 105, 28, 3, 100, -68, 27, 13, 94, 102, -128, -13, 21, 96, -62, -85, 4, -112, -127, -81, 33, -2, 117, -125, 0, -34, 55, -110, 48, 49, -108, 0, -110, -36, 2, -82, 55, 25, -112, -110, 10, 76, 122, -117, 0, 34, -109, -127, -76, 116, 6, 76, -103, -118, 73, -61, 116, 2, 120, -89, -71, -15, 118, 6, 1, -28, -99, -23, -64, 12, -125, 0, -58, -69, -64, -52, 89, 4, -112, -39, -26, -70, 51, 125, 4, -112, 57, 89, -56, -98, -101, 66, 0, 95, -114, 3, -13, -26, 19, 64, -110, 23, -64, -79, -112, 1, 82, -35, -123, -8, -95, 12, -16, -103, -21, 94, -76, -104, 0, 50, 101, 38, -122, 119, 51, 8, -112, -5, -98, 11, 105, -75, 9, 80, 81, 58, -19, -17, 51, 64, -1, 0, -8, 112, 12, 1, -60, -6, -111, -71, -18, -39, 4, -112, -113, -13, -112, 95, -112, 66, -128, -62, 34, -83, -8, -109, 37, 4, -112, -91, -79, -16, -92, 50, -64, -8, 20, 88, -58, 0, -55, 1, 74, 24, -80, 124, 36, 44, 43, 8, -112, -16, -103, -85, 120, -27, 42, 2, -84, 94, 3, -1, 90, 67, 29, -92, -41, 1, -42, 89, -43, 55, -50, 54, 24, -120, 14, 34, -114, -58, -25, 37, 112, 127, 65, 28, 62, -33, -105, -59, -38, 87, -99, 9, -16, -11, 122, -8, 55, 16, 23, -56, 23, 14, 108, -12, -86, 95, 81, 91, 28, -80, 105, 51, 81, 4, -52, 10, 110, 73, 37, -54, -52, -106, -83, 46, -84, -116, 36, -64, 55, 107, 80, -70, -115, -88, -83, -37, -21, 1, 59, -104, 98, -4, -83, -122, -84, -47, 68, -71, -33, -71, 11, -10, -35, -60, 11, -108, -66, -57, -115, -52, 72, 2, -20, -51, -122, 127, -97, -95, 14, 2, 17, -64, 119, 78, -11, 103, 87, -17, 98, -34, -29, 90, -60, -61, -66, 60, 26, -98, -3, 68, 116, -16, 29, -48, -76, -52, -125, 4, 88, -67, 9, -2, 67, 68, -4, -15, 30, 6, -70, 39, -86, 7, 44, -37, 17, 32, -21, 40, 17, -31, 118, -106, -64, 50, -120, 8, -119, -127, -17, 93, -104, -80, -124, 0, 63, 100, 35, -65, 61, -111, -116, -73, 31, 3, -114, 7, 8, 112, -62, 60, -43, 109, -120, -80, -66, 116, 42, -20, 63, 18, -3, 67, -32, -92, 27, -89, 14, 18, -96, 111, -39, -67, 79, -11, -3, -128, -17, 52, -16, -109, 83, -67, 105, -46, -51, 61, -34, 21, 70, -76, 101, 63, -97, -127, 103, 60, -47, -8, 21, -106, 87, 122, -110, -85, 6, -65, -4, -118, -46, -77, -122, 58, -16, -98, -82, 28, 43, -86, 2, 33, -25, -52, -32, 114, -108, 104, -64, -25, -100, -127, -3, 60, -47, -30, -5, -54, 93, -72, -16, 27, 1, 98, -4, -56, 46, 32, -26, 26, 9, 23, -127, -33, -1, 32, -64, 17, 13, 101, 42, 73, -27, -50, 63, -105, 74, -48, -5, 50, 49, -3, 9, 20, 37, -95, -86, 61, -82, 4, 98, 74, -111, -1, -89, -95, 14, 114, -51, 123, -4, 87, -94, -6, -56, -53, -8, 27, -56, -69, 66, 12, -43, -82, 94, -125, -25, 58, 49, -74, 43, -68, -95, 21, 87, 57, -109, -71, 27, -4, -109, -121, -78, 127, 83, -44, -127, -43, 124, -113, 111, 22, -118, 50, -48, -1, 3, -4, -86, -109, -54, 10, 80, 17, -8, -1, 23, 117, -112, 123, -53, 108, 41, 50, -44, -63, 109, -80, -19, 79, -78, 17, 39, 44, -102, 0, 0, 0, 0, 73, 69, 78, 68, -82, 66, 96, -126}; } ================================================ FILE: app/src/main/java/io/legado/app/ui/font/FontAdapter.kt ================================================ package io.legado.app.ui.font import android.content.Context import android.graphics.Typeface import android.os.Build import android.view.ViewGroup import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.constant.AppLog import io.legado.app.databinding.ItemFontBinding import io.legado.app.utils.* import java.io.File import java.net.URLDecoder class FontAdapter(context: Context, curFilePath: String, val callBack: CallBack) : RecyclerAdapter(context) { private val curName = kotlin.runCatching { URLDecoder.decode(curFilePath, "utf-8") }.getOrNull()?.substringAfterLast(File.separator) override fun getViewBinding(parent: ViewGroup): ItemFontBinding { return ItemFontBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemFontBinding, item: FileDoc, payloads: MutableList ) { binding.run { kotlin.runCatching { val typeface: Typeface? = if (item.isContentScheme) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.contentResolver .openFileDescriptor(item.uri, "r")?.use { Typeface.Builder(it.fileDescriptor).build() } } else { Typeface.createFromFile(RealPathUtil.getPath(context, item.uri)) } } else { Typeface.createFromFile(item.uri.path!!) } tvFont.typeface = typeface }.onFailure { it.printOnDebug() AppLog.put("读取字体 ${item.name} 出错\n${it.localizedMessage}", it, true) } tvFont.text = item.name root.setOnClickListener { callBack.onFontSelect(item) } if (item.name == curName) { ivChecked.visible() } else { ivChecked.invisible() } } } override fun registerListener(holder: ItemViewHolder, binding: ItemFontBinding) { holder.itemView.setOnClickListener { getItem(holder.layoutPosition)?.let { callBack.onFontSelect(it) } } } interface CallBack { fun onFontSelect(docItem: FileDoc) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/font/FontSelectDialog.kt ================================================ package io.legado.app.ui.font import android.net.Uri import android.os.Bundle import android.view.MenuItem import android.view.View import androidx.appcompat.widget.Toolbar import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.constant.AppLog import io.legado.app.constant.PreferKey import io.legado.app.databinding.DialogFontSelectBinding import io.legado.app.help.config.AppConfig import io.legado.app.lib.dialogs.SelectItem import io.legado.app.lib.dialogs.alert import io.legado.app.lib.permission.Permissions import io.legado.app.lib.permission.PermissionsCompat import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.file.HandleFileContract import io.legado.app.utils.FileDoc import io.legado.app.utils.FileUtils import io.legado.app.utils.RealPathUtil import io.legado.app.utils.applyTint import io.legado.app.utils.cnCompare import io.legado.app.utils.externalFiles import io.legado.app.utils.getPrefString import io.legado.app.utils.isContentScheme import io.legado.app.utils.list import io.legado.app.utils.listFileDocs import io.legado.app.utils.putPrefString import io.legado.app.utils.setLayout import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.launch import java.io.File /** * 字体选择对话框 */ class FontSelectDialog : BaseDialogFragment(R.layout.dialog_font_select), Toolbar.OnMenuItemClickListener, FontAdapter.CallBack { private val fontRegex = Regex("(?i).*\\.[ot]tf") private val binding by viewBinding(DialogFontSelectBinding::bind) private val adapter by lazy { val curFontPath = callBack?.curFontPath ?: "" FontAdapter(requireContext(), curFontPath, this) } private val selectFontDir = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> if (uri.isContentScheme()) { putPrefString(PreferKey.fontFolder, uri.toString()) val doc = DocumentFile.fromTreeUri(requireContext(), uri) if (doc != null) { loadFontFiles(FileDoc.fromDocumentFile(doc)) } else { RealPathUtil.getPath(requireContext(), uri)?.let { path -> loadFontFilesByPermission(path) } } } else { uri.path?.let { path -> putPrefString(PreferKey.fontFolder, path) loadFontFilesByPermission(path) } } } } override fun onStart() { super.onStart() setLayout(0.9f, 0.9f) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) binding.toolBar.setTitle(R.string.select_font) binding.toolBar.inflateMenu(R.menu.font_select) binding.toolBar.menu.applyTint(requireContext()) binding.toolBar.setOnMenuItemClickListener(this) binding.recyclerView.layoutManager = LinearLayoutManager(context) binding.recyclerView.adapter = adapter val fontPath = getPrefString(PreferKey.fontFolder) if (fontPath.isNullOrEmpty()) { openFolder() } else { if (fontPath.isContentScheme()) { val doc = DocumentFile.fromTreeUri(requireContext(), Uri.parse(fontPath)) if (doc?.canRead() == true) { loadFontFiles(FileDoc.fromDocumentFile(doc)) } else { openFolder() } } else { loadFontFilesByPermission(fontPath) } } } override fun onMenuItemClick(item: MenuItem?): Boolean { when (item?.itemId) { R.id.menu_default -> { val requireContext = requireContext() alert(titleResource = R.string.system_typeface) { items( requireContext.resources.getStringArray(R.array.system_typefaces).toList() ) { _, i -> AppConfig.systemTypefaces = i onDefaultFontChange() dismissAllowingStateLoss() } } } R.id.menu_other -> { openFolder() } } return true } private fun openFolder() { lifecycleScope.launch { val defaultPath = "SD${File.separator}Fonts" selectFontDir.launch { otherActions = arrayListOf(SelectItem(defaultPath, -1)) } } } private fun getLocalFonts(): ArrayList { val path = FileUtils.getPath(requireContext().externalFiles, "font") return File(path).listFileDocs { it.name.matches(fontRegex) } } private fun loadFontFilesByPermission(path: String) { PermissionsCompat.Builder() .addPermissions(*Permissions.Group.STORAGE) .rationale(R.string.tip_perm_request_storage) .onGranted { loadFontFiles( FileDoc.fromFile(File(path)) ) } .request() } private fun loadFontFiles(fileDoc: FileDoc) { execute { val fontItems = fileDoc.list { it.name.matches(fontRegex) } ?: ArrayList() mergeFontItems(fontItems, getLocalFonts()) }.onSuccess { adapter.setItems(it) }.onError { AppLog.put("加载字体文件失败\n${it.localizedMessage}", it) toastOnUi("getFontFiles:${it.localizedMessage}") } } private fun mergeFontItems( items1: ArrayList, items2: ArrayList ): List { val items = ArrayList(items1) items2.forEach { item2 -> var isInFirst = false items1.forEach for1@{ item1 -> if (item2.name == item1.name) { isInFirst = true return@for1 } } if (!isInFirst) { items.add(item2) } } return items.sortedWith { o1, o2 -> o1.name.cnCompare(o2.name) } } override fun onFontSelect(docItem: FileDoc) { execute { callBack?.selectFont(docItem.toString()) }.onSuccess { dismissAllowingStateLoss() } } private fun onDefaultFontChange() { callBack?.selectFont("") } private val callBack: CallBack? get() = (parentFragment as? CallBack) ?: (activity as? CallBack) interface CallBack { fun selectFont(path: String) val curFontPath: String } } ================================================ FILE: app/src/main/java/io/legado/app/ui/login/SourceLoginActivity.kt ================================================ package io.legado.app.ui.login import android.os.Bundle import androidx.activity.viewModels import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.data.entities.BaseSource import io.legado.app.databinding.ActivitySourceLoginBinding import io.legado.app.utils.showDialogFragment import io.legado.app.utils.viewbindingdelegate.viewBinding class SourceLoginActivity : VMBaseActivity() { override val binding by viewBinding(ActivitySourceLoginBinding::inflate) override val viewModel by viewModels() override fun onActivityCreated(savedInstanceState: Bundle?) { viewModel.initData(intent, success = { source -> initView(source) }, error = { finish() }) } private fun initView(source: BaseSource) { if (source.loginUi.isNullOrEmpty()) { supportFragmentManager.beginTransaction() .replace(R.id.fl_fragment, WebViewLoginFragment(), "webViewLogin") .commit() } else { showDialogFragment() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/login/SourceLoginDialog.kt ================================================ package io.legado.app.ui.login import android.content.DialogInterface import android.os.Bundle import android.text.InputType import android.view.View import android.view.ViewGroup import androidx.core.view.setPadding import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import com.script.rhino.runScriptWithContext import io.legado.app.R import io.legado.app.base.BaseDialogFragment 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.databinding.DialogLoginBinding import io.legado.app.databinding.ItemFilletTextBinding import io.legado.app.databinding.ItemSourceEditBinding import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.about.AppLogDialog import io.legado.app.utils.GSON import io.legado.app.utils.applyTint import io.legado.app.utils.dpToPx import io.legado.app.utils.isAbsUrl 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.showDialogFragment import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import splitties.init.appCtx import splitties.views.onClick class SourceLoginDialog : BaseDialogFragment(R.layout.dialog_login, true) { private val binding by viewBinding(DialogLoginBinding::bind) private val viewModel by activityViewModels() override fun onStart() { super.onStart() setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) } 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)) } 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) } } } } } catch (e: NullPointerException) { AppLog.put("登录UI JSON 数据错误", e, true) } binding.toolBar.inflateMenu(R.menu.source_login) binding.toolBar.menu.applyTint(requireContext()) binding.toolBar.setOnMenuItemClickListener { item -> when (item.itemId) { R.id.menu_ok -> { val loginData = getLoginData(loginUi) login(source, loginData) } R.id.menu_show_login_header -> alert { setTitle(R.string.login_header) source.getLoginHeader()?.let { loginHeader -> setMessage(loginHeader) positiveButton(R.string.copy_text) { appCtx.sendToClip(loginHeader) } } } R.id.menu_del_login_header -> source.removeLoginHeader() R.id.menu_log -> showDialogFragment() } return@setOnMenuItemClickListener true } } private fun handleButtonClick(source: BaseSource, rowUi: RowUi, loginUi: List) { lifecycleScope.launch(IO) { if (rowUi.action.isAbsUrl()) { context?.openUrl(rowUi.action!!) } else if (rowUi.action != null) { // JavaScript val buttonFunctionJS = rowUi.action!! val loginJS = source.getLoginJs() ?: return@launch kotlin.runCatching { runScriptWithContext { source.evalJS("$loginJS\n$buttonFunctionJS") { put("result", getLoginData(loginUi)) } } }.onFailure { e -> ensureActive() AppLog.put("LoginUI Button ${rowUi.name} JavaScript error", e) } } } } private fun getLoginData(loginUi: List?): HashMap { val loginData = hashMapOf() loginUi?.forEachIndexed { index, rowUi -> when (rowUi.type) { "text", "password" -> { val rowView = binding.root.findViewById(index + 1000) ItemSourceEditBinding.bind(rowView).editText.text?.let { loginData[rowUi.name] = it.toString() } } } } return loginData } private fun login(source: BaseSource, loginData: HashMap) { lifecycleScope.launch(IO) { if (loginData.isEmpty()) { source.removeLoginInfo() withContext(Main) { dismiss() } } else if (source.putLoginInfo(GSON.toJson(loginData))) { try { runScriptWithContext { source.login() } context?.toastOnUi(R.string.success) withContext(Main) { dismiss() } } catch (e: Exception) { AppLog.put("登录出错\n${e.localizedMessage}", e) context?.toastOnUi("登录出错\n${e.localizedMessage}") e.printOnDebug() } } } } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) activity?.finish() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/login/SourceLoginViewModel.kt ================================================ package io.legado.app.ui.login import android.app.Application 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.data.appDb import io.legado.app.data.entities.BaseSource import io.legado.app.exception.NoStackTraceException import io.legado.app.utils.toastOnUi class SourceLoginViewModel(application: Application) : BaseViewModel(application) { var source: BaseSource? = null var headerMap: Map = emptyMap() 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()) } headerMap = runScriptWithContext { source?.getHeaderMap(true) ?: emptyMap() } source }.onSuccess { if (it != null) { success.invoke(it) } else { context.toastOnUi("未找到书源") } }.onError { error.invoke() AppLog.put("登录 UI 初始化失败\n$it", it, true) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/login/WebViewLoginFragment.kt ================================================ package io.legado.app.ui.login import android.annotation.SuppressLint import android.graphics.Bitmap import android.net.Uri import android.net.http.SslError import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.View import android.webkit.CookieManager import android.webkit.SslErrorHandler import android.webkit.WebChromeClient import android.webkit.WebResourceRequest import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient import androidx.fragment.app.activityViewModels import io.legado.app.R import io.legado.app.base.BaseFragment import io.legado.app.constant.AppConst import io.legado.app.data.entities.BaseSource import io.legado.app.databinding.FragmentWebViewLoginBinding import io.legado.app.help.http.CookieStore import io.legado.app.lib.theme.accentColor import io.legado.app.utils.NetworkUtils import io.legado.app.utils.gone import io.legado.app.utils.longSnackbar import io.legado.app.utils.openUrl import io.legado.app.utils.snackbar import io.legado.app.utils.viewbindingdelegate.viewBinding class WebViewLoginFragment : BaseFragment(R.layout.fragment_web_view_login) { private val binding by viewBinding(FragmentWebViewLoginBinding::bind) private val viewModel by activityViewModels() private var checking = false override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { setSupportToolbar(binding.titleBar.toolbar) viewModel.source?.let { binding.titleBar.title = getString(R.string.login_source, it.getTag()) initWebView(it) } } override fun onCompatCreateOptionsMenu(menu: Menu) { menuInflater.inflate(R.menu.source_webview_login, menu) } override fun onCompatOptionsItemSelected(item: MenuItem) { when (item.itemId) { R.id.menu_ok -> { if (!checking) { checking = true binding.titleBar.snackbar(R.string.check_host_cookie) viewModel.source?.let { loadUrl(it) } } } } } @SuppressLint("SetJavaScriptEnabled") private fun initWebView(source: BaseSource) { binding.progressBar.fontColor = accentColor binding.webView.settings.apply { mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW domStorageEnabled = true useWideViewPort = true loadWithOverviewMode = true builtInZoomControls = true javaScriptEnabled = true displayZoomControls = false viewModel.headerMap[AppConst.UA_NAME]?.let { userAgentString = it } } val cookieManager = CookieManager.getInstance() binding.webView.webViewClient = object : WebViewClient() { override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { val cookie = cookieManager.getCookie(url) CookieStore.setCookie(source.getKey(), cookie) super.onPageStarted(view, url, favicon) } override fun onPageFinished(view: WebView?, url: String?) { val cookie = cookieManager.getCookie(url) CookieStore.setCookie(source.getKey(), cookie) if (checking) { activity?.finish() } super.onPageFinished(view, url) } override fun shouldOverrideUrlLoading( view: WebView, request: WebResourceRequest ): Boolean { return shouldOverrideUrlLoading(request.url) } @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION", "KotlinRedundantDiagnosticSuppress") override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { return shouldOverrideUrlLoading(Uri.parse(url)) } private fun shouldOverrideUrlLoading(url: Uri): Boolean { when (url.scheme) { "http", "https" -> { return false } else -> { binding.root.longSnackbar(R.string.jump_to_another_app, R.string.confirm) { context?.openUrl(url) } return true } } } @SuppressLint("WebViewClientOnReceivedSslError") override fun onReceivedSslError( view: WebView?, handler: SslErrorHandler?, error: SslError? ) { handler?.proceed() } } binding.webView.webChromeClient = object : WebChromeClient() { override fun onProgressChanged(view: WebView?, newProgress: Int) { super.onProgressChanged(view, newProgress) binding.progressBar.setDurProgress(newProgress) binding.progressBar.gone(newProgress == 100) } } loadUrl(source) } private fun loadUrl(source: BaseSource) { val loginUrl = source.loginUrl ?: return val absoluteUrl = NetworkUtils.getAbsoluteURL(source.getKey(), loginUrl) binding.webView.loadUrl(absoluteUrl, viewModel.headerMap) } override fun onDestroy() { super.onDestroy() binding.webView.destroy() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/main/MainActivity.kt ================================================ @file:Suppress("DEPRECATION") package io.legado.app.ui.main import android.os.Bundle import android.text.format.DateUtils import android.view.MenuItem import android.view.ViewGroup import androidx.activity.addCallback import androidx.activity.viewModels import androidx.core.view.get import androidx.core.view.postDelayed import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentStatePagerAdapter import androidx.lifecycle.lifecycleScope import androidx.viewpager.widget.ViewPager import com.google.android.material.bottomnavigation.BottomNavigationView import io.legado.app.BuildConfig import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.constant.AppConst.appInfo import io.legado.app.constant.EventBus import io.legado.app.constant.PreferKey import io.legado.app.databinding.ActivityMainBinding import io.legado.app.databinding.DialogEditTextBinding import io.legado.app.help.AppWebDav import io.legado.app.help.book.BookHelp import io.legado.app.help.config.AppConfig import io.legado.app.help.config.LocalConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.storage.Backup import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.elevation import io.legado.app.lib.theme.primaryColor import io.legado.app.service.BaseReadAloudService import io.legado.app.ui.about.CrashLogsDialog import io.legado.app.ui.main.bookshelf.BaseBookshelfFragment import io.legado.app.ui.main.bookshelf.style1.BookshelfFragment1 import io.legado.app.ui.main.bookshelf.style2.BookshelfFragment2 import io.legado.app.ui.main.explore.ExploreFragment import io.legado.app.ui.main.my.MyFragment import io.legado.app.ui.main.rss.RssFragment import io.legado.app.ui.widget.dialog.TextDialog import io.legado.app.ui.widget.text.BadgeView import io.legado.app.utils.isCreated import io.legado.app.utils.navigationBarHeight import io.legado.app.utils.observeEvent import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat import io.legado.app.utils.showDialogFragment import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import splitties.views.bottomPadding import kotlin.coroutines.resume /** * 主界面 */ @Suppress("PrivatePropertyName") class MainActivity : VMBaseActivity(), BottomNavigationView.OnNavigationItemSelectedListener, BottomNavigationView.OnNavigationItemReselectedListener { override val binding by viewBinding(ActivityMainBinding::inflate) override val viewModel by viewModels() private val idBookshelf = 0 private val idBookshelf1 = 11 private val idBookshelf2 = 12 private val idExplore = 1 private val idRss = 2 private val idMy = 3 private var exitTime: Long = 0 private var bookshelfReselected: Long = 0 private var exploreReselected: Long = 0 private var pagePosition = 0 private val fragmentMap = hashMapOf() private var bottomMenuCount = 4 private val EXIT_INTERVAL = 2000L private val realPositions = arrayOf(idBookshelf, idExplore, idRss, idMy) private val adapter by lazy { TabFragmentPageAdapter(supportFragmentManager) } private var onUpBooksBadgeView: BadgeView? = null override fun onActivityCreated(savedInstanceState: Bundle?) { upBottomMenu() initView() upHomePage() onBackPressedDispatcher.addCallback(this) { if (pagePosition != 0) { binding.viewPagerMain.currentItem = 0 return@addCallback } (fragmentMap[getFragmentId(0)] as? BookshelfFragment2)?.let { if (it.back()) { return@addCallback } } if (System.currentTimeMillis() - exitTime > EXIT_INTERVAL) { toastOnUi(R.string.double_click_exit) exitTime = System.currentTimeMillis() } else { if (BaseReadAloudService.pause) { finish() } else { moveTaskToBack(true) } } } } override fun onPostCreate(savedInstanceState: Bundle?) { super.onPostCreate(savedInstanceState) lifecycleScope.launch { //隐私协议 if (!privacyPolicy()) return@launch //版本更新 upVersion() //设置本地密码 setLocalPassword() notifyAppCrash() //备份同步 backupSync() //自动更新书籍 val isAutoRefreshedBook = savedInstanceState?.getBoolean("isAutoRefreshedBook") ?: false if (AppConfig.autoRefreshBook && !isAutoRefreshedBook) { binding.viewPagerMain.postDelayed(1000) { viewModel.upAllBookToc() } } binding.viewPagerMain.postDelayed(3000) { viewModel.postLoad() } } } override fun onNavigationItemSelected(item: MenuItem): Boolean = binding.run { when (item.itemId) { R.id.menu_bookshelf -> viewPagerMain.setCurrentItem(0, false) R.id.menu_discovery -> viewPagerMain.setCurrentItem(realPositions.indexOf(idExplore), false) R.id.menu_rss -> viewPagerMain.setCurrentItem(realPositions.indexOf(idRss), false) R.id.menu_my_config -> viewPagerMain.setCurrentItem(realPositions.indexOf(idMy), false) } return false } override fun onNavigationItemReselected(item: MenuItem) { when (item.itemId) { R.id.menu_bookshelf -> { if (System.currentTimeMillis() - bookshelfReselected > 300) { bookshelfReselected = System.currentTimeMillis() } else { (fragmentMap[getFragmentId(0)] as? BaseBookshelfFragment)?.gotoTop() } } R.id.menu_discovery -> { if (System.currentTimeMillis() - exploreReselected > 300) { exploreReselected = System.currentTimeMillis() } else { (fragmentMap[1] as? ExploreFragment)?.compressExplore() } } } } private fun initView() = binding.run { viewPagerMain.setEdgeEffectColor(primaryColor) viewPagerMain.offscreenPageLimit = 3 viewPagerMain.adapter = adapter viewPagerMain.addOnPageChangeListener(PageChangeCallback()) bottomNavigationView.elevation = elevation bottomNavigationView.setOnNavigationItemSelectedListener(this@MainActivity) bottomNavigationView.setOnNavigationItemReselectedListener(this@MainActivity) if (AppConfig.isEInkMode) { bottomNavigationView.setBackgroundResource(R.drawable.bg_eink_border_top) } bottomNavigationView.setOnApplyWindowInsetsListenerCompat { view, windowInsets -> val height = windowInsets.navigationBarHeight view.bottomPadding = height windowInsets.inset(0, 0, 0, height) } } /** * 用户隐私与协议 */ private suspend fun privacyPolicy(): Boolean = suspendCancellableCoroutine sc@{ block -> if (LocalConfig.privacyPolicyOk) { block.resume(true) return@sc } val privacyPolicy = String(assets.open("privacyPolicy.md").readBytes()) alert(getString(R.string.privacy_policy), privacyPolicy) { positiveButton(R.string.agree) { LocalConfig.privacyPolicyOk = true block.resume(true) } negativeButton(R.string.refuse) { finish() block.resume(false) } } } /** * 版本更新日志 */ private suspend fun upVersion() = suspendCancellableCoroutine sc@{ block -> if (LocalConfig.versionCode == appInfo.versionCode) { block.resume(null) return@sc } LocalConfig.versionCode = appInfo.versionCode if (LocalConfig.isFirstOpenApp) { val help = String(assets.open("web/help/md/appHelp.md").readBytes()) val dialog = TextDialog(getString(R.string.help), help, TextDialog.Mode.MD) dialog.setOnDismissListener { block.resume(null) } showDialogFragment(dialog) } else if (!BuildConfig.DEBUG) { val log = String(assets.open("updateLog.md").readBytes()) val dialog = TextDialog(getString(R.string.update_log), log, TextDialog.Mode.MD) dialog.setOnDismissListener { block.resume(null) } showDialogFragment(dialog) } else { block.resume(null) } } /** * 设置本地密码 */ private suspend fun setLocalPassword() = suspendCancellableCoroutine sc@{ block -> if (LocalConfig.password != null) { block.resume(null) return@sc } alert(R.string.set_local_password, R.string.set_local_password_summary) { val editTextBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = "password" } customView { editTextBinding.root } onDismiss { block.resume(null) } okButton { LocalConfig.password = editTextBinding.editView.text.toString() } cancelButton { LocalConfig.password = "" } } } private fun notifyAppCrash() { if (!LocalConfig.appCrash || BuildConfig.DEBUG) { return } LocalConfig.appCrash = false alert(getString(R.string.draw), "检测到阅读发生了崩溃,是否打开崩溃日志以便报告问题?") { yesButton { showDialogFragment() } noButton() } } /** * 备份同步 */ private fun backupSync() { if (!AppConfig.autoCheckNewBackup) { return } lifecycleScope.launch { val lastBackupFile = withContext(IO) { AppWebDav.lastBackUp().getOrNull() } ?: return@launch if (lastBackupFile.lastModify - LocalConfig.lastBackup > DateUtils.MINUTE_IN_MILLIS) { LocalConfig.lastBackup = lastBackupFile.lastModify alert(R.string.restore, R.string.webdav_after_local_restore_confirm) { cancelButton() okButton { viewModel.restoreWebDav(lastBackupFile.displayName) } } } } } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) if (AppConfig.autoRefreshBook) { outState.putBoolean("isAutoRefreshedBook", true) } } override fun onDestroy() { super.onDestroy() Coroutine.async { BookHelp.clearInvalidCache() } if (!BuildConfig.DEBUG) { Backup.autoBack(this) } } /** * 如果重启太快fragment不会重建,这里更新一下书架的排序 */ override fun recreate() { (fragmentMap[getFragmentId(0)] as? BaseBookshelfFragment)?.run { upSort() } super.recreate() } override fun observeLiveBus() { viewModel.onUpBooksLiveData.observe(this) { if (onUpBooksBadgeView == null) { onUpBooksBadgeView = binding.bottomNavigationView.addBadgeView(0) } onUpBooksBadgeView!!.setBadgeCount(it) } observeEvent(EventBus.RECREATE) { recreate() } observeEvent(EventBus.NOTIFY_MAIN) { binding.apply { if (it) { bottomNavigationView.menu.clear() bottomNavigationView.inflateMenu(R.menu.main_bnv) onUpBooksBadgeView = null } upBottomMenu() if (it) { viewPagerMain.setCurrentItem(bottomMenuCount - 1, false) } } } observeEvent(PreferKey.threadCount) { viewModel.upPool() } } private fun upBottomMenu() { val showDiscovery = AppConfig.showDiscovery val showRss = AppConfig.showRSS binding.bottomNavigationView.menu.let { menu -> menu.findItem(R.id.menu_discovery).isVisible = showDiscovery menu.findItem(R.id.menu_rss).isVisible = showRss } var index = 0 if (showDiscovery) { index++ realPositions[index] = idExplore } if (showRss) { index++ realPositions[index] = idRss } index++ realPositions[index] = idMy bottomMenuCount = index + 1 adapter.notifyDataSetChanged() } private fun upHomePage() { when (AppConfig.defaultHomePage) { "bookshelf" -> {} "explore" -> if (AppConfig.showDiscovery) { binding.viewPagerMain.setCurrentItem(realPositions.indexOf(idExplore), false) } "rss" -> if (AppConfig.showRSS) { binding.viewPagerMain.setCurrentItem(realPositions.indexOf(idRss), false) } "my" -> binding.viewPagerMain.setCurrentItem(realPositions.indexOf(idMy), false) } } private fun getFragmentId(position: Int): Int { val id = realPositions[position] if (id == idBookshelf) { return if (AppConfig.bookGroupStyle == 1) idBookshelf2 else idBookshelf1 } return id } private inner class PageChangeCallback : ViewPager.SimpleOnPageChangeListener() { override fun onPageSelected(position: Int) { pagePosition = position binding.bottomNavigationView.menu[realPositions[position]].isChecked = true } } @Suppress("DEPRECATION") private inner class TabFragmentPageAdapter(fm: FragmentManager) : FragmentStatePagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { private fun getId(position: Int): Int { return getFragmentId(position) } override fun getItemPosition(any: Any): Int { val position = (any as MainFragmentInterface).position ?: return POSITION_NONE val fragmentId = getId(position) if ((fragmentId == idBookshelf1 && any is BookshelfFragment1) || (fragmentId == idBookshelf2 && any is BookshelfFragment2) || (fragmentId == idExplore && any is ExploreFragment) || (fragmentId == idRss && any is RssFragment) || (fragmentId == idMy && any is MyFragment) ) { return POSITION_UNCHANGED } return POSITION_NONE } override fun getItem(position: Int): Fragment { return when (getId(position)) { idBookshelf1 -> BookshelfFragment1(position) idBookshelf2 -> BookshelfFragment2(position) idExplore -> ExploreFragment(position) idRss -> RssFragment(position) else -> MyFragment(position) } } override fun getCount(): Int { return bottomMenuCount } override fun instantiateItem(container: ViewGroup, position: Int): Any { var fragment = super.instantiateItem(container, position) as Fragment if (fragment.isCreated && getItemPosition(fragment) == POSITION_NONE) { destroyItem(container, position, fragment) fragment = super.instantiateItem(container, position) as Fragment } fragmentMap[getId(position)] = fragment return fragment } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/main/MainFragmentInterface.kt ================================================ package io.legado.app.ui.main interface MainFragmentInterface { val position: Int? } ================================================ FILE: app/src/main/java/io/legado/app/ui/main/MainViewModel.kt ================================================ package io.legado.app.ui.main import android.app.Application import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import androidx.recyclerview.widget.RecyclerView.RecycledViewPool import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog import io.legado.app.constant.BookType import io.legado.app.constant.EventBus import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookSource import io.legado.app.help.AppWebDav import io.legado.app.help.DefaultData import io.legado.app.help.book.BookHelp import io.legado.app.help.book.addType import io.legado.app.help.book.isLocal import io.legado.app.help.book.isUpError import io.legado.app.help.book.removeType import io.legado.app.help.book.sync import io.legado.app.help.config.AppConfig import io.legado.app.model.CacheBook import io.legado.app.model.ReadBook import io.legado.app.model.webBook.WebBook import io.legado.app.service.CacheBookService import io.legado.app.utils.onEachParallel import io.legado.app.utils.postEvent import kotlinx.coroutines.Job import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.util.LinkedList import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executors import kotlin.math.min class MainViewModel(application: Application) : BaseViewModel(application) { private var threadCount = AppConfig.threadCount private var poolSize = min(threadCount, AppConst.MAX_THREAD) private var upTocPool = Executors.newFixedThreadPool(poolSize).asCoroutineDispatcher() private val waitUpTocBooks = LinkedList() private val onUpTocBooks = ConcurrentHashMap.newKeySet() val onUpBooksLiveData = MutableLiveData() private var upTocJob: Job? = null private var cacheBookJob: Job? = null val booksListRecycledViewPool = RecycledViewPool().apply { setMaxRecycledViews(0, 30) } val booksGridRecycledViewPool = RecycledViewPool().apply { setMaxRecycledViews(0, 100) } init { deleteNotShelfBook() } override fun onCleared() { super.onCleared() upTocPool.close() } fun upPool() { threadCount = AppConfig.threadCount if (upTocJob?.isActive == true || cacheBookJob?.isActive == true) { return } val newPoolSize = min(threadCount, AppConst.MAX_THREAD) if (poolSize == newPoolSize) { return } poolSize = newPoolSize upTocPool.close() upTocPool = Executors.newFixedThreadPool(poolSize).asCoroutineDispatcher() } fun isUpdate(bookUrl: String): Boolean { return onUpTocBooks.contains(bookUrl) } fun upAllBookToc() { execute { addToWaitUp(appDb.bookDao.hasUpdateBooks) } } fun upToc(books: List) { execute(context = upTocPool) { books.filter { !it.isLocal && it.canUpdate }.let { addToWaitUp(it) } } } @Synchronized private fun addToWaitUp(books: List) { books.forEach { book -> if (!waitUpTocBooks.contains(book.bookUrl) && !onUpTocBooks.contains(book.bookUrl)) { waitUpTocBooks.add(book.bookUrl) } } if (upTocJob == null) { startUpTocJob() } } private fun startUpTocJob() { upPool() postUpBooksLiveData() upTocJob = viewModelScope.launch(upTocPool) { flow { while (true) { emit(waitUpTocBooks.poll() ?: break) } }.onEachParallel(threadCount) { onUpTocBooks.add(it) postEvent(EventBus.UP_BOOKSHELF, it) updateToc(it) }.onEach { onUpTocBooks.remove(it) postEvent(EventBus.UP_BOOKSHELF, it) postUpBooksLiveData() }.onCompletion { upTocJob = null if (waitUpTocBooks.isNotEmpty()) { startUpTocJob() } if (it == null && cacheBookJob == null && !CacheBookService.isRun) { //所有目录更新完再开始缓存章节 cacheBook() } }.catch { AppLog.put("更新目录出错\n${it.localizedMessage}", it) }.collect() } } private suspend fun updateToc(bookUrl: String) { val book = appDb.bookDao.getBook(bookUrl) ?: return val source = appDb.bookSourceDao.getBookSource(book.origin) if (source == null) { if (!book.isUpError) { book.addType(BookType.updateError) appDb.bookDao.update(book) } return } kotlin.runCatching { val oldBook = book.copy() if (book.tocUrl.isBlank()) { WebBook.getBookInfoAwait(source, book) } else { WebBook.runPreUpdateJs(source, book) } val toc = WebBook.getChapterListAwait(source, book).getOrThrow() book.sync(oldBook) book.removeType(BookType.updateError) if (book.bookUrl == bookUrl) { appDb.bookDao.update(book) } else { appDb.bookDao.replace(oldBook, book) BookHelp.updateCacheFolder(oldBook, book) } appDb.bookChapterDao.delByBook(bookUrl) appDb.bookChapterDao.insert(*toc.toTypedArray()) ReadBook.onChapterListUpdated(book) addDownload(source, book) }.onFailure { currentCoroutineContext().ensureActive() AppLog.put("${book.name} 更新目录失败\n${it.localizedMessage}", it) //这里可能因为时间太长书籍信息已经更改,所以重新获取 appDb.bookDao.getBook(book.bookUrl)?.let { book -> book.addType(BookType.updateError) appDb.bookDao.update(book) } } } fun postUpBooksLiveData(reset: Boolean = false) { if (AppConfig.showWaitUpCount) { onUpBooksLiveData.postValue(waitUpTocBooks.size + onUpTocBooks.size) } else if (reset) { onUpBooksLiveData.postValue(0) } } @Synchronized private fun addDownload(source: BookSource, book: Book) { if (AppConfig.preDownloadNum == 0) return val endIndex = min( book.totalChapterNum - 1, book.durChapterIndex.plus(AppConfig.preDownloadNum) ) val cacheBook = CacheBook.getOrCreate(source, book) cacheBook.addDownload(book.durChapterIndex, endIndex) } /** * 缓存书籍 */ private fun cacheBook() { if (AppConfig.preDownloadNum == 0) return cacheBookJob?.cancel() cacheBookJob = viewModelScope.launch(upTocPool) { launch { while (isActive && CacheBook.isRun) { //有目录更新是不缓存,优先更新目录,现在更多网站限制并发 CacheBook.setWorkingState(waitUpTocBooks.isEmpty() && onUpTocBooks.isEmpty()) delay(1000) } } CacheBook.startProcessJob(upTocPool) } } fun postLoad() { execute { if (appDb.httpTTSDao.count == 0) { DefaultData.httpTTS.let { appDb.httpTTSDao.insert(*it.toTypedArray()) } } } } fun restoreWebDav(name: String) { execute { AppWebDav.restoreWebDav(name) } } private fun deleteNotShelfBook() { execute { appDb.bookDao.deleteNotShelfBook() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/main/bookshelf/BaseBookshelfFragment.kt ================================================ package io.legado.app.ui.main.bookshelf import android.annotation.SuppressLint import android.view.Menu import android.view.MenuItem import androidx.core.view.indices import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.LiveData import io.legado.app.R import io.legado.app.base.VMBaseFragment import io.legado.app.constant.EventBus import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookGroup import io.legado.app.databinding.DialogBookshelfConfigBinding import io.legado.app.databinding.DialogEditTextBinding import io.legado.app.help.DirectLinkUpload import io.legado.app.help.config.AppConfig import io.legado.app.lib.dialogs.alert import io.legado.app.ui.about.AppLogDialog import io.legado.app.ui.book.cache.CacheActivity import io.legado.app.ui.book.group.GroupManageDialog import io.legado.app.ui.book.import.local.ImportBookActivity import io.legado.app.ui.book.import.remote.RemoteBookActivity import io.legado.app.ui.book.manage.BookshelfManageActivity import io.legado.app.ui.book.search.SearchActivity import io.legado.app.ui.file.HandleFileContract import io.legado.app.ui.main.MainFragmentInterface import io.legado.app.ui.main.MainViewModel import io.legado.app.ui.widget.dialog.WaitDialog import io.legado.app.utils.checkByIndex import io.legado.app.utils.getCheckedIndex import io.legado.app.utils.isAbsUrl import io.legado.app.utils.postEvent import io.legado.app.utils.readText import io.legado.app.utils.sendToClip import io.legado.app.utils.showDialogFragment import io.legado.app.utils.startActivity import io.legado.app.utils.toastOnUi abstract class BaseBookshelfFragment(layoutId: Int) : VMBaseFragment(layoutId), MainFragmentInterface { override val position: Int? get() = arguments?.getInt("position") val activityViewModel by activityViewModels() override val viewModel by viewModels() private val importBookshelf = registerForActivityResult(HandleFileContract()) { kotlin.runCatching { it.uri?.readText(requireContext())?.let { text -> viewModel.importBookshelf(text, groupId) } }.onFailure { toastOnUi(it.localizedMessage ?: "ERROR") } } private val exportResult = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> alert(R.string.export_success) { if (uri.toString().isAbsUrl()) { setMessage(DirectLinkUpload.getSummary()) } val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = getString(R.string.path) editView.setText(uri.toString()) } customView { alertBinding.root } okButton { requireContext().sendToClip(uri.toString()) } } } } abstract val groupId: Long abstract val books: List private var groupsLiveData: LiveData>? = null private val waitDialog by lazy { WaitDialog(requireContext()).apply { setOnCancelListener { viewModel.addBookJob?.cancel() } } } abstract fun gotoTop() override fun onCompatCreateOptionsMenu(menu: Menu) { menuInflater.inflate(R.menu.main_bookshelf, menu) } override fun onCompatOptionsItemSelected(item: MenuItem) { super.onCompatOptionsItemSelected(item) when (item.itemId) { R.id.menu_remote -> startActivity() R.id.menu_search -> startActivity() R.id.menu_update_toc -> activityViewModel.upToc(books) R.id.menu_bookshelf_layout -> configBookshelf() R.id.menu_group_manage -> showDialogFragment() R.id.menu_add_local -> startActivity() R.id.menu_add_url -> showAddBookByUrlAlert() R.id.menu_bookshelf_manage -> startActivity { putExtra("groupId", groupId) } R.id.menu_download -> startActivity { putExtra("groupId", groupId) } R.id.menu_export_bookshelf -> viewModel.exportBookshelf(books) { file -> exportResult.launch { mode = HandleFileContract.EXPORT fileData = HandleFileContract.FileData("bookshelf.json", file, "application/json") } } R.id.menu_import_bookshelf -> importBookshelfAlert(groupId) R.id.menu_log -> showDialogFragment() } } protected fun initBookGroupData() { groupsLiveData?.removeObservers(viewLifecycleOwner) groupsLiveData = appDb.bookGroupDao.show.apply { observe(viewLifecycleOwner) { upGroup(it) } } } abstract fun upGroup(data: List) abstract fun upSort() override fun observeLiveBus() { viewModel.addBookProgressLiveData.observe(this) { count -> if (count < 0) { waitDialog.dismiss() } else { waitDialog.setText("添加中... ($count)") } } } @SuppressLint("InflateParams") fun showAddBookByUrlAlert() { alert(titleResource = R.string.add_book_url) { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = "url" } customView { alertBinding.root } okButton { alertBinding.editView.text?.toString()?.let { waitDialog.setText("添加中...") waitDialog.show() viewModel.addBookByUrl(it) } } cancelButton() } } @SuppressLint("InflateParams") fun configBookshelf() { alert(titleResource = R.string.bookshelf_layout) { var bookshelfLayout = AppConfig.bookshelfLayout var bookshelfSort = AppConfig.bookshelfSort val alertBinding = DialogBookshelfConfigBinding.inflate(layoutInflater) .apply { if (AppConfig.bookGroupStyle !in 0..? = null fun addBookByUrl(bookUrls: String) { var successCount = 0 addBookJob = execute { val hasBookUrlPattern: List by lazy { appDb.bookSourceDao.hasBookUrlPattern } val urls = bookUrls.split("\n") for (url in urls) { val bookUrl = url.trim() if (bookUrl.isEmpty()) continue if (appDb.bookDao.getBook(bookUrl) != null) { successCount++ continue } val baseUrl = NetworkUtils.getBaseUrl(bookUrl) ?: continue var source = appDb.bookSourceDao.getBookSourceAddBook(baseUrl) if (source == null) { for (bookSource in hasBookUrlPattern) { try { val bs = bookSource.getBookSource()!! if (bookUrl.matches(bs.bookUrlPattern!!.toRegex())) { source = bs break } } catch (_: Exception) { } } } val bookSource = source ?: continue val book = Book( bookUrl = bookUrl, origin = bookSource.bookSourceUrl, originName = bookSource.bookSourceName ) kotlin.runCatching { WebBook.getBookInfoAwait(bookSource, book) }.onSuccess { val dbBook = appDb.bookDao.getBook(it.name, it.author) if (dbBook != null) { val toc = WebBook.getChapterListAwait(bookSource, it).getOrThrow() dbBook.migrateTo(it, toc) appDb.bookDao.insert(it) appDb.bookChapterDao.insert(*toc.toTypedArray()) } else { it.order = appDb.bookDao.minOrder - 1 it.save() } successCount++ addBookProgressLiveData.postValue(successCount) } } }.onSuccess { if (successCount > 0) { context.toastOnUi(R.string.success) } else { context.toastOnUi("添加网址失败") } }.onError { AppLog.put("添加网址出错\n${it.localizedMessage}", it, true) }.onFinally { addBookProgressLiveData.postValue(-1) } } fun exportBookshelf(books: List?, success: (file: File) -> Unit) { execute { books?.let { val path = "${context.filesDir}/books.json" FileUtils.delete(path) val file = FileUtils.createFileWithReplace(path) FileOutputStream(file).use { out -> val writer = JsonWriter(OutputStreamWriter(out, "UTF-8")) writer.setIndent(" ") writer.beginArray() books.forEach { val bookMap = hashMapOf() bookMap["name"] = it.name bookMap["author"] = it.author bookMap["intro"] = it.getDisplayIntro() GSON.toJson(bookMap, bookMap::class.java, writer) } writer.endArray() writer.close() } file } ?: throw NoStackTraceException("书籍不能为空") }.onSuccess { success(it) }.onError { context.toastOnUi("导出书籍出错\n${it.localizedMessage}") } } fun importBookshelf(str: String, groupId: Long) { execute { val text = str.trim() when { text.isAbsUrl() -> { okHttpClient.newCallResponseBody { url(text) }.decompressed().text().let { importBookshelf(it, groupId) } } text.isJsonArray() -> { importBookshelfByJson(text, groupId) } else -> { throw NoStackTraceException("格式不对") } } }.onError { context.toastOnUi(it.localizedMessage ?: "ERROR") } } private fun importBookshelfByJson(json: String, groupId: Long) { execute { val bookSourceParts = appDb.bookSourceDao.allEnabledPart val semaphore = Semaphore(AppConfig.threadCount) GSON.fromJsonArray>(json).getOrThrow().forEach { bookInfo -> val name = bookInfo["name"] ?: "" val author = bookInfo["author"] ?: "" if (name.isEmpty() || appDb.bookDao.has(name, author)) { return@forEach } semaphore.withPermit { WebBook.preciseSearch( this, bookSourceParts, name, author, semaphore = semaphore ).onSuccess { val book = it.first if (groupId > 0) { book.group = groupId } book.save() }.onError { e -> context.toastOnUi(e.localizedMessage) } } } }.onError { it.printOnDebug() }.onFinally { context.toastOnUi(R.string.success) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/main/bookshelf/style1/BookshelfFragment1.kt ================================================ @file:Suppress("DEPRECATION") package io.legado.app.ui.main.bookshelf.style1 import android.os.Bundle import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.SearchView import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentStatePagerAdapter import com.google.android.material.tabs.TabLayout import io.legado.app.R import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookGroup import io.legado.app.databinding.FragmentBookshelf1Binding import io.legado.app.help.config.AppConfig import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.book.group.GroupEditDialog import io.legado.app.ui.book.search.SearchActivity import io.legado.app.ui.main.bookshelf.BaseBookshelfFragment import io.legado.app.ui.main.bookshelf.style1.books.BooksFragment import io.legado.app.utils.isCreated import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.showDialogFragment import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlin.collections.set /** * 书架界面 */ class BookshelfFragment1() : BaseBookshelfFragment(R.layout.fragment_bookshelf1), TabLayout.OnTabSelectedListener, SearchView.OnQueryTextListener { constructor(position: Int) : this() { val bundle = Bundle() bundle.putInt("position", position) arguments = bundle } private val binding by viewBinding(FragmentBookshelf1Binding::bind) private val adapter by lazy { TabFragmentPageAdapter(childFragmentManager) } private val tabLayout: TabLayout by lazy { binding.titleBar.findViewById(R.id.tab_layout) } private val bookGroups = mutableListOf() private val fragmentMap = hashMapOf() override val groupId: Long get() = selectedGroup?.groupId ?: 0 override val books: List get() { val fragment = fragmentMap[groupId] return fragment?.getBooks() ?: emptyList() } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { setSupportToolbar(binding.titleBar.toolbar) initView() initBookGroupData() } private val selectedGroup: BookGroup? get() = bookGroups.getOrNull(tabLayout.selectedTabPosition) private fun initView() { binding.viewPagerBookshelf.setEdgeEffectColor(primaryColor) tabLayout.isTabIndicatorFullWidth = false tabLayout.tabMode = TabLayout.MODE_SCROLLABLE tabLayout.setSelectedTabIndicatorColor(requireContext().accentColor) tabLayout.setupWithViewPager(binding.viewPagerBookshelf) binding.viewPagerBookshelf.offscreenPageLimit = 1 binding.viewPagerBookshelf.adapter = adapter } override fun onQueryTextSubmit(query: String?): Boolean { SearchActivity.start(requireContext(), query) return false } override fun onQueryTextChange(newText: String?): Boolean { return false } @Synchronized override fun upGroup(data: List) { if (data.isEmpty()) { appDb.bookGroupDao.enableGroup(BookGroup.IdAll) } else { if (data != bookGroups) { bookGroups.clear() bookGroups.addAll(data) adapter.notifyDataSetChanged() selectLastTab() for (i in 0 until adapter.count) { tabLayout.getTabAt(i)?.view?.setOnLongClickListener { showDialogFragment(GroupEditDialog(bookGroups[i])) true } } } } } override fun upSort() { adapter.notifyDataSetChanged() } private fun selectLastTab() { tabLayout.post { tabLayout.removeOnTabSelectedListener(this) tabLayout.getTabAt(AppConfig.saveTabPosition)?.select() tabLayout.addOnTabSelectedListener(this) } } override fun onTabReselected(tab: TabLayout.Tab) { selectedGroup?.let { group -> fragmentMap[group.groupId]?.let { toastOnUi("${group.groupName}(${it.getBooksCount()})") } } } override fun onTabUnselected(tab: TabLayout.Tab) = Unit override fun onTabSelected(tab: TabLayout.Tab) { AppConfig.saveTabPosition = tab.position } override fun gotoTop() { fragmentMap[groupId]?.gotoTop() } private inner class TabFragmentPageAdapter(fm: FragmentManager) : FragmentStatePagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { override fun getPageTitle(position: Int): CharSequence { return bookGroups[position].groupName } /** * 确定视图位置是否更改时调用 * @return POSITION_NONE 已更改,刷新视图. POSITION_UNCHANGED 未更改,不刷新视图 */ override fun getItemPosition(any: Any): Int { val fragment = any as BooksFragment val position = fragment.position val group = bookGroups.getOrNull(position) if (fragment.groupId != group?.groupId) { return POSITION_NONE } val bookSort = group.getRealBookSort() fragment.setEnableRefresh(group.enableRefresh) if (fragment.bookSort != bookSort) { fragment.upBookSort(bookSort) } return POSITION_UNCHANGED } override fun getItem(position: Int): Fragment { val group = bookGroups[position] return BooksFragment(position, group) } override fun getCount(): Int { return bookGroups.size } override fun instantiateItem(container: ViewGroup, position: Int): Any { var fragment = super.instantiateItem(container, position) as BooksFragment val group = bookGroups[position] /** * Activity recreate 会复用之前的 Fragment,不正确的需要重新创建 */ if (fragment.isCreated && getItemPosition(fragment) == POSITION_NONE) { destroyItem(container, position, fragment) fragment = super.instantiateItem(container, position) as BooksFragment } fragmentMap[group.groupId] = fragment return fragment } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/main/bookshelf/style1/books/BaseBooksAdapter.kt ================================================ package io.legado.app.ui.main.bookshelf.style1.books import android.content.Context import androidx.core.os.bundleOf import androidx.recyclerview.widget.DiffUtil import androidx.viewbinding.ViewBinding import io.legado.app.base.adapter.DiffRecyclerAdapter import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.data.entities.Book abstract class BaseBooksAdapter(context: Context) : DiffRecyclerAdapter(context) { override val keepScrollPosition = true override val diffItemCallback: DiffUtil.ItemCallback = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: Book, newItem: Book): Boolean { return oldItem.name == newItem.name && oldItem.author == newItem.author } override fun areContentsTheSame(oldItem: Book, newItem: Book): Boolean { return when { oldItem.durChapterTime != newItem.durChapterTime -> false oldItem.name != newItem.name -> false oldItem.author != newItem.author -> false oldItem.durChapterTitle != newItem.durChapterTitle -> false oldItem.latestChapterTitle != newItem.latestChapterTitle -> false oldItem.lastCheckCount != newItem.lastCheckCount -> false oldItem.getDisplayCover() != newItem.getDisplayCover() -> false oldItem.getUnreadChapterNum() != newItem.getUnreadChapterNum() -> false else -> true } } override fun getChangePayload(oldItem: Book, newItem: Book): Any? { val bundle = bundleOf() if (oldItem.name != newItem.name) { bundle.putString("name", newItem.name) } if (oldItem.author != newItem.author) { bundle.putString("author", newItem.author) } if (oldItem.durChapterTitle != newItem.durChapterTitle) { bundle.putString("dur", newItem.durChapterTitle) } if (oldItem.latestChapterTitle != newItem.latestChapterTitle) { bundle.putString("last", newItem.latestChapterTitle) } if (oldItem.getDisplayCover() != newItem.getDisplayCover()) { bundle.putString("cover", newItem.getDisplayCover()) } if (oldItem.lastCheckCount != newItem.lastCheckCount || oldItem.durChapterTime != newItem.durChapterTime || oldItem.getUnreadChapterNum() != newItem.getUnreadChapterNum() || oldItem.lastCheckCount != newItem.lastCheckCount ) { bundle.putBoolean("refresh", true) } if (oldItem.latestChapterTime != newItem.latestChapterTime) { bundle.putBoolean("lastUpdateTime", true) } if (bundle.isEmpty) return null return bundle } } override fun onViewRecycled(holder: ItemViewHolder) { super.onViewRecycled(holder) holder.itemView.setOnClickListener(null) holder.itemView.setOnLongClickListener(null) } fun notification(bookUrl: String) { getItems().forEachIndexed { i, it -> if (it.bookUrl == bookUrl) { notifyItemChanged(i, bundleOf(Pair("refresh", null), Pair("lastUpdateTime", null))) return } } } fun upLastUpdateTime() { notifyItemRangeChanged(0, itemCount, bundleOf(Pair("lastUpdateTime", null))) } interface CallBack { fun open(book: Book) fun openBookInfo(book: Book) fun isUpdate(bookUrl: String): Boolean } } ================================================ FILE: app/src/main/java/io/legado/app/ui/main/bookshelf/style1/books/BooksAdapterGrid.kt ================================================ package io.legado.app.ui.main.bookshelf.style1.books import android.content.Context import android.os.Bundle import android.view.ViewGroup import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.data.entities.Book import io.legado.app.databinding.ItemBookshelfGridBinding import io.legado.app.help.book.isLocal import io.legado.app.help.config.AppConfig import io.legado.app.utils.invisible import splitties.views.onLongClick class BooksAdapterGrid(context: Context, private val callBack: CallBack) : BaseBooksAdapter(context) { override fun getViewBinding(parent: ViewGroup): ItemBookshelfGridBinding { return ItemBookshelfGridBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemBookshelfGridBinding, item: Book, payloads: MutableList ) = binding.run { if (payloads.isEmpty()) { tvName.text = item.name ivCover.load(item.getDisplayCover(), item.name, item.author, false, item.origin) upRefresh(binding, item) } else { for (i in payloads.indices) { val bundle = payloads[i] as Bundle bundle.keySet().forEach { when (it) { "name" -> tvName.text = item.name "cover" -> ivCover.load(item.getDisplayCover(), item.name, item.author, false, item.origin) "refresh" -> upRefresh(binding, item) } } } } } private fun upRefresh(binding: ItemBookshelfGridBinding, item: Book) { if (!item.isLocal && callBack.isUpdate(item.bookUrl)) { binding.bvUnread.invisible() binding.rlLoading.visible() } else { binding.rlLoading.inVisible() if (AppConfig.showUnread) { binding.bvUnread.setBadgeCount(item.getUnreadChapterNum()) binding.bvUnread.setHighlight(item.lastCheckCount > 0) } else { binding.bvUnread.invisible() } } } override fun registerListener(holder: ItemViewHolder, binding: ItemBookshelfGridBinding) { holder.itemView.apply { setOnClickListener { getItem(holder.layoutPosition)?.let { callBack.open(it) } } onLongClick { getItem(holder.layoutPosition)?.let { callBack.openBookInfo(it) } } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/main/bookshelf/style1/books/BooksAdapterList.kt ================================================ package io.legado.app.ui.main.bookshelf.style1.books import android.content.Context import android.os.Bundle import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.data.entities.Book import io.legado.app.databinding.ItemBookshelfListBinding import io.legado.app.help.book.isLocal import io.legado.app.help.config.AppConfig import io.legado.app.utils.invisible import io.legado.app.utils.toTimeAgo import splitties.views.onLongClick class BooksAdapterList( context: Context, private val fragment: Fragment, private val callBack: CallBack, private val lifecycle: Lifecycle ) : BaseBooksAdapter(context) { override fun getViewBinding(parent: ViewGroup): ItemBookshelfListBinding { return ItemBookshelfListBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemBookshelfListBinding, item: Book, payloads: MutableList ) = binding.run { if (payloads.isEmpty()) { tvName.text = item.name tvAuthor.text = item.author tvRead.text = item.durChapterTitle tvLast.text = item.latestChapterTitle ivCover.load(item.getDisplayCover(), item.name, item.author, false, item.origin) upRefresh(binding, item) upLastUpdateTime(binding, item) } else { for (i in payloads.indices) { val bundle = payloads[i] as Bundle bundle.keySet().forEach { when (it) { "name" -> tvName.text = item.name "author" -> tvAuthor.text = item.author "dur" -> tvRead.text = item.durChapterTitle "last" -> tvLast.text = item.latestChapterTitle "cover" -> ivCover.load( item.getDisplayCover(), item.name, item.author, false, item.origin, fragment, lifecycle ) "refresh" -> upRefresh(binding, item) "lastUpdateTime" -> upLastUpdateTime(binding, item) } } } } } private fun upRefresh(binding: ItemBookshelfListBinding, item: Book) { if (!item.isLocal && callBack.isUpdate(item.bookUrl)) { binding.bvUnread.invisible() binding.rlLoading.visible() } else { binding.rlLoading.gone() if (AppConfig.showUnread) { binding.bvUnread.setHighlight(item.lastCheckCount > 0) binding.bvUnread.setBadgeCount(item.getUnreadChapterNum()) } else { binding.bvUnread.invisible() } } } private fun upLastUpdateTime(binding: ItemBookshelfListBinding, item: Book) { if (AppConfig.showLastUpdateTime && !item.isLocal) { val time = item.latestChapterTime.toTimeAgo() if (binding.tvLastUpdateTime.text != time) { binding.tvLastUpdateTime.text = time } } else { binding.tvLastUpdateTime.text = "" } } override fun registerListener(holder: ItemViewHolder, binding: ItemBookshelfListBinding) { holder.itemView.apply { setOnClickListener { getItem(holder.layoutPosition)?.let { callBack.open(it) } } onLongClick { getItem(holder.layoutPosition)?.let { callBack.openBookInfo(it) } } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/main/bookshelf/style1/books/BooksFragment.kt ================================================ package io.legado.app.ui.main.bookshelf.style1.books import android.annotation.SuppressLint import android.os.Bundle import android.view.View import android.view.ViewConfiguration import androidx.core.view.isGone import androidx.fragment.app.activityViewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.Adapter.StateRestorationPolicy import io.legado.app.R import io.legado.app.base.BaseFragment import io.legado.app.constant.AppLog import io.legado.app.constant.EventBus import io.legado.app.data.AppDatabase import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookGroup import io.legado.app.databinding.FragmentBooksBinding import io.legado.app.help.config.AppConfig import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.book.info.BookInfoActivity import io.legado.app.ui.main.MainViewModel import io.legado.app.utils.cnCompare import io.legado.app.utils.flowWithLifecycleAndDatabaseChangeFirst import io.legado.app.utils.observeEvent import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.startActivity import io.legado.app.utils.startActivityForBook import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlin.math.max /** * 书架界面 */ class BooksFragment() : BaseFragment(R.layout.fragment_books), BaseBooksAdapter.CallBack { constructor(position: Int, group: BookGroup) : this() { val bundle = Bundle() bundle.putInt("position", position) bundle.putLong("groupId", group.groupId) bundle.putInt("bookSort", group.getRealBookSort()) bundle.putBoolean("enableRefresh", group.enableRefresh) arguments = bundle } private val binding by viewBinding(FragmentBooksBinding::bind) private val activityViewModel by activityViewModels() private val bookshelfLayout by lazy { AppConfig.bookshelfLayout } private val booksAdapter: BaseBooksAdapter<*> by lazy { if (bookshelfLayout == 0) { BooksAdapterList(requireContext(), this, this, viewLifecycleOwner.lifecycle) } else { BooksAdapterGrid(requireContext(), this) } } private var booksFlowJob: Job? = null var position = 0 private set var groupId = -1L private set var bookSort = 0 private set private var upLastUpdateTimeJob: Job? = null private var enableRefresh = true override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { arguments?.let { position = it.getInt("position", 0) groupId = it.getLong("groupId", -1) bookSort = it.getInt("bookSort", 0) enableRefresh = it.getBoolean("enableRefresh", true) binding.refreshLayout.isEnabled = enableRefresh } initRecyclerView() upRecyclerData() } private fun initRecyclerView() { binding.rvBookshelf.setEdgeEffectColor(primaryColor) upFastScrollerBar() binding.refreshLayout.setColorSchemeColors(accentColor) binding.refreshLayout.setOnRefreshListener { binding.refreshLayout.isRefreshing = false activityViewModel.upToc(booksAdapter.getItems()) } if (bookshelfLayout == 0) { binding.rvBookshelf.layoutManager = LinearLayoutManager(context) } else { binding.rvBookshelf.layoutManager = GridLayoutManager(context, bookshelfLayout + 2) } if (bookshelfLayout == 0) { binding.rvBookshelf.setRecycledViewPool(activityViewModel.booksListRecycledViewPool) } else { binding.rvBookshelf.setRecycledViewPool(activityViewModel.booksGridRecycledViewPool) } booksAdapter.stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY binding.rvBookshelf.adapter = booksAdapter booksAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { val layoutManager = binding.rvBookshelf.layoutManager if (positionStart == 0 && itemCount == 1 && layoutManager is LinearLayoutManager) { val scrollTo = layoutManager.findFirstVisibleItemPosition() - itemCount binding.rvBookshelf.scrollToPosition(max(0, scrollTo)) } } override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) { val layoutManager = binding.rvBookshelf.layoutManager if (toPosition == 0 && itemCount == 1 && layoutManager is LinearLayoutManager) { val scrollTo = layoutManager.findFirstVisibleItemPosition() - itemCount binding.rvBookshelf.scrollToPosition(max(0, scrollTo)) } } }) startLastUpdateTimeJob() } private fun upFastScrollerBar() { val showBookshelfFastScroller = AppConfig.showBookshelfFastScroller binding.rvBookshelf.setFastScrollEnabled(showBookshelfFastScroller) if (showBookshelfFastScroller) { binding.rvBookshelf.scrollBarSize = 0 } else { binding.rvBookshelf.scrollBarSize = ViewConfiguration.get(requireContext()).scaledScrollBarSize } } fun upBookSort(sort: Int) { binding.root.post { arguments?.putInt("bookSort", sort) bookSort = sort upRecyclerData() } } fun setEnableRefresh(enable: Boolean) { enableRefresh = enable binding.refreshLayout.isEnabled = enable } /** * 更新书籍列表信息 */ private fun upRecyclerData() { booksFlowJob?.cancel() booksFlowJob = viewLifecycleOwner.lifecycleScope.launch { appDb.bookDao.flowByGroup(groupId).map { list -> //排序 when (bookSort) { 1 -> list.sortedByDescending { it.latestChapterTime } 2 -> list.sortedWith { o1, o2 -> o1.name.cnCompare(o2.name) } 3 -> list.sortedBy { it.order } // 综合排序 issue #3192 4 -> list.sortedByDescending { max(it.latestChapterTime, it.durChapterTime) } // 按作者排序 5 -> list.sortedWith { o1, o2 -> o1.author.cnCompare(o2.author) } else -> list.sortedByDescending { it.durChapterTime } } }.flowWithLifecycleAndDatabaseChangeFirst( viewLifecycleOwner.lifecycle, Lifecycle.State.RESUMED, AppDatabase.BOOK_TABLE_NAME ).catch { AppLog.put("书架更新出错", it) }.conflate().flowOn(Dispatchers.Default).collect { list -> binding.tvEmptyMsg.isGone = list.isNotEmpty() binding.refreshLayout.isEnabled = enableRefresh && list.isNotEmpty() booksAdapter.setItems(list) delay(100) } } } private fun startLastUpdateTimeJob() { upLastUpdateTimeJob?.cancel() if (!AppConfig.showLastUpdateTime || bookshelfLayout != 0) { return } upLastUpdateTimeJob = viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.RESUMED) { while (isActive) { booksAdapter.upLastUpdateTime() delay(30 * 1000) } } } } fun getBooks(): List { return booksAdapter.getItems() } fun gotoTop() { if (AppConfig.isEInkMode) { binding.rvBookshelf.scrollToPosition(0) } else { binding.rvBookshelf.smoothScrollToPosition(0) } } fun getBooksCount(): Int { return booksAdapter.itemCount } override fun onDestroyView() { super.onDestroyView() /** * 将 RecyclerView 中的视图全部回收到 RecycledViewPool 中 */ binding.rvBookshelf.setItemViewCacheSize(0) binding.rvBookshelf.adapter = null } override fun open(book: Book) { startActivityForBook(book) } override fun openBookInfo(book: Book) { startActivity { putExtra("name", book.name) putExtra("author", book.author) } } override fun isUpdate(bookUrl: String): Boolean { return activityViewModel.isUpdate(bookUrl) } @SuppressLint("NotifyDataSetChanged") override fun observeLiveBus() { super.observeLiveBus() observeEvent(EventBus.UP_BOOKSHELF) { booksAdapter.notification(it) } observeEvent(EventBus.BOOKSHELF_REFRESH) { booksAdapter.notifyDataSetChanged() startLastUpdateTimeJob() upFastScrollerBar() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/main/bookshelf/style2/BaseBooksAdapter.kt ================================================ package io.legado.app.ui.main.bookshelf.style2 import android.content.Context import android.view.LayoutInflater import androidx.core.os.bundleOf import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookGroup abstract class BaseBooksAdapter( val context: Context, val callBack: CallBack ) : RecyclerView.Adapter() { protected val inflater: LayoutInflater = LayoutInflater.from(context) private val diffItemCallback = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: Any, newItem: Any): Boolean { return when { oldItem is Book && newItem is Book -> { oldItem.name == newItem.name && oldItem.author == newItem.author } oldItem is BookGroup && newItem is BookGroup -> { oldItem.groupId == newItem.groupId } else -> false } } override fun areContentsTheSame(oldItem: Any, newItem: Any): Boolean { return when { oldItem is Book && newItem is Book -> { oldItem.durChapterTime == newItem.durChapterTime && oldItem.name == newItem.name && oldItem.author == newItem.author && oldItem.durChapterTitle == newItem.durChapterTitle && oldItem.latestChapterTitle == newItem.latestChapterTitle && oldItem.lastCheckCount == newItem.lastCheckCount && oldItem.getDisplayCover() == newItem.getDisplayCover() && oldItem.getUnreadChapterNum() == newItem.getUnreadChapterNum() } oldItem is BookGroup && newItem is BookGroup -> { oldItem.groupName == newItem.groupName && oldItem.cover == newItem.cover } else -> false } } override fun getChangePayload(oldItem: Any, newItem: Any): Any? { val bundle = bundleOf() when { oldItem is Book && newItem is Book -> { if (oldItem.name != newItem.name) { bundle.putString("name", newItem.name) } if (oldItem.author != newItem.author) { bundle.putString("author", newItem.author) } if (oldItem.durChapterTitle != newItem.durChapterTitle) { bundle.putString("dur", newItem.durChapterTitle) } if (oldItem.latestChapterTitle != newItem.latestChapterTitle) { bundle.putString("last", newItem.latestChapterTitle) } if (oldItem.getDisplayCover() != newItem.getDisplayCover()) { bundle.putString("cover", newItem.getDisplayCover()) } if (oldItem.lastCheckCount != newItem.lastCheckCount || oldItem.durChapterTime != newItem.durChapterTime || oldItem.getUnreadChapterNum() != newItem.getUnreadChapterNum() || oldItem.lastCheckCount != newItem.lastCheckCount ) { bundle.putBoolean("refresh", true) } } oldItem is BookGroup && newItem is BookGroup -> { if (oldItem.groupName != newItem.groupName) { bundle.putString("groupName", newItem.groupName) } if (oldItem.cover != newItem.cover) { bundle.putString("cover", newItem.cover) } } } if (bundle.isEmpty) return null return bundle } } private val asyncListDiffer by lazy { AsyncListDiffer(this, diffItemCallback) } fun updateItems() { asyncListDiffer.submitList(callBack.getItems()) } fun notification(bookUrl: String) { for (i in 0 until itemCount) { getItem(i).let { if (it is Book && it.bookUrl == bookUrl) { notifyItemChanged(i, bundleOf(Pair("refresh", null))) return } } } } fun getItems() = asyncListDiffer.currentList fun getItem(position: Int) = getItems().getOrNull(position) override fun getItemCount(): Int { return getItems().size } override fun getItemViewType(position: Int): Int { if (getItem(position) is BookGroup) { return 1 } return 0 } final override fun onBindViewHolder(holder: VH, position: Int) {} interface CallBack { fun onItemClick(item: Any) fun onItemLongClick(item: Any) fun isUpdate(bookUrl: String): Boolean fun getItems(): List } } ================================================ FILE: app/src/main/java/io/legado/app/ui/main/bookshelf/style2/BooksAdapterGrid.kt ================================================ package io.legado.app.ui.main.bookshelf.style2 import android.content.Context import android.os.Bundle import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookGroup import io.legado.app.databinding.ItemBookshelfGridBinding import io.legado.app.databinding.ItemBookshelfGridGroupBinding import io.legado.app.help.book.isLocal import io.legado.app.help.config.AppConfig import io.legado.app.utils.invisible import splitties.views.onLongClick @Suppress("UNUSED_PARAMETER") class BooksAdapterGrid(context: Context, callBack: CallBack) : BaseBooksAdapter(context, callBack) { override fun onCreateViewHolder( parent: ViewGroup, viewType: Int ): RecyclerView.ViewHolder { return when (viewType) { 1 -> GroupViewHolder(ItemBookshelfGridGroupBinding.inflate(inflater, parent, false)) else -> BookViewHolder(ItemBookshelfGridBinding.inflate(inflater, parent, false)) } } override fun onBindViewHolder( holder: RecyclerView.ViewHolder, position: Int, payloads: MutableList ) { when (holder) { is BookViewHolder -> (getItem(position) as? Book)?.let { holder.registerListener(it) holder.onBind(it, position, payloads) } is GroupViewHolder -> (getItem(position) as? BookGroup)?.let { holder.registerListener(it) holder.onBind(it, position, payloads) } } } inner class BookViewHolder(val binding: ItemBookshelfGridBinding) : RecyclerView.ViewHolder(binding.root) { fun onBind(item: Book, position: Int) = binding.run { tvName.text = item.name ivCover.load(item.getDisplayCover(), item.name, item.author, false, item.origin) upRefresh(this, item) } fun onBind(item: Book, position: Int, payloads: MutableList) = binding.run { if (payloads.isEmpty()) { onBind(item, position) } else { for (i in payloads.indices) { val bundle = payloads[i] as Bundle bundle.keySet().forEach { when (it) { "name" -> tvName.text = item.name "cover" -> ivCover.load( item.getDisplayCover(), item.name, item.author, false, item.origin ) "refresh" -> upRefresh(this, item) } } } } } fun registerListener(item: Any) { binding.root.setOnClickListener { callBack.onItemClick(item) } binding.root.onLongClick { callBack.onItemLongClick(item) } } private fun upRefresh(binding: ItemBookshelfGridBinding, item: Book) { if (!item.isLocal && callBack.isUpdate(item.bookUrl)) { binding.bvUnread.invisible() binding.rlLoading.visible() } else { binding.rlLoading.inVisible() if (AppConfig.showUnread) { binding.bvUnread.setBadgeCount(item.getUnreadChapterNum()) binding.bvUnread.setHighlight(item.lastCheckCount > 0) } else { binding.bvUnread.invisible() } } } } inner class GroupViewHolder(val binding: ItemBookshelfGridGroupBinding) : RecyclerView.ViewHolder(binding.root) { fun onBind(item: BookGroup, position: Int) = binding.run { tvName.text = item.groupName ivCover.load(item.cover) } fun onBind(item: BookGroup, position: Int, payloads: MutableList) = binding.run { if (payloads.isEmpty()) { onBind(item, position) } else { for (i in payloads.indices) { val bundle = payloads[i] as Bundle bundle.keySet().forEach { when (it) { "groupName" -> tvName.text = item.groupName "cover" -> ivCover.load(item.cover) } } } } } fun registerListener(item: Any) { binding.root.setOnClickListener { callBack.onItemClick(item) } binding.root.onLongClick { callBack.onItemLongClick(item) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/main/bookshelf/style2/BooksAdapterList.kt ================================================ package io.legado.app.ui.main.bookshelf.style2 import android.content.Context import android.os.Bundle import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookGroup import io.legado.app.databinding.ItemBookshelfListBinding import io.legado.app.databinding.ItemBookshelfListGroupBinding import io.legado.app.help.book.isLocal import io.legado.app.help.config.AppConfig import io.legado.app.utils.gone import io.legado.app.utils.invisible import io.legado.app.utils.visible import splitties.views.onLongClick @Suppress("UNUSED_PARAMETER") class BooksAdapterList(context: Context, callBack: CallBack) : BaseBooksAdapter(context, callBack) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return when (viewType) { 1 -> GroupViewHolder(ItemBookshelfListGroupBinding.inflate(inflater, parent, false)) else -> BookViewHolder(ItemBookshelfListBinding.inflate(inflater, parent, false)) } } override fun onBindViewHolder( holder: RecyclerView.ViewHolder, position: Int, payloads: MutableList ) { when (holder) { is BookViewHolder -> (getItem(position) as? Book)?.let { holder.registerListener(it) holder.onBind(it, position, payloads) } is GroupViewHolder -> (getItem(position) as? BookGroup)?.let { holder.registerListener(it) holder.onBind(it, position, payloads) } } } inner class BookViewHolder(val binding: ItemBookshelfListBinding) : RecyclerView.ViewHolder(binding.root) { fun onBind(item: Book, position: Int) = binding.run { tvName.text = item.name tvAuthor.text = item.author tvRead.text = item.durChapterTitle tvLast.text = item.latestChapterTitle ivCover.load(item.getDisplayCover(), item.name, item.author, false, item.origin) flHasNew.visible() ivAuthor.visible() ivLast.visible() ivRead.visible() upRefresh(this, item) } fun onBind(item: Book, position: Int, payloads: MutableList) = binding.run { if (payloads.isEmpty()) { onBind(item, position) } else { for (i in payloads.indices) { val bundle = payloads[i] as Bundle bundle.keySet().forEach { when (it) { "name" -> tvName.text = item.name "author" -> tvAuthor.text = item.author "dur" -> tvRead.text = item.durChapterTitle "last" -> tvLast.text = item.latestChapterTitle "cover" -> ivCover.load( item.getDisplayCover(), item.name, item.author, false, item.origin ) "refresh" -> upRefresh(this, item) } } } } } fun registerListener(item: Any) { binding.root.setOnClickListener { callBack.onItemClick(item) } binding.root.onLongClick { callBack.onItemLongClick(item) } } private fun upRefresh(binding: ItemBookshelfListBinding, item: Book) { if (!item.isLocal && callBack.isUpdate(item.bookUrl)) { binding.bvUnread.invisible() binding.rlLoading.visible() } else { binding.rlLoading.gone() if (AppConfig.showUnread) { binding.bvUnread.setHighlight(item.lastCheckCount > 0) binding.bvUnread.setBadgeCount(item.getUnreadChapterNum()) } else { binding.bvUnread.invisible() } } } } inner class GroupViewHolder(val binding: ItemBookshelfListGroupBinding) : RecyclerView.ViewHolder(binding.root) { fun onBind(item: BookGroup, position: Int) = binding.run { tvName.text = item.groupName ivCover.load(item.cover) flHasNew.gone() ivAuthor.gone() ivLast.gone() ivRead.gone() tvAuthor.gone() tvLast.gone() tvRead.gone() } fun onBind(item: BookGroup, position: Int, payloads: MutableList) = binding.run { if (payloads.isEmpty()) { onBind(item, position) } else { for (i in payloads.indices) { val bundle = payloads[i] as Bundle bundle.keySet().forEach { when (it) { "groupName" -> tvName.text = item.groupName "cover" -> ivCover.load(item.cover) } } } } } fun registerListener(item: Any) { binding.root.setOnClickListener { callBack.onItemClick(item) } binding.root.onLongClick { callBack.onItemLongClick(item) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/main/bookshelf/style2/BookshelfFragment2.kt ================================================ package io.legado.app.ui.main.bookshelf.style2 import android.annotation.SuppressLint import android.os.Bundle import android.view.View import androidx.appcompat.widget.SearchView import androidx.core.view.isGone import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import io.legado.app.R import io.legado.app.constant.AppLog import io.legado.app.constant.EventBus import io.legado.app.data.AppDatabase import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookGroup import io.legado.app.databinding.FragmentBookshelf2Binding import io.legado.app.help.config.AppConfig import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.book.group.GroupEditDialog import io.legado.app.ui.book.info.BookInfoActivity import io.legado.app.ui.book.search.SearchActivity import io.legado.app.ui.main.bookshelf.BaseBookshelfFragment import io.legado.app.utils.cnCompare import io.legado.app.utils.flowWithLifecycleAndDatabaseChangeFirst import io.legado.app.utils.observeEvent import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.showDialogFragment import io.legado.app.utils.startActivity import io.legado.app.utils.startActivityForBook import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlin.math.max /** * 书架界面 */ class BookshelfFragment2() : BaseBookshelfFragment(R.layout.fragment_bookshelf2), SearchView.OnQueryTextListener, BaseBooksAdapter.CallBack { constructor(position: Int) : this() { val bundle = Bundle() bundle.putInt("position", position) arguments = bundle } private val binding by viewBinding(FragmentBookshelf2Binding::bind) private val bookshelfLayout by lazy { AppConfig.bookshelfLayout } private val booksAdapter: BaseBooksAdapter<*> by lazy { if (bookshelfLayout == 0) { BooksAdapterList(requireContext(), this) } else { BooksAdapterGrid(requireContext(), this) } } private var bookGroups: List = emptyList() private var booksFlowJob: Job? = null override var groupId = BookGroup.IdRoot override var books: List = emptyList() private var enableRefresh = true override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { setSupportToolbar(binding.titleBar.toolbar) initRecyclerView() initBookGroupData() initBooksData() } private fun initRecyclerView() { binding.rvBookshelf.setEdgeEffectColor(primaryColor) binding.refreshLayout.setColorSchemeColors(accentColor) binding.refreshLayout.setOnRefreshListener { binding.refreshLayout.isRefreshing = false activityViewModel.upToc(books) } if (bookshelfLayout == 0) { binding.rvBookshelf.layoutManager = LinearLayoutManager(context) } else { binding.rvBookshelf.layoutManager = GridLayoutManager(context, bookshelfLayout + 2) } binding.rvBookshelf.itemAnimator = null binding.rvBookshelf.adapter = booksAdapter booksAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { val layoutManager = binding.rvBookshelf.layoutManager if (positionStart == 0 && layoutManager is LinearLayoutManager) { val scrollTo = layoutManager.findFirstVisibleItemPosition() - itemCount binding.rvBookshelf.scrollToPosition(max(0, scrollTo)) } } override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) { val layoutManager = binding.rvBookshelf.layoutManager if (toPosition == 0 && layoutManager is LinearLayoutManager) { val scrollTo = layoutManager.findFirstVisibleItemPosition() - itemCount binding.rvBookshelf.scrollToPosition(max(0, scrollTo)) } } }) } override fun upGroup(data: List) { if (data != bookGroups) { bookGroups = data booksAdapter.updateItems() binding.tvEmptyMsg.isGone = getItemCount() > 0 binding.refreshLayout.isEnabled = enableRefresh && getItemCount() > 0 } } override fun upSort() { initBooksData() } private fun initBooksData() { if (groupId == BookGroup.IdRoot) { if (isAdded) { binding.titleBar.title = getString(R.string.bookshelf) binding.refreshLayout.isEnabled = true enableRefresh = true } } else { bookGroups.firstOrNull { groupId == it.groupId }?.let { binding.titleBar.title = "${getString(R.string.bookshelf)}(${it.groupName})" binding.refreshLayout.isEnabled = it.enableRefresh enableRefresh = it.enableRefresh } } booksFlowJob?.cancel() booksFlowJob = viewLifecycleOwner.lifecycleScope.launch { appDb.bookDao.flowByGroup(groupId).map { list -> //排序 when (AppConfig.getBookSortByGroupId(groupId)) { 1 -> list.sortedByDescending { it.latestChapterTime } 2 -> list.sortedWith { o1, o2 -> o1.name.cnCompare(o2.name) } 3 -> list.sortedBy { it.order } 4 -> list.sortedByDescending { max(it.latestChapterTime, it.durChapterTime) } else -> list.sortedByDescending { it.durChapterTime } } }.flowWithLifecycleAndDatabaseChangeFirst( viewLifecycleOwner.lifecycle, Lifecycle.State.RESUMED, AppDatabase.BOOK_TABLE_NAME ).catch { AppLog.put("书架更新出错", it) }.conflate().flowOn(Dispatchers.Default).collect { list -> books = list booksAdapter.updateItems() binding.tvEmptyMsg.isGone = getItemCount() > 0 binding.refreshLayout.isEnabled = enableRefresh && getItemCount() > 0 delay(100) } } } fun back(): Boolean { if (groupId != BookGroup.IdRoot) { groupId = BookGroup.IdRoot initBooksData() return true } return false } override fun onQueryTextSubmit(query: String?): Boolean { SearchActivity.start(requireContext(), query) return false } override fun onQueryTextChange(newText: String?): Boolean { return false } override fun gotoTop() { if (AppConfig.isEInkMode) { binding.rvBookshelf.scrollToPosition(0) } else { binding.rvBookshelf.smoothScrollToPosition(0) } } override fun onItemClick(item: Any) { when (item) { is Book -> startActivityForBook(item) is BookGroup -> { groupId = item.groupId initBooksData() } } } override fun onItemLongClick(item: Any) { when (item) { is Book -> startActivity { putExtra("name", item.name) putExtra("author", item.author) } is BookGroup -> showDialogFragment(GroupEditDialog(item)) } } override fun isUpdate(bookUrl: String): Boolean { return activityViewModel.isUpdate(bookUrl) } fun getItemCount(): Int { return if (groupId == BookGroup.IdRoot) { bookGroups.size + books.size } else { books.size } } override fun getItems(): List { if (groupId != BookGroup.IdRoot) { return books } return bookGroups + books } @SuppressLint("NotifyDataSetChanged") override fun observeLiveBus() { super.observeLiveBus() observeEvent(EventBus.UP_BOOKSHELF) { booksAdapter.notification(it) } observeEvent(EventBus.BOOKSHELF_REFRESH) { booksAdapter.notifyDataSetChanged() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/main/explore/ExploreAdapter.kt ================================================ package io.legado.app.ui.main.explore import android.content.Context import android.view.View import android.view.ViewGroup import android.widget.PopupMenu import android.widget.TextView import androidx.core.view.children import com.google.android.flexbox.FlexboxLayout import io.legado.app.R import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.rule.ExploreKind import io.legado.app.databinding.ItemFilletTextBinding import io.legado.app.databinding.ItemFindBookBinding import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.source.clearExploreKindsCache import io.legado.app.help.source.exploreKinds import io.legado.app.lib.theme.accentColor import io.legado.app.ui.login.SourceLoginActivity import io.legado.app.ui.widget.dialog.TextDialog import io.legado.app.utils.activity import io.legado.app.utils.dpToPx import io.legado.app.utils.gone import io.legado.app.utils.removeLastElement import io.legado.app.utils.showDialogFragment import io.legado.app.utils.startActivity import io.legado.app.utils.visible import kotlinx.coroutines.CoroutineScope import splitties.views.onLongClick class ExploreAdapter(context: Context, val callBack: CallBack) : RecyclerAdapter(context) { private val recycler = arrayListOf() private var exIndex = -1 private var scrollTo = -1 override fun getViewBinding(parent: ViewGroup): ItemFindBookBinding { return ItemFindBookBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemFindBookBinding, item: BookSourcePart, payloads: MutableList ) { binding.run { if (holder.layoutPosition == itemCount - 1) { root.setPadding(16.dpToPx(), 12.dpToPx(), 16.dpToPx(), 12.dpToPx()) } else { root.setPadding(16.dpToPx(), 12.dpToPx(), 16.dpToPx(), 0) } if (payloads.isEmpty()) { tvName.text = item.bookSourceName } if (exIndex == holder.layoutPosition) { ivStatus.setImageResource(R.drawable.ic_arrow_down) rotateLoading.loadingColor = context.accentColor rotateLoading.visible() if (scrollTo >= 0) { callBack.scrollTo(scrollTo) } Coroutine.async(callBack.scope) { item.exploreKinds() }.onSuccess { kindList -> upKindList(flexbox, item.bookSourceUrl, kindList) }.onFinally { rotateLoading.gone() if (scrollTo >= 0) { callBack.scrollTo(scrollTo) scrollTo = -1 } } } else kotlin.runCatching { ivStatus.setImageResource(R.drawable.ic_arrow_right) rotateLoading.gone() recyclerFlexbox(flexbox) flexbox.gone() } } } private fun upKindList(flexbox: FlexboxLayout, sourceUrl: String, kinds: List) { if (kinds.isNotEmpty()) kotlin.runCatching { recyclerFlexbox(flexbox) flexbox.visible() kinds.forEach { kind -> val tv = getFlexboxChild(flexbox) flexbox.addView(tv) tv.text = kind.title kind.style().apply(tv) if (kind.url.isNullOrBlank()) { tv.setOnClickListener(null) } else { tv.setOnClickListener { if (kind.title.startsWith("ERROR:")) { it.activity?.showDialogFragment(TextDialog("ERROR", kind.url)) } else { callBack.openExplore(sourceUrl, kind.title, kind.url) } } } } } } @Synchronized private fun getFlexboxChild(flexbox: FlexboxLayout): TextView { return if (recycler.isEmpty()) { ItemFilletTextBinding.inflate(inflater, flexbox, false).root } else { recycler.removeLastElement() as TextView } } @Synchronized private fun recyclerFlexbox(flexbox: FlexboxLayout) { recycler.addAll(flexbox.children) flexbox.removeAllViews() } override fun registerListener(holder: ItemViewHolder, binding: ItemFindBookBinding) { binding.apply { llTitle.setOnClickListener { val position = holder.layoutPosition val oldEx = exIndex exIndex = if (exIndex == position) -1 else position notifyItemChanged(oldEx, false) if (exIndex != -1) { scrollTo = position callBack.scrollTo(position) notifyItemChanged(position, false) } } llTitle.onLongClick { showMenu(llTitle, holder.layoutPosition) } } } fun compressExplore(): Boolean { return if (exIndex < 0) { false } else { val oldExIndex = exIndex exIndex = -1 notifyItemChanged(oldExIndex) true } } private fun showMenu(view: View, position: Int): Boolean { val source = getItem(position) ?: return true val popupMenu = PopupMenu(context, view) popupMenu.inflate(R.menu.explore_item) popupMenu.menu.findItem(R.id.menu_login).isVisible = source.hasLoginUrl popupMenu.setOnMenuItemClickListener { when (it.itemId) { R.id.menu_edit -> callBack.editSource(source.bookSourceUrl) R.id.menu_top -> callBack.toTop(source) R.id.menu_search -> callBack.searchBook(source) R.id.menu_login -> context.startActivity { putExtra("type", "bookSource") putExtra("key", source.bookSourceUrl) } R.id.menu_refresh -> Coroutine.async(callBack.scope) { source.clearExploreKindsCache() }.onSuccess { notifyItemChanged(position) } R.id.menu_del -> callBack.deleteSource(source) } true } popupMenu.show() return true } interface CallBack { val scope: CoroutineScope fun scrollTo(pos: Int) fun openExplore(sourceUrl: String, title: String, exploreUrl: String?) fun editSource(sourceUrl: String) fun toTop(source: BookSourcePart) fun deleteSource(source: BookSourcePart) fun searchBook(bookSource: BookSourcePart) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/main/explore/ExploreDiffItemCallBack.kt ================================================ package io.legado.app.ui.main.explore import androidx.recyclerview.widget.DiffUtil import io.legado.app.data.entities.BookSourcePart class ExploreDiffItemCallBack : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: BookSourcePart, newItem: BookSourcePart): Boolean { return oldItem == newItem } override fun areContentsTheSame(oldItem: BookSourcePart, newItem: BookSourcePart): Boolean { return oldItem.bookSourceName == newItem.bookSourceName } } ================================================ FILE: app/src/main/java/io/legado/app/ui/main/explore/ExploreFragment.kt ================================================ package io.legado.app.ui.main.explore import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.SubMenu import android.view.View import androidx.appcompat.widget.SearchView import androidx.core.view.isGone import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import io.legado.app.R import io.legado.app.base.VMBaseFragment import io.legado.app.constant.AppLog import io.legado.app.data.AppDatabase import io.legado.app.data.appDb import io.legado.app.data.entities.BookSourcePart import io.legado.app.databinding.FragmentExploreBinding import io.legado.app.help.config.AppConfig import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.primaryColor import io.legado.app.lib.theme.primaryTextColor import io.legado.app.ui.book.explore.ExploreShowActivity import io.legado.app.ui.book.search.SearchActivity import io.legado.app.ui.book.search.SearchScope import io.legado.app.ui.book.source.edit.BookSourceEditActivity import io.legado.app.ui.main.MainFragmentInterface import io.legado.app.utils.applyTint import io.legado.app.utils.flowWithLifecycleAndDatabaseChange import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.startActivity import io.legado.app.utils.transaction import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch /** * 发现界面 */ class ExploreFragment() : VMBaseFragment(R.layout.fragment_explore), MainFragmentInterface, ExploreAdapter.CallBack { constructor(position: Int) : this() { val bundle = Bundle() bundle.putInt("position", position) arguments = bundle } override val position: Int? get() = arguments?.getInt("position") override val viewModel by viewModels() private val binding by viewBinding(FragmentExploreBinding::bind) private val adapter by lazy { ExploreAdapter(requireContext(), this) } private val linearLayoutManager by lazy { LinearLayoutManager(context) } private val searchView: SearchView by lazy { binding.titleBar.findViewById(R.id.search_view) } private val diffItemCallBack = ExploreDiffItemCallBack() private val groups = linkedSetOf() private var exploreFlowJob: Job? = null private var groupsMenu: SubMenu? = null override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { setSupportToolbar(binding.titleBar.toolbar) initSearchView() initRecyclerView() initGroupData() upExploreData() } override fun onCompatCreateOptionsMenu(menu: Menu) { super.onCompatCreateOptionsMenu(menu) menuInflater.inflate(R.menu.main_explore, menu) groupsMenu = menu.findItem(R.id.menu_group)?.subMenu upGroupsMenu() } override fun onPause() { super.onPause() searchView.clearFocus() } private fun initSearchView() { searchView.applyTint(primaryTextColor) searchView.isSubmitButtonEnabled = true searchView.queryHint = getString(R.string.screen_find) searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { return false } override fun onQueryTextChange(newText: String?): Boolean { upExploreData(newText) return false } }) } private fun initRecyclerView() { binding.rvFind.setEdgeEffectColor(primaryColor) binding.rvFind.layoutManager = linearLayoutManager binding.rvFind.adapter = adapter adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { super.onItemRangeInserted(positionStart, itemCount) if (positionStart == 0) { binding.rvFind.scrollToPosition(0) } } }) } private fun initGroupData() { viewLifecycleOwner.lifecycleScope.launch { appDb.bookSourceDao.flowExploreGroups() .flowWithLifecycleAndDatabaseChange( viewLifecycleOwner.lifecycle, Lifecycle.State.RESUMED, AppDatabase.BOOK_SOURCE_TABLE_NAME ) .conflate() .distinctUntilChanged() .collect { groups.clear() groups.addAll(it) upGroupsMenu() delay(500) } } } private fun upExploreData(searchKey: String? = null) { exploreFlowJob?.cancel() exploreFlowJob = viewLifecycleOwner.lifecycleScope.launch { when { searchKey.isNullOrBlank() -> { appDb.bookSourceDao.flowExplore() } searchKey.startsWith("group:") -> { val key = searchKey.substringAfter("group:") appDb.bookSourceDao.flowGroupExplore(key) } else -> { appDb.bookSourceDao.flowExplore(searchKey) } }.flowWithLifecycleAndDatabaseChange( viewLifecycleOwner.lifecycle, Lifecycle.State.RESUMED, AppDatabase.BOOK_SOURCE_TABLE_NAME ).catch { AppLog.put("发现界面更新数据出错", it) }.conflate().flowOn(IO).collect { binding.tvEmptyMsg.isGone = it.isNotEmpty() || searchView.query.isNotEmpty() adapter.setItems(it, diffItemCallBack) delay(500) } } } private fun upGroupsMenu() = groupsMenu?.transaction { subMenu -> subMenu.removeGroup(R.id.menu_group_text) groups.forEach { subMenu.add(R.id.menu_group_text, Menu.NONE, Menu.NONE, it) } } override val scope: CoroutineScope get() = viewLifecycleOwner.lifecycleScope override fun onCompatOptionsItemSelected(item: MenuItem) { super.onCompatOptionsItemSelected(item) if (item.groupId == R.id.menu_group_text) { searchView.setQuery("group:${item.title}", true) } } override fun scrollTo(pos: Int) { (binding.rvFind.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(pos, 0) } override fun openExplore(sourceUrl: String, title: String, exploreUrl: String?) { if (exploreUrl.isNullOrBlank()) return startActivity { putExtra("exploreName", title) putExtra("sourceUrl", sourceUrl) putExtra("exploreUrl", exploreUrl) } } override fun editSource(sourceUrl: String) { startActivity { putExtra("sourceUrl", sourceUrl) } } override fun toTop(source: BookSourcePart) { viewModel.topSource(source) } override fun deleteSource(source: BookSourcePart) { alert(R.string.draw) { setMessage(getString(R.string.sure_del) + "\n" + source.bookSourceName) noButton() yesButton { viewModel.deleteSource(source) } } } override fun searchBook(bookSource: BookSourcePart) { startActivity { putExtra("searchScope", SearchScope(bookSource).toString()) } } fun compressExplore() { if (!adapter.compressExplore()) { if (AppConfig.isEInkMode) { binding.rvFind.scrollToPosition(0) } else { binding.rvFind.smoothScrollToPosition(0) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/main/explore/ExploreViewModel.kt ================================================ package io.legado.app.ui.main.explore import android.app.Application import io.legado.app.base.BaseViewModel import io.legado.app.data.appDb import io.legado.app.data.entities.BookSourcePart import io.legado.app.help.config.SourceConfig import io.legado.app.help.source.SourceHelp class ExploreViewModel(application: Application) : BaseViewModel(application) { fun topSource(bookSource: BookSourcePart) { execute { val minXh = appDb.bookSourceDao.minOrder bookSource.customOrder = minXh - 1 appDb.bookSourceDao.upOrder(bookSource) } } fun deleteSource(source: BookSourcePart) { execute { SourceHelp.deleteBookSource(source.bookSourceUrl) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/main/my/MyFragment.kt ================================================ package io.legado.app.ui.main.my import android.content.SharedPreferences import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.View import androidx.preference.Preference import io.legado.app.R import io.legado.app.base.BaseFragment import io.legado.app.constant.EventBus import io.legado.app.constant.PreferKey import io.legado.app.databinding.FragmentMyConfigBinding import io.legado.app.help.config.ThemeConfig import io.legado.app.lib.dialogs.selector import io.legado.app.lib.prefs.NameListPreference import io.legado.app.lib.prefs.SwitchPreference import io.legado.app.lib.prefs.fragment.PreferenceFragment import io.legado.app.lib.theme.primaryColor import io.legado.app.service.WebService import io.legado.app.ui.about.AboutActivity import io.legado.app.ui.about.ReadRecordActivity import io.legado.app.ui.book.bookmark.AllBookmarkActivity import io.legado.app.ui.book.source.manage.BookSourceActivity import io.legado.app.ui.book.toc.rule.TxtTocRuleActivity import io.legado.app.ui.config.ConfigActivity import io.legado.app.ui.config.ConfigTag import io.legado.app.ui.dict.rule.DictRuleActivity import io.legado.app.ui.file.FileManageActivity import io.legado.app.ui.main.MainFragmentInterface import io.legado.app.ui.replace.ReplaceRuleActivity import io.legado.app.utils.LogUtils import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.observeEventSticky import io.legado.app.utils.openUrl import io.legado.app.utils.putPrefBoolean import io.legado.app.utils.sendToClip import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.showHelp import io.legado.app.utils.startActivity import io.legado.app.utils.viewbindingdelegate.viewBinding class MyFragment() : BaseFragment(R.layout.fragment_my_config), MainFragmentInterface { constructor(position: Int) : this() { val bundle = Bundle() bundle.putInt("position", position) arguments = bundle } override val position: Int? get() = arguments?.getInt("position") private val binding by viewBinding(FragmentMyConfigBinding::bind) override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { setSupportToolbar(binding.titleBar.toolbar) val fragmentTag = "prefFragment" var preferenceFragment = childFragmentManager.findFragmentByTag(fragmentTag) if (preferenceFragment == null) preferenceFragment = MyPreferenceFragment() childFragmentManager.beginTransaction() .replace(R.id.pre_fragment, preferenceFragment, fragmentTag).commit() } override fun onCompatCreateOptionsMenu(menu: Menu) { menuInflater.inflate(R.menu.main_my, menu) } override fun onCompatOptionsItemSelected(item: MenuItem) { when (item.itemId) { R.id.menu_help -> showHelp("appHelp") } } /** * 配置 */ class MyPreferenceFragment : PreferenceFragment(), SharedPreferences.OnSharedPreferenceChangeListener { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { putPrefBoolean(PreferKey.webService, WebService.isRun) addPreferencesFromResource(R.xml.pref_main) findPreference("webService")?.onLongClick { if (!WebService.isRun) { return@onLongClick false } context?.selector(arrayListOf("复制地址", "浏览器打开")) { _, i -> when (i) { 0 -> context?.sendToClip(it.summary.toString()) 1 -> context?.openUrl(it.summary.toString()) } } true } observeEventSticky(EventBus.WEB_SERVICE) { findPreference(PreferKey.webService)?.let { it.isChecked = WebService.isRun it.summary = if (WebService.isRun) { WebService.hostAddress } else { getString(R.string.web_service_desc) } } } findPreference(PreferKey.themeMode)?.let { it.setOnPreferenceChangeListener { _, _ -> view?.post { ThemeConfig.applyDayNight(requireContext()) } true } } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) listView.setEdgeEffectColor(primaryColor) } override fun onResume() { super.onResume() preferenceManager.sharedPreferences?.registerOnSharedPreferenceChangeListener(this) } override fun onPause() { preferenceManager.sharedPreferences?.unregisterOnSharedPreferenceChangeListener(this) super.onPause() } override fun onSharedPreferenceChanged( sharedPreferences: SharedPreferences?, key: String? ) { when (key) { PreferKey.webService -> { if (requireContext().getPrefBoolean("webService")) { WebService.start(requireContext()) } else { WebService.stop(requireContext()) } } "recordLog" -> LogUtils.upLevel() } } override fun onPreferenceTreeClick(preference: Preference): Boolean { when (preference.key) { "bookSourceManage" -> startActivity() "replaceManage" -> startActivity() "dictRuleManage" -> startActivity() "txtTocRuleManage" -> startActivity() "bookmark" -> startActivity() "setting" -> startActivity { putExtra("configTag", ConfigTag.OTHER_CONFIG) } "web_dav_setting" -> startActivity { putExtra("configTag", ConfigTag.BACKUP_CONFIG) } "theme_setting" -> startActivity { putExtra("configTag", ConfigTag.THEME_CONFIG) } "fileManage" -> startActivity() "readRecord" -> startActivity() "about" -> startActivity() "exit" -> activity?.finish() } return super.onPreferenceTreeClick(preference) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/main/rss/RssAdapter.kt ================================================ package io.legado.app.ui.main.rss import android.content.Context import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.PopupMenu import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import com.bumptech.glide.request.RequestOptions import io.legado.app.R import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.data.entities.RssSource import io.legado.app.databinding.ItemRssBinding import io.legado.app.help.glide.ImageLoader import io.legado.app.help.glide.OkHttpModelLoader import splitties.views.onLongClick class RssAdapter( context: Context, private val fragment: Fragment, private val callBack: CallBack, private val lifecycle: Lifecycle ) : RecyclerAdapter(context) { override fun getViewBinding(parent: ViewGroup): ItemRssBinding { return ItemRssBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemRssBinding, item: RssSource, payloads: MutableList ) { binding.apply { tvName.text = item.sourceName val options = RequestOptions() .set(OkHttpModelLoader.sourceOriginOption, item.sourceUrl) ImageLoader.load(fragment, lifecycle, item.sourceIcon) .apply(options) .centerCrop() .placeholder(R.drawable.image_rss) .error(R.drawable.image_rss) .into(ivIcon) } } override fun registerListener(holder: ItemViewHolder, binding: ItemRssBinding) { binding.apply { root.setOnClickListener { getItemByLayoutPosition(holder.layoutPosition)?.let { callBack.openRss(it) } } root.onLongClick { getItemByLayoutPosition(holder.layoutPosition)?.let { showMenu(ivIcon, it) } } } } private fun showMenu(view: View, rssSource: RssSource) { val popupMenu = PopupMenu(context, view) popupMenu.inflate(R.menu.rss_main_item) popupMenu.setOnMenuItemClickListener { when (it.itemId) { R.id.menu_top -> callBack.toTop(rssSource) R.id.menu_edit -> callBack.edit(rssSource) R.id.menu_del -> callBack.del(rssSource) R.id.menu_disable -> callBack.disable(rssSource) } true } popupMenu.show() } interface CallBack { fun openRss(rssSource: RssSource) fun toTop(rssSource: RssSource) fun edit(rssSource: RssSource) fun del(rssSource: RssSource) fun disable(rssSource: RssSource) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/main/rss/RssFragment.kt ================================================ package io.legado.app.ui.main.rss import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.SubMenu import android.view.View import androidx.appcompat.widget.SearchView import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import io.legado.app.R import io.legado.app.base.VMBaseFragment import io.legado.app.constant.AppLog import io.legado.app.data.AppDatabase import io.legado.app.data.appDb import io.legado.app.data.entities.RssSource import io.legado.app.databinding.FragmentRssBinding import io.legado.app.databinding.ItemRssBinding import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.primaryColor import io.legado.app.lib.theme.primaryTextColor import io.legado.app.ui.main.MainFragmentInterface import io.legado.app.ui.rss.article.RssSortActivity import io.legado.app.ui.rss.favorites.RssFavoritesActivity import io.legado.app.ui.rss.read.ReadRssActivity import io.legado.app.ui.rss.source.edit.RssSourceEditActivity import io.legado.app.ui.rss.source.manage.RssSourceActivity import io.legado.app.ui.rss.subscription.RuleSubActivity import io.legado.app.utils.applyTint import io.legado.app.utils.flowWithLifecycleAndDatabaseChange import io.legado.app.utils.openUrl import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.startActivity import io.legado.app.utils.transaction import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Job import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch /** * 订阅界面 */ class RssFragment() : VMBaseFragment(R.layout.fragment_rss), MainFragmentInterface, RssAdapter.CallBack { constructor(position: Int) : this() { val bundle = Bundle() bundle.putInt("position", position) arguments = bundle } override val position: Int? get() = arguments?.getInt("position") private val binding by viewBinding(FragmentRssBinding::bind) override val viewModel by viewModels() private val adapter by lazy { RssAdapter(requireContext(), this, this, viewLifecycleOwner.lifecycle) } private val searchView: SearchView by lazy { binding.titleBar.findViewById(R.id.search_view) } private var groupsFlowJob: Job? = null private var rssFlowJob: Job? = null private val groups = linkedSetOf() private var groupsMenu: SubMenu? = null override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { setSupportToolbar(binding.titleBar.toolbar) initSearchView() initRecyclerView() initGroupData() upRssFlowJob() } override fun onCompatCreateOptionsMenu(menu: Menu) { menuInflater.inflate(R.menu.main_rss, menu) groupsMenu = menu.findItem(R.id.menu_group)?.subMenu upGroupsMenu() } override fun onCompatOptionsItemSelected(item: MenuItem) { super.onCompatOptionsItemSelected(item) when (item.itemId) { R.id.menu_rss_config -> startActivity() R.id.menu_rss_star -> startActivity() else -> if (item.groupId == R.id.menu_group_text) { searchView.setQuery("group:${item.title}", true) } } } override fun onPause() { super.onPause() searchView.clearFocus() } private fun upGroupsMenu() = groupsMenu?.transaction { subMenu -> subMenu.removeGroup(R.id.menu_group_text) groups.forEach { subMenu.add(R.id.menu_group_text, Menu.NONE, Menu.NONE, it) } } private fun initSearchView() { searchView.applyTint(primaryTextColor) searchView.isSubmitButtonEnabled = true searchView.queryHint = getString(R.string.rss) searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { return false } override fun onQueryTextChange(newText: String?): Boolean { upRssFlowJob(newText) return false } }) } private fun initRecyclerView() { binding.recyclerView.setEdgeEffectColor(primaryColor) binding.recyclerView.adapter = adapter adapter.addHeaderView { ItemRssBinding.inflate(layoutInflater, it, false).apply { tvName.setText(R.string.rule_subscription) ivIcon.setImageResource(R.drawable.image_legado) root.setOnClickListener { startActivity() } } } } private fun initGroupData() { groupsFlowJob?.cancel() groupsFlowJob = viewLifecycleOwner.lifecycleScope.launch { appDb.rssSourceDao.flowEnabledGroups().catch { AppLog.put("订阅界面获取分组数据失败\n${it.localizedMessage}", it) }.flowWithLifecycleAndDatabaseChange( viewLifecycleOwner.lifecycle, Lifecycle.State.RESUMED, AppDatabase.RSS_SOURCE_TABLE_NAME ).conflate().collect { groups.clear() groups.addAll(it) upGroupsMenu() } } } private fun upRssFlowJob(searchKey: String? = null) { rssFlowJob?.cancel() rssFlowJob = viewLifecycleOwner.lifecycleScope.launch { when { searchKey.isNullOrEmpty() -> appDb.rssSourceDao.flowEnabled() searchKey.startsWith("group:") -> { val key = searchKey.substringAfter("group:") appDb.rssSourceDao.flowEnabledByGroup(key) } else -> appDb.rssSourceDao.flowEnabled(searchKey) }.flowWithLifecycleAndDatabaseChange( viewLifecycleOwner.lifecycle, Lifecycle.State.RESUMED, AppDatabase.RSS_SOURCE_TABLE_NAME ).catch { AppLog.put("订阅界面更新数据出错", it) }.flowOn(IO).collect { adapter.setItems(it) } } } override fun openRss(rssSource: RssSource) { if (rssSource.singleUrl) { viewModel.getSingleUrl(rssSource) { url -> if (url.startsWith("http", true)) { startActivity { putExtra("title", rssSource.sourceName) putExtra("origin", url) } } else { context?.openUrl(url) } } } else { startActivity { putExtra("url", rssSource.sourceUrl) } } } override fun toTop(rssSource: RssSource) { viewModel.topSource(rssSource) } override fun edit(rssSource: RssSource) { startActivity { putExtra("sourceUrl", rssSource.sourceUrl) } } override fun del(rssSource: RssSource) { alert(R.string.draw) { setMessage(getString(R.string.sure_del) + "\n" + rssSource.sourceName) noButton() yesButton { viewModel.del(rssSource) } } } override fun disable(rssSource: RssSource) { viewModel.disable(rssSource) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/main/rss/RssViewModel.kt ================================================ package io.legado.app.ui.main.rss import android.app.Application import com.script.rhino.runScriptWithContext import io.legado.app.base.BaseViewModel import io.legado.app.data.appDb import io.legado.app.data.entities.RssSource import io.legado.app.help.source.SourceHelp import io.legado.app.utils.toastOnUi class RssViewModel(application: Application) : BaseViewModel(application) { fun topSource(vararg sources: RssSource) { execute { sources.sortBy { it.customOrder } val minOrder = appDb.rssSourceDao.minOrder - 1 val array = Array(sources.size) { sources[it].copy(customOrder = minOrder - it) } appDb.rssSourceDao.update(*array) } } fun bottomSource(vararg sources: RssSource) { execute { sources.sortBy { it.customOrder } val maxOrder = appDb.rssSourceDao.maxOrder + 1 val array = Array(sources.size) { sources[it].copy(customOrder = maxOrder + it) } appDb.rssSourceDao.update(*array) } } fun del(vararg rssSource: RssSource) { execute { SourceHelp.deleteRssSources(rssSource.toList()) } } fun disable(rssSource: RssSource) { execute { rssSource.enabled = false appDb.rssSourceDao.update(rssSource) } } fun getSingleUrl(rssSource: RssSource, onSuccess: (url: String) -> Unit) { execute { var sortUrl = rssSource.sortUrl if (!sortUrl.isNullOrBlank()) { if (sortUrl.startsWith("", false) || sortUrl.startsWith("@js:", false) ) { val jsStr = if (sortUrl.startsWith("@")) { sortUrl.substring(4) } else { sortUrl.substring(4, sortUrl.lastIndexOf("<")) } val result = runScriptWithContext { rssSource.evalJS(jsStr)?.toString() } if (!result.isNullOrBlank()) { sortUrl = result } } if (sortUrl.contains("::")) { return@execute sortUrl.split("::")[1] } else { return@execute sortUrl } } rssSource.sourceUrl }.timeout(10000) .onSuccess { onSuccess.invoke(it) }.onError { context.toastOnUi(it.localizedMessage) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/qrcode/QrCodeActivity.kt ================================================ package io.legado.app.ui.qrcode import android.content.Intent import android.graphics.BitmapFactory import android.os.Bundle import android.view.Menu import android.view.MenuItem import com.google.zxing.Result import io.legado.app.R import io.legado.app.base.BaseActivity import io.legado.app.databinding.ActivityQrcodeCaptureBinding import io.legado.app.ui.file.HandleFileContract import io.legado.app.utils.QRCodeUtils import io.legado.app.utils.readBytes import io.legado.app.utils.viewbindingdelegate.viewBinding class QrCodeActivity : BaseActivity(), ScanResultCallback { override val binding by viewBinding(ActivityQrcodeCaptureBinding::inflate) private val selectQrImage = registerForActivityResult(HandleFileContract()) { it.uri?.readBytes(this)?.let { bytes -> val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) onScanResultCallback(QRCodeUtils.parseCodeResult(bitmap)) } } override fun onActivityCreated(savedInstanceState: Bundle?) { val fTag = "qrCodeFragment" val qrCodeFragment = QrCodeFragment() supportFragmentManager.beginTransaction() .replace(R.id.fl_content, qrCodeFragment, fTag) .commit() } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.qr_code_scan, menu) return super.onCompatCreateOptionsMenu(menu) } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.action_choose_from_gallery -> selectQrImage.launch { mode = HandleFileContract.IMAGE } } return super.onCompatOptionsItemSelected(item) } override fun onScanResultCallback(result: Result?) { val intent = Intent() intent.putExtra("result", result?.text) setResult(RESULT_OK, intent) finish() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/qrcode/QrCodeFragment.kt ================================================ package io.legado.app.ui.qrcode import com.google.zxing.Result import com.king.camera.scan.AnalyzeResult import com.king.camera.scan.CameraScan import com.king.zxing.BarcodeCameraScanFragment import com.king.zxing.DecodeConfig import com.king.zxing.DecodeFormatManager import com.king.zxing.analyze.MultiFormatAnalyzer class QrCodeFragment : BarcodeCameraScanFragment() { override fun initCameraScan(cameraScan: CameraScan) { super.initCameraScan(cameraScan) //初始化解码配置 val decodeConfig = DecodeConfig() //如果只有识别二维码的需求,这样设置效率会更高,不设置默认为DecodeFormatManager.DEFAULT_HINTS decodeConfig.hints = DecodeFormatManager.QR_CODE_HINTS //设置是否全区域识别,默认false decodeConfig.isFullAreaScan = true //设置识别区域比例,默认0.8,设置的比例最终会在预览区域裁剪基于此比例的一个矩形进行扫码识别 decodeConfig.areaRectRatio = 0.8f //在启动预览之前,设置分析器,只识别二维码 cameraScan.setAnalyzer(MultiFormatAnalyzer(decodeConfig)) } override fun onScanResultCallback(result: AnalyzeResult) { cameraScan.setAnalyzeImage(false) (activity as? QrCodeActivity)?.onScanResultCallback(result.result) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/qrcode/QrCodeResult.kt ================================================ package io.legado.app.ui.qrcode import android.app.Activity.RESULT_OK import android.content.Context import android.content.Intent import androidx.activity.result.contract.ActivityResultContract class QrCodeResult : ActivityResultContract() { override fun createIntent(context: Context, input: Unit?): Intent { return Intent(context, QrCodeActivity::class.java) } override fun parseResult(resultCode: Int, intent: Intent?): String? { if (resultCode == RESULT_OK) { intent?.getStringExtra("result")?.let { return it } } return null } } ================================================ FILE: app/src/main/java/io/legado/app/ui/qrcode/ScanResultCallback.kt ================================================ package io.legado.app.ui.qrcode import com.google.zxing.Result interface ScanResultCallback { fun onScanResultCallback(result: Result?) } ================================================ FILE: app/src/main/java/io/legado/app/ui/replace/GroupManageDialog.kt ================================================ package io.legado.app.ui.replace import android.annotation.SuppressLint import android.content.Context import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.data.appDb import io.legado.app.databinding.DialogEditTextBinding import io.legado.app.databinding.DialogRecyclerViewBinding import io.legado.app.databinding.ItemGroupManageBinding import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.backgroundColor import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.widget.recycler.VerticalDivider import io.legado.app.utils.applyTint import io.legado.app.utils.requestInputMethod import io.legado.app.utils.setLayout import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.launch class GroupManageDialog : BaseDialogFragment(R.layout.dialog_recycler_view), Toolbar.OnMenuItemClickListener { private val viewModel: ReplaceRuleViewModel by activityViewModels() private val binding by viewBinding(DialogRecyclerViewBinding::bind) private val adapter by lazy { GroupAdapter(requireContext()) } override fun onStart() { super.onStart() setLayout(0.9f, 0.9f) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { view.setBackgroundColor(backgroundColor) binding.toolBar.setBackgroundColor(primaryColor) initView() initData() } private fun initView() = binding.run { toolBar.title = getString(R.string.group_manage) toolBar.inflateMenu(R.menu.group_manage) toolBar.menu.applyTint(requireContext()) toolBar.setOnMenuItemClickListener(this@GroupManageDialog) recyclerView.layoutManager = LinearLayoutManager(requireContext()) recyclerView.addItemDecoration(VerticalDivider(requireContext())) recyclerView.adapter = adapter } private fun initData() { lifecycleScope.launch { appDb.replaceRuleDao.flowGroups().conflate().collect { adapter.setItems(it) } } } override fun onMenuItemClick(item: MenuItem?): Boolean { when (item?.itemId) { R.id.menu_add -> addGroup() } return true } @SuppressLint("InflateParams") private fun addGroup() { alert(title = getString(R.string.add_group)) { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.setHint(R.string.group_name) } customView { alertBinding.root } okButton { alertBinding.editView.text?.toString()?.let { if (it.isNotBlank()) { viewModel.addGroup(it) } } } cancelButton() }.requestInputMethod() } @SuppressLint("InflateParams") private fun editGroup(group: String) { alert(title = getString(R.string.group_edit)) { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.setHint(R.string.group_name) editView.setText(group) } customView { alertBinding.root } okButton { viewModel.upGroup(group, alertBinding.editView.text?.toString()) } cancelButton() }.requestInputMethod() } private inner class GroupAdapter(context: Context) : RecyclerAdapter(context) { override fun getViewBinding(parent: ViewGroup): ItemGroupManageBinding { return ItemGroupManageBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemGroupManageBinding, item: String, payloads: MutableList ) { binding.run { root.setBackgroundColor(context.backgroundColor) tvGroup.text = item } } override fun registerListener(holder: ItemViewHolder, binding: ItemGroupManageBinding) { binding.apply { tvEdit.setOnClickListener { getItem(holder.layoutPosition)?.let { editGroup(it) } } tvDel.setOnClickListener { getItem(holder.layoutPosition)?.let { viewModel.delGroup(it) } } } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/replace/ReplaceRuleActivity.kt ================================================ package io.legado.app.ui.replace import android.annotation.SuppressLint import android.app.Activity import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.SubMenu import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.SearchView import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.ReplaceRule import io.legado.app.databinding.ActivityReplaceRuleBinding import io.legado.app.databinding.DialogEditTextBinding import io.legado.app.help.DirectLinkUpload import io.legado.app.help.book.ContentProcessor import io.legado.app.help.coroutine.Coroutine import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.primaryColor import io.legado.app.lib.theme.primaryTextColor import io.legado.app.ui.association.ImportReplaceRuleDialog import io.legado.app.ui.file.HandleFileContract import io.legado.app.ui.qrcode.QrCodeResult import io.legado.app.ui.replace.edit.ReplaceEditActivity import io.legado.app.ui.widget.SelectActionBar import io.legado.app.ui.widget.recycler.DragSelectTouchHelper import io.legado.app.ui.widget.recycler.ItemTouchCallback import io.legado.app.ui.widget.recycler.VerticalDivider import io.legado.app.utils.ACache import io.legado.app.utils.GSON import io.legado.app.utils.applyTint import io.legado.app.utils.isAbsUrl import io.legado.app.utils.launch import io.legado.app.utils.sendToClip import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.showDialogFragment import io.legado.app.utils.showHelp import io.legado.app.utils.splitNotBlank import io.legado.app.utils.transaction import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch /** * 替换规则管理 */ class ReplaceRuleActivity : VMBaseActivity(), SearchView.OnQueryTextListener, PopupMenu.OnMenuItemClickListener, SelectActionBar.CallBack, ReplaceRuleAdapter.CallBack { override val binding by viewBinding(ActivityReplaceRuleBinding::inflate) override val viewModel by viewModels() private val importRecordKey = "replaceRuleRecordKey" private val adapter by lazy { ReplaceRuleAdapter(this, this) } private val searchView: SearchView by lazy { binding.titleBar.findViewById(R.id.search_view) } private var groups = arrayListOf() private var groupMenu: SubMenu? = null private var replaceRuleFlowJob: Job? = null private var dataInit = false private val qrCodeResult = registerForActivityResult(QrCodeResult()) { it ?: return@registerForActivityResult showDialogFragment(ImportReplaceRuleDialog(it)) } private val editActivity = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == RESULT_OK) { setResult(RESULT_OK) } } private val importDoc = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> showDialogFragment(ImportReplaceRuleDialog(uri.toString())) } } private val exportResult = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> alert(R.string.export_success) { if (uri.toString().isAbsUrl()) { setMessage(DirectLinkUpload.getSummary()) } val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = getString(R.string.path) editView.setText(uri.toString()) } customView { alertBinding.root } okButton { sendToClip(uri.toString()) } } } } override fun onActivityCreated(savedInstanceState: Bundle?) { initRecyclerView() initSearchView() initSelectActionView() observeReplaceRuleData() observeGroupData() } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.replace_rule, menu) return super.onCompatCreateOptionsMenu(menu) } override fun onPrepareOptionsMenu(menu: Menu): Boolean { groupMenu = menu.findItem(R.id.menu_group)?.subMenu upGroupMenu() return super.onPrepareOptionsMenu(menu) } private fun initRecyclerView() { binding.recyclerView.setEdgeEffectColor(primaryColor) binding.recyclerView.layoutManager = LinearLayoutManager(this) binding.recyclerView.adapter = adapter binding.recyclerView.addItemDecoration(VerticalDivider(this)) val itemTouchCallback = ItemTouchCallback(adapter) itemTouchCallback.isCanDrag = true val dragSelectTouchHelper: DragSelectTouchHelper = DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50) dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView) // When this page is opened, it is in selection mode dragSelectTouchHelper.activeSlideSelect() // Note: need judge selection first, so add ItemTouchHelper after it. ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView) } private fun initSearchView() { searchView.applyTint(primaryTextColor) searchView.queryHint = getString(R.string.replace_purify_search) searchView.setOnQueryTextListener(this) } override fun selectAll(selectAll: Boolean) { if (selectAll) { adapter.selectAll() } else { adapter.revertSelection() } } override fun revertSelection() { adapter.revertSelection() } override fun onClickSelectBarMainAction() { alert(titleResource = R.string.draw, messageResource = R.string.sure_del) { yesButton { viewModel.delSelection(adapter.selection) } noButton() } } private fun initSelectActionView() { binding.selectActionBar.setMainActionText(R.string.delete) binding.selectActionBar.inflateMenu(R.menu.replace_rule_sel) binding.selectActionBar.setOnMenuItemClickListener(this) binding.selectActionBar.setCallBack(this) } private fun observeReplaceRuleData(searchKey: String? = null) { dataInit = false replaceRuleFlowJob?.cancel() replaceRuleFlowJob = lifecycleScope.launch { when { searchKey.isNullOrEmpty() -> { appDb.replaceRuleDao.flowAll() } searchKey == getString(R.string.no_group) -> { appDb.replaceRuleDao.flowNoGroup() } searchKey.startsWith("group:") -> { val key = searchKey.substringAfter("group:") appDb.replaceRuleDao.flowGroupSearch("%$key%") } else -> { appDb.replaceRuleDao.flowSearch("%$searchKey%") } }.catch { AppLog.put("替换规则管理界面更新数据出错", it) }.flowOn(IO).conflate().collect { if (dataInit) { setResult(Activity.RESULT_OK) } adapter.setItems(it, adapter.diffItemCallBack) dataInit = true delay(100) } } } private fun observeGroupData() { lifecycleScope.launch { appDb.replaceRuleDao.flowGroups().collect { groups.clear() groups.addAll(it) upGroupMenu() } } } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_add_replace_rule -> editActivity.launch(ReplaceEditActivity.startIntent(this)) R.id.menu_group_manage -> showDialogFragment() R.id.menu_del_selection -> viewModel.delSelection(adapter.selection) R.id.menu_import_onLine -> showImportDialog() R.id.menu_import_local -> importDoc.launch { mode = HandleFileContract.FILE allowExtensions = arrayOf("txt", "json") } R.id.menu_import_qr -> qrCodeResult.launch() R.id.menu_help -> showHelp("replaceRuleHelp") R.id.menu_group_null -> { searchView.setQuery(getString(R.string.no_group), true) } else -> if (item.groupId == R.id.replace_group) { searchView.setQuery("group:${item.title}", true) } } return super.onCompatOptionsItemSelected(item) } override fun onMenuItemClick(item: MenuItem?): Boolean { when (item?.itemId) { R.id.menu_enable_selection -> viewModel.enableSelection(adapter.selection) R.id.menu_disable_selection -> viewModel.disableSelection(adapter.selection) R.id.menu_top_sel -> viewModel.topSelect(adapter.selection) R.id.menu_bottom_sel -> viewModel.bottomSelect(adapter.selection) R.id.menu_export_selection -> exportResult.launch { mode = HandleFileContract.EXPORT fileData = HandleFileContract.FileData( "exportReplaceRule.json", GSON.toJson(adapter.selection).toByteArray(), "application/json" ) } } return false } private fun upGroupMenu() = groupMenu?.transaction { menu -> menu.removeGroup(R.id.replace_group) groups.forEach { menu.add(R.id.replace_group, Menu.NONE, Menu.NONE, it) } } @SuppressLint("InflateParams") private fun showImportDialog() { val aCache = ACache.get(cacheDir = false) val cacheUrls: MutableList = aCache .getAsString(importRecordKey) ?.splitNotBlank(",") ?.toMutableList() ?: mutableListOf() alert(titleResource = R.string.import_on_line) { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = "url" editView.setFilterValues(cacheUrls) editView.delCallBack = { cacheUrls.remove(it) aCache.put(importRecordKey, cacheUrls.joinToString(",")) } } customView { alertBinding.root } okButton { val text = alertBinding.editView.text?.toString() text?.let { if (it.isAbsUrl() && !cacheUrls.contains(it)) { cacheUrls.add(0, it) aCache.put(importRecordKey, cacheUrls.joinToString(",")) } showDialogFragment( ImportReplaceRuleDialog(it) ) } } cancelButton() } } override fun onQueryTextChange(newText: String?): Boolean { observeReplaceRuleData(newText) return false } override fun onQueryTextSubmit(query: String?): Boolean { return false } override fun onDestroy() { super.onDestroy() Coroutine.async { ContentProcessor.upReplaceRules() } } override fun upCountView() { binding.selectActionBar.upCountView( adapter.selection.size, adapter.itemCount ) } override fun update(vararg rule: ReplaceRule) { setResult(RESULT_OK) viewModel.update(*rule) } override fun delete(rule: ReplaceRule) { alert(R.string.draw) { setMessage(getString(R.string.sure_del) + "\n" + rule.name) noButton() yesButton { setResult(RESULT_OK) viewModel.delete(rule) } } } override fun edit(rule: ReplaceRule) { setResult(RESULT_OK) editActivity.launch(ReplaceEditActivity.startIntent(this, rule.id)) } override fun toTop(rule: ReplaceRule) { setResult(RESULT_OK) viewModel.toTop(rule) } override fun toBottom(rule: ReplaceRule) { setResult(RESULT_OK) viewModel.toBottom(rule) } override fun upOrder() { setResult(RESULT_OK) viewModel.upOrder() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/replace/ReplaceRuleAdapter.kt ================================================ package io.legado.app.ui.replace import android.content.Context import android.os.Bundle import android.view.View import android.view.ViewGroup import android.widget.PopupMenu import androidx.core.os.bundleOf import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import io.legado.app.R import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.data.entities.ReplaceRule import io.legado.app.databinding.ItemReplaceRuleBinding import io.legado.app.lib.theme.backgroundColor import io.legado.app.ui.widget.recycler.DragSelectTouchHelper import io.legado.app.ui.widget.recycler.ItemTouchCallback import io.legado.app.utils.ColorUtils class ReplaceRuleAdapter(context: Context, var callBack: CallBack) : RecyclerAdapter(context), ItemTouchCallback.Callback { private val selected = linkedSetOf() val selection: List get() { return getItems().filter { selected.contains(it) } } val diffItemCallBack = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: ReplaceRule, newItem: ReplaceRule): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame(oldItem: ReplaceRule, newItem: ReplaceRule): Boolean { if (oldItem.name != newItem.name) { return false } if (oldItem.group != newItem.group) { return false } if (oldItem.isEnabled != newItem.isEnabled) { return false } return true } override fun getChangePayload(oldItem: ReplaceRule, newItem: ReplaceRule): Any? { val payload = Bundle() if (oldItem.name != newItem.name || oldItem.group != newItem.group ) { payload.putBoolean("upName", true) } if (oldItem.isEnabled != newItem.isEnabled) { payload.putBoolean("enabled", newItem.isEnabled) } if (payload.isEmpty) { return null } return payload } } fun selectAll() { getItems().forEach { selected.add(it) } notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null))) callBack.upCountView() } fun revertSelection() { getItems().forEach { if (selected.contains(it)) { selected.remove(it) } else { selected.add(it) } } notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null))) callBack.upCountView() } override fun getViewBinding(parent: ViewGroup): ItemReplaceRuleBinding { return ItemReplaceRuleBinding.inflate(inflater, parent, false) } override fun onCurrentListChanged() { callBack.upCountView() } override fun convert( holder: ItemViewHolder, binding: ItemReplaceRuleBinding, item: ReplaceRule, payloads: MutableList ) { binding.run { if (payloads.isEmpty()) { root.setBackgroundColor(ColorUtils.withAlpha(context.backgroundColor, 0.5f)) cbName.text = item.getDisplayNameGroup() swtEnabled.isChecked = item.isEnabled cbName.isChecked = selected.contains(item) } else { for (i in payloads.indices) { val bundle = payloads[i] as Bundle bundle.keySet().forEach { when (it) { "selected" -> cbName.isChecked = selected.contains(item) "upName" -> cbName.text = item.getDisplayNameGroup() "enabled" -> swtEnabled.isChecked = item.isEnabled } } } } } } override fun registerListener(holder: ItemViewHolder, binding: ItemReplaceRuleBinding) { binding.apply { swtEnabled.setOnUserCheckedChangeListener { isChecked -> getItem(holder.layoutPosition)?.let { it.isEnabled = isChecked callBack.update(it) } } ivEdit.setOnClickListener { getItem(holder.layoutPosition)?.let { callBack.edit(it) } } cbName.setOnClickListener { getItem(holder.layoutPosition)?.let { if (cbName.isChecked) { selected.add(it) } else { selected.remove(it) } } callBack.upCountView() } ivMenuMore.setOnClickListener { showMenu(ivMenuMore, holder.layoutPosition) } } } private fun showMenu(view: View, position: Int) { val item = getItem(position) ?: return val popupMenu = PopupMenu(context, view) popupMenu.inflate(R.menu.replace_rule_item) popupMenu.setOnMenuItemClickListener { menuItem -> when (menuItem.itemId) { R.id.menu_top -> callBack.toTop(item) R.id.menu_bottom -> callBack.toBottom(item) R.id.menu_del -> { callBack.delete(item) selected.remove(item) } } true } popupMenu.show() } override fun swap(srcPosition: Int, targetPosition: Int): Boolean { val srcItem = getItem(srcPosition) val targetItem = getItem(targetPosition) if (srcItem != null && targetItem != null) { if (srcItem.order == targetItem.order) { callBack.upOrder() } else { val srcOrder = srcItem.order srcItem.order = targetItem.order targetItem.order = srcOrder movedItems.add(srcItem) movedItems.add(targetItem) } } swapItem(srcPosition, targetPosition) return true } private val movedItems = linkedSetOf() override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { if (movedItems.isNotEmpty()) { callBack.update(*movedItems.toTypedArray()) movedItems.clear() } } val dragSelectCallback: DragSelectTouchHelper.Callback = object : DragSelectTouchHelper.AdvanceCallback(Mode.ToggleAndReverse) { override fun currentSelectedId(): MutableSet { return selected } override fun getItemId(position: Int): ReplaceRule { return getItem(position)!! } override fun updateSelectState(position: Int, isSelected: Boolean): Boolean { getItem(position)?.let { if (isSelected) { selected.add(it) } else { selected.remove(it) } notifyItemChanged(position, bundleOf(Pair("selected", null))) callBack.upCountView() return true } return false } } interface CallBack { fun update(vararg rule: ReplaceRule) fun delete(rule: ReplaceRule) fun edit(rule: ReplaceRule) fun toTop(rule: ReplaceRule) fun toBottom(rule: ReplaceRule) fun upOrder() fun upCountView() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt ================================================ package io.legado.app.ui.replace import android.app.Application import android.text.TextUtils import io.legado.app.base.BaseViewModel import io.legado.app.data.appDb import io.legado.app.data.entities.ReplaceRule import io.legado.app.utils.splitNotBlank /** * 替换规则数据修改 * 修改数据要copy,直接修改会导致界面不刷新 */ class ReplaceRuleViewModel(application: Application) : BaseViewModel(application) { fun update(vararg rule: ReplaceRule) { execute { appDb.replaceRuleDao.update(*rule) } } fun delete(rule: ReplaceRule) { execute { appDb.replaceRuleDao.delete(rule) } } fun toTop(rule: ReplaceRule) { execute { rule.order = appDb.replaceRuleDao.minOrder - 1 appDb.replaceRuleDao.update(rule) } } fun topSelect(rules: List) { execute { var minOrder = appDb.replaceRuleDao.minOrder - rules.size rules.forEach { it.order = ++minOrder } appDb.replaceRuleDao.update(*rules.toTypedArray()) } } fun toBottom(rule: ReplaceRule) { execute { rule.order = appDb.replaceRuleDao.maxOrder + 1 appDb.replaceRuleDao.update(rule) } } fun bottomSelect(rules: List) { execute { var maxOrder = appDb.replaceRuleDao.maxOrder rules.forEach { it.order = maxOrder++ } appDb.replaceRuleDao.update(*rules.toTypedArray()) } } fun upOrder() { execute { val rules = appDb.replaceRuleDao.all for ((index, rule) in rules.withIndex()) { rule.order = index + 1 } appDb.replaceRuleDao.update(*rules.toTypedArray()) } } fun enableSelection(rules: List) { execute { val array = Array(rules.size) { rules[it].copy(isEnabled = true) } appDb.replaceRuleDao.update(*array) } } fun disableSelection(rules: List) { execute { val array = Array(rules.size) { rules[it].copy(isEnabled = false) } appDb.replaceRuleDao.update(*array) } } fun delSelection(rules: List) { execute { appDb.replaceRuleDao.delete(*rules.toTypedArray()) } } fun addGroup(group: String) { execute { val sources = appDb.replaceRuleDao.noGroup sources.forEach { source -> source.group = group } appDb.replaceRuleDao.update(*sources.toTypedArray()) } } fun upGroup(oldGroup: String, newGroup: String?) { execute { val sources = appDb.replaceRuleDao.getByGroup(oldGroup) sources.forEach { source -> source.group?.splitNotBlank(",")?.toHashSet()?.let { it.remove(oldGroup) if (!newGroup.isNullOrEmpty()) it.add(newGroup) source.group = TextUtils.join(",", it) } } appDb.replaceRuleDao.update(*sources.toTypedArray()) } } fun delGroup(group: String) { execute { execute { val sources = appDb.replaceRuleDao.getByGroup(group) sources.forEach { source -> source.group?.splitNotBlank(",")?.toHashSet()?.let { it.remove(group) source.group = TextUtils.join(",", it) } } appDb.replaceRuleDao.update(*sources.toTypedArray()) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditActivity.kt ================================================ package io.legado.app.ui.replace.edit import android.content.Context import android.content.Intent import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.widget.EditText import androidx.activity.viewModels import androidx.lifecycle.lifecycleScope import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.data.entities.ReplaceRule import io.legado.app.databinding.ActivityReplaceEditBinding import io.legado.app.lib.dialogs.SelectItem import io.legado.app.ui.widget.keyboard.KeyboardToolPop import io.legado.app.utils.GSON import io.legado.app.utils.imeHeight import io.legado.app.utils.sendToClip import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat import io.legado.app.utils.showHelp import io.legado.app.utils.viewbindingdelegate.viewBinding /** * 编辑替换规则 */ class ReplaceEditActivity : VMBaseActivity(), KeyboardToolPop.CallBack { companion object { fun startIntent( context: Context, id: Long = -1, pattern: String? = null, isRegex: Boolean = false, scope: String? = null ): Intent { val intent = Intent(context, ReplaceEditActivity::class.java) intent.putExtra("id", id) intent.putExtra("pattern", pattern) intent.putExtra("isRegex", isRegex) intent.putExtra("scope", scope) return intent } } override val binding by viewBinding(ActivityReplaceEditBinding::inflate) override val viewModel by viewModels() private val softKeyboardTool by lazy { KeyboardToolPop(this, lifecycleScope, binding.root, this) } override fun onActivityCreated(savedInstanceState: Bundle?) { softKeyboardTool.attachToWindow(window) initView() viewModel.initData(intent) { upReplaceView(it) } } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.replace_edit, menu) return super.onCompatCreateOptionsMenu(menu) } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_save -> viewModel.save(getReplaceRule()) { setResult(RESULT_OK) finish() } R.id.menu_copy_rule -> sendToClip(GSON.toJson(getReplaceRule())) R.id.menu_paste_rule -> viewModel.pasteRule { upReplaceView(it) } } return true } override fun onDestroy() { super.onDestroy() softKeyboardTool.dismiss() } private fun initView() { binding.ivHelp.setOnClickListener { showHelp("regexHelp") } binding.root.setOnApplyWindowInsetsListenerCompat { _, windowInsets -> softKeyboardTool.initialPadding = windowInsets.imeHeight windowInsets } } private fun upReplaceView(replaceRule: ReplaceRule) = binding.run { etName.setText(replaceRule.name) etGroup.setText(replaceRule.group) etReplaceRule.setText(replaceRule.pattern) cbUseRegex.isChecked = replaceRule.isRegex etReplaceTo.setText(replaceRule.replacement) cbScopeTitle.isChecked = replaceRule.scopeTitle cbScopeContent.isChecked = replaceRule.scopeContent etScope.setText(replaceRule.scope) etExcludeScope.setText(replaceRule.excludeScope) etTimeout.setText(replaceRule.timeoutMillisecond.toString()) } private fun getReplaceRule(): ReplaceRule = binding.run { val replaceRule: ReplaceRule = viewModel.replaceRule ?: ReplaceRule() replaceRule.name = etName.text.toString() replaceRule.group = etGroup.text.toString() replaceRule.pattern = etReplaceRule.text.toString() replaceRule.isRegex = cbUseRegex.isChecked replaceRule.replacement = etReplaceTo.text.toString() replaceRule.scopeTitle = cbScopeTitle.isChecked replaceRule.scopeContent = cbScopeContent.isChecked replaceRule.scope = etScope.text.toString() replaceRule.excludeScope = etExcludeScope.text.toString() replaceRule.timeoutMillisecond = etTimeout.text.toString().ifEmpty { "3000" }.toLong() return replaceRule } override fun helpActions(): List> { return arrayListOf( SelectItem("正则教程", "regexHelp") ) } override fun onHelpActionSelect(action: String) { when (action) { "regexHelp" -> showHelp("regexHelp") } } override fun sendText(text: String) { if (text.isBlank()) return val view = window?.decorView?.findFocus() if (view is EditText) { val start = view.selectionStart val end = view.selectionEnd //获取EditText的文字 val edit = view.editableText if (start < 0 || start >= edit.length) { edit.append(text) } else if (start > end) { edit.replace(end, start, text) } else { //光标所在位置插入文字 edit.replace(start, end, text) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditViewModel.kt ================================================ package io.legado.app.ui.replace.edit import android.app.Application import android.content.Intent import io.legado.app.base.BaseViewModel import io.legado.app.data.appDb import io.legado.app.data.entities.ReplaceRule import io.legado.app.exception.NoStackTraceException import io.legado.app.utils.* import kotlinx.coroutines.Dispatchers class ReplaceEditViewModel(application: Application) : BaseViewModel(application) { var replaceRule: ReplaceRule? = null fun initData(intent: Intent, finally: (replaceRule: ReplaceRule) -> Unit) { execute { val id = intent.getLongExtra("id", -1) replaceRule = if (id > 0) { appDb.replaceRuleDao.findById(id) } else { val pattern = intent.getStringExtra("pattern") ?: "" val isRegex = intent.getBooleanExtra("isRegex", false) val scope = intent.getStringExtra("scope") ReplaceRule( name = pattern, pattern = pattern, isRegex = isRegex, scope = scope ) } }.onFinally { replaceRule?.let { finally(it) } } } fun pasteRule(success: (ReplaceRule) -> Unit) { execute(context = Dispatchers.Main) { val text = context.getClipText() if (text.isNullOrBlank()) { throw NoStackTraceException("剪贴板为空") } GSON.fromJsonObject(text).getOrNull() ?: throw NoStackTraceException("格式不对") }.onSuccess { success.invoke(it) }.onError { context.toastOnUi(it.localizedMessage ?: "Error") it.printOnDebug() } } fun save(replaceRule: ReplaceRule, success: () -> Unit) { execute { replaceRule.checkValid() if (replaceRule.order == Int.MIN_VALUE) { replaceRule.order = appDb.replaceRuleDao.maxOrder + 1 } appDb.replaceRuleDao.insert(replaceRule) }.onSuccess { success() }.onError { context.toastOnUi("save error, ${it.localizedMessage}") } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/article/BaseRssArticlesAdapter.kt ================================================ package io.legado.app.ui.rss.article import android.content.Context import androidx.viewbinding.ViewBinding import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.data.entities.RssArticle abstract class BaseRssArticlesAdapter(context: Context, val callBack: CallBack) : RecyclerAdapter(context) { interface CallBack { val isGridLayout: Boolean fun readRss(rssArticle: RssArticle) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/article/ReadRecordDialog.kt ================================================ package io.legado.app.ui.rss.article import android.content.Context import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.fragment.app.viewModels import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.data.entities.RssReadRecord import io.legado.app.databinding.DialogRecyclerViewBinding import io.legado.app.databinding.ItemRssReadRecordBinding import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.primaryColor import io.legado.app.utils.setLayout import io.legado.app.utils.viewbindingdelegate.viewBinding class ReadRecordDialog : BaseDialogFragment(R.layout.dialog_recycler_view), Toolbar.OnMenuItemClickListener { private val viewModel by viewModels() private val binding by viewBinding(DialogRecyclerViewBinding::bind) private val adapter by lazy { ReadRecordAdapter(requireContext()) } override fun onStart() { super.onStart() setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.run { toolBar.setBackgroundColor(primaryColor) toolBar.setTitle(R.string.read_record) toolBar.inflateMenu(R.menu.rss_read_record) toolBar.setOnMenuItemClickListener(this@ReadRecordDialog) recyclerView.layoutManager = LinearLayoutManager(requireContext()) recyclerView.adapter = adapter } adapter.setItems(viewModel.getRecords()) } override fun onMenuItemClick(item: MenuItem?): Boolean { when (item?.itemId) { R.id.menu_clear -> { alert(R.string.draw) { val countRead = viewModel.countRecords() setMessage(getString(R.string.sure_del) + "\n" + countRead + " " + getString(R.string.read_record)) noButton() yesButton { viewModel.deleteAllRecord() adapter.clearItems() } } } } return true } inner class ReadRecordAdapter(context: Context) : RecyclerAdapter(context) { override fun getViewBinding(parent: ViewGroup): ItemRssReadRecordBinding { return ItemRssReadRecordBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemRssReadRecordBinding, item: RssReadRecord, payloads: MutableList ) { binding.textTitle.text = item.title binding.textRecord.text = item.record } override fun registerListener(holder: ItemViewHolder, binding: ItemRssReadRecordBinding) { } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter.kt ================================================ package io.legado.app.ui.rss.article import android.annotation.SuppressLint import android.content.Context import android.graphics.drawable.Drawable import android.view.ViewGroup import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.target.Target import io.legado.app.R import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.data.entities.RssArticle import io.legado.app.databinding.ItemRssArticleBinding import io.legado.app.help.glide.ImageLoader import io.legado.app.help.glide.OkHttpModelLoader import io.legado.app.utils.getCompatColor import io.legado.app.utils.gone import io.legado.app.utils.visible class RssArticlesAdapter(context: Context, callBack: CallBack) : BaseRssArticlesAdapter(context, callBack) { override fun getViewBinding(parent: ViewGroup): ItemRssArticleBinding { return ItemRssArticleBinding.inflate(inflater, parent, false) } @SuppressLint("CheckResult") override fun convert( holder: ItemViewHolder, binding: ItemRssArticleBinding, item: RssArticle, payloads: MutableList ) { binding.run { tvTitle.text = item.title tvPubDate.text = item.pubDate if (item.image.isNullOrBlank() && !callBack.isGridLayout) { imageView.gone() } else { val options = RequestOptions().set(OkHttpModelLoader.sourceOriginOption, item.origin) ImageLoader.load(context, item.image).apply(options).apply { if (callBack.isGridLayout) { placeholder(R.drawable.image_rss_article) } else { addListener(object : RequestListener { override fun onLoadFailed( e: GlideException?, model: Any?, target: Target, isFirstResource: Boolean ): Boolean { imageView.gone() return false } override fun onResourceReady( resource: Drawable, model: Any, target: Target?, dataSource: DataSource, isFirstResource: Boolean ): Boolean { imageView.visible() return false } }) } }.into(imageView) } if (item.read) { tvTitle.setTextColor(context.getCompatColor(R.color.tv_text_summary)) } else { tvTitle.setTextColor(context.getCompatColor(R.color.primaryText)) } } } override fun registerListener(holder: ItemViewHolder, binding: ItemRssArticleBinding) { holder.itemView.setOnClickListener { getItem(holder.layoutPosition)?.let { callBack.readRss(it) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter1.kt ================================================ package io.legado.app.ui.rss.article import android.annotation.SuppressLint import android.content.Context import android.graphics.drawable.Drawable import android.view.ViewGroup import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.target.Target import io.legado.app.R import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.data.entities.RssArticle import io.legado.app.databinding.ItemRssArticle1Binding import io.legado.app.help.glide.ImageLoader import io.legado.app.help.glide.OkHttpModelLoader import io.legado.app.utils.getCompatColor import io.legado.app.utils.gone import io.legado.app.utils.visible class RssArticlesAdapter1(context: Context, callBack: CallBack) : BaseRssArticlesAdapter(context, callBack) { override fun getViewBinding(parent: ViewGroup): ItemRssArticle1Binding { return ItemRssArticle1Binding.inflate(inflater, parent, false) } @SuppressLint("CheckResult") override fun convert( holder: ItemViewHolder, binding: ItemRssArticle1Binding, item: RssArticle, payloads: MutableList ) { binding.run { tvTitle.text = item.title tvPubDate.text = item.pubDate if (item.image.isNullOrBlank() && !callBack.isGridLayout) { imageView.gone() } else { val options = RequestOptions().set(OkHttpModelLoader.sourceOriginOption, item.origin) ImageLoader.load(context, item.image).apply(options).apply { if (callBack.isGridLayout) { placeholder(R.drawable.image_rss_article) } else { addListener(object : RequestListener { override fun onLoadFailed( e: GlideException?, model: Any?, target: Target, isFirstResource: Boolean ): Boolean { imageView.gone() return false } override fun onResourceReady( resource: Drawable, model: Any, target: Target?, dataSource: DataSource, isFirstResource: Boolean ): Boolean { imageView.visible() return false } }) } }.into(imageView) } if (item.read) { tvTitle.setTextColor(context.getCompatColor(R.color.tv_text_summary)) } else { tvTitle.setTextColor(context.getCompatColor(R.color.primaryText)) } } } override fun registerListener(holder: ItemViewHolder, binding: ItemRssArticle1Binding) { holder.itemView.setOnClickListener { getItem(holder.layoutPosition)?.let { callBack.readRss(it) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter2.kt ================================================ package io.legado.app.ui.rss.article import android.annotation.SuppressLint import android.content.Context import android.graphics.drawable.Drawable import android.view.ViewGroup import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.target.Target import io.legado.app.R import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.data.entities.RssArticle import io.legado.app.databinding.ItemRssArticle2Binding import io.legado.app.help.glide.ImageLoader import io.legado.app.help.glide.OkHttpModelLoader import io.legado.app.utils.getCompatColor import io.legado.app.utils.gone import io.legado.app.utils.visible class RssArticlesAdapter2(context: Context, callBack: CallBack) : BaseRssArticlesAdapter(context, callBack) { override fun getViewBinding(parent: ViewGroup): ItemRssArticle2Binding { return ItemRssArticle2Binding.inflate(inflater, parent, false) } @SuppressLint("CheckResult") override fun convert( holder: ItemViewHolder, binding: ItemRssArticle2Binding, item: RssArticle, payloads: MutableList ) { binding.run { tvTitle.text = item.title tvPubDate.text = item.pubDate if (item.image.isNullOrBlank() && !callBack.isGridLayout) { imageView.gone() } else { val options = RequestOptions().set(OkHttpModelLoader.sourceOriginOption, item.origin) ImageLoader.load(context, item.image).apply(options).apply { if (callBack.isGridLayout) { placeholder(R.drawable.image_rss_article) } else { addListener(object : RequestListener { override fun onLoadFailed( e: GlideException?, model: Any?, target: Target, isFirstResource: Boolean ): Boolean { imageView.gone() return false } override fun onResourceReady( resource: Drawable, model: Any, target: Target?, dataSource: DataSource, isFirstResource: Boolean ): Boolean { imageView.visible() return false } }) } }.into(imageView) } if (item.read) { tvTitle.setTextColor(context.getCompatColor(R.color.tv_text_summary)) } else { tvTitle.setTextColor(context.getCompatColor(R.color.primaryText)) } } } override fun registerListener(holder: ItemViewHolder, binding: ItemRssArticle2Binding) { holder.itemView.setOnClickListener { getItem(holder.layoutPosition)?.let { callBack.readRss(it) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/article/RssArticlesFragment.kt ================================================ package io.legado.app.ui.rss.article import android.os.Bundle import android.view.View import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import io.legado.app.R import io.legado.app.base.VMBaseFragment import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.RssArticle import io.legado.app.databinding.FragmentRssArticlesBinding import io.legado.app.databinding.ViewLoadMoreBinding import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.rss.read.ReadRssActivity import io.legado.app.ui.widget.recycler.LoadMoreView import io.legado.app.ui.widget.recycler.VerticalDivider import io.legado.app.utils.applyNavigationBarPadding import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.startActivity import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch class RssArticlesFragment() : VMBaseFragment(R.layout.fragment_rss_articles), BaseRssArticlesAdapter.CallBack { constructor(sortName: String, sortUrl: String) : this() { arguments = Bundle().apply { putString("sortName", sortName) putString("sortUrl", sortUrl) } } private val binding by viewBinding(FragmentRssArticlesBinding::bind) private val activityViewModel by activityViewModels() override val viewModel by viewModels() private val adapter: BaseRssArticlesAdapter<*> by lazy { when (activityViewModel.rssSource?.articleStyle) { 1 -> RssArticlesAdapter1(requireContext(), this@RssArticlesFragment) 2 -> RssArticlesAdapter2(requireContext(), this@RssArticlesFragment) else -> RssArticlesAdapter(requireContext(), this@RssArticlesFragment) } } private val loadMoreView: LoadMoreView by lazy { LoadMoreView(requireContext()) } private var articlesFlowJob: Job? = null override val isGridLayout: Boolean get() = activityViewModel.isGridLayout override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { viewModel.init(arguments) initView() initData() } private fun initView() = binding.run { refreshLayout.setColorSchemeColors(accentColor) recyclerView.setEdgeEffectColor(primaryColor) recyclerView.applyNavigationBarPadding() loadMoreView.setOnClickListener { if (!loadMoreView.isLoading) { scrollToBottom(true) } } recyclerView.layoutManager = if (activityViewModel.isGridLayout) { recyclerView.setPadding(8, 0, 8, 0) GridLayoutManager(requireContext(), 2) } else { recyclerView.addItemDecoration(VerticalDivider(requireContext())) LinearLayoutManager(requireContext()) } recyclerView.adapter = adapter adapter.addFooterView { ViewLoadMoreBinding.bind(loadMoreView) } refreshLayout.setOnRefreshListener { loadArticles() } recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { super.onScrolled(recyclerView, dx, dy) if (!recyclerView.canScrollVertically(1)) { scrollToBottom() } } }) viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.RESUMED) { refreshLayout.isRefreshing = true loadArticles() this@launch.cancel() } } } private fun initData() { val rssUrl = activityViewModel.url ?: return articlesFlowJob?.cancel() articlesFlowJob = viewLifecycleOwner.lifecycleScope.launch { appDb.rssArticleDao.flowByOriginSort(rssUrl, viewModel.sortName).catch { AppLog.put("订阅文章界面获取数据失败\n${it.localizedMessage}", it) }.flowOn(IO).collect { adapter.setItems(it) } } } private fun loadArticles() { activityViewModel.rssSource?.let { viewModel.loadArticles(it) } } private fun scrollToBottom(forceLoad: Boolean = false) { if (viewModel.isLoading) return if ((loadMoreView.hasMore && adapter.getActualItemCount() > 0) || forceLoad) { loadMoreView.hasMore() activityViewModel.rssSource?.let { viewModel.loadMore(it) } } } override fun observeLiveBus() { viewModel.loadErrorLiveData.observe(viewLifecycleOwner) { loadMoreView.error(it) } viewModel.loadFinallyLiveData.observe(viewLifecycleOwner) { hasMore -> binding.refreshLayout.isRefreshing = false if (!hasMore) { loadMoreView.noMore() } } } override fun readRss(rssArticle: RssArticle) { activityViewModel.read(rssArticle) startActivity { putExtra("title", rssArticle.title) putExtra("origin", rssArticle.origin) putExtra("link", rssArticle.link) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/article/RssArticlesViewModel.kt ================================================ package io.legado.app.ui.rss.article import android.app.Application import android.os.Bundle import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.RssArticle import io.legado.app.data.entities.RssSource import io.legado.app.model.rss.Rss import io.legado.app.utils.stackTraceStr import kotlinx.coroutines.Dispatchers.IO class RssArticlesViewModel(application: Application) : BaseViewModel(application) { val loadFinallyLiveData = MutableLiveData() val loadErrorLiveData = MutableLiveData() var isLoading = true var order = System.currentTimeMillis() private var nextPageUrl: String? = null var sortName: String = "" var sortUrl: String = "" var page = 1 fun init(bundle: Bundle?) { bundle?.let { sortName = it.getString("sortName") ?: "" sortUrl = it.getString("sortUrl") ?: "" } } fun loadArticles(rssSource: RssSource) { isLoading = true page = 1 order = System.currentTimeMillis() Rss.getArticles(viewModelScope, sortName, sortUrl, rssSource, page).onSuccess(IO) { nextPageUrl = it.second val articles = it.first articles.forEach { rssArticle -> rssArticle.order = order-- } appDb.rssArticleDao.insert(*articles.toTypedArray()) if (!rssSource.ruleNextPage.isNullOrEmpty()) { appDb.rssArticleDao.clearOld(rssSource.sourceUrl, sortName, order) } val hasMore = articles.isNotEmpty() && !rssSource.ruleNextPage.isNullOrEmpty() loadFinallyLiveData.postValue(hasMore) isLoading = false }.onError { loadFinallyLiveData.postValue(false) AppLog.put("rss获取内容失败", it) loadErrorLiveData.postValue(it.stackTraceStr) } } fun loadMore(rssSource: RssSource) { isLoading = true page++ val pageUrl = nextPageUrl if (pageUrl.isNullOrEmpty()) { loadFinallyLiveData.postValue(false) return } Rss.getArticles(viewModelScope, sortName, pageUrl, rssSource, page).onSuccess(IO) { nextPageUrl = it.second loadMoreSuccess(it.first) isLoading = false }.onError { loadFinallyLiveData.postValue(false) AppLog.put("rss获取内容失败", it) loadErrorLiveData.postValue(it.stackTraceStr) } } private fun loadMoreSuccess(articles: MutableList) { if (articles.isEmpty()) { loadFinallyLiveData.postValue(false) return } val firstArticle = articles.first() val dbFirstArticle = appDb.rssArticleDao.get(firstArticle.origin, firstArticle.link) val lastArticle = articles.last() val dbLastArticle = appDb.rssArticleDao.get(lastArticle.origin, lastArticle.link) if (dbFirstArticle != null && dbLastArticle != null) { loadFinallyLiveData.postValue(false) } else { articles.forEach { it.order = order-- } appDb.rssArticleDao.append(*articles.toTypedArray()) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/article/RssSortActivity.kt ================================================ @file:Suppress("DEPRECATION") package io.legado.app.ui.rss.article import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.ViewGroup import androidx.activity.viewModels import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentStatePagerAdapter import androidx.lifecycle.lifecycleScope import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.databinding.ActivityRssArtivlesBinding import io.legado.app.help.source.sortUrls import io.legado.app.lib.theme.accentColor import io.legado.app.ui.login.SourceLoginActivity 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.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class RssSortActivity : VMBaseActivity(), VariableDialog.Callback { override val binding by viewBinding(ActivityRssArtivlesBinding::inflate) override val viewModel by viewModels() private val adapter by lazy { TabFragmentPageAdapter() } private val sortList = mutableListOf>() private val fragmentMap = hashMapOf() private val editSourceResult = registerForActivityResult( StartActivityContract(RssSourceEditActivity::class.java) ) { if (it.resultCode == RESULT_OK) { viewModel.initData(intent) { upFragments() } } } override fun onActivityCreated(savedInstanceState: Bundle?) { binding.viewPager.adapter = adapter binding.tabLayout.setupWithViewPager(binding.viewPager) binding.tabLayout.setSelectedTabIndicatorColor(accentColor) viewModel.titleLiveData.observe(this) { binding.titleBar.title = it } viewModel.initData(intent) { upFragments() } } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.rss_articles, menu) return super.onCompatCreateOptionsMenu(menu) } override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { menu.findItem(R.id.menu_login)?.isVisible = !viewModel.rssSource?.loginUrl.isNullOrBlank() return super.onMenuOpened(featureId, menu) } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_login -> startActivity { putExtra("type", "rssSource") putExtra("key", viewModel.rssSource?.sourceUrl) } R.id.menu_refresh_sort -> viewModel.clearSortCache { upFragments() } R.id.menu_set_source_variable -> setSourceVariable() R.id.menu_edit_source -> viewModel.rssSource?.sourceUrl?.let { editSourceResult.launch { putExtra("sourceUrl", it) } } R.id.menu_clear -> { viewModel.url?.let { viewModel.clearArticles() } } R.id.menu_switch_layout -> { viewModel.switchLayout() upFragments() } R.id.menu_read_record -> { showDialogFragment() } } return super.onCompatOptionsItemSelected(item) } private fun upFragments() { lifecycleScope.launch { viewModel.rssSource?.sortUrls()?.let { sortList.clear() sortList.addAll(it) } if (sortList.size == 1) { binding.tabLayout.gone() } else { binding.tabLayout.visible() } adapter.notifyDataSetChanged() } } private fun setSourceVariable() { lifecycleScope.launch { val source = viewModel.rssSource if (source == null) { toastOnUi("源不存在") return@launch } val comment = source.getDisplayVariableComment("源变量可在js中通过source.getVariable()获取") val variable = withContext(Dispatchers.IO) { source.getVariable() } showDialogFragment( VariableDialog( getString(R.string.set_source_variable), source.getKey(), variable, comment ) ) } } override fun setVariable(key: String, variable: String?) { viewModel.rssSource?.setVariable(variable) } private inner class TabFragmentPageAdapter : FragmentStatePagerAdapter(supportFragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { override fun getItemPosition(`object`: Any): Int { return POSITION_NONE } override fun getPageTitle(position: Int): CharSequence { return sortList[position].first } override fun getItem(position: Int): Fragment { val sort = sortList[position] return RssArticlesFragment(sort.first, sort.second) } override fun getCount(): Int { return sortList.size } override fun instantiateItem(container: ViewGroup, position: Int): Any { val fragment = super.instantiateItem(container, position) as Fragment fragmentMap[sortList[position].first] = fragment return fragment } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/article/RssSortViewModel.kt ================================================ package io.legado.app.ui.rss.article import android.app.Application import android.content.Intent import androidx.lifecycle.MutableLiveData import io.legado.app.base.BaseViewModel import io.legado.app.data.appDb import io.legado.app.data.entities.RssArticle import io.legado.app.data.entities.RssReadRecord import io.legado.app.data.entities.RssSource import io.legado.app.help.source.removeSortCache class RssSortViewModel(application: Application) : BaseViewModel(application) { var url: String? = null var rssSource: RssSource? = null val titleLiveData = MutableLiveData() var order = System.currentTimeMillis() val isGridLayout get() = rssSource?.articleStyle == 2 fun initData(intent: Intent, finally: () -> Unit) { execute { url = intent.getStringExtra("url") url?.let { url -> rssSource = appDb.rssSourceDao.getByKey(url) rssSource?.let { titleLiveData.postValue(it.sourceName) } ?: let { rssSource = RssSource(sourceUrl = url) } } }.onFinally { finally() } } fun switchLayout() { rssSource?.let { if (it.articleStyle < 2) { it.articleStyle += 1 } else { it.articleStyle = 0 } execute { appDb.rssSourceDao.update(it) } } } fun read(rssArticle: RssArticle) { execute { val rssReadRecord = RssReadRecord( record = rssArticle.link, title = rssArticle.title, readTime = System.currentTimeMillis() ) appDb.rssReadRecordDao.insertRecord(rssReadRecord) } } fun clearArticles() { execute { url?.let { appDb.rssArticleDao.delete(it) } order = System.currentTimeMillis() }.onSuccess { } } fun clearSortCache(onFinally: () -> Unit) { execute { rssSource?.removeSortCache() }.onFinally { onFinally.invoke() } } fun getRecords(): List { return appDb.rssReadRecordDao.getRecords() } fun countRecords() : Int { return appDb.rssReadRecordDao.countRecords } fun deleteAllRecord() { execute { appDb.rssReadRecordDao.deleteAllRecord() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesActivity.kt ================================================ @file:Suppress("DEPRECATION") package io.legado.app.ui.rss.favorites import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.SubMenu import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentStatePagerAdapter import androidx.lifecycle.lifecycleScope import androidx.viewpager.widget.ViewPager import io.legado.app.R import io.legado.app.base.BaseActivity import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.databinding.ActivityRssFavoritesBinding import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.accentColor import io.legado.app.utils.gone import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch /** * 收藏夹 */ class RssFavoritesActivity : BaseActivity() { override val binding by viewBinding(ActivityRssFavoritesBinding::inflate) private val adapter by lazy { TabFragmentPageAdapter() } private var groupList = mutableListOf() private var groupsMenu: SubMenu? = null private var currentGroup = "" override fun onActivityCreated(savedInstanceState: Bundle?) { initView() upFragments() } override fun onResume() { super.onResume() //从ReadRssActivity退出时,判断是否需要重新定位tabLayout选中项 if (currentGroup.isNotEmpty() && groupList.isNotEmpty()) { var item = groupList.indexOf(currentGroup) val currentItem = binding.viewPager.currentItem //如果坐标没有变化,则结束 if (item == currentItem) { return } if (item == -1) { item = currentItem } lifecycleScope.launch { delay(100) binding.tabLayout.getTabAt(item)?.select() } } } private fun initView() { binding.viewPager.adapter = adapter binding.viewPager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener { override fun onPageScrolled( position: Int, positionOffset: Float, positionOffsetPixels: Int ) { } override fun onPageSelected(position: Int) { currentGroup = groupList[position] } override fun onPageScrollStateChanged(state: Int) {} }) binding.tabLayout.setupWithViewPager(binding.viewPager) binding.tabLayout.setSelectedTabIndicatorColor(accentColor) } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.rss_favorites, menu) groupsMenu = menu.findItem(R.id.menu_group)?.subMenu upGroupsMenu() return super.onCompatCreateOptionsMenu(menu) } private fun upGroupsMenu() = groupsMenu?.let { subMenu -> subMenu.removeGroup(R.id.menu_group) groupList.forEachIndexed { index, it -> subMenu.add(R.id.menu_group, Menu.NONE, index, it) } } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { if (item.groupId == R.id.menu_group) { binding.viewPager.setCurrentItem(item.order) } else { when (item.itemId) { R.id.menu_del_group -> deleteGroup() R.id.menu_del_all -> deleteAll() } } return super.onCompatOptionsItemSelected(item) } private fun upFragments() { lifecycleScope.launch { appDb.rssStarDao.flowGroups().catch { AppLog.put("订阅分组数据获取失败\n${it.localizedMessage}", it) }.distinctUntilChanged().flowOn(IO).collect { groupList.clear() groupList.addAll(it) if (groupList.size == 1) { binding.tabLayout.gone() } else { binding.tabLayout.visible() } if (groupsMenu != null) { upGroupsMenu() } adapter.notifyDataSetChanged() } } } private fun deleteGroup() { if (groupList.isEmpty()) { return } alert(R.string.draw) { val item = binding.viewPager.currentItem val group = groupList[item] setMessage( getString(R.string.sure_del) + "\n<" + group + ">" + getString(R.string.group) ) noButton() yesButton { appDb.rssStarDao.deleteByGroup(group) } } } private fun deleteAll() { alert(R.string.draw) { setMessage( getString(R.string.sure_del) + "\n<" + getString(R.string.all) + ">" + getString(R.string.favorite) ) noButton() yesButton { appDb.rssStarDao.deleteAll() } } } private inner class TabFragmentPageAdapter : FragmentStatePagerAdapter(supportFragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { override fun getItemPosition(`object`: Any): Int { return POSITION_NONE } override fun getPageTitle(position: Int): CharSequence { return groupList[position] } override fun getItem(position: Int): Fragment { val group = groupList[position] return RssFavoritesFragment(group) } override fun getCount(): Int { return groupList.size } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesAdapter.kt ================================================ package io.legado.app.ui.rss.favorites import android.content.Context import android.graphics.drawable.Drawable import android.view.ViewGroup import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.target.Target import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.data.entities.RssStar import io.legado.app.databinding.ItemRssArticleBinding import io.legado.app.help.glide.ImageLoader import io.legado.app.help.glide.OkHttpModelLoader import io.legado.app.utils.gone import io.legado.app.utils.visible class RssFavoritesAdapter(context: Context, val callBack: CallBack) : RecyclerAdapter(context) { override fun getViewBinding(parent: ViewGroup): ItemRssArticleBinding { return ItemRssArticleBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemRssArticleBinding, item: RssStar, payloads: MutableList ) { binding.run { tvTitle.text = item.title tvPubDate.text = item.pubDate if (item.image.isNullOrBlank()) { imageView.gone() } else { val options = RequestOptions().set(OkHttpModelLoader.sourceOriginOption, item.origin) ImageLoader.load(context, item.image) .apply(options) .addListener(object : RequestListener { override fun onLoadFailed( e: GlideException?, model: Any?, target: Target, isFirstResource: Boolean ): Boolean { imageView.gone() return false } override fun onResourceReady( resource: Drawable, model: Any, target: Target?, dataSource: DataSource, isFirstResource: Boolean ): Boolean { imageView.visible() return false } }) .into(imageView) } } } override fun registerListener(holder: ItemViewHolder, binding: ItemRssArticleBinding) { holder.itemView.setOnClickListener { getItem(holder.layoutPosition)?.let { callBack.readRss(it) } } holder.itemView.setOnLongClickListener { getItem(holder.layoutPosition)?.let { callBack.delStar(it) } true } } interface CallBack { fun readRss(rssStar: RssStar) fun delStar(rssStar: RssStar) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesDialog.kt ================================================ package io.legado.app.ui.rss.favorites import android.os.Bundle import android.view.View import android.view.ViewGroup import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.data.entities.RssArticle import io.legado.app.databinding.DialogRssFavoriteConfigBinding import io.legado.app.lib.theme.primaryColor import io.legado.app.utils.setLayout import io.legado.app.utils.viewbindingdelegate.viewBinding class RssFavoritesDialog() : BaseDialogFragment(R.layout.dialog_rss_favorite_config, true) { constructor(rssArticle: RssArticle) : this() { arguments = Bundle().apply { putString("title", rssArticle.title) putString("group", rssArticle.group) } } private val binding by viewBinding(DialogRssFavoriteConfigBinding::bind) override fun onStart() { super.onStart() setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) val arguments = arguments ?: let { dismiss() return } var title = arguments.getString("title") var group = arguments.getString("group") binding.run { editTitle.setText(title) editGroup.setText(group) tvCancel.setOnClickListener { dismiss() } tvOk.setOnClickListener { val editTitle = editTitle.text.toString() if (editTitle.isNotBlank()) { title = editTitle } val editGroup = editGroup.text.toString() if (editGroup.isNotBlank()) { group = editGroup } callback?.updateFavorite(title, group) dismiss() } tvFooterLeft.setOnClickListener { callback?.deleteFavorite() dismiss() } } } val callback get() = (parentFragment as? Callback) ?: (activity as? Callback) interface Callback { fun updateFavorite(title: String?, group: String?) fun deleteFavorite() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesFragment.kt ================================================ package io.legado.app.ui.rss.favorites import android.os.Bundle import android.view.View import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.R import io.legado.app.base.VMBaseFragment import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.RssStar import io.legado.app.databinding.FragmentRssArticlesBinding import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.rss.read.ReadRssActivity import io.legado.app.ui.widget.recycler.VerticalDivider import io.legado.app.utils.applyNavigationBarPadding import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.startActivity import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch class RssFavoritesFragment() : VMBaseFragment(R.layout.fragment_rss_articles), RssFavoritesAdapter.CallBack { constructor(group: String) : this() { arguments = Bundle().apply { putString("group", group) } } private val binding by viewBinding(FragmentRssArticlesBinding::bind) override val viewModel by viewModels() private val adapter: RssFavoritesAdapter by lazy { RssFavoritesAdapter(requireContext(), this@RssFavoritesFragment) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { initView() loadArticles() } private fun initView() = binding.run { refreshLayout.isEnabled = false recyclerView.setEdgeEffectColor(primaryColor) recyclerView.layoutManager = run { recyclerView.addItemDecoration(VerticalDivider(requireContext())) LinearLayoutManager(requireContext()) } recyclerView.adapter = adapter recyclerView.applyNavigationBarPadding() } private fun loadArticles() { lifecycleScope.launch { val group = arguments?.getString("group") ?: "默认分组" appDb.rssStarDao.flowByGroup(group).catch { AppLog.put("订阅文章界面获取数据失败\n${it.localizedMessage}", it) }.flowOn(IO).collect { adapter.setItems(it) } } } override fun readRss(rssStar: RssStar) { startActivity { putExtra("title", rssStar.title) putExtra("origin", rssStar.origin) putExtra("link", rssStar.link) } } override fun delStar(rssStar: RssStar) { alert(R.string.draw) { setMessage(getString(R.string.sure_del) + "\n<" + rssStar.title + ">") noButton() yesButton { appDb.rssStarDao.delete(rssStar.origin, rssStar.link) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesViewModel.kt ================================================ package io.legado.app.ui.rss.favorites import android.app.Application import io.legado.app.base.BaseViewModel class RssFavoritesViewModel(application: Application) : BaseViewModel(application) { } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/read/ReadRssActivity.kt ================================================ package io.legado.app.ui.rss.read import android.annotation.SuppressLint import android.content.pm.ActivityInfo import android.content.res.Configuration import android.net.Uri import android.net.http.SslError import android.os.Bundle import android.os.SystemClock import android.view.Menu import android.view.MenuItem import android.view.View import android.view.WindowManager import android.webkit.JavascriptInterface 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 import androidx.activity.addCallback import androidx.activity.viewModels import androidx.core.view.WindowInsetsCompat import androidx.core.view.size import androidx.lifecycle.lifecycleScope import com.script.rhino.runScriptWithContext import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.constant.AppConst import io.legado.app.constant.AppConst.imagePathKey import io.legado.app.constant.AppLog import io.legado.app.data.entities.RssSource import io.legado.app.databinding.ActivityRssReadBinding import io.legado.app.help.config.AppConfig import io.legado.app.help.http.CookieManager import io.legado.app.lib.dialogs.SelectItem import io.legado.app.lib.dialogs.selector import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.primaryTextColor import io.legado.app.model.Download import io.legado.app.ui.association.OnLineImportActivity import io.legado.app.ui.file.HandleFileContract import io.legado.app.ui.login.SourceLoginActivity import io.legado.app.ui.rss.favorites.RssFavoritesDialog import io.legado.app.utils.ACache import io.legado.app.utils.NetworkUtils import io.legado.app.utils.gone import io.legado.app.utils.invisible import io.legado.app.utils.isTrue import io.legado.app.utils.keepScreenOn import io.legado.app.utils.longSnackbar import io.legado.app.utils.openUrl import io.legado.app.utils.setDarkeningAllowed import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat import io.legado.app.utils.setTintMutate 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 import io.legado.app.utils.toggleSystemBar import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible 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阅读界面 */ class ReadRssActivity : VMBaseActivity(), RssFavoritesDialog.Callback { override val binding by viewBinding(ActivityRssReadBinding::inflate) override val viewModel by viewModels() private var starMenuItem: MenuItem? = null private var ttsMenuItem: MenuItem? = null private var customWebViewCallback: WebChromeClient.CustomViewCallback? = null private val selectImageDir = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> ACache.get().put(imagePathKey, uri.toString()) viewModel.saveImage(it.value, uri) } } private val rssJsExtensions by lazy { RssJsExtensions(this) } fun getSource(): RssSource? { return viewModel.rssSource } override fun onActivityCreated(savedInstanceState: Bundle?) { viewModel.upStarMenuData.observe(this) { upStarMenu() } viewModel.upTtsMenuData.observe(this) { upTtsMenu(it) } binding.titleBar.title = intent.getStringExtra("title") initView() initWebView() initLiveData() viewModel.initData(intent) onBackPressedDispatcher.addCallback(this) { if (binding.customWebView.size > 0) { customWebViewCallback?.onCustomViewHidden() return@addCallback } else if (binding.webView.canGoBack() && binding.webView.copyBackForwardList().size > 1 ) { binding.webView.goBack() return@addCallback } finish() } } @Suppress("DEPRECATION") @SuppressLint("SwitchIntDef") override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) when (newConfig.orientation) { Configuration.ORIENTATION_LANDSCAPE -> { window.clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN) window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) } Configuration.ORIENTATION_PORTRAIT -> { window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) window.addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN) } } } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.rss_read, menu) return super.onCompatCreateOptionsMenu(menu) } override fun onPrepareOptionsMenu(menu: Menu): Boolean { starMenuItem = menu.findItem(R.id.menu_rss_star) ttsMenuItem = menu.findItem(R.id.menu_aloud) upStarMenu() return super.onPrepareOptionsMenu(menu) } override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { menu.findItem(R.id.menu_login)?.isVisible = !viewModel.rssSource?.loginUrl.isNullOrBlank() return super.onMenuOpened(featureId, menu) } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_rss_refresh -> viewModel.refresh { binding.webView.reload() } R.id.menu_rss_star -> { viewModel.addFavorite() viewModel.rssArticle?.let { showDialogFragment(RssFavoritesDialog(it)) } } R.id.menu_share_it -> { binding.webView.url?.let { share(it) } ?: viewModel.rssArticle?.let { share(it.link) } ?: toastOnUi(R.string.null_url) } R.id.menu_aloud -> readAloud() R.id.menu_login -> startActivity { putExtra("type", "rssSource") putExtra("key", viewModel.rssSource?.sourceUrl) } R.id.menu_browser_open -> binding.webView.url?.let { openUrl(it) } ?: toastOnUi("url null") } return super.onCompatOptionsItemSelected(item) } override fun updateFavorite(title: String?, group: String?) { viewModel.rssArticle?.let { if (title != null) { it.title = title } if (group != null) { it.group = group } } viewModel.updateFavorite() } override fun deleteFavorite() { viewModel.delFavorite() } @JavascriptInterface fun isNightTheme(): Boolean { return AppConfig.isNightTheme } private fun initView() { binding.root.setOnApplyWindowInsetsListenerCompat { view, windowInsets -> val typeMask = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime() val insets = windowInsets.getInsets(typeMask) view.bottomPadding = insets.bottom windowInsets } } @SuppressLint("SetJavaScriptEnabled", "JavascriptInterface") private fun initWebView() { binding.progressBar.fontColor = accentColor binding.webView.webChromeClient = CustomWebChromeClient() binding.webView.webViewClient = CustomWebViewClient() binding.webView.settings.apply { mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW domStorageEnabled = true allowContentAccess = true builtInZoomControls = true displayZoomControls = false setDarkeningAllowed(AppConfig.isNightTheme) } binding.webView.addJavascriptInterface(this, "thisActivity") binding.webView.setOnLongClickListener { val hitTestResult = binding.webView.hitTestResult if (hitTestResult.type == WebView.HitTestResult.IMAGE_TYPE || hitTestResult.type == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE ) { hitTestResult.extra?.let { webPic -> selector( arrayListOf( SelectItem(getString(R.string.action_save), "save"), SelectItem(getString(R.string.select_folder), "selectFolder") ) ) { _, charSequence, _ -> when (charSequence.value) { "save" -> saveImage(webPic) "selectFolder" -> selectSaveFolder(null) } } return@setOnLongClickListener true } } return@setOnLongClickListener false } binding.webView.setDownloadListener { url, _, contentDisposition, _, _ -> var fileName = URLUtil.guessFileName(url, contentDisposition, null) fileName = URLDecoder.decode(fileName, "UTF-8") binding.llView.longSnackbar(fileName, getString(R.string.action_download)) { Download.start(this, url, fileName) } } } private fun saveImage(webPic: String) { val path = ACache.get().getAsString(imagePathKey) if (path.isNullOrEmpty()) { selectSaveFolder(webPic) } else { viewModel.saveImage(webPic, Uri.parse(path)) } } private fun selectSaveFolder(webPic: String?) { val default = arrayListOf>() val path = ACache.get().getAsString(imagePathKey) if (!path.isNullOrEmpty()) { default.add(SelectItem(path, -1)) } selectImageDir.launch { otherActions = default value = webPic } } @SuppressLint("SetJavaScriptEnabled") private fun initLiveData() { viewModel.contentLiveData.observe(this) { content -> viewModel.rssArticle?.let { upJavaScriptEnable() val url = NetworkUtils.getAbsoluteURL(it.origin, it.link) val html = viewModel.clHtml(content) binding.webView.settings.userAgentString = viewModel.headerMap[AppConst.UA_NAME] ?: AppConfig.userAgent if (viewModel.rssSource?.loadWithBaseUrl == true) { binding.webView .loadDataWithBaseURL(url, html, "text/html", "utf-8", url)//不想用baseUrl进else } else { binding.webView .loadDataWithBaseURL(null, html, "text/html;charset=utf-8", "utf-8", url) } } } viewModel.urlLiveData.observe(this) { upJavaScriptEnable() CookieManager.applyToWebView(it.url) binding.webView.settings.userAgentString = it.getUserAgent() binding.webView.loadUrl(it.url, it.headerMap) } } @SuppressLint("SetJavaScriptEnabled") private fun upJavaScriptEnable() { if (viewModel.rssSource?.enableJs == true) { binding.webView.settings.javaScriptEnabled = true } } private fun upStarMenu() { starMenuItem?.isVisible = viewModel.rssArticle != null if (viewModel.rssStar != null) { starMenuItem?.setIcon(R.drawable.ic_star) starMenuItem?.setTitle(R.string.in_favorites) } else { starMenuItem?.setIcon(R.drawable.ic_star_border) starMenuItem?.setTitle(R.string.out_favorites) } starMenuItem?.icon?.setTintMutate(primaryTextColor) } private fun upTtsMenu(isPlaying: Boolean) { lifecycleScope.launch { if (isPlaying) { ttsMenuItem?.setIcon(R.drawable.ic_stop_black_24dp) ttsMenuItem?.setTitle(R.string.aloud_stop) } else { ttsMenuItem?.setIcon(R.drawable.ic_volume_up) ttsMenuItem?.setTitle(R.string.read_aloud) } ttsMenuItem?.icon?.setTintMutate(primaryTextColor) } } @SuppressLint("SetJavaScriptEnabled") private fun readAloud() { if (viewModel.tts?.isSpeaking == true) { viewModel.tts?.stop() upTtsMenu(false) } else { binding.webView.settings.javaScriptEnabled = true binding.webView.evaluateJavascript("document.documentElement.outerHTML") { val html = StringEscapeUtils.unescapeJson(it) .replace("^\"|\"$".toRegex(), "") viewModel.readAloud( Jsoup.parse(html) .textArray() .joinToString("\n") ) } } } override fun onDestroy() { super.onDestroy() binding.webView.destroy() } inner class CustomWebChromeClient : WebChromeClient() { override fun onProgressChanged(view: WebView?, newProgress: Int) { super.onProgressChanged(view, newProgress) binding.progressBar.setDurProgress(newProgress) binding.progressBar.gone(newProgress == 100) } override fun onShowCustomView(view: View?, callback: CustomViewCallback?) { requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR binding.llView.invisible() binding.customWebView.addView(view) customWebViewCallback = callback keepScreenOn(true) toggleSystemBar(false) } override fun onHideCustomView() { binding.customWebView.removeAllViews() binding.llView.visible() requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED keepScreenOn(false) toggleSystemBar(true) } } inner class CustomWebViewClient : WebViewClient() { override fun shouldOverrideUrlLoading( view: WebView, request: WebResourceRequest ): Boolean { return shouldOverrideUrlLoading(request.url) } @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION", "KotlinRedundantDiagnosticSuppress") override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { return shouldOverrideUrlLoading(Uri.parse(url)) } /** * 如果有黑名单,黑名单匹配返回空白, * 没有黑名单再判断白名单,在白名单中的才通过, * 都没有不做处理 */ override fun shouldInterceptRequest( view: WebView, request: WebResourceRequest ): WebResourceResponse? { val url = request.url.toString() val source = viewModel.rssSource ?: return super.shouldInterceptRequest(view, request) val blacklist = source.contentBlacklist?.splitNotBlank(",") if (!blacklist.isNullOrEmpty()) { blacklist.forEach { try { if (url.startsWith(it) || url.matches(it.toRegex())) { return createEmptyResource() } } catch (e: PatternSyntaxException) { AppLog.put("黑名单规则正则语法错误 源名称:${source.sourceName} 正则:$it", e) } } } else { val whitelist = source.contentWhitelist?.splitNotBlank(",") if (!whitelist.isNullOrEmpty()) { whitelist.forEach { try { if (url.startsWith(it) || url.matches(it.toRegex())) { return super.shouldInterceptRequest(view, request) } } catch (e: PatternSyntaxException) { val msg = "白名单规则正则语法错误 源名称:${source.sourceName} 正则:$it" AppLog.put(msg, e) } } return createEmptyResource() } } return super.shouldInterceptRequest(view, request) } override fun onPageFinished(view: WebView, url: String) { super.onPageFinished(view, url) view.title?.let { title -> if (title != url && title != view.url && title.isNotBlank() && url != "about:blank" && !url.contains(title) ) { binding.titleBar.title = title } else { binding.titleBar.title = intent.getStringExtra("title") } } viewModel.rssSource?.injectJs?.let { if (it.isNotBlank()) { view.evaluateJavascript(it, null) } } } private fun createEmptyResource(): WebResourceResponse { return WebResourceResponse( "text/plain", "utf-8", ByteArrayInputStream("".toByteArray()) ) } private fun shouldOverrideUrlLoading(url: Uri): Boolean { val source = viewModel.rssSource val js = source?.shouldOverrideUrlLoading if (!js.isNullOrBlank()) { val t = SystemClock.uptimeMillis() val result = kotlin.runCatching { runScriptWithContext(lifecycleScope.coroutineContext) { source.evalJS(js) { put("java", rssJsExtensions) put("url", url.toString()) }.toString() } }.onFailure { AppLog.put("${source.getTag()}: url跳转拦截js出错", it) }.getOrNull() if (SystemClock.uptimeMillis() - t > 30) { AppLog.put("${source.getTag()}: url跳转拦截js执行耗时过长") } if (result.isTrue()) { return true } } when (url.scheme) { "http", "https", "jsbridge" -> { return false } "legado", "yuedu" -> { startActivity { data = url } return true } else -> { binding.root.longSnackbar(R.string.jump_to_another_app, R.string.confirm) { openUrl(url) } return true } } } @SuppressLint("WebViewClientOnReceivedSslError") override fun onReceivedSslError( view: WebView?, handler: SslErrorHandler?, error: SslError? ) { handler?.proceed() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/read/ReadRssViewModel.kt ================================================ package io.legado.app.ui.rss.read import android.app.Application import android.content.Intent import android.net.Uri import android.util.Base64 import android.webkit.URLUtil import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.script.rhino.runScriptWithContext import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppConst import io.legado.app.constant.AppConst.imagePathKey import io.legado.app.data.appDb import io.legado.app.data.entities.RssArticle import io.legado.app.data.entities.RssSource import io.legado.app.data.entities.RssStar import io.legado.app.exception.NoStackTraceException import io.legado.app.help.TTS import io.legado.app.help.http.newCallResponseBody import io.legado.app.help.http.okHttpClient import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.model.rss.Rss import io.legado.app.utils.ACache import io.legado.app.utils.toastOnUi import io.legado.app.utils.writeBytes import kotlinx.coroutines.Dispatchers.IO import splitties.init.appCtx import java.util.Date import kotlin.coroutines.coroutineContext class ReadRssViewModel(application: Application) : BaseViewModel(application) { var rssSource: RssSource? = null var rssArticle: RssArticle? = null var tts: TTS? = null val contentLiveData = MutableLiveData() val urlLiveData = MutableLiveData() var rssStar: RssStar? = null val upTtsMenuData = MutableLiveData() val upStarMenuData = MutableLiveData() var headerMap: Map = emptyMap() fun initData(intent: Intent) { execute { val origin = intent.getStringExtra("origin") ?: return@execute val link = intent.getStringExtra("link") rssSource = appDb.rssSourceDao.getByKey(origin) headerMap = runScriptWithContext { rssSource?.getHeaderMap() ?: emptyMap() } if (link != null) { rssStar = appDb.rssStarDao.get(origin, link) rssArticle = rssStar?.toRssArticle() ?: appDb.rssArticleDao.get(origin, link) val rssArticle = rssArticle ?: return@execute if (!rssArticle.description.isNullOrBlank()) { contentLiveData.postValue(rssArticle.description!!) } else { rssSource?.let { val ruleContent = it.ruleContent if (!ruleContent.isNullOrBlank()) { loadContent(rssArticle, ruleContent) } else { loadUrl(rssArticle.link, rssArticle.origin) } } ?: loadUrl(rssArticle.link, rssArticle.origin) } } else { val ruleContent = rssSource?.ruleContent if (ruleContent.isNullOrBlank()) { loadUrl(origin, origin) } else { val rssArticle = RssArticle() rssArticle.origin = origin rssArticle.link = origin rssArticle.title = rssSource!!.sourceName loadContent(rssArticle, ruleContent) } } }.onFinally { upStarMenuData.postValue(true) } } private suspend fun loadUrl(url: String, baseUrl: String) { val analyzeUrl = AnalyzeUrl( mUrl = url, baseUrl = baseUrl, source = rssSource, coroutineContext = coroutineContext, hasLoginHeader = false ) urlLiveData.postValue(analyzeUrl) } private fun loadContent(rssArticle: RssArticle, ruleContent: String) { val source = rssSource ?: return Rss.getContent(viewModelScope, rssArticle, ruleContent, source) .onSuccess(IO) { body -> rssArticle.description = body appDb.rssArticleDao.insert(rssArticle) rssStar?.let { it.description = body appDb.rssStarDao.insert(it) } contentLiveData.postValue(body) }.onError { contentLiveData.postValue("加载正文失败\n${it.stackTraceToString()}") } } fun refresh(finish: () -> Unit) { val rssArticle = rssArticle ?: return finish.invoke() if (!rssArticle.description.isNullOrBlank()) { return finish.invoke() } val rssSource = rssSource ?: let { appCtx.toastOnUi("订阅源不存在") return finish.invoke() } val ruleContent = rssSource.ruleContent if (!ruleContent.isNullOrBlank()) { loadContent(rssArticle, ruleContent) } else { finish.invoke() } } fun favorite() { execute { rssStar?.let { appDb.rssStarDao.delete(it.origin, it.link) rssStar = null } ?: rssArticle?.toStar()?.let { appDb.rssStarDao.insert(it) rssStar = it } }.onSuccess { upStarMenuData.postValue(true) } } fun addFavorite() { execute { rssStar ?: rssArticle?.toStar()?.let { appDb.rssStarDao.insert(it) rssStar = it } }.onSuccess { upStarMenuData.postValue(true) } } fun updateFavorite() { execute { rssArticle?.toStar()?.let { appDb.rssStarDao.update(it) rssStar = it } }.onSuccess { upStarMenuData.postValue(true) } } fun delFavorite() { execute { rssStar?.let { appDb.rssStarDao.delete(it.origin, it.link) rssStar = null } }.onSuccess { upStarMenuData.postValue(true) } } fun saveImage(webPic: String?, uri: Uri) { webPic ?: return execute { val fileName = "${AppConst.fileNameFormat.format(Date(System.currentTimeMillis()))}.jpg" val byteArray = webData2bitmap(webPic) ?: throw NoStackTraceException("NULL") uri.writeBytes(context, fileName, byteArray) }.onError { ACache.get().remove(imagePathKey) context.toastOnUi("保存图片失败:${it.localizedMessage}") }.onSuccess { context.toastOnUi("保存成功") } } private suspend fun webData2bitmap(data: String): ByteArray? { return if (URLUtil.isValidUrl(data)) { okHttpClient.newCallResponseBody { url(data) }.bytes() } else { Base64.decode(data.split(",").toTypedArray()[1], Base64.DEFAULT) } } fun clHtml(content: String): String { return when { !rssSource?.style.isNullOrEmpty() -> { """ $content """.trimIndent() } content.contains(" $content """.trimIndent() } } } @Synchronized fun readAloud(text: String) { if (tts == null) { tts = TTS().apply { setSpeakStateListener(object : TTS.SpeakStateListener { override fun onStart() { upTtsMenuData.postValue(true) } override fun onDone() { upTtsMenuData.postValue(false) } }) } } tts?.speak(text) } override fun onCleared() { super.onCleared() tts?.clearTts() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/read/RssJsExtensions.kt ================================================ package io.legado.app.ui.rss.read import io.legado.app.data.entities.BaseSource import io.legado.app.help.JsExtensions import io.legado.app.ui.association.AddToBookshelfDialog import io.legado.app.ui.book.search.SearchActivity import io.legado.app.utils.showDialogFragment @Suppress("unused") class RssJsExtensions(private val activity: ReadRssActivity) : JsExtensions { override fun getSource(): BaseSource? { return activity.getSource() } fun searchBook(key: String) { SearchActivity.start(activity, key) } fun addBook(bookUrl: String) { activity.showDialogFragment(AddToBookshelfDialog(bookUrl)) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/read/VisibleWebView.kt ================================================ package io.legado.app.ui.rss.read import android.content.Context import android.util.AttributeSet import android.view.View import android.webkit.WebView class VisibleWebView( context: Context, attrs: AttributeSet? = null ) : WebView(context, attrs) { override fun onWindowVisibilityChanged(visibility: Int) { super.onWindowVisibilityChanged(View.VISIBLE) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugActivity.kt ================================================ package io.legado.app.ui.rss.source.debug import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.widget.SearchView import androidx.activity.viewModels import androidx.lifecycle.lifecycleScope import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.databinding.ActivitySourceDebugBinding import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.widget.dialog.TextDialog import io.legado.app.utils.applyNavigationBarPadding import io.legado.app.utils.gone import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.showDialogFragment import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.launch class RssSourceDebugActivity : VMBaseActivity() { override val binding by viewBinding(ActivitySourceDebugBinding::inflate) override val viewModel by viewModels() private val adapter by lazy { RssSourceDebugAdapter(this) } override fun onActivityCreated(savedInstanceState: Bundle?) { initRecyclerView() initSearchView() viewModel.observe { state, msg -> lifecycleScope.launch { adapter.addItem(msg) if (state == -1 || state == 1000) { binding.rotateLoading.gone() } } } viewModel.initData(intent.getStringExtra("key")) { startDebug() } } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.rss_source_debug, menu) return super.onCompatCreateOptionsMenu(menu) } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_list_src -> showDialogFragment(TextDialog("Html", viewModel.listSrc)) R.id.menu_content_src -> showDialogFragment(TextDialog("Html", viewModel.contentSrc)) } return super.onCompatOptionsItemSelected(item) } private fun initRecyclerView() { binding.recyclerView.setEdgeEffectColor(primaryColor) binding.recyclerView.adapter = adapter binding.recyclerView.applyNavigationBarPadding() binding.rotateLoading.loadingColor = accentColor } private fun initSearchView() { binding.titleBar.findViewById(R.id.search_view).gone() } private fun startDebug() { adapter.clearItems() viewModel.rssSource?.let { binding.rotateLoading.visible() viewModel.startDebug(it) } ?: toastOnUi(R.string.error_no_source) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugAdapter.kt ================================================ package io.legado.app.ui.rss.source.debug import android.content.Context import android.view.View import android.view.ViewGroup import io.legado.app.R import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.databinding.ItemLogBinding class RssSourceDebugAdapter(context: Context) : RecyclerAdapter(context) { override fun getViewBinding(parent: ViewGroup): ItemLogBinding { return ItemLogBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemLogBinding, item: String, payloads: MutableList ) { binding.apply { if (textView.getTag(R.id.tag1) == null) { val listener = object : View.OnAttachStateChangeListener { override fun onViewAttachedToWindow(v: View) { textView.isCursorVisible = false textView.isCursorVisible = true } override fun onViewDetachedFromWindow(v: View) {} } textView.addOnAttachStateChangeListener(listener) textView.setTag(R.id.tag1, listener) } textView.text = item } } override fun registerListener(holder: ItemViewHolder, binding: ItemLogBinding) { //nothing } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugModel.kt ================================================ package io.legado.app.ui.rss.source.debug import android.app.Application import io.legado.app.base.BaseViewModel import io.legado.app.data.appDb import io.legado.app.data.entities.RssSource import io.legado.app.model.Debug class RssSourceDebugModel(application: Application) : BaseViewModel(application), Debug.Callback { var rssSource: RssSource? = null private var callback: ((Int, String) -> Unit)? = null var listSrc: String? = null var contentSrc: String? = null fun initData(sourceUrl: String?, finally: () -> Unit) { sourceUrl?.let { execute { rssSource = appDb.rssSourceDao.getByKey(sourceUrl) }.onFinally { finally() } } } fun observe(callback: (Int, String) -> Unit) { this.callback = callback } fun startDebug(source: RssSource) { execute { Debug.callback = this@RssSourceDebugModel Debug.startDebug(this, source) } } override fun printLog(state: Int, msg: String) { when (state) { 10 -> listSrc = msg 20 -> contentSrc = msg else -> callback?.invoke(state, msg) } } override fun onCleared() { super.onCleared() Debug.cancelDebug(true) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditActivity.kt ================================================ package io.legado.app.ui.rss.source.edit import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.widget.EditText import androidx.activity.viewModels import androidx.lifecycle.lifecycleScope import com.google.android.material.tabs.TabLayout import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.data.entities.RssSource import io.legado.app.databinding.ActivityRssSourceEditBinding import io.legado.app.help.config.LocalConfig import io.legado.app.lib.dialogs.SelectItem import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.backgroundColor import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.file.HandleFileContract import io.legado.app.ui.login.SourceLoginActivity import io.legado.app.ui.qrcode.QrCodeResult import io.legado.app.ui.rss.source.debug.RssSourceDebugActivity import io.legado.app.ui.widget.dialog.UrlOptionDialog import io.legado.app.ui.widget.dialog.VariableDialog import io.legado.app.ui.widget.keyboard.KeyboardToolPop import io.legado.app.ui.widget.text.EditEntity import io.legado.app.utils.GSON import io.legado.app.utils.imeHeight import io.legado.app.utils.isContentScheme import io.legado.app.utils.isTrue import io.legado.app.utils.launch import io.legado.app.utils.navigationBarHeight import io.legado.app.utils.sendToClip import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat import io.legado.app.utils.share import io.legado.app.utils.shareWithQr import io.legado.app.utils.showDialogFragment import io.legado.app.utils.showHelp import io.legado.app.utils.startActivity import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import splitties.views.bottomPadding class RssSourceEditActivity : VMBaseActivity(), KeyboardToolPop.CallBack, VariableDialog.Callback { override val binding by viewBinding(ActivityRssSourceEditBinding::inflate) override val viewModel by viewModels() private val softKeyboardTool by lazy { KeyboardToolPop(this, lifecycleScope, binding.root, this) } private val adapter by lazy { RssSourceEditAdapter() } private val sourceEntities: ArrayList = ArrayList() private val listEntities: ArrayList = ArrayList() private val webViewEntities: ArrayList = ArrayList() private val selectDoc = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> if (uri.isContentScheme()) { sendText(uri.toString()) } else { sendText(uri.path.toString()) } } } private val qrCodeResult = registerForActivityResult(QrCodeResult()) { it?.let { viewModel.importSource(it) { source: RssSource -> upSourceView(source) } } } override fun onActivityCreated(savedInstanceState: Bundle?) { softKeyboardTool.attachToWindow(window) initView() viewModel.initData(intent) { upSourceView(viewModel.rssSource) } } override fun onPostCreate(savedInstanceState: Bundle?) { super.onPostCreate(savedInstanceState) if (!LocalConfig.ruleHelpVersionIsLast) { showHelp("ruleHelp") } } override fun finish() { val source = getRssSource() if (!source.equal(viewModel.rssSource ?: RssSource())) { alert(R.string.exit) { setMessage(R.string.exit_no_save) positiveButton(R.string.yes) negativeButton(R.string.no) { super.finish() } } } else { super.finish() } } override fun onDestroy() { super.onDestroy() softKeyboardTool.dismiss() } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.source_edit, menu) return super.onCompatCreateOptionsMenu(menu) } override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { menu.findItem(R.id.menu_login)?.isVisible = !viewModel.rssSource?.loginUrl.isNullOrBlank() menu.findItem(R.id.menu_auto_complete)?.isChecked = viewModel.autoComplete return super.onMenuOpened(featureId, menu) } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_save -> viewModel.save(getRssSource()) { setResult(RESULT_OK) finish() } R.id.menu_debug_source -> viewModel.save(getRssSource()) { source -> startActivity { putExtra("key", source.sourceUrl) } } R.id.menu_login -> viewModel.save(getRssSource()) { startActivity { putExtra("type", "rssSource") putExtra("key", it.sourceUrl) } } R.id.menu_set_source_variable -> setSourceVariable() R.id.menu_clear_cookie -> viewModel.clearCookie(getRssSource().sourceUrl) R.id.menu_auto_complete -> viewModel.autoComplete = !viewModel.autoComplete R.id.menu_copy_source -> sendToClip(GSON.toJson(getRssSource())) R.id.menu_qr_code_camera -> qrCodeResult.launch() R.id.menu_paste_source -> viewModel.pasteSource { upSourceView(it) } R.id.menu_share_str -> share(GSON.toJson(getRssSource())) R.id.menu_share_qr -> shareWithQr( GSON.toJson(getRssSource()), getString(R.string.share_rss_source), ErrorCorrectionLevel.L ) R.id.menu_help -> showHelp("ruleHelp") } return super.onCompatOptionsItemSelected(item) } private fun initView() { binding.tabLayout.addTab(binding.tabLayout.newTab().apply { setText(R.string.source_tab_base) }) binding.tabLayout.addTab(binding.tabLayout.newTab().apply { setText(R.string.source_tab_list) }) binding.tabLayout.addTab(binding.tabLayout.newTab().apply { text = "WEB_VIEW" }) binding.recyclerView.setEdgeEffectColor(primaryColor) binding.recyclerView.adapter = adapter binding.tabLayout.setBackgroundColor(backgroundColor) binding.tabLayout.setSelectedTabIndicatorColor(accentColor) binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { override fun onTabReselected(tab: TabLayout.Tab?) { } override fun onTabUnselected(tab: TabLayout.Tab?) { } override fun onTabSelected(tab: TabLayout.Tab?) { setEditEntities(tab?.position) } }) binding.recyclerView.setOnApplyWindowInsetsListenerCompat { view, windowInsets -> val navigationBarHeight = windowInsets.navigationBarHeight val imeHeight = windowInsets.imeHeight view.bottomPadding = if (imeHeight == 0) navigationBarHeight else 0 softKeyboardTool.initialPadding = imeHeight windowInsets } } private fun setEditEntities(tabPosition: Int?) { when (tabPosition) { 1 -> adapter.editEntities = listEntities 2 -> adapter.editEntities = webViewEntities else -> adapter.editEntities = sourceEntities } binding.recyclerView.scrollToPosition(0) } private fun upSourceView(rssSource: RssSource?) { val rs = rssSource ?: RssSource() rs.let { binding.cbIsEnable.isChecked = rs.enabled binding.cbSingleUrl.isChecked = rs.singleUrl binding.cbIsEnableCookie.isChecked = rs.enabledCookieJar == true } sourceEntities.clear() sourceEntities.apply { add(EditEntity("sourceName", rs.sourceName, R.string.source_name)) add(EditEntity("sourceUrl", rs.sourceUrl, R.string.source_url)) add(EditEntity("sourceIcon", rs.sourceIcon, R.string.source_icon)) add(EditEntity("sourceGroup", rs.sourceGroup, R.string.source_group)) add(EditEntity("sourceComment", rs.sourceComment, R.string.comment)) add(EditEntity("sortUrl", rs.sortUrl, R.string.sort_url)) add(EditEntity("loginUrl", rs.loginUrl, R.string.login_url)) add(EditEntity("loginUi", rs.loginUi, R.string.login_ui)) add(EditEntity("loginCheckJs", rs.loginCheckJs, R.string.login_check_js)) add(EditEntity("coverDecodeJs", rs.coverDecodeJs, R.string.cover_decode_js)) add(EditEntity("header", rs.header, R.string.source_http_header)) add(EditEntity("variableComment", rs.variableComment, R.string.variable_comment)) add(EditEntity("concurrentRate", rs.concurrentRate, R.string.concurrent_rate)) add(EditEntity("jsLib", rs.jsLib, "jsLib")) } listEntities.clear() listEntities.apply { add(EditEntity("ruleArticles", rs.ruleArticles, R.string.r_articles)) add(EditEntity("ruleNextPage", rs.ruleNextPage, R.string.r_next)) add(EditEntity("ruleTitle", rs.ruleTitle, R.string.r_title)) add(EditEntity("rulePubDate", rs.rulePubDate, R.string.r_date)) add(EditEntity("ruleDescription", rs.ruleDescription, R.string.r_description)) add(EditEntity("ruleImage", rs.ruleImage, R.string.r_image)) add(EditEntity("ruleLink", rs.ruleLink, R.string.r_link)) } webViewEntities.clear() webViewEntities.apply { add( EditEntity( "enableJs", rs.enableJs.toString(), R.string.enable_js, EditEntity.ViewType.checkBox ) ) add( EditEntity( "loadWithBaseUrl", rs.loadWithBaseUrl.toString(), R.string.load_with_base_url, EditEntity.ViewType.checkBox ) ) add(EditEntity("ruleContent", rs.ruleContent, R.string.r_content)) add(EditEntity("style", rs.style, R.string.r_style)) add(EditEntity("injectJs", rs.injectJs, R.string.r_inject_js)) add(EditEntity("contentWhitelist", rs.contentWhitelist, R.string.c_whitelist)) add(EditEntity("contentBlacklist", rs.contentBlacklist, R.string.c_blacklist)) add( EditEntity( "shouldOverrideUrlLoading", rs.shouldOverrideUrlLoading, "url跳转拦截(js, 返回true拦截,js变量url,可以通过js打开url,比如调用阅读搜索,添加书架等,简化规则写法,不用webView js注入)" ) ) } binding.tabLayout.selectTab(binding.tabLayout.getTabAt(0)) setEditEntities(0) } private fun getRssSource(): RssSource { val source = viewModel.rssSource?.copy() ?: RssSource() source.enabled = binding.cbIsEnable.isChecked source.singleUrl = binding.cbSingleUrl.isChecked source.enabledCookieJar = binding.cbIsEnableCookie.isChecked sourceEntities.forEach { it.value = it.value?.takeIf { s -> s.isNotBlank() } when (it.key) { "sourceName" -> source.sourceName = it.value ?: "" "sourceUrl" -> source.sourceUrl = it.value ?: "" "sourceIcon" -> source.sourceIcon = it.value ?: "" "sourceGroup" -> source.sourceGroup = it.value "sourceComment" -> source.sourceComment = it.value "loginUrl" -> source.loginUrl = it.value "loginUi" -> source.loginUi = it.value "loginCheckJs" -> source.loginCheckJs = it.value "coverDecodeJs" -> source.coverDecodeJs = it.value "header" -> source.header = it.value "variableComment" -> source.variableComment = it.value "concurrentRate" -> source.concurrentRate = it.value "sortUrl" -> source.sortUrl = it.value "jsLib" -> source.jsLib = it.value } } listEntities.forEach { it.value = it.value?.takeIf { s -> s.isNotBlank() } when (it.key) { "ruleArticles" -> source.ruleArticles = it.value "ruleNextPage" -> source.ruleNextPage = viewModel.ruleComplete(it.value, source.ruleArticles, 2) "ruleTitle" -> source.ruleTitle = viewModel.ruleComplete(it.value, source.ruleArticles) "rulePubDate" -> source.rulePubDate = viewModel.ruleComplete(it.value, source.ruleArticles) "ruleDescription" -> source.ruleDescription = viewModel.ruleComplete(it.value, source.ruleArticles) "ruleImage" -> source.ruleImage = viewModel.ruleComplete(it.value, source.ruleArticles, 3) "ruleLink" -> source.ruleLink = viewModel.ruleComplete(it.value, source.ruleArticles) } } webViewEntities.forEach { it.value = it.value?.takeIf { s -> s.isNotBlank() } when (it.key) { "enableJs" -> source.enableJs = it.value.isTrue() "loadWithBaseUrl" -> source.loadWithBaseUrl = it.value.isTrue() "ruleContent" -> source.ruleContent = viewModel.ruleComplete(it.value, source.ruleArticles) "style" -> source.style = it.value "injectJs" -> source.injectJs = it.value "contentWhitelist" -> source.contentWhitelist = it.value "contentBlacklist" -> source.contentBlacklist = it.value "shouldOverrideUrlLoading" -> source.shouldOverrideUrlLoading = it.value } } return source } private fun setSourceVariable() { viewModel.save(getRssSource()) { source -> lifecycleScope.launch { val comment = source.getDisplayVariableComment("源变量可在js中通过source.getVariable()获取") val variable = withContext(Dispatchers.IO) { source.getVariable() } showDialogFragment( VariableDialog( getString(R.string.set_source_variable), source.getKey(), variable, comment ) ) } } } override fun setVariable(key: String, variable: String?) { viewModel.rssSource?.setVariable(variable) } override fun helpActions(): List> { return arrayListOf( SelectItem("插入URL参数", "urlOption"), SelectItem("订阅源教程", "ruleHelp"), SelectItem("js教程", "jsHelp"), SelectItem("正则教程", "regexHelp"), SelectItem("选择文件", "selectFile"), ) } override fun onHelpActionSelect(action: String) { when (action) { "urlOption" -> UrlOptionDialog(this) { sendText(it) }.show() "ruleHelp" -> showHelp("ruleHelp") "jsHelp" -> showHelp("jsHelp") "regexHelp" -> showHelp("regexHelp") "selectFile" -> selectDoc.launch { mode = HandleFileContract.FILE } } } override fun sendText(text: String) { if (text.isBlank()) return val view = window.decorView.findFocus() if (view is EditText) { val start = view.selectionStart val end = view.selectionEnd val edit = view.editableText//获取EditText的文字 if (start < 0 || start >= edit.length) { edit.append(text) } else if (start > end) { edit.replace(end, start, text) } else { edit.replace(start, end, text)//光标所在位置插入文字 } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditAdapter.kt ================================================ package io.legado.app.ui.rss.source.edit import android.annotation.SuppressLint import android.text.Editable import android.text.TextWatcher import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import io.legado.app.R import io.legado.app.databinding.ItemSourceEditBinding import io.legado.app.databinding.ItemSourceEditCheckBoxBinding import io.legado.app.help.config.AppConfig import io.legado.app.ui.widget.code.addJsPattern import io.legado.app.ui.widget.code.addJsonPattern import io.legado.app.ui.widget.code.addLegadoPattern import io.legado.app.ui.widget.text.EditEntity import io.legado.app.utils.isTrue class RssSourceEditAdapter : RecyclerView.Adapter() { val editEntityMaxLine = AppConfig.sourceEditMaxLine var editEntities: ArrayList = ArrayList() @SuppressLint("NotifyDataSetChanged") set(value) { field = value notifyDataSetChanged() } override fun getItemViewType(position: Int): Int { val item = editEntities[position] return item.viewType } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return when (viewType) { EditEntity.ViewType.checkBox -> { val binding = ItemSourceEditCheckBoxBinding .inflate(LayoutInflater.from(parent.context), parent, false) CheckBoxViewHolder(binding) } else -> { val binding = ItemSourceEditBinding .inflate(LayoutInflater.from(parent.context), parent, false) binding.editText.addLegadoPattern() binding.editText.addJsonPattern() binding.editText.addJsPattern() EditTextViewHolder(binding) } } } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { when (holder) { is CheckBoxViewHolder -> holder.bind(editEntities[position]) is EditTextViewHolder -> holder.bind(editEntities[position]) } } override fun getItemCount(): Int { return editEntities.size } inner class EditTextViewHolder(val binding: ItemSourceEditBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(editEntity: EditEntity) = binding.run { editText.maxLines = editEntityMaxLine if (editText.getTag(R.id.tag1) == null) { val listener = object : View.OnAttachStateChangeListener { override fun onViewAttachedToWindow(v: View) { editText.isCursorVisible = false editText.isCursorVisible = true editText.isFocusable = true editText.isFocusableInTouchMode = true } override fun onViewDetachedFromWindow(v: View) { } } editText.addOnAttachStateChangeListener(listener) editText.setTag(R.id.tag1, listener) } editText.getTag(R.id.tag2)?.let { if (it is TextWatcher) { editText.removeTextChangedListener(it) } } editText.setText(editEntity.value) textInputLayout.hint = editEntity.hint val textWatcher = object : TextWatcher { override fun beforeTextChanged( s: CharSequence, start: Int, count: Int, after: Int ) { } override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { } override fun afterTextChanged(s: Editable?) { editEntity.value = (s?.toString()) } } editText.addTextChangedListener(textWatcher) editText.setTag(R.id.tag2, textWatcher) } } class CheckBoxViewHolder(val binding: ItemSourceEditCheckBoxBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(editEntity: EditEntity) = binding.run { checkBox.text = editEntity.hint checkBox.isChecked = editEntity.value.isTrue() checkBox.setOnUserCheckedChangeListener { isChecked -> editEntity.value = isChecked.toString() } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditViewModel.kt ================================================ package io.legado.app.ui.rss.source.edit import android.app.Application import android.content.Intent import io.legado.app.R import io.legado.app.base.BaseViewModel import io.legado.app.data.appDb import io.legado.app.data.entities.RssSource import io.legado.app.exception.NoStackTraceException import io.legado.app.help.AppCacheManager import io.legado.app.help.RuleComplete import io.legado.app.help.http.CookieStore import io.legado.app.help.source.removeSortCache import io.legado.app.model.SharedJsScope import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonObject import io.legado.app.utils.getClipText import io.legado.app.utils.printOnDebug import io.legado.app.utils.stackTraceStr import io.legado.app.utils.toastOnUi import kotlinx.coroutines.Dispatchers class RssSourceEditViewModel(application: Application) : BaseViewModel(application) { var autoComplete = false var rssSource: RssSource? = null fun initData(intent: Intent, onFinally: () -> Unit) { execute { val key = intent.getStringExtra("sourceUrl") if (key != null) { appDb.rssSourceDao.getByKey(key)?.let { rssSource = it } } }.onFinally { onFinally() } } fun save(source: RssSource, success: ((RssSource) -> Unit)) { execute { if (source.sourceUrl.isBlank() || source.sourceName.isBlank()) { throw NoStackTraceException(context.getString(R.string.non_null_name_url)) } val oldSource = rssSource ?: RssSource() if (!source.equal(oldSource)) { source.lastUpdateTime = System.currentTimeMillis() if (oldSource.sortUrl != source.sortUrl) { oldSource.removeSortCache() } if (oldSource.jsLib != source.jsLib) { SharedJsScope.remove(oldSource.jsLib) } } rssSource?.let { appDb.rssSourceDao.delete(it) //更新收藏的源地址 if (it.sourceUrl != source.sourceUrl) { appDb.rssStarDao.updateOrigin(source.sourceUrl, it.sourceUrl) appDb.rssArticleDao.updateOrigin(source.sourceUrl, it.sourceUrl) appDb.cacheDao.deleteSourceVariables(it.sourceUrl) AppCacheManager.clearSourceVariables() } } appDb.rssSourceDao.insert(source) rssSource = source source }.onSuccess { success(it) }.onError { context.toastOnUi(it.localizedMessage) it.printOnDebug() } } fun pasteSource(onSuccess: (source: RssSource) -> Unit) { execute(context = Dispatchers.Main) { var source: RssSource? = null context.getClipText()?.let { json -> source = GSON.fromJsonObject(json).getOrThrow() } source }.onError { context.toastOnUi(it.localizedMessage) }.onSuccess { if (it != null) { onSuccess(it) } else { context.toastOnUi("格式不对") } } } fun importSource(text: String, finally: (source: RssSource) -> Unit) { execute { val text1 = text.trim() GSON.fromJsonObject(text1).getOrThrow().let { finally.invoke(it) } }.onError { context.toastOnUi(it.stackTraceStr) } } fun clearCookie(url: String) { execute { CookieStore.removeCookie(url) } } fun ruleComplete(rule: String?, preRule: String? = null, type: Int = 1): String? { if (autoComplete) { return RuleComplete.autoComplete(rule, preRule, type) } return rule } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/source/manage/GroupManageDialog.kt ================================================ package io.legado.app.ui.rss.source.manage import android.annotation.SuppressLint import android.content.Context import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.data.appDb import io.legado.app.databinding.DialogEditTextBinding import io.legado.app.databinding.DialogRecyclerViewBinding import io.legado.app.databinding.ItemGroupManageBinding import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.backgroundColor import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.widget.recycler.VerticalDivider import io.legado.app.utils.applyTint import io.legado.app.utils.requestInputMethod import io.legado.app.utils.setLayout import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.launch class GroupManageDialog : BaseDialogFragment(R.layout.dialog_recycler_view), Toolbar.OnMenuItemClickListener { private val viewModel: RssSourceViewModel by activityViewModels() private val binding by viewBinding(DialogRecyclerViewBinding::bind) private val adapter by lazy { GroupAdapter(requireContext()) } override fun onStart() { super.onStart() setLayout(0.9f, 0.9f) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) = binding.run { toolBar.setBackgroundColor(primaryColor) toolBar.title = getString(R.string.group_manage) toolBar.inflateMenu(R.menu.group_manage) toolBar.menu.applyTint(requireContext()) toolBar.setOnMenuItemClickListener(this@GroupManageDialog) recyclerView.layoutManager = LinearLayoutManager(requireContext()) recyclerView.addItemDecoration(VerticalDivider(requireContext())) recyclerView.adapter = adapter tvOk.setTextColor(requireContext().accentColor) tvOk.visible() tvOk.setOnClickListener { dismissAllowingStateLoss() } initData() } private fun initData() { lifecycleScope.launch { appDb.rssSourceDao.flowGroups().conflate().collect { adapter.setItems(it) } } } override fun onMenuItemClick(item: MenuItem?): Boolean { when (item?.itemId) { R.id.menu_add -> addGroup() } return true } @SuppressLint("InflateParams") private fun addGroup() { alert(title = getString(R.string.add_group)) { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.setHint(R.string.group_name) } customView { alertBinding.root } okButton { alertBinding.editView.text?.toString()?.let { if (it.isNotBlank()) { viewModel.addGroup(it) } } } cancelButton() }.requestInputMethod() } @SuppressLint("InflateParams") private fun editGroup(group: String) { alert(title = getString(R.string.group_edit)) { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.setHint(R.string.group_name) editView.setText(group) } customView { alertBinding.root } okButton { viewModel.upGroup(group, alertBinding.editView.text?.toString()) } cancelButton() }.requestInputMethod() } private inner class GroupAdapter(context: Context) : RecyclerAdapter(context) { override fun getViewBinding(parent: ViewGroup): ItemGroupManageBinding { return ItemGroupManageBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemGroupManageBinding, item: String, payloads: MutableList ) { binding.run { root.setBackgroundColor(context.backgroundColor) tvGroup.text = item } } override fun registerListener(holder: ItemViewHolder, binding: ItemGroupManageBinding) { binding.apply { tvEdit.setOnClickListener { getItem(holder.layoutPosition)?.let { editGroup(it) } } tvDel.setOnClickListener { getItem(holder.layoutPosition)?.let { viewModel.delGroup(it) } } } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceActivity.kt ================================================ package io.legado.app.ui.rss.source.manage import android.annotation.SuppressLint import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.SubMenu import androidx.activity.viewModels import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.SearchView import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.ItemTouchHelper import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.RssSource import io.legado.app.databinding.ActivityRssSourceBinding import io.legado.app.databinding.DialogEditTextBinding import io.legado.app.help.DirectLinkUpload import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.primaryColor import io.legado.app.lib.theme.primaryTextColor import io.legado.app.ui.association.ImportRssSourceDialog import io.legado.app.ui.file.HandleFileContract import io.legado.app.ui.qrcode.QrCodeResult import io.legado.app.ui.rss.source.edit.RssSourceEditActivity import io.legado.app.ui.widget.SelectActionBar import io.legado.app.ui.widget.recycler.DragSelectTouchHelper import io.legado.app.ui.widget.recycler.ItemTouchCallback import io.legado.app.ui.widget.recycler.VerticalDivider import io.legado.app.utils.ACache import io.legado.app.utils.applyTint import io.legado.app.utils.dpToPx import io.legado.app.utils.isAbsUrl import io.legado.app.utils.launch import io.legado.app.utils.sendToClip import io.legado.app.utils.setEdgeEffectColor import io.legado.app.utils.share import io.legado.app.utils.showDialogFragment import io.legado.app.utils.showHelp import io.legado.app.utils.splitNotBlank import io.legado.app.utils.startActivity import io.legado.app.utils.transaction import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch /** * 订阅源管理 */ class RssSourceActivity : VMBaseActivity(), PopupMenu.OnMenuItemClickListener, SelectActionBar.CallBack, RssSourceAdapter.CallBack { override val binding by viewBinding(ActivityRssSourceBinding::inflate) override val viewModel by viewModels() private val importRecordKey = "rssSourceRecordKey" private val adapter by lazy { RssSourceAdapter(this, this) } private val searchView: SearchView by lazy { binding.titleBar.findViewById(R.id.search_view) } private var sourceFlowJob: Job? = null private var groups = arrayListOf() private var groupMenu: SubMenu? = null private val qrCodeResult = registerForActivityResult(QrCodeResult()) { it ?: return@registerForActivityResult showDialogFragment(ImportRssSourceDialog(it)) } private val importDoc = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> showDialogFragment(ImportRssSourceDialog(uri.toString())) } } private val exportResult = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> alert(R.string.export_success) { if (uri.toString().isAbsUrl()) { setMessage(DirectLinkUpload.getSummary()) } val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = getString(R.string.path) editView.setText(uri.toString()) } customView { alertBinding.root } okButton { sendToClip(uri.toString()) } } } } override fun onActivityCreated(savedInstanceState: Bundle?) { initRecyclerView() initSearchView() initGroupFlow() upSourceFlow() initSelectActionBar() } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.rss_source, menu) return super.onCompatCreateOptionsMenu(menu) } override fun onPrepareOptionsMenu(menu: Menu): Boolean { groupMenu = menu.findItem(R.id.menu_group)?.subMenu upGroupMenu() return super.onPrepareOptionsMenu(menu) } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_add -> startActivity() R.id.menu_import_local -> importDoc.launch { mode = HandleFileContract.FILE allowExtensions = arrayOf("txt", "json") } R.id.menu_import_onLine -> showImportDialog() R.id.menu_import_qr -> qrCodeResult.launch() R.id.menu_group_manage -> showDialogFragment() R.id.menu_import_default -> viewModel.importDefault() R.id.menu_enabled_group -> { searchView.setQuery(getString(R.string.enabled), true) } R.id.menu_disabled_group -> { searchView.setQuery(getString(R.string.disabled), true) } R.id.menu_group_login -> { searchView.setQuery(getString(R.string.need_login), true) } R.id.menu_group_null -> { searchView.setQuery(getString(R.string.no_group), true) } R.id.menu_help -> showHelp("SourceMRssHelp") else -> if (item.groupId == R.id.source_group) { searchView.setQuery("group:${item.title}", true) } } return super.onCompatOptionsItemSelected(item) } override fun onMenuItemClick(item: MenuItem?): Boolean { when (item?.itemId) { R.id.menu_enable_selection -> viewModel.enableSelection(adapter.selection) R.id.menu_disable_selection -> viewModel.disableSelection(adapter.selection) R.id.menu_add_group -> selectionAddToGroups() R.id.menu_remove_group -> selectionRemoveFromGroups() R.id.menu_top_sel -> viewModel.topSource(*adapter.selection.toTypedArray()) R.id.menu_bottom_sel -> viewModel.bottomSource(*adapter.selection.toTypedArray()) R.id.menu_export_selection -> viewModel.saveToFile(adapter.selection) { file -> exportResult.launch { mode = HandleFileContract.EXPORT fileData = HandleFileContract.FileData( "exportRssSource.json", file, "application/json" ) } } R.id.menu_share_source -> viewModel.saveToFile(adapter.selection) { share(it) } R.id.menu_check_selected_interval -> adapter.checkSelectedInterval() } return true } private fun initRecyclerView() { binding.recyclerView.setEdgeEffectColor(primaryColor) binding.recyclerView.addItemDecoration(VerticalDivider(this)) binding.recyclerView.adapter = adapter // When this page is opened, it is in selection mode val dragSelectTouchHelper: DragSelectTouchHelper = DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50) dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView) dragSelectTouchHelper.activeSlideSelect() // Note: need judge selection first, so add ItemTouchHelper after it. val itemTouchCallback = ItemTouchCallback(adapter) itemTouchCallback.isCanDrag = true ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView) } private fun initSearchView() { binding.titleBar.findViewById(R.id.search_view).let { it.applyTint(primaryTextColor) it.onActionViewExpanded() it.queryHint = getString(R.string.search_rss_source) it.clearFocus() it.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { return false } override fun onQueryTextChange(newText: String?): Boolean { upSourceFlow(newText) return false } }) } } private fun initSelectActionBar() { binding.selectActionBar.setMainActionText(R.string.delete) binding.selectActionBar.inflateMenu(R.menu.rss_source_sel) binding.selectActionBar.setOnMenuItemClickListener(this) binding.selectActionBar.setCallBack(this) } private fun initGroupFlow() { lifecycleScope.launch { appDb.rssSourceDao.flowGroups().conflate().collect { groups.clear() groups.addAll(it) upGroupMenu() } } } @SuppressLint("InflateParams") private fun selectionAddToGroups() { alert(titleResource = R.string.add_group) { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.setHint(R.string.group_name) editView.setFilterValues(groups.toList()) editView.dropDownHeight = 180.dpToPx() } customView { alertBinding.root } okButton { alertBinding.editView.text?.toString()?.let { if (it.isNotEmpty()) { viewModel.selectionAddToGroups(adapter.selection, it) } } } cancelButton() } } @SuppressLint("InflateParams") private fun selectionRemoveFromGroups() { alert(titleResource = R.string.remove_group) { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.setHint(R.string.group_name) editView.setFilterValues(groups.toList()) editView.dropDownHeight = 180.dpToPx() } customView { alertBinding.root } okButton { alertBinding.editView.text?.toString()?.let { if (it.isNotEmpty()) { viewModel.selectionRemoveFromGroups(adapter.selection, it) } } } cancelButton() } } override fun selectAll(selectAll: Boolean) { if (selectAll) { adapter.selectAll() } else { adapter.revertSelection() } } override fun revertSelection() { adapter.revertSelection() } override fun onClickSelectBarMainAction() { delSourceDialog() } private fun delSourceDialog() { alert(titleResource = R.string.draw, messageResource = R.string.sure_del) { yesButton { viewModel.del(*adapter.selection.toTypedArray()) } noButton() } } private fun upGroupMenu() = groupMenu?.transaction { menu -> menu.removeGroup(R.id.source_group) groups.forEach { menu.add(R.id.source_group, Menu.NONE, Menu.NONE, it) } } private fun upSourceFlow(searchKey: String? = null) { sourceFlowJob?.cancel() sourceFlowJob = lifecycleScope.launch { when { searchKey.isNullOrBlank() -> { appDb.rssSourceDao.flowAll() } searchKey == getString(R.string.enabled) -> { appDb.rssSourceDao.flowEnabled() } searchKey == getString(R.string.disabled) -> { appDb.rssSourceDao.flowDisabled() } searchKey == getString(R.string.need_login) -> { appDb.rssSourceDao.flowLogin() } searchKey == getString(R.string.no_group) -> { appDb.rssSourceDao.flowNoGroup() } searchKey.startsWith("group:") -> { val key = searchKey.substringAfter("group:") appDb.rssSourceDao.flowGroupSearch(key) } else -> { appDb.rssSourceDao.flowSearch(searchKey) } }.catch { AppLog.put("订阅源管理界面更新数据出错", it) }.flowOn(IO).conflate().collect { adapter.setItems(it, adapter.diffItemCallback) delay(100) } } } override fun upCountView() { binding.selectActionBar.upCountView( adapter.selection.size, adapter.itemCount ) } @SuppressLint("InflateParams") private fun showImportDialog() { val aCache = ACache.get(cacheDir = false) val cacheUrls: MutableList = aCache .getAsString(importRecordKey) ?.splitNotBlank(",") ?.toMutableList() ?: mutableListOf() alert(titleResource = R.string.import_on_line) { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { editView.hint = "url" editView.setFilterValues(cacheUrls) editView.delCallBack = { cacheUrls.remove(it) aCache.put(importRecordKey, cacheUrls.joinToString(",")) } } customView { alertBinding.root } okButton { val text = alertBinding.editView.text?.toString() text?.let { if (it.isAbsUrl() && !cacheUrls.contains(it)) { cacheUrls.add(0, it) aCache.put(importRecordKey, cacheUrls.joinToString(",")) } showDialogFragment( ImportRssSourceDialog(it) ) } } cancelButton() } } override fun del(source: RssSource) { alert(R.string.draw) { setMessage(getString(R.string.sure_del) + "\n" + source.sourceName) noButton() yesButton { viewModel.del(source) } } } override fun edit(source: RssSource) { startActivity { putExtra("sourceUrl", source.sourceUrl) } } override fun update(vararg source: RssSource) { viewModel.update(*source) } override fun toTop(source: RssSource) { viewModel.topSource(source) } override fun toBottom(source: RssSource) { viewModel.bottomSource(source) } override fun upOrder() { viewModel.upOrder() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceAdapter.kt ================================================ package io.legado.app.ui.rss.source.manage import android.content.Context import android.os.Bundle import android.view.View import android.view.ViewGroup import android.widget.PopupMenu import androidx.core.os.bundleOf import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import io.legado.app.R import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.data.entities.RssSource import io.legado.app.databinding.ItemRssSourceBinding import io.legado.app.lib.theme.backgroundColor import io.legado.app.ui.widget.recycler.DragSelectTouchHelper import io.legado.app.ui.widget.recycler.ItemTouchCallback import io.legado.app.utils.ColorUtils import java.util.Collections class RssSourceAdapter(context: Context, val callBack: CallBack) : RecyclerAdapter(context), ItemTouchCallback.Callback { private val selected = linkedSetOf() val selection: List get() { return getItems().filter { selected.contains(it) } } val diffItemCallback = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: RssSource, newItem: RssSource): Boolean { return oldItem.sourceUrl == newItem.sourceUrl } override fun areContentsTheSame(oldItem: RssSource, newItem: RssSource): Boolean { return oldItem.sourceName == newItem.sourceName && oldItem.sourceGroup == newItem.sourceGroup && oldItem.enabled == newItem.enabled } override fun getChangePayload(oldItem: RssSource, newItem: RssSource): Any? { val payload = Bundle() if (oldItem.sourceName != newItem.sourceName || oldItem.sourceGroup != newItem.sourceGroup ) { payload.putBoolean("upName", true) } if (oldItem.enabled != newItem.enabled) { payload.putBoolean("enabled", newItem.enabled) } if (payload.isEmpty) { return null } return payload } } override fun getViewBinding(parent: ViewGroup): ItemRssSourceBinding { return ItemRssSourceBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemRssSourceBinding, item: RssSource, payloads: MutableList ) { binding.run { if (payloads.isEmpty()) { root.setBackgroundColor(ColorUtils.withAlpha(context.backgroundColor, 0.5f)) cbSource.text = item.getDisplayNameGroup() swtEnabled.isChecked = item.enabled cbSource.isChecked = selected.contains(item) } else { for (i in payloads.indices) { val bundle = payloads[i] as Bundle bundle.keySet().forEach { when (it) { "upName" -> cbSource.text = item.getDisplayNameGroup() "enabled" -> swtEnabled.isChecked = bundle.getBoolean("enabled") "selected" -> cbSource.isChecked = selected.contains(item) } } } } } } override fun registerListener(holder: ItemViewHolder, binding: ItemRssSourceBinding) { binding.apply { swtEnabled.setOnUserCheckedChangeListener { checked -> getItem(holder.layoutPosition)?.let { it.enabled = checked callBack.update(it) } } cbSource.setOnUserCheckedChangeListener { checked -> getItem(holder.layoutPosition)?.let { if (checked) { selected.add(it) } else { selected.remove(it) } callBack.upCountView() } } ivEdit.setOnClickListener { getItem(holder.layoutPosition)?.let { callBack.edit(it) } } ivMenuMore.setOnClickListener { showMenu(ivMenuMore, holder.layoutPosition) } } } override fun onCurrentListChanged() { callBack.upCountView() } fun selectAll() { getItems().forEach { selected.add(it) } notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null))) callBack.upCountView() } fun revertSelection() { getItems().forEach { if (selected.contains(it)) { selected.remove(it) } else { selected.add(it) } } notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null))) callBack.upCountView() } fun checkSelectedInterval() { val selectedPosition = linkedSetOf() getItems().forEachIndexed { index, it -> if (selected.contains(it)) { selectedPosition.add(index) } } val minPosition = Collections.min(selectedPosition) val maxPosition = Collections.max(selectedPosition) val itemCount = maxPosition - minPosition + 1 for (i in minPosition..maxPosition) { getItem(i)?.let { selected.add(it) } } notifyItemRangeChanged(minPosition, itemCount, bundleOf(Pair("selected", null))) callBack.upCountView() } private fun showMenu(view: View, position: Int) { val source = getItem(position) ?: return val popupMenu = PopupMenu(context, view) popupMenu.inflate(R.menu.rss_source_item) popupMenu.setOnMenuItemClickListener { menuItem -> when (menuItem.itemId) { R.id.menu_top -> callBack.toTop(source) R.id.menu_bottom -> callBack.toBottom(source) R.id.menu_del -> { callBack.del(source) selected.remove(source) } } true } popupMenu.show() } override fun swap(srcPosition: Int, targetPosition: Int): Boolean { val srcItem = getItem(srcPosition) val targetItem = getItem(targetPosition) if (srcItem != null && targetItem != null) { if (srcItem.customOrder == targetItem.customOrder) { callBack.upOrder() } else { val srcOrder = srcItem.customOrder srcItem.customOrder = targetItem.customOrder targetItem.customOrder = srcOrder movedItems.add(srcItem) movedItems.add(targetItem) } } swapItem(srcPosition, targetPosition) return true } private val movedItems = hashSetOf() override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { if (movedItems.isNotEmpty()) { callBack.update(*movedItems.toTypedArray()) movedItems.clear() } } val dragSelectCallback: DragSelectTouchHelper.Callback = object : DragSelectTouchHelper.AdvanceCallback(Mode.ToggleAndReverse) { override fun currentSelectedId(): MutableSet { return selected } override fun getItemId(position: Int): RssSource { return getItem(position)!! } override fun updateSelectState(position: Int, isSelected: Boolean): Boolean { getItem(position)?.let { if (isSelected) { selected.add(it) } else { selected.remove(it) } notifyItemChanged(position, bundleOf(Pair("selected", null))) callBack.upCountView() return true } return false } } interface CallBack { fun del(source: RssSource) fun edit(source: RssSource) fun update(vararg source: RssSource) fun toTop(source: RssSource) fun toBottom(source: RssSource) fun upOrder() fun upCountView() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceViewModel.kt ================================================ package io.legado.app.ui.rss.source.manage import android.app.Application import android.text.TextUtils import io.legado.app.base.BaseViewModel import io.legado.app.data.appDb import io.legado.app.data.entities.RssSource import io.legado.app.help.DefaultData import io.legado.app.help.source.SourceHelp import io.legado.app.utils.FileUtils import io.legado.app.utils.GSON import io.legado.app.utils.splitNotBlank import io.legado.app.utils.stackTraceStr import io.legado.app.utils.toastOnUi import java.io.File /** * 订阅源管理数据修改 * 修改数据要copy,直接修改会导致界面不刷新 */ class RssSourceViewModel(application: Application) : BaseViewModel(application) { fun topSource(vararg sources: RssSource) { execute { sources.sortBy { it.customOrder } val minOrder = appDb.rssSourceDao.minOrder - 1 val array = Array(sources.size) { sources[it].copy(customOrder = minOrder - it) } appDb.rssSourceDao.update(*array) } } fun bottomSource(vararg sources: RssSource) { execute { sources.sortBy { it.customOrder } val maxOrder = appDb.rssSourceDao.maxOrder + 1 val array = Array(sources.size) { sources[it].copy(customOrder = maxOrder + it) } appDb.rssSourceDao.update(*array) } } fun del(vararg rssSource: RssSource) { execute { SourceHelp.deleteRssSources(rssSource.toList()) } } fun update(vararg rssSource: RssSource) { execute { appDb.rssSourceDao.update(*rssSource) } } fun upOrder() { execute { val sources = appDb.rssSourceDao.all for ((index: Int, source: RssSource) in sources.withIndex()) { source.customOrder = index + 1 } appDb.rssSourceDao.update(*sources.toTypedArray()) } } fun enableSelection(sources: List) { execute { val array = Array(sources.size) { sources[it].copy(enabled = true) } appDb.rssSourceDao.update(*array) } } fun disableSelection(sources: List) { execute { val array = Array(sources.size) { sources[it].copy(enabled = false) } appDb.rssSourceDao.update(*array) } } fun saveToFile(sources: List, success: (file: File) -> Unit) { execute { val path = "${context.filesDir}/shareRssSource.json" FileUtils.delete(path) val file = FileUtils.createFileWithReplace(path) file.writeText(GSON.toJson(sources)) file }.onSuccess { success.invoke(it) }.onError { context.toastOnUi(it.stackTraceStr) } } fun selectionAddToGroups(sources: List, groups: String) { execute { val array = Array(sources.size) { sources[it].copy().addGroup(groups) } appDb.rssSourceDao.update(*array) } } fun selectionRemoveFromGroups(sources: List, groups: String) { execute { val array = Array(sources.size) { sources[it].copy().removeGroup(groups) } appDb.rssSourceDao.update(*array) } } fun addGroup(group: String) { execute { val sources = appDb.rssSourceDao.noGroup sources.forEach { source -> source.sourceGroup = group } appDb.rssSourceDao.update(*sources.toTypedArray()) } } fun upGroup(oldGroup: String, newGroup: String?) { execute { val sources = appDb.rssSourceDao.getByGroup(oldGroup) sources.forEach { source -> source.sourceGroup?.splitNotBlank(",")?.toHashSet()?.let { it.remove(oldGroup) if (!newGroup.isNullOrEmpty()) it.add(newGroup) source.sourceGroup = TextUtils.join(",", it) } } appDb.rssSourceDao.update(*sources.toTypedArray()) } } fun delGroup(group: String) { execute { execute { val sources = appDb.rssSourceDao.getByGroup(group) sources.forEach { source -> source.sourceGroup?.splitNotBlank(",")?.toHashSet()?.let { it.remove(group) source.sourceGroup = TextUtils.join(",", it) } } appDb.rssSourceDao.update(*sources.toTypedArray()) } } } fun importDefault() { execute { DefaultData.importDefaultRssSources() } } fun disable(rssSource: RssSource) { execute { rssSource.enabled = false appDb.rssSourceDao.update(rssSource) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubActivity.kt ================================================ package io.legado.app.ui.rss.subscription import android.os.Bundle import android.view.Menu import android.view.MenuItem import androidx.core.view.isGone import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.ItemTouchHelper import io.legado.app.R import io.legado.app.base.BaseActivity import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.RuleSub import io.legado.app.databinding.ActivityRuleSubBinding import io.legado.app.databinding.DialogRuleSubEditBinding import io.legado.app.lib.dialogs.alert import io.legado.app.ui.association.ImportBookSourceDialog import io.legado.app.ui.association.ImportReplaceRuleDialog import io.legado.app.ui.association.ImportRssSourceDialog import io.legado.app.ui.widget.recycler.ItemTouchCallback import io.legado.app.utils.applyNavigationBarPadding import io.legado.app.utils.showDialogFragment import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** * 规则订阅界面 */ class RuleSubActivity : BaseActivity(), RuleSubAdapter.Callback { override val binding by viewBinding(ActivityRuleSubBinding::inflate) private val adapter by lazy { RuleSubAdapter(this, this) } override fun onActivityCreated(savedInstanceState: Bundle?) { initView() initData() } override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.source_subscription, menu) return super.onCompatCreateOptionsMenu(menu) } override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_add -> { val order = appDb.ruleSubDao.maxOrder + 1 editSubscription(RuleSub(customOrder = order)) } } return super.onCompatOptionsItemSelected(item) } private fun initView() { binding.recyclerView.adapter = adapter binding.recyclerView.applyNavigationBarPadding() val itemTouchCallback = ItemTouchCallback(adapter) itemTouchCallback.isCanDrag = true ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView) } private fun initData() { lifecycleScope.launch { appDb.ruleSubDao.flowAll().catch { AppLog.put("规则订阅界面获取数据失败\n${it.localizedMessage}", it) }.flowOn(IO).conflate().collect { binding.tvEmptyMsg.isGone = it.isNotEmpty() adapter.setItems(it) } } } override fun openSubscription(ruleSub: RuleSub) { when (ruleSub.type) { 0 -> showDialogFragment( ImportBookSourceDialog(ruleSub.url) ) 1 -> showDialogFragment( ImportRssSourceDialog(ruleSub.url) ) 2 -> showDialogFragment( ImportReplaceRuleDialog(ruleSub.url) ) } } override fun editSubscription(ruleSub: RuleSub) { alert(R.string.rule_subscription) { val alertBinding = DialogRuleSubEditBinding.inflate(layoutInflater).apply { if (ruleSub.type !in 0..(context), ItemTouchCallback.Callback { private val typeArray = context.resources.getStringArray(R.array.rule_type) override fun convert( holder: ItemViewHolder, binding: ItemRuleSubBinding, item: RuleSub, payloads: MutableList ) { binding.tvType.text = typeArray[item.type] binding.tvName.text = item.name binding.tvUrl.text = item.url } override fun registerListener(holder: ItemViewHolder, binding: ItemRuleSubBinding) { binding.root.setOnClickListener { callBack.openSubscription(getItem(holder.layoutPosition)!!) } binding.ivEdit.setOnClickListener { callBack.editSubscription(getItem(holder.layoutPosition)!!) } binding.ivMenuMore.setOnClickListener { showMenu(binding.ivMenuMore, holder.layoutPosition) } } private fun showMenu(view: View, position: Int) { val source = getItem(position) ?: return val popupMenu = PopupMenu(context, view) popupMenu.inflate(R.menu.source_sub_item) popupMenu.setOnMenuItemClickListener { menuItem -> when (menuItem.itemId) { R.id.menu_del -> callBack.delSubscription(source) } true } popupMenu.show() } override fun getViewBinding(parent: ViewGroup): ItemRuleSubBinding { return ItemRuleSubBinding.inflate(inflater, parent, false) } override fun swap(srcPosition: Int, targetPosition: Int): Boolean { val srcItem = getItem(srcPosition) val targetItem = getItem(targetPosition) if (srcItem != null && targetItem != null) { if (srcItem.customOrder == targetItem.customOrder) { callBack.upOrder() } else { val srcOrder = srcItem.customOrder srcItem.customOrder = targetItem.customOrder targetItem.customOrder = srcOrder movedItems.add(srcItem) movedItems.add(targetItem) } } swapItem(srcPosition, targetPosition) return true } private val movedItems = hashSetOf() override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { if (movedItems.isNotEmpty()) { callBack.updateSourceSub(*movedItems.toTypedArray()) movedItems.clear() } } interface Callback { fun openSubscription(ruleSub: RuleSub) fun editSubscription(ruleSub: RuleSub) fun delSubscription(ruleSub: RuleSub) fun updateSourceSub(vararg ruleSub: RuleSub) fun upOrder() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/welcome/WelcomeActivity.kt ================================================ package io.legado.app.ui.welcome import android.content.Intent import android.graphics.drawable.BitmapDrawable import android.os.Bundle import androidx.core.view.postDelayed import io.legado.app.base.BaseActivity import io.legado.app.constant.PreferKey import io.legado.app.constant.Theme import io.legado.app.data.appDb import io.legado.app.databinding.ActivityWelcomeBinding import io.legado.app.help.config.AppConfig import io.legado.app.help.config.ThemeConfig import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.backgroundColor import io.legado.app.ui.book.read.ReadBookActivity import io.legado.app.ui.main.MainActivity import io.legado.app.utils.BitmapUtils import io.legado.app.utils.fullScreen import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.getPrefString import io.legado.app.utils.setStatusBarColorAuto import io.legado.app.utils.startActivity import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible import io.legado.app.utils.windowSize open class WelcomeActivity : BaseActivity() { override val binding by viewBinding(ActivityWelcomeBinding::inflate) override fun onActivityCreated(savedInstanceState: Bundle?) { binding.ivBook.setColorFilter(accentColor) binding.vwTitleLine.setBackgroundColor(accentColor) // 避免从桌面启动程序后,会重新实例化入口类的activity if (intent.flags and Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT != 0) { finish() } else { binding.root.postDelayed(600) { startMainActivity() } } } override fun setupSystemBar() { fullScreen() setStatusBarColorAuto(backgroundColor, true, fullScreen) upNavigationBarColor() } override fun upBackgroundImage() { if (getPrefBoolean(PreferKey.customWelcome)) { kotlin.runCatching { when (ThemeConfig.getTheme()) { Theme.Dark -> getPrefString(PreferKey.welcomeImageDark)?.let { path -> val size = windowManager.windowSize BitmapUtils.decodeBitmap(path, size.widthPixels, size.heightPixels).let { binding.tvLegado.visible(AppConfig.welcomeShowTextDark) binding.ivBook.visible(AppConfig.welcomeShowIconDark) binding.tvGzh.visible(AppConfig.welcomeShowTextDark) window.decorView.background = BitmapDrawable(resources, it) return } } else -> getPrefString(PreferKey.welcomeImage)?.let { path -> val size = windowManager.windowSize BitmapUtils.decodeBitmap(path, size.widthPixels, size.heightPixels).let { binding.tvLegado.visible(AppConfig.welcomeShowText) binding.ivBook.visible(AppConfig.welcomeShowIcon) binding.tvGzh.visible(AppConfig.welcomeShowText) window.decorView.background = BitmapDrawable(resources, it) return } } } } } super.upBackgroundImage() } private fun startMainActivity() { startActivity() if (getPrefBoolean(PreferKey.defaultToRead) && appDb.bookDao.lastReadBook != null) { startActivity() } finish() } } class Launcher1 : WelcomeActivity() class Launcher2 : WelcomeActivity() class Launcher3 : WelcomeActivity() class Launcher4 : WelcomeActivity() class Launcher5 : WelcomeActivity() class Launcher6 : WelcomeActivity() ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/BatteryView.kt ================================================ package io.legado.app.ui.widget import android.annotation.SuppressLint import android.content.Context import android.graphics.Canvas import android.graphics.Paint import android.graphics.Rect import android.graphics.Typeface import android.os.Build import android.text.StaticLayout import android.util.AttributeSet import androidx.annotation.ColorInt import androidx.appcompat.widget.AppCompatTextView import io.legado.app.help.config.AppConfig import io.legado.app.utils.canvasrecorder.CanvasRecorderFactory import io.legado.app.utils.canvasrecorder.recordIfNeededThenDraw import io.legado.app.utils.dpToPx class BatteryView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : AppCompatTextView(context, attrs) { private val batteryTypeface by lazy { Typeface.createFromAsset(context.assets, "font/number.ttf") } private val batteryPaint = Paint() private val outFrame = Rect() private val polar = Rect() private val canvasRecorder = CanvasRecorderFactory.create() var isBattery = false set(value) { field = value if (value && !isInEditMode) { super.setTypeface(batteryTypeface) postInvalidate() } } private var battery: Int = 0 init { setPadding(4.dpToPx(), 3.dpToPx(), 6.dpToPx(), 3.dpToPx()) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { isFallbackLineSpacing = false } batteryPaint.strokeWidth = 1f.dpToPx() batteryPaint.isAntiAlias = true batteryPaint.color = paint.color } override fun setTypeface(tf: Typeface?) { if (!isBattery) { super.setTypeface(tf) } } fun setColor(@ColorInt color: Int) { setTextColor(color) batteryPaint.color = color invalidate() } @SuppressLint("SetTextI18n") fun setBattery(battery: Int, text: String? = null) { this.battery = battery if (text.isNullOrEmpty()) { setText(battery.toString()) } else { setText("$text $battery") } } override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { super.onLayout(changed, left, top, right, bottom) canvasRecorder.invalidate() } override fun onDraw(canvas: Canvas) { if (AppConfig.optimizeRender) { canvasRecorder.recordIfNeededThenDraw(canvas, width, height) { super.onDraw(this) drawBattery(this) } } else { super.onDraw(canvas) drawBattery(canvas) } } private fun drawBattery(canvas: Canvas) { if (!isBattery) return layout.getLineBounds(0, outFrame) val batteryStart = layout .getPrimaryHorizontal(text.length - battery.toString().length) .toInt() + 2.dpToPx() val batteryEnd = batteryStart + StaticLayout.getDesiredWidth(battery.toString(), paint).toInt() + 4.dpToPx() outFrame.set( batteryStart, 2.dpToPx(), batteryEnd, height - 2.dpToPx() ) val dj = (outFrame.bottom - outFrame.top) / 3 polar.set( batteryEnd, outFrame.top + dj, batteryEnd + 2.dpToPx(), outFrame.bottom - dj ) batteryPaint.style = Paint.Style.STROKE canvas.drawRect(outFrame, batteryPaint) batteryPaint.style = Paint.Style.FILL canvas.drawRect(polar, batteryPaint) } @Suppress("UNNECESSARY_SAFE_CALL") override fun invalidate() { super.invalidate() canvasRecorder?.invalidate() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/DetailSeekBar.kt ================================================ package io.legado.app.ui.widget import android.content.Context import android.graphics.PorterDuff import android.util.AttributeSet import android.view.LayoutInflater import android.widget.FrameLayout import android.widget.SeekBar import androidx.appcompat.widget.TooltipCompat import io.legado.app.R import io.legado.app.databinding.ViewDetailSeekBarBinding import io.legado.app.lib.theme.bottomBackground import io.legado.app.lib.theme.getPrimaryTextColor import io.legado.app.ui.widget.seekbar.SeekBarChangeListener import io.legado.app.utils.ColorUtils import io.legado.app.utils.progressAdd class DetailSeekBar @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : FrameLayout(context, attrs), SeekBarChangeListener { private var binding: ViewDetailSeekBarBinding = ViewDetailSeekBarBinding.inflate(LayoutInflater.from(context), this, true) private val isBottomBackground: Boolean var valueFormat: ((progress: Int) -> String)? = null var onChanged: ((progress: Int) -> Unit)? = null var progress: Int get() = binding.seekBar.progress set(value) { binding.seekBar.progress = value upValue() } var max: Int get() = binding.seekBar.max set(value) { binding.seekBar.max = value } init { val typedArray = context.obtainStyledAttributes(attrs, R.styleable.DetailSeekBar) isBottomBackground = typedArray.getBoolean(R.styleable.DetailSeekBar_isBottomBackground, false) val title = typedArray.getText(R.styleable.DetailSeekBar_title) binding.tvSeekTitle.apply { text = title TooltipCompat.setTooltipText(this, title) } binding.seekBar.max = typedArray.getInteger(R.styleable.DetailSeekBar_max, 0) typedArray.recycle() if (isBottomBackground && !isInEditMode) { val isLight = ColorUtils.isColorLight(context.bottomBackground) val textColor = context.getPrimaryTextColor(isLight) binding.tvSeekTitle.setTextColor(textColor) binding.ivSeekPlus.setColorFilter(textColor, PorterDuff.Mode.SRC_IN) binding.ivSeekReduce.setColorFilter(textColor, PorterDuff.Mode.SRC_IN) binding.tvSeekValue.setTextColor(textColor) } binding.ivSeekPlus.setOnClickListener { binding.seekBar.progressAdd(1) onChanged?.invoke(binding.seekBar.progress) } binding.ivSeekReduce.setOnClickListener { binding.seekBar.progressAdd(-1) onChanged?.invoke(binding.seekBar.progress) } binding.seekBar.setOnSeekBarChangeListener(this) } private fun upValue(progress: Int = binding.seekBar.progress) { valueFormat?.let { binding.tvSeekValue.text = it.invoke(progress) } ?: let { binding.tvSeekValue.text = progress.toString() } } override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { upValue(progress) } override fun onStartTrackingTouch(seekBar: SeekBar) { } override fun onStopTrackingTouch(seekBar: SeekBar) { onChanged?.invoke(binding.seekBar.progress) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/LabelsBar.kt ================================================ package io.legado.app.ui.widget import android.content.Context import android.util.AttributeSet import android.widget.LinearLayout import android.widget.TextView import io.legado.app.ui.widget.text.AccentBgTextView import io.legado.app.utils.dpToPx @Suppress("unused", "MemberVisibilityCanBePrivate") class LabelsBar @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : LinearLayout(context, attrs) { private val unUsedViews = arrayListOf() private val usedViews = arrayListOf() var textSize = 12f fun setLabels(labels: Array) { clear() labels.forEach { addLabel(it) } } fun setLabels(labels: List) { clear() labels.forEach { addLabel(it) } } fun clear() { unUsedViews.addAll(usedViews) usedViews.clear() removeAllViews() } fun addLabel(label: String) { val tv = if (unUsedViews.isEmpty()) { AccentBgTextView(context, null).apply { setPadding(3.dpToPx(), 0, 3.dpToPx(), 0) setRadius(2) val lp = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) lp.setMargins(0, 0, 2.dpToPx(), 0) layoutParams = lp text = label maxLines = 1 usedViews.add(this) } } else { unUsedViews.last().apply { usedViews.add(this) unUsedViews.removeAt(unUsedViews.lastIndex) } } tv.textSize = textSize tv.text = label addView(tv) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/NoChildScrollNestedScrollView.kt ================================================ package io.legado.app.ui.widget import android.content.Context import android.graphics.Rect import android.util.AttributeSet import android.view.View import androidx.core.widget.NestedScrollView class NoChildScrollNestedScrollView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, ) : NestedScrollView(context, attrs) { private var isRequestChildFocus = false override fun requestChildFocus(child: View?, focused: View?) { isRequestChildFocus = true super.requestChildFocus(child, focused) isRequestChildFocus = false } override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect?): Int { if (isRequestChildFocus) { return 0 } return super.computeScrollDeltaToGetChildRectOnScreen(rect) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/PopupAction.kt ================================================ package io.legado.app.ui.widget import android.content.Context import android.view.ViewGroup import android.widget.PopupWindow import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.databinding.ItemTextBinding import io.legado.app.databinding.PopupActionBinding import io.legado.app.lib.dialogs.SelectItem import splitties.systemservices.layoutInflater class PopupAction(private val context: Context) : PopupWindow(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) { val binding = PopupActionBinding.inflate(context.layoutInflater) val adapter by lazy { Adapter(context).apply { setHasStableIds(true) } } var onActionClick: ((action: String) -> Unit)? = null init { contentView = binding.root isTouchable = true isOutsideTouchable = false isFocusable = true binding.recyclerView.adapter = adapter } fun setItems(items: List>) { adapter.setItems(items) } inner class Adapter(context: Context) : RecyclerAdapter, ItemTextBinding>(context) { override fun getItemId(position: Int): Long { return position.toLong() } override fun getViewBinding(parent: ViewGroup): ItemTextBinding { return ItemTextBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemTextBinding, item: SelectItem, payloads: MutableList ) { with(binding) { textView.text = item.title } } override fun registerListener(holder: ItemViewHolder, binding: ItemTextBinding) { holder.itemView.setOnClickListener { getItem(holder.layoutPosition)?.let { item -> onActionClick?.invoke(item.value) } } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/ReaderInfoBarView.kt ================================================ package io.legado.app.ui.widget import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Rect import android.util.AttributeSet import android.view.View import androidx.annotation.AttrRes import androidx.core.content.ContextCompat import androidx.core.content.res.use import androidx.core.graphics.ColorUtils import androidx.core.view.WindowInsetsCompat import io.legado.app.utils.dpToPx import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat import java.text.SimpleDateFormat import java.util.Date import kotlin.math.min import com.google.android.material.R as materialR class ReaderInfoBarView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0, ) : View(context, attrs, defStyleAttr) { companion object { const val ALIGN_LEFT = 0 const val ALIGN_CENTER = 1 } private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val textBounds = Rect() private val timeFormat = SimpleDateFormat.getTimeInstance(SimpleDateFormat.SHORT) private val timeReceiver = TimeReceiver() private var insetLeft: Int = 0 private var insetRight: Int = 0 private var insetTop: Int = 0 private var cutoutInsetLeft = 0 private var cutoutInsetRight = 0 private val colorText = ColorUtils.setAlphaComponent( context.obtainStyledAttributes(intArrayOf(materialR.attr.colorOnSurface)).use { it.getColor(0, Color.BLACK) }, 200, ) private val colorOutline = ColorUtils.setAlphaComponent( context.obtainStyledAttributes(intArrayOf(materialR.attr.colorSurface)).use { it.getColor(0, Color.WHITE) }, 200, ) var textInfoAlignment: Int = ALIGN_CENTER set(value) { field = value updateTextSize() invalidate() } private var timeText = timeFormat.format(Date()) private var text: String = "" private val innerHeight get() = height - paddingTop - paddingBottom - insetTop private val innerWidth get() = width - paddingLeft - paddingRight - insetLeft - insetRight init { val insetStart = 10.dpToPx() val insetEnd = 10.dpToPx() paint.strokeWidth = 2f.dpToPx() paint.setShadowLayer(2f, 1f, 1f, Color.GRAY) insetLeft = insetStart insetRight = insetEnd insetTop = minOf(insetLeft, insetRight) setOnApplyWindowInsetsListenerCompat { _, windowInsets -> val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) if (insets.left >= paddingLeft) { cutoutInsetLeft = insets.left } if (insets.right >= paddingRight) { cutoutInsetRight = insets.right } windowInsets } } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) val ty = innerHeight / 2f + textBounds.height() / 2f - textBounds.bottom val textX = when (textInfoAlignment) { ALIGN_CENTER -> { val textWidth = paint.measureText(text) (width / 2f).coerceIn( paddingLeft + insetLeft + cutoutInsetLeft + textWidth / 2, width - paddingRight - insetRight - cutoutInsetRight - textWidth / 2 ) } else -> (paddingLeft + insetLeft + cutoutInsetLeft).toFloat() } paint.textAlign = when (textInfoAlignment) { ALIGN_CENTER -> Paint.Align.CENTER else -> Paint.Align.LEFT } canvas.drawTextOutline( text, textX, paddingTop + insetTop + ty, ) paint.textAlign = Paint.Align.RIGHT canvas.drawTextOutline( timeText, (width - paddingRight - insetRight - cutoutInsetRight).toFloat(), paddingTop + insetTop + ty, ) } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) updateTextSize() } override fun onAttachedToWindow() { super.onAttachedToWindow() ContextCompat.registerReceiver( context, timeReceiver, IntentFilter(Intent.ACTION_TIME_TICK), ContextCompat.RECEIVER_EXPORTED, ) } override fun onDetachedFromWindow() { super.onDetachedFromWindow() context.unregisterReceiver(timeReceiver) } fun update(label: String) { text = label updateTextSize() invalidate() } private fun updateTextSize() { val testTextSize = 48f paint.textSize = testTextSize paint.getTextBounds(text, 0, text.length, textBounds) val maxTextHeight = innerHeight * 0.8f val scaleFactor = min( maxTextHeight / textBounds.height(), calculateMaxWidthScale() ) paint.textSize = testTextSize * scaleFactor paint.getTextBounds(text, 0, text.length, textBounds) } private fun calculateMaxWidthScale(): Float { return when (textInfoAlignment) { ALIGN_CENTER -> { val availableWidth = innerWidth - cutoutInsetLeft - cutoutInsetRight val requiredWidth = paint.measureText(text) if (requiredWidth > availableWidth) availableWidth / requiredWidth else 1f } else -> 1f } } private fun Canvas.drawTextOutline(text: String, x: Float, y: Float) { paint.color = colorOutline paint.style = Paint.Style.STROKE drawText(text, x, y, paint) paint.color = colorText paint.style = Paint.Style.FILL drawText(text, x, y, paint) } private inner class TimeReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { timeText = timeFormat.format(Date()) invalidate() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/SearchView.kt ================================================ package io.legado.app.ui.widget import android.annotation.SuppressLint import android.app.SearchableInfo import android.content.Context import android.graphics.Canvas import android.graphics.Paint import android.graphics.drawable.Drawable import android.os.Build import android.text.Spannable import android.text.SpannableStringBuilder import android.text.style.ImageSpan import android.util.AttributeSet import android.util.TypedValue import android.view.Gravity import android.widget.TextView import androidx.appcompat.widget.SearchView import io.legado.app.R import io.legado.app.utils.printOnDebug class SearchView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : SearchView(context, attrs) { private var mSearchHintIcon: Drawable? = null private var textView: TextView? = null @SuppressLint("UseCompatLoadingForDrawables") override fun onLayout( changed: Boolean, left: Int, top: Int, right: Int, bottom: Int ) { super.onLayout(changed, left, top, right, bottom) try { if (textView == null) { textView = findViewById(androidx.appcompat.R.id.search_src_text) mSearchHintIcon = this.context.getDrawable(R.drawable.ic_search_hint) } // 改变字体 textView!!.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) textView!!.gravity = Gravity.CENTER_VERTICAL if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { textView!!.isLocalePreferredLineHeightForMinimumUsed = false } updateQueryHint() } catch (e: Exception) { e.printOnDebug() } } private fun getDecoratedHint(hintText: CharSequence): CharSequence { // If the field is always expanded or we don't have a search hint icon, // then don't add the search icon to the hint. if (mSearchHintIcon == null) { return hintText } val textSize = textView!!.textSize.toInt() mSearchHintIcon!!.setBounds(0, 0, textSize, textSize) val ssb = SpannableStringBuilder(" ") ssb.setSpan(CenteredImageSpan(mSearchHintIcon), 1, 2, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) ssb.append(hintText) return ssb } private fun updateQueryHint() { textView?.let { it.hint = getDecoratedHint(queryHint ?: "") } } override fun setIconifiedByDefault(iconified: Boolean) { super.setIconifiedByDefault(iconified) updateQueryHint() } override fun setSearchableInfo(searchable: SearchableInfo?) { super.setSearchableInfo(searchable) searchable?.let { updateQueryHint() } } override fun setQueryHint(hint: CharSequence?) { super.setQueryHint(hint) updateQueryHint() } internal class CenteredImageSpan(drawable: Drawable?) : ImageSpan(drawable!!) { override fun draw( canvas: Canvas, text: CharSequence, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint ) { // image to draw val b = drawable // font metrics of text to be replaced val fm = paint.fontMetricsInt val transY = ((y + fm.descent + y + fm.ascent) / 2 - b.bounds.bottom / 2) canvas.save() canvas.translate(x, transY.toFloat()) b.draw(canvas) canvas.restore() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/SelectActionBar.kt ================================================ package io.legado.app.ui.widget import android.content.Context import android.graphics.PorterDuff import android.util.AttributeSet import android.view.LayoutInflater import android.view.Menu import android.widget.FrameLayout import androidx.annotation.MenuRes import androidx.annotation.StringRes import androidx.appcompat.widget.PopupMenu import io.legado.app.R import io.legado.app.databinding.ViewSelectActionBarBinding import io.legado.app.lib.theme.TintHelper import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.bottomBackground import io.legado.app.lib.theme.elevation import io.legado.app.lib.theme.getPrimaryTextColor import io.legado.app.lib.theme.getSecondaryDisabledTextColor import io.legado.app.utils.ColorUtils import io.legado.app.utils.applyNavigationBarPadding import io.legado.app.utils.visible @Suppress("unused") class SelectActionBar @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : FrameLayout(context, attrs) { private val bgIsLight = ColorUtils.isColorLight(context.bottomBackground) private val primaryTextColor = context.getPrimaryTextColor(bgIsLight) private val disabledColor = context.getSecondaryDisabledTextColor(bgIsLight) private var callBack: CallBack? = null private var selMenu: PopupMenu? = null private val binding = ViewSelectActionBarBinding .inflate(LayoutInflater.from(context), this, true) init { if (!isInEditMode) { setBackgroundColor(context.bottomBackground) elevation = context.elevation binding.cbSelectedAll.setTextColor(primaryTextColor) TintHelper.setTint(binding.cbSelectedAll, context.accentColor, !bgIsLight) binding.ivMenuMore.setColorFilter(disabledColor, PorterDuff.Mode.SRC_IN) binding.cbSelectedAll.setOnUserCheckedChangeListener { isChecked -> callBack?.selectAll(isChecked) } binding.btnRevertSelection.setOnClickListener { callBack?.revertSelection() } binding.btnSelectActionMain.setOnClickListener { callBack?.onClickSelectBarMainAction() } binding.ivMenuMore.setOnClickListener { selMenu?.show() } applyNavigationBarPadding() } } fun setMainActionText(text: String) = binding.run { btnSelectActionMain.text = text btnSelectActionMain.visible() } fun setMainActionText(@StringRes id: Int) = binding.run { btnSelectActionMain.setText(id) btnSelectActionMain.visible() } fun inflateMenu(@MenuRes resId: Int): Menu? { selMenu = PopupMenu(context, binding.ivMenuMore) selMenu?.inflate(resId) binding.ivMenuMore.visible() return selMenu?.menu } fun setCallBack(callBack: CallBack) { this.callBack = callBack } fun setOnMenuItemClickListener(listener: PopupMenu.OnMenuItemClickListener) { selMenu?.setOnMenuItemClickListener(listener) } fun upCountView(selectCount: Int, allCount: Int) = binding.run { if (selectCount == 0) { cbSelectedAll.isChecked = false } else { cbSelectedAll.isChecked = selectCount >= allCount } //重置全选的文字 if (cbSelectedAll.isChecked) { cbSelectedAll.text = context.getString( R.string.select_cancel_count, selectCount, allCount ) } else { cbSelectedAll.text = context.getString( R.string.select_all_count, selectCount, allCount ) } setMenuClickable(selectCount > 0) } private fun setMenuClickable(isClickable: Boolean) = binding.run { btnRevertSelection.isEnabled = isClickable btnRevertSelection.isClickable = isClickable btnSelectActionMain.isEnabled = isClickable btnSelectActionMain.isClickable = isClickable if (isClickable) { ivMenuMore.setColorFilter(primaryTextColor, PorterDuff.Mode.SRC_IN) } else { ivMenuMore.setColorFilter(disabledColor, PorterDuff.Mode.SRC_IN) } ivMenuMore.isEnabled = isClickable ivMenuMore.isClickable = isClickable } interface CallBack { fun selectAll(selectAll: Boolean) fun revertSelection() fun onClickSelectBarMainAction() {} } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/ShadowLayout.kt ================================================ package io.legado.app.ui.widget import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.RectF import android.util.AttributeSet import android.view.View import android.widget.RelativeLayout import io.legado.app.R import io.legado.app.utils.getCompatColor /** * ShadowLayout.java * * Created by lijiankun on 17/8/11. */ @Suppress("unused") class ShadowLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : RelativeLayout(context, attrs) { private val mPaint = Paint(Paint.ANTI_ALIAS_FLAG) private val mRectF = RectF() /** * 阴影的颜色 */ private var mShadowColor = Color.TRANSPARENT /** * 阴影的大小范围 */ private var mShadowRadius = 0f /** * 阴影 x 轴的偏移量 */ private var mShadowDx = 0f /** * 阴影 y 轴的偏移量 */ private var mShadowDy = 0f /** * 阴影显示的边界 */ private var mShadowSide = ALL /** * 阴影的形状,圆形/矩形 */ private var mShadowShape = SHAPE_RECTANGLE init { setLayerType(View.LAYER_TYPE_SOFTWARE, null) // 关闭硬件加速 setWillNotDraw(false) // 调用此方法后,才会执行 onDraw(Canvas) 方法 val typedArray = context.obtainStyledAttributes(attrs, R.styleable.ShadowLayout) mShadowColor = typedArray.getColor( R.styleable.ShadowLayout_shadowColor, context.getCompatColor(android.R.color.black) ) mShadowRadius = typedArray.getDimension(R.styleable.ShadowLayout_shadowRadius, dip2px(0f)) mShadowDx = typedArray.getDimension(R.styleable.ShadowLayout_shadowDx, dip2px(0f)) mShadowDy = typedArray.getDimension(R.styleable.ShadowLayout_shadowDy, dip2px(0f)) mShadowSide = typedArray.getInt(R.styleable.ShadowLayout_shadowSide, ALL) mShadowShape = typedArray.getInt( R.styleable.ShadowLayout_shadowShape, SHAPE_RECTANGLE ) typedArray.recycle() setUpShadowPaint() } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) val effect = mShadowRadius + dip2px(5f) var rectLeft = 0f var rectTop = 0f var rectRight = this.measuredWidth.toFloat() var rectBottom = this.measuredHeight.toFloat() var paddingLeft = 0 var paddingTop = 0 var paddingRight = 0 var paddingBottom = 0 this.width if (mShadowSide and LEFT == LEFT) { rectLeft = effect paddingLeft = effect.toInt() } if (mShadowSide and TOP == TOP) { rectTop = effect paddingTop = effect.toInt() } if (mShadowSide and RIGHT == RIGHT) { rectRight = this.measuredWidth - effect paddingRight = effect.toInt() } if (mShadowSide and BOTTOM == BOTTOM) { rectBottom = this.measuredHeight - effect paddingBottom = effect.toInt() } if (mShadowDy != 0.0f) { rectBottom -= mShadowDy paddingBottom += mShadowDy.toInt() } if (mShadowDx != 0.0f) { rectRight -= mShadowDx paddingRight += mShadowDx.toInt() } mRectF.left = rectLeft mRectF.top = rectTop mRectF.right = rectRight mRectF.bottom = rectBottom setPadding(paddingLeft, paddingTop, paddingRight, paddingBottom) } /** * 真正绘制阴影的方法 */ override fun onDraw(canvas: Canvas) { super.onDraw(canvas) setUpShadowPaint() if (mShadowShape == SHAPE_RECTANGLE) { canvas.drawRect(mRectF, mPaint) } else if (mShadowShape == SHAPE_OVAL) { canvas.drawCircle( mRectF.centerX(), mRectF.centerY(), mRectF.width().coerceAtMost(mRectF.height()) / 2, mPaint ) } } fun setShadowColor(shadowColor: Int) { mShadowColor = shadowColor requestLayout() postInvalidate() } fun setShadowRadius(shadowRadius: Float) { mShadowRadius = shadowRadius requestLayout() postInvalidate() } private fun setUpShadowPaint() { mPaint.reset() mPaint.isAntiAlias = true mPaint.color = Color.TRANSPARENT mPaint.setShadowLayer(mShadowRadius, mShadowDx, mShadowDy, mShadowColor) } /** * dip2px dp 值转 px 值 * * @param dpValue dp 值 * @return px 值 */ private fun dip2px(dpValue: Float): Float { val dm = context.resources.displayMetrics val scale = dm.density return dpValue * scale + 0.5f } companion object { const val ALL = 0x1111 const val LEFT = 0x0001 const val TOP = 0x0010 const val RIGHT = 0x0100 const val BOTTOM = 0x1000 const val SHAPE_RECTANGLE = 0x0001 const val SHAPE_OVAL = 0x0010 } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/TitleBar.kt ================================================ package io.legado.app.ui.widget import android.content.Context import android.content.res.ColorStateList import android.graphics.PorterDuff import android.graphics.PorterDuffColorFilter import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.util.AttributeSet import android.view.Menu import android.view.View import android.widget.ImageView import androidx.annotation.ColorInt import androidx.annotation.StyleRes import androidx.appcompat.widget.Toolbar import androidx.core.graphics.alpha import androidx.core.view.WindowInsetsCompat import androidx.core.view.children import com.google.android.material.appbar.AppBarLayout import io.legado.app.R import io.legado.app.help.config.AppConfig import io.legado.app.lib.theme.elevation import io.legado.app.lib.theme.primaryColor import io.legado.app.utils.activity import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat import splitties.views.bottomPadding import splitties.views.topPadding @Suppress("unused", "MemberVisibilityCanBePrivate") class TitleBar @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : AppBarLayout(context, attrs) { val toolbar: Toolbar val menu: Menu get() = toolbar.menu var title: CharSequence? get() = toolbar.title set(title) { if (toolbar.title != title) { toolbar.title = title } } var subtitle: CharSequence? get() = toolbar.subtitle set(subtitle) { if (toolbar.subtitle != subtitle) { toolbar.subtitle = subtitle } } private val displayHomeAsUp: Boolean private val navigationIconTint: ColorStateList? private val navigationIconTintMode: Int private val fitStatusBar: Boolean private val fitNavigationBar: Boolean private val attachToActivity: Boolean init { val a = context.obtainStyledAttributes( attrs, R.styleable.TitleBar, R.attr.titleBarStyle, 0 ) navigationIconTint = a.getColorStateList(R.styleable.TitleBar_navigationIconTint) navigationIconTintMode = a.getInt(R.styleable.TitleBar_navigationIconTintMode, 9) attachToActivity = a.getBoolean(R.styleable.TitleBar_attachToActivity, true) displayHomeAsUp = a.getBoolean(R.styleable.TitleBar_displayHomeAsUp, true) fitStatusBar = a.getBoolean(R.styleable.TitleBar_fitStatusBar, true) fitNavigationBar = a.getBoolean(R.styleable.TitleBar_fitNavigationBar, false) val navigationIcon = a.getDrawable(R.styleable.TitleBar_navigationIcon) val navigationContentDescription = a.getText(R.styleable.TitleBar_navigationContentDescription) val titleText = a.getString(R.styleable.TitleBar_title) val subtitleText = a.getString(R.styleable.TitleBar_subtitle) when (a.getInt(R.styleable.TitleBar_themeMode, 0)) { 1 -> inflate(context, R.layout.view_title_bar_dark, this) else -> inflate(context, R.layout.view_title_bar, this) } toolbar = findViewById(R.id.toolbar) toolbar.apply { navigationIcon?.let { this.navigationIcon = it this.navigationContentDescription = navigationContentDescription } if (a.hasValue(R.styleable.TitleBar_titleTextAppearance)) { this.setTitleTextAppearance( context, a.getResourceId(R.styleable.TitleBar_titleTextAppearance, 0) ) } if (a.hasValue(R.styleable.TitleBar_titleTextColor)) { this.setTitleTextColor(a.getColor(R.styleable.TitleBar_titleTextColor, -0x1)) } if (a.hasValue(R.styleable.TitleBar_subtitleTextAppearance)) { this.setSubtitleTextAppearance( context, a.getResourceId(R.styleable.TitleBar_subtitleTextAppearance, 0) ) } if (a.hasValue(R.styleable.TitleBar_subtitleTextColor)) { this.setSubtitleTextColor(a.getColor(R.styleable.TitleBar_subtitleTextColor, -0x1)) } if (a.hasValue(R.styleable.TitleBar_contentInsetLeft) || a.hasValue(R.styleable.TitleBar_contentInsetRight) ) { this.setContentInsetsAbsolute( a.getDimensionPixelSize(R.styleable.TitleBar_contentInsetLeft, 0), a.getDimensionPixelSize(R.styleable.TitleBar_contentInsetRight, 0) ) } if (a.hasValue(R.styleable.TitleBar_contentInsetStart) || a.hasValue(R.styleable.TitleBar_contentInsetEnd) ) { this.setContentInsetsRelative( a.getDimensionPixelSize(R.styleable.TitleBar_contentInsetStart, 0), a.getDimensionPixelSize(R.styleable.TitleBar_contentInsetEnd, 0) ) } if (a.hasValue(R.styleable.TitleBar_contentInsetStartWithNavigation)) { this.contentInsetStartWithNavigation = a.getDimensionPixelOffset( R.styleable.TitleBar_contentInsetStartWithNavigation, 0 ) } if (a.hasValue(R.styleable.TitleBar_contentInsetEndWithActions)) { this.contentInsetEndWithActions = a.getDimensionPixelOffset( R.styleable.TitleBar_contentInsetEndWithActions, 0 ) } if (!titleText.isNullOrBlank()) { this.title = titleText } if (!subtitleText.isNullOrBlank()) { this.subtitle = subtitleText } if (a.hasValue(R.styleable.TitleBar_contentLayout)) { inflate(context, a.getResourceId(R.styleable.TitleBar_contentLayout, 0), this) } } if (!isInEditMode) { // if (fitStatusBar) { // setPadding(paddingLeft, context.statusBarHeight, paddingRight, paddingBottom) // } // // if (fitNavigationBar) { // setPadding(paddingLeft, paddingTop, paddingRight, context.navigationBarHeight) // } if (fitStatusBar || fitNavigationBar) { setOnApplyWindowInsetsListenerCompat { _, windowInsets -> val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) if (fitStatusBar) { topPadding = insets.top } if (fitNavigationBar) { bottomPadding = insets.bottom } windowInsets } } if (AppConfig.isEInkMode) { setBackgroundResource(R.drawable.bg_eink_border_bottom) } else { setBackgroundColor(context.primaryColor) } stateListAnimator = null elevation = context.elevation } a.recycle() } override fun onAttachedToWindow() { super.onAttachedToWindow() attachToActivity() } fun setNavigationOnClickListener(clickListener: ((View) -> Unit)) { toolbar.setNavigationOnClickListener(clickListener) } fun setTitle(titleId: Int) { toolbar.setTitle(titleId) } fun setSubTitle(subtitleId: Int) { toolbar.setSubtitle(subtitleId) } fun setTitleTextColor(@ColorInt color: Int) { toolbar.setTitleTextColor(color) } fun setTitleTextAppearance(@StyleRes resId: Int) { toolbar.setTitleTextAppearance(context, resId) } fun setSubTitleTextColor(@ColorInt color: Int) { toolbar.setSubtitleTextColor(color) } fun setSubTitleTextAppearance(@StyleRes resId: Int) { toolbar.setSubtitleTextAppearance(context, resId) } fun setTextColor(@ColorInt color: Int) { setTitleTextColor(color) setSubTitleTextColor(color) } fun setColorFilter(@ColorInt color: Int) { val colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_ATOP) toolbar.children.firstOrNull { it is ImageView }?.background?.colorFilter = colorFilter toolbar.navigationIcon?.colorFilter = colorFilter toolbar.overflowIcon?.colorFilter = colorFilter toolbar.menu.children.forEach { it.icon?.colorFilter = colorFilter } } override fun setBackgroundColor(color: Int) { if (color.alpha < 255) { //这里不能改为0f,改为0f在横屏模式下文字和图标颜色会变 elevation = 0.1f } super.setBackgroundColor(color) } override fun setBackground(background: Drawable?) { if (background is ColorDrawable) { if (background.alpha < 255) { //这里不能改为0f,改为0f在横屏模式下文字和图标颜色会变 elevation = 0.1f } } super.setBackground(background) } fun onMultiWindowModeChanged(isInMultiWindowMode: Boolean, fullScreen: Boolean) { // if (fitStatusBar) { // val topPadding = if (!isInMultiWindowMode && fullScreen) context.statusBarHeight else 0 // setPadding(paddingLeft, topPadding, paddingRight, paddingBottom) // } } private fun attachToActivity() { if (attachToActivity) { activity?.let { it.setSupportActionBar(toolbar) it.supportActionBar?.setDisplayHomeAsUpEnabled(displayHomeAsUp) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/anima/RefreshProgressBar.kt ================================================ package io.legado.app.ui.widget.anima import android.content.Context import android.graphics.Canvas import android.graphics.Paint import android.graphics.Rect import android.graphics.RectF import android.os.Looper import android.util.AttributeSet import android.view.View import io.legado.app.R @Suppress("unused", "MemberVisibilityCanBePrivate") class RefreshProgressBar @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : View(context, attrs) { private var a = 1 private var durProgress = 0 private var secondDurProgress = 0 var maxProgress = 100 var secondMaxProgress = 100 var bgColor = 0x00000000 var secondColor = -0x3e3e3f var fontColor = -0xc9c9ca var speed = 2 var secondFinalProgress = 0 private set private var paint: Paint = Paint() private val bgRect = Rect() private val secondRect = Rect() private val fontRectF = RectF() var isAutoLoading: Boolean = false set(loading) { field = loading if (!loading) { secondDurProgress = 0 secondFinalProgress = 0 } maxProgress = 0 invalidate() } init { paint.style = Paint.Style.FILL val a = context.obtainStyledAttributes(attrs, R.styleable.RefreshProgressBar) speed = a.getDimensionPixelSize(R.styleable.RefreshProgressBar_speed, speed) maxProgress = a.getInt(R.styleable.RefreshProgressBar_max_progress, maxProgress) durProgress = a.getInt(R.styleable.RefreshProgressBar_dur_progress, durProgress) secondDurProgress = a.getDimensionPixelSize( R.styleable.RefreshProgressBar_second_dur_progress, secondDurProgress ) secondFinalProgress = secondDurProgress secondMaxProgress = a.getDimensionPixelSize( R.styleable.RefreshProgressBar_second_max_progress, secondMaxProgress ) bgColor = a.getColor(R.styleable.RefreshProgressBar_bg_color, bgColor) secondColor = a.getColor(R.styleable.RefreshProgressBar_second_color, secondColor) fontColor = a.getColor(R.styleable.RefreshProgressBar_font_color, fontColor) a.recycle() } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) paint.color = bgColor bgRect.set(0, 0, measuredWidth, measuredHeight) canvas.drawRect(bgRect, paint) if (secondDurProgress > 0 && secondMaxProgress > 0) { var secondDur = secondDurProgress if (secondDur > secondMaxProgress) { secondDur = secondMaxProgress } paint.color = secondColor val tempW = (measuredWidth.toFloat() * 1.0f * (secondDur * 1.0f / secondMaxProgress)).toInt() secondRect.set( measuredWidth / 2 - tempW / 2, 0, measuredWidth / 2 + tempW / 2, measuredHeight ) canvas.drawRect(secondRect, paint) } if (durProgress > 0 && maxProgress > 0) { paint.color = fontColor fontRectF.set( 0f, 0f, measuredWidth.toFloat() * 1.0f * (durProgress * 1.0f / maxProgress), measuredHeight.toFloat() ) canvas.drawRect(fontRectF, paint) } if (this.isAutoLoading) { if (secondDurProgress >= secondMaxProgress) { a = -1 } else if (secondDurProgress <= 0) { a = 1 } secondDurProgress += a * speed if (secondDurProgress < 0) secondDurProgress = 0 else if (secondDurProgress > secondMaxProgress) secondDurProgress = secondMaxProgress secondFinalProgress = secondDurProgress invalidate() } else { if (secondDurProgress != secondFinalProgress) { if (secondDurProgress > secondFinalProgress) { secondDurProgress -= speed if (secondDurProgress < secondFinalProgress) { secondDurProgress = secondFinalProgress } } else { secondDurProgress += speed if (secondDurProgress > secondFinalProgress) { secondDurProgress = secondFinalProgress } } this.invalidate() } } } fun getDurProgress(): Int { return durProgress } fun setDurProgress(durProgress: Int) { var durProgress1 = durProgress if (durProgress1 < 0) { durProgress1 = 0 } if (durProgress1 > maxProgress) { durProgress1 = maxProgress } this.durProgress = durProgress1 if (Looper.myLooper() == Looper.getMainLooper()) { this.invalidate() } else { this.postInvalidate() } } fun getSecondDurProgress(): Int { return secondDurProgress } fun setSecondDurProgress(secondDur: Int) { this.secondDurProgress = secondDur this.secondFinalProgress = secondDurProgress if (Looper.myLooper() == Looper.getMainLooper()) { this.invalidate() } else { this.postInvalidate() } } fun setSecondDurProgressWithAnim(secondDur: Int) { var secondDur1 = secondDur if (secondDur1 < 0) { secondDur1 = 0 } if (secondDur1 > secondMaxProgress) { secondDur1 = secondMaxProgress } this.secondFinalProgress = secondDur1 if (Looper.myLooper() == Looper.getMainLooper()) { this.invalidate() } else { this.postInvalidate() } } fun clean() { durProgress = 0 secondDurProgress = 0 secondFinalProgress = 0 if (Looper.myLooper() == Looper.getMainLooper()) { this.invalidate() } else { this.postInvalidate() } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/anima/RotateLoading.kt ================================================ package io.legado.app.ui.widget.anima import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.RectF import android.util.AttributeSet import android.view.View import io.legado.app.R import io.legado.app.lib.theme.accentColor import io.legado.app.utils.dpToPx /** * RotateLoading * Created by Victor on 2015/4/28. */ @Suppress("MemberVisibilityCanBePrivate") class RotateLoading @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : View(context, attrs) { private var mPaint: Paint private var loadingRectF: RectF? = null private var shadowRectF: RectF? = null private var topDegree = 10 private var bottomDegree = 190 private var arc: Float = 0.toFloat() private var thisWidth: Int = 0 private var changeBigger = true private var shadowPosition: Int = 0 private var hideMode = GONE var isStarted = false private set var loadingColor: Int = 0 set(value) { field = value invalidate() } private var speedOfDegree: Int = 0 private var speedOfArc: Float = 0.toFloat() private val shown = Runnable { this.startInternal() } private val hidden = Runnable { this.stopInternal() } init { loadingColor = context.accentColor thisWidth = DEFAULT_WIDTH.dpToPx() shadowPosition = DEFAULT_SHADOW_POSITION.dpToPx() speedOfDegree = DEFAULT_SPEED_OF_DEGREE if (null != attrs) { val typedArray = context.obtainStyledAttributes(attrs, R.styleable.RotateLoading) loadingColor = typedArray.getColor(R.styleable.RotateLoading_loading_color, loadingColor) thisWidth = typedArray.getDimensionPixelSize( R.styleable.RotateLoading_loading_width, DEFAULT_WIDTH.dpToPx() ) shadowPosition = typedArray.getInt( R.styleable.RotateLoading_shadow_position, DEFAULT_SHADOW_POSITION ) speedOfDegree = typedArray.getInt(R.styleable.RotateLoading_loading_speed, DEFAULT_SPEED_OF_DEGREE) typedArray.recycle() } speedOfArc = (speedOfDegree / 4).toFloat() mPaint = Paint() mPaint.color = loadingColor mPaint.isAntiAlias = true mPaint.style = Paint.Style.STROKE mPaint.strokeWidth = thisWidth.toFloat() mPaint.strokeCap = Paint.Cap.ROUND } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) arc = 10f loadingRectF = RectF( (2 * thisWidth).toFloat(), (2 * thisWidth).toFloat(), (w - 2 * thisWidth).toFloat(), (h - 2 * thisWidth).toFloat() ) shadowRectF = RectF( (2 * thisWidth + shadowPosition).toFloat(), (2 * thisWidth + shadowPosition).toFloat(), (w - 2 * thisWidth + shadowPosition).toFloat(), (h - 2 * thisWidth + shadowPosition).toFloat() ) } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) if (!isStarted) { return } mPaint.color = Color.parseColor("#1a000000") shadowRectF?.let { canvas.drawArc(it, topDegree.toFloat(), arc, false, mPaint) canvas.drawArc(it, bottomDegree.toFloat(), arc, false, mPaint) } mPaint.color = loadingColor loadingRectF?.let { canvas.drawArc(it, topDegree.toFloat(), arc, false, mPaint) canvas.drawArc(it, bottomDegree.toFloat(), arc, false, mPaint) } topDegree += speedOfDegree bottomDegree += speedOfDegree if (topDegree > 360) { topDegree -= 360 } if (bottomDegree > 360) { bottomDegree -= 360 } if (changeBigger) { if (arc < 160) { arc += speedOfArc invalidate() } } else { if (arc > speedOfDegree) { arc -= 2 * speedOfArc invalidate() } } if (arc >= 160 || arc <= 10) { changeBigger = !changeBigger invalidate() } } override fun onAttachedToWindow() { super.onAttachedToWindow() if (visibility == VISIBLE) { startInternal() } } override fun onDetachedFromWindow() { super.onDetachedFromWindow() isStarted = false animate().cancel() removeCallbacks(shown) removeCallbacks(hidden) } fun visible() { removeCallbacks(shown) removeCallbacks(hidden) post(shown) } fun inVisible() { hideMode = INVISIBLE removeCallbacks(shown) removeCallbacks(hidden) stopInternal() } fun gone() { hideMode = GONE removeCallbacks(shown) removeCallbacks(hidden) stopInternal() } private fun startInternal() { startAnimator() isStarted = true invalidate() } private fun stopInternal() { stopAnimator() invalidate() } private fun startAnimator() { animate().cancel() animate().scaleX(1.0f) .scaleY(1.0f) .setListener(object : AnimatorListenerAdapter() { override fun onAnimationStart(animation: Animator) { visibility = VISIBLE } }) .start() } private fun stopAnimator() { animate().cancel() isStarted = false this.visibility = hideMode } companion object { private const val DEFAULT_WIDTH = 6 private const val DEFAULT_SHADOW_POSITION = 2 private const val DEFAULT_SPEED_OF_DEGREE = 10 } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/ExplosionAnimator.kt ================================================ /* * Copyright (C) 2015 tyrantgit * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package io.legado.app.ui.widget.anima.explosion_field import android.animation.ValueAnimator import android.annotation.SuppressLint import android.graphics.* import android.view.View import android.view.animation.AccelerateInterpolator import java.util.* import kotlin.math.pow @SuppressLint("Recycle") class ExplosionAnimator(private val mContainer: View, bitmap: Bitmap, bound: Rect) : ValueAnimator() { private val mPaint: Paint = Paint() private val mParticles: Array private val mBound: Rect = Rect(bound) init { val partLen = 15 mParticles = arrayOfNulls(partLen * partLen) val random = Random(System.currentTimeMillis()) val w = bitmap.width / (partLen + 2) val h = bitmap.height / (partLen + 2) for (i in 0 until partLen) { for (j in 0 until partLen) { mParticles[i * partLen + j] = generateParticle(bitmap.getPixel((j + 1) * w, (i + 1) * h), random) } } setFloatValues(0f, END_VALUE) interpolator = DEFAULT_INTERPOLATOR duration = DEFAULT_DURATION } private fun generateParticle(color: Int, random: Random): Particle { val particle = Particle() particle.color = color particle.radius = V if (random.nextFloat() < 0.2f) { particle.baseRadius = V + (X - V) * random.nextFloat() } else { particle.baseRadius = W + (V - W) * random.nextFloat() } val nextFloat = random.nextFloat() particle.top = mBound.height() * (0.18f * random.nextFloat() + 0.2f) particle.top = if (nextFloat < 0.2f) particle.top else particle.top + particle.top * 0.2f * random.nextFloat() particle.bottom = mBound.height() * (random.nextFloat() - 0.5f) * 1.8f var f = if (nextFloat < 0.2f) particle.bottom else if (nextFloat < 0.8f) particle.bottom * 0.6f else particle.bottom * 0.3f particle.bottom = f particle.mag = 4.0f * particle.top / particle.bottom particle.neg = -particle.mag / particle.bottom f = mBound.centerX() + Y * (random.nextFloat() - 0.5f) particle.baseCx = f particle.cx = f f = mBound.centerY() + Y * (random.nextFloat() - 0.5f) particle.baseCy = f particle.cy = f particle.life = END_VALUE / 10 * random.nextFloat() particle.overflow = 0.4f * random.nextFloat() particle.alpha = 1f return particle } fun draw(canvas: Canvas): Boolean { if (!isStarted) { return false } for (particle in mParticles) { particle?.let { particle.advance(animatedValue as Float) if (particle.alpha > 0f) { mPaint.color = particle.color mPaint.alpha = (Color.alpha(particle.color) * particle.alpha).toInt() canvas.drawCircle(particle.cx, particle.cy, particle.radius, mPaint) } } } mContainer.invalidate() return true } override fun start() { super.start() mContainer.invalidate() } private inner class Particle { var alpha: Float = 0.toFloat() var color: Int = 0 var cx: Float = 0.toFloat() var cy: Float = 0.toFloat() var radius: Float = 0.toFloat() var baseCx: Float = 0.toFloat() var baseCy: Float = 0.toFloat() var baseRadius: Float = 0.toFloat() var top: Float = 0.toFloat() var bottom: Float = 0.toFloat() var mag: Float = 0.toFloat() var neg: Float = 0.toFloat() var life: Float = 0.toFloat() var overflow: Float = 0.toFloat() fun advance(factor: Float) { var f = 0f var normalization = factor / END_VALUE if (normalization < life || normalization > 1f - overflow) { alpha = 0f return } normalization = (normalization - life) / (1f - life - overflow) val f2 = normalization * END_VALUE if (normalization >= 0.7f) { f = (normalization - 0.7f) / 0.3f } alpha = 1f - f f = bottom * f2 cx = baseCx + f cy = (baseCy - this.neg * f.toDouble().pow(2.0)).toFloat() - f * mag radius = V + (baseRadius - V) * f2 } } companion object { internal var DEFAULT_DURATION: Long = 0x400 private val DEFAULT_INTERPOLATOR = AccelerateInterpolator(0.6f) private const val END_VALUE = 1.4f private val X = Utils.dp2Px(5).toFloat() private val Y = Utils.dp2Px(20).toFloat() private val V = Utils.dp2Px(2).toFloat() private val W = Utils.dp2Px(1).toFloat() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/ExplosionField.kt ================================================ package io.legado.app.ui.widget.anima.explosion_field import android.app.Activity import android.view.View import android.view.ViewGroup import android.view.Window object ExplosionField { fun attach2Window(activity: Activity): ExplosionView { val rootView = activity.findViewById(Window.ID_ANDROID_CONTENT) as ViewGroup val explosionField = ExplosionView(activity) rootView.addView( explosionField, ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) ) return explosionField } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/ExplosionView.kt ================================================ /* * Copyright (C) 2015 tyrantgit * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package io.legado.app.ui.widget.anima.explosion_field import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.animation.ValueAnimator import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Rect import android.media.MediaPlayer import android.util.AttributeSet import android.view.View import io.legado.app.utils.DebugLog import java.util.* @Suppress("unused") class ExplosionView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : View(context, attrs) { private var customDuration = ExplosionAnimator.DEFAULT_DURATION private var idPlayAnimationEffect = 0 private var mZAnimatorListener: OnAnimatorListener? = null private var mOnClickListener: OnClickListener? = null private val mExplosions = ArrayList() private val mExpandInset = IntArray(2) init { Arrays.fill(mExpandInset, Utils.dp2Px(32)) } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) for (explosion in mExplosions) { explosion.draw(canvas) } } fun playSoundAnimationEffect(id: Int) { this.idPlayAnimationEffect = id } fun setCustomDuration(customDuration: Long) { this.customDuration = customDuration } fun addActionEvent(iEvents: OnAnimatorListener) { this.mZAnimatorListener = iEvents } fun expandExplosionBound(dx: Int, dy: Int) { mExpandInset[0] = dx mExpandInset[1] = dy } @JvmOverloads fun explode(bitmap: Bitmap?, bound: Rect, startDelay: Long, view: View? = null) { val currentDuration = customDuration val explosion = ExplosionAnimator(this, bitmap!!, bound) explosion.addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { mExplosions.remove(animation) view?.let { view.scaleX = 1f view.scaleY = 1f view.alpha = 1f //view.setOnClickListener(mOnClickListener)//set event } } }) explosion.startDelay = startDelay explosion.duration = currentDuration mExplosions.add(explosion) explosion.start() } @JvmOverloads fun explode(view: View, restartState: Boolean? = false) { val r = Rect() view.getGlobalVisibleRect(r) val location = IntArray(2) getLocationOnScreen(location) r.offset(-location[0], -location[1]) r.inset(-mExpandInset[0], -mExpandInset[1]) val startDelay = 100 val animator = ValueAnimator.ofFloat(0f, 1f).setDuration(150) animator.addUpdateListener(object : ValueAnimator.AnimatorUpdateListener { var random = Random() override fun onAnimationUpdate(animation: ValueAnimator) { view.translationX = (random.nextFloat() - 0.5f) * view.width.toFloat() * 0.05f view.translationY = (random.nextFloat() - 0.5f) * view.height.toFloat() * 0.05f } }) animator.addListener(object : Animator.AnimatorListener { override fun onAnimationStart(animator: Animator) { if (idPlayAnimationEffect != 0) MediaPlayer.create(context, idPlayAnimationEffect).start() } override fun onAnimationEnd(animator: Animator) { if (mZAnimatorListener != null) { mZAnimatorListener!!.onAnimationEnd(animator, this@ExplosionView) } } override fun onAnimationCancel(animator: Animator) { DebugLog.i(javaClass.name, "CANCEL") } override fun onAnimationRepeat(animator: Animator) { DebugLog.i(javaClass.name, "REPEAT") } }) animator.start() view.animate().setDuration(150).setStartDelay(startDelay.toLong()).scaleX(0f).scaleY(0f) .alpha(0f).start() if (restartState!!) explode(Utils.createBitmapFromView(view), r, startDelay.toLong(), view) else explode(Utils.createBitmapFromView(view), r, startDelay.toLong()) } fun clear() { mExplosions.clear() invalidate() } override fun setOnClickListener(mOnClickListener: OnClickListener?) { this.mOnClickListener = mOnClickListener } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/OnAnimatorListener.kt ================================================ package io.legado.app.ui.widget.anima.explosion_field import android.animation.Animator import android.view.View interface OnAnimatorListener { fun onAnimationEnd(animator: Animator, view: View) } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/Utils.kt ================================================ /* * Copyright (C) 2015 tyrantgit * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package io.legado.app.ui.widget.anima.explosion_field import android.content.res.Resources import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.drawable.BitmapDrawable import android.view.View import android.widget.ImageView import io.legado.app.utils.printOnDebug import kotlin.math.roundToInt object Utils { private val DENSITY = Resources.getSystem().displayMetrics.density private val sCanvas = Canvas() fun dp2Px(dp: Int): Int { return (dp * DENSITY).roundToInt() } fun createBitmapFromView(view: View): Bitmap? { if (view is ImageView) { val drawable = view.drawable if (drawable != null && drawable is BitmapDrawable) { return drawable.bitmap } } view.clearFocus() val bitmap = createBitmapSafely( view.width, view.height, Bitmap.Config.ARGB_8888, 1 ) if (bitmap != null) { synchronized(sCanvas) { val canvas = sCanvas canvas.setBitmap(bitmap) view.draw(canvas) canvas.setBitmap(null) } } return bitmap } private fun createBitmapSafely( width: Int, height: Int, config: Bitmap.Config, retryCount: Int ): Bitmap? { try { return Bitmap.createBitmap(width, height, config) } catch (e: OutOfMemoryError) { e.printOnDebug() if (retryCount > 0) { System.gc() return createBitmapSafely(width, height, config, retryCount - 1) } return null } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/checkbox/SmoothCheckBox.kt ================================================ package io.legado.app.ui.widget.checkbox import android.animation.ValueAnimator import android.content.Context import android.graphics.* import android.util.AttributeSet import android.view.View import android.view.animation.LinearInterpolator import android.widget.Checkable import androidx.core.view.postDelayed import io.legado.app.R import io.legado.app.lib.theme.ThemeStore import io.legado.app.utils.dpToPx import io.legado.app.utils.getCompatColor import kotlin.math.min import kotlin.math.pow import kotlin.math.roundToInt import kotlin.math.sqrt class SmoothCheckBox @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : View(context, attrs), Checkable { private var mPaint: Paint private var mTickPaint: Paint private var mFloorPaint: Paint private var mTickPoints: Array private var mCenterPoint: Point private var mTickPath: Path private var mLeftLineDistance = 0f private var mRightLineDistance = 0f private var mDrewDistance = 0f private var mScaleVal = 1.0f private var mFloorScale = 1.0f private var mWidth = 0 private var mAnimDuration = 0 private var mStrokeWidth = 0 private var mCheckedColor = 0 private var mUnCheckedColor = 0 private var mFloorColor = 0 private var mFloorUnCheckedColor = 0 private var mChecked = false private var mTickDrawing = false var onCheckedChangeListener: ((checkBox: SmoothCheckBox, isChecked: Boolean) -> Unit)? = null init { val ta = context.obtainStyledAttributes(attrs, R.styleable.SmoothCheckBox) var tickColor = ThemeStore.accentColor(context) mCheckedColor = context.getCompatColor(R.color.background_menu) mUnCheckedColor = context.getCompatColor(R.color.background_menu) mFloorColor = context.getCompatColor(R.color.transparent30) tickColor = ta.getColor(R.styleable.SmoothCheckBox_color_tick, tickColor) mAnimDuration = ta.getInt(R.styleable.SmoothCheckBox_duration, DEF_ANIM_DURATION) mFloorColor = ta.getColor(R.styleable.SmoothCheckBox_color_unchecked_stroke, mFloorColor) mCheckedColor = ta.getColor(R.styleable.SmoothCheckBox_color_checked, mCheckedColor) mUnCheckedColor = ta.getColor(R.styleable.SmoothCheckBox_color_unchecked, mUnCheckedColor) mStrokeWidth = ta.getDimensionPixelSize(R.styleable.SmoothCheckBox_stroke_width, 0) ta.recycle() mFloorUnCheckedColor = mFloorColor mTickPaint = Paint(Paint.ANTI_ALIAS_FLAG) mTickPaint.style = Paint.Style.STROKE mTickPaint.strokeCap = Paint.Cap.ROUND mTickPaint.color = tickColor mFloorPaint = Paint(Paint.ANTI_ALIAS_FLAG) mFloorPaint.style = Paint.Style.FILL mFloorPaint.color = mFloorColor mPaint = Paint(Paint.ANTI_ALIAS_FLAG) mPaint.style = Paint.Style.FILL mPaint.color = mCheckedColor mTickPath = Path() mCenterPoint = Point() mTickPoints = arrayOf(Point(), Point(), Point()) setOnClickListener { toggle() mTickDrawing = false mDrewDistance = 0f if (isChecked) { startCheckedAnimation() } else { startUnCheckedAnimation() } } } override fun isChecked(): Boolean { return mChecked } override fun setChecked(checked: Boolean) { mChecked = checked reset() invalidate() onCheckedChangeListener?.invoke(this@SmoothCheckBox, mChecked) } override fun toggle() { this.isChecked = !isChecked } /** * checked with animation * * @param checked checked * @param animate change with animation */ fun setChecked(checked: Boolean, animate: Boolean) { if (animate) { mTickDrawing = false mChecked = checked mDrewDistance = 0f if (checked) { startCheckedAnimation() } else { startUnCheckedAnimation() } onCheckedChangeListener?.invoke(this@SmoothCheckBox, mChecked) } else { this.isChecked = checked } } private fun reset() { mTickDrawing = true mFloorScale = 1.0f mScaleVal = if (isChecked) 0f else 1.0f mFloorColor = if (isChecked) mCheckedColor else mFloorUnCheckedColor mDrewDistance = if (isChecked) mLeftLineDistance + mRightLineDistance else 0f } private fun measureSize(measureSpec: Int): Int { val defSize: Int = DEF_DRAW_SIZE.dpToPx() val specSize = MeasureSpec.getSize(measureSpec) val specMode = MeasureSpec.getMode(measureSpec) var result = 0 when (specMode) { MeasureSpec.UNSPECIFIED, MeasureSpec.AT_MOST -> result = min(defSize, specSize) MeasureSpec.EXACTLY -> result = specSize } return result } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) setMeasuredDimension(measureSize(widthMeasureSpec), measureSize(heightMeasureSpec)) } override fun onLayout( changed: Boolean, left: Int, top: Int, right: Int, bottom: Int ) { mWidth = measuredWidth mStrokeWidth = if (mStrokeWidth == 0) measuredWidth / 10 else mStrokeWidth mStrokeWidth = if (mStrokeWidth > measuredWidth / 5) measuredWidth / 5 else mStrokeWidth mStrokeWidth = if (mStrokeWidth < 3) 3 else mStrokeWidth mCenterPoint.x = mWidth / 2 mCenterPoint.y = measuredHeight / 2 mTickPoints[0].x = (measuredWidth.toFloat() / 30 * 7).roundToInt() mTickPoints[0].y = (measuredHeight.toFloat() / 30 * 14).roundToInt() mTickPoints[1].x = (measuredWidth.toFloat() / 30 * 13).roundToInt() mTickPoints[1].y = (measuredHeight.toFloat() / 30 * 20).roundToInt() mTickPoints[2].x = (measuredWidth.toFloat() / 30 * 22).roundToInt() mTickPoints[2].y = (measuredHeight.toFloat() / 30 * 10).roundToInt() mLeftLineDistance = sqrt( (mTickPoints[1].x - mTickPoints[0].x.toDouble()).pow(2.0) + (mTickPoints[1].y - mTickPoints[0].y.toDouble()).pow(2.0) ).toFloat() mRightLineDistance = sqrt( (mTickPoints[2].x - mTickPoints[1].x.toDouble()).pow(2.0) + (mTickPoints[2].y - mTickPoints[1].y.toDouble()).pow(2.0) ).toFloat() mTickPaint.strokeWidth = mStrokeWidth.toFloat() } override fun onDraw(canvas: Canvas) { drawBorder(canvas) drawCenter(canvas) drawTick(canvas) } private fun drawCenter(canvas: Canvas) { mPaint.color = mUnCheckedColor val radius = (mCenterPoint.x - mStrokeWidth) * mScaleVal canvas.drawCircle(mCenterPoint.x.toFloat(), mCenterPoint.y.toFloat(), radius, mPaint) } private fun drawBorder(canvas: Canvas) { mFloorPaint.color = mFloorColor val radius = mCenterPoint.x canvas.drawCircle( mCenterPoint.x.toFloat(), mCenterPoint.y.toFloat(), radius * mFloorScale, mFloorPaint ) } private fun drawTick(canvas: Canvas) { if (mTickDrawing && isChecked) { drawTickPath(canvas) } } private fun drawTickPath(canvas: Canvas) { mTickPath.reset() // draw left of the tick if (mDrewDistance < mLeftLineDistance) { val step: Float = if (mWidth / 20.0f < 3) 3f else mWidth / 20.0f mDrewDistance += step val stopX = mTickPoints[0].x + (mTickPoints[1].x - mTickPoints[0].x) * mDrewDistance / mLeftLineDistance val stopY = mTickPoints[0].y + (mTickPoints[1].y - mTickPoints[0].y) * mDrewDistance / mLeftLineDistance mTickPath.moveTo(mTickPoints[0].x.toFloat(), mTickPoints[0].y.toFloat()) mTickPath.lineTo(stopX, stopY) canvas.drawPath(mTickPath, mTickPaint) if (mDrewDistance > mLeftLineDistance) { mDrewDistance = mLeftLineDistance } } else { mTickPath.moveTo(mTickPoints[0].x.toFloat(), mTickPoints[0].y.toFloat()) mTickPath.lineTo(mTickPoints[1].x.toFloat(), mTickPoints[1].y.toFloat()) canvas.drawPath(mTickPath, mTickPaint) // draw right of the tick if (mDrewDistance < mLeftLineDistance + mRightLineDistance) { val stopX = mTickPoints[1].x + (mTickPoints[2].x - mTickPoints[1].x) * (mDrewDistance - mLeftLineDistance) / mRightLineDistance val stopY = mTickPoints[1].y - (mTickPoints[1].y - mTickPoints[2].y) * (mDrewDistance - mLeftLineDistance) / mRightLineDistance mTickPath.reset() mTickPath.moveTo(mTickPoints[1].x.toFloat(), mTickPoints[1].y.toFloat()) mTickPath.lineTo(stopX, stopY) canvas.drawPath(mTickPath, mTickPaint) val step: Float = if (mWidth / 20f < 3) 3f else mWidth / 20f mDrewDistance += step } else { mTickPath.reset() mTickPath.moveTo(mTickPoints[1].x.toFloat(), mTickPoints[1].y.toFloat()) mTickPath.lineTo(mTickPoints[2].x.toFloat(), mTickPoints[2].y.toFloat()) canvas.drawPath(mTickPath, mTickPaint) } } // invalidate if (mDrewDistance < mLeftLineDistance + mRightLineDistance) { postDelayed(10) { this.postInvalidate() } } } private fun startCheckedAnimation() { val animator = ValueAnimator.ofFloat(1.0f, 0f) animator.duration = mAnimDuration / 3 * 2.toLong() animator.interpolator = LinearInterpolator() animator.addUpdateListener { animation: ValueAnimator -> mScaleVal = animation.animatedValue as Float mFloorColor = getGradientColor( mUnCheckedColor, mCheckedColor, 1 - mScaleVal ) postInvalidate() } animator.start() val floorAnimator = ValueAnimator.ofFloat(1.0f, 0.8f, 1.0f) floorAnimator.duration = mAnimDuration.toLong() floorAnimator.interpolator = LinearInterpolator() floorAnimator.addUpdateListener { animation: ValueAnimator -> mFloorScale = animation.animatedValue as Float postInvalidate() } floorAnimator.start() drawTickDelayed() } private fun startUnCheckedAnimation() { val animator = ValueAnimator.ofFloat(0f, 1.0f) animator.duration = mAnimDuration.toLong() animator.interpolator = LinearInterpolator() animator.addUpdateListener { animation: ValueAnimator -> mScaleVal = animation.animatedValue as Float mFloorColor = getGradientColor( mCheckedColor, mFloorUnCheckedColor, mScaleVal ) postInvalidate() } animator.start() val floorAnimator = ValueAnimator.ofFloat(1.0f, 0.8f, 1.0f) floorAnimator.duration = mAnimDuration.toLong() floorAnimator.interpolator = LinearInterpolator() floorAnimator.addUpdateListener { animation: ValueAnimator -> mFloorScale = animation.animatedValue as Float postInvalidate() } floorAnimator.start() } private fun drawTickDelayed() { postDelayed(mAnimDuration.toLong()) { mTickDrawing = true postInvalidate() } } companion object { private const val DEF_DRAW_SIZE = 25 private const val DEF_ANIM_DURATION = 300 private fun getGradientColor(startColor: Int, endColor: Int, percent: Float): Int { val startA = Color.alpha(startColor) val startR = Color.red(startColor) val startG = Color.green(startColor) val startB = Color.blue(startColor) val endA = Color.alpha(endColor) val endR = Color.red(endColor) val endG = Color.green(endColor) val endB = Color.blue(endColor) val currentA = (startA * (1 - percent) + endA * percent).toInt() val currentR = (startR * (1 - percent) + endR * percent).toInt() val currentG = (startG * (1 - percent) + endG * percent).toInt() val currentB = (startB * (1 - percent) + endB * percent).toInt() return Color.argb(currentA, currentR, currentG, currentB) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/code/CodeView.kt ================================================ package io.legado.app.ui.widget.code import android.content.Context import android.graphics.Canvas import android.graphics.Paint import android.graphics.Paint.FontMetricsInt import android.graphics.Rect import android.os.Handler import android.os.Looper import android.text.* import android.text.style.BackgroundColorSpan import android.text.style.ForegroundColorSpan import android.text.style.ReplacementSpan import android.util.AttributeSet import androidx.annotation.ColorInt import io.legado.app.ui.widget.text.ScrollMultiAutoCompleteTextView import java.util.* import java.util.regex.Matcher import java.util.regex.Pattern import kotlin.math.roundToInt @Suppress("unused") class CodeView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : ScrollMultiAutoCompleteTextView(context, attrs) { private var tabWidth = 0 private var tabWidthInCharacters = 0 private var mUpdateDelayTime = 500 private var modified = true private var highlightWhileTextChanging = true private var hasErrors = false private var mRemoveErrorsWhenTextChanged = true private val mUpdateHandler = Handler(Looper.getMainLooper()) private var mAutoCompleteTokenizer: Tokenizer? = null private val displayDensity = resources.displayMetrics.density private val mErrorHashSet: SortedMap = TreeMap() private val mSyntaxPatternMap: MutableMap = HashMap() private var mIndentCharacterList = mutableListOf('{', '+', '-', '*', '/', '=') private val mUpdateRunnable = Runnable { val source = text highlightWithoutChange(source) } private val mEditorTextWatcher: TextWatcher = object : TextWatcher { private var start = 0 private var count = 0 override fun beforeTextChanged( charSequence: CharSequence, start: Int, before: Int, count: Int ) { this.start = start this.count = count } override fun onTextChanged( charSequence: CharSequence, start: Int, before: Int, count: Int ) { if (!modified) return if (highlightWhileTextChanging) { if (mSyntaxPatternMap.isNotEmpty()) { convertTabs(editableText, start, count) mUpdateHandler.postDelayed(mUpdateRunnable, mUpdateDelayTime.toLong()) } } if (mRemoveErrorsWhenTextChanged) removeAllErrorLines() } override fun afterTextChanged(editable: Editable) { if (!highlightWhileTextChanging) { if (!modified) return cancelHighlighterRender() if (mSyntaxPatternMap.isNotEmpty()) { convertTabs(editableText, start, count) mUpdateHandler.postDelayed(mUpdateRunnable, mUpdateDelayTime.toLong()) } } } } init { if (mAutoCompleteTokenizer == null) { mAutoCompleteTokenizer = KeywordTokenizer() } setTokenizer(mAutoCompleteTokenizer) filters = arrayOf( InputFilter { source, start, end, dest, dStart, dEnd -> if (modified && end - start == 1 && start < source.length && dStart < dest.length) { val c = source[start] if (c == '\n') { return@InputFilter autoIndent(source, dest, dStart, dEnd) } } source } ) addTextChangedListener(mEditorTextWatcher) } override fun showDropDown() { val screenPoint = IntArray(2) getLocationOnScreen(screenPoint) val displayFrame = Rect() getWindowVisibleDisplayFrame(displayFrame) val position = selectionStart val layout = layout val line = layout.getLineForOffset(position) val verticalDistanceInDp = (750 + 140 * line) / displayDensity dropDownVerticalOffset = verticalDistanceInDp.toInt() val horizontalDistanceInDp = layout.getPrimaryHorizontal(position) / displayDensity dropDownHorizontalOffset = horizontalDistanceInDp.toInt() super.showDropDown() } private fun autoIndent( source: CharSequence, dest: Spanned, dStart: Int, dEnd: Int ): CharSequence { var indent = "" var iStart = dStart - 1 var dataBefore = false var pt = 0 while (iStart > -1) { val c = dest[iStart] if (c == '\n') break if (c != ' ' && c != '\t') { if (!dataBefore) { if (mIndentCharacterList.contains(c)) --pt dataBefore = true } if (c == '(') { --pt } else if (c == ')') { ++pt } } --iStart } if (iStart > -1) { val charAtCursor = dest[dStart] var iEnd: Int = ++iStart while (iEnd < dEnd) { val c = dest[iEnd] if (charAtCursor != '\n' && c == '/' && iEnd + 1 < dEnd && dest[iEnd] == c) { iEnd += 2 break } if (c != ' ' && c != '\t') { break } ++iEnd } indent += dest.subSequence(iStart, iEnd) } if (pt < 0) { indent += "\t" } return source.toString() + indent } private fun highlightSyntax(editable: Editable) { if (mSyntaxPatternMap.isEmpty()) return for (pattern in mSyntaxPatternMap.keys) { val color = mSyntaxPatternMap[pattern]!! val m = pattern.matcher(editable) while (m.find()) { createForegroundColorSpan(editable, m, color) } } } private fun highlightErrorLines(editable: Editable) { if (mErrorHashSet.isEmpty()) return val maxErrorLineValue = mErrorHashSet.lastKey() var lineNumber = 0 val matcher = PATTERN_LINE.matcher(editable) while (matcher.find()) { if (mErrorHashSet.containsKey(lineNumber)) { val color = mErrorHashSet[lineNumber]!! createBackgroundColorSpan(editable, matcher, color) } lineNumber += 1 if (lineNumber > maxErrorLineValue) break } } private fun createForegroundColorSpan( editable: Editable, matcher: Matcher, @ColorInt color: Int ) { editable.setSpan( ForegroundColorSpan(color), matcher.start(), matcher.end(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE ) } private fun createBackgroundColorSpan( editable: Editable, matcher: Matcher, @ColorInt color: Int ) { editable.setSpan( BackgroundColorSpan(color), matcher.start(), matcher.end(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE ) } private fun highlight(editable: Editable): Editable { // if (editable.isEmpty() || editable.length > 1024) return editable if (editable.length !in 1..1024) { return editable } try { clearSpans(editable) highlightErrorLines(editable) highlightSyntax(editable) } catch (e: IllegalStateException) { e.printStackTrace() } return editable } private fun highlightWithoutChange(editable: Editable) { modified = false highlight(editable) modified = true } fun setTextHighlighted(text: CharSequence?) { if (text.isNullOrEmpty()) return cancelHighlighterRender() removeAllErrorLines() modified = false setText(highlight(SpannableStringBuilder(text))) modified = true } fun setTabWidth(characters: Int) { if (tabWidthInCharacters == characters) return tabWidthInCharacters = characters tabWidth = (paint.measureText("m") * characters).roundToInt() } private fun clearSpans(editable: Editable) { val length = editable.length val foregroundSpans = editable.getSpans( 0, length, ForegroundColorSpan::class.java ) run { var i = foregroundSpans.size while (i-- > 0) { editable.removeSpan(foregroundSpans[i]) } } val backgroundSpans = editable.getSpans( 0, length, BackgroundColorSpan::class.java ) var i = backgroundSpans.size while (i-- > 0) { editable.removeSpan(backgroundSpans[i]) } } fun cancelHighlighterRender() { mUpdateHandler.removeCallbacks(mUpdateRunnable) } private fun convertTabs(editable: Editable, start: Int, count: Int) { var startIndex = start if (tabWidth < 1) return val s = editable.toString() val stop = startIndex + count while (s.indexOf("\t", startIndex).also { startIndex = it } > -1 && startIndex < stop) { editable.setSpan( TabWidthSpan(), startIndex, startIndex + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE ) ++startIndex } } fun setSyntaxPatternsMap(syntaxPatterns: Map?) { if (mSyntaxPatternMap.isNotEmpty()) mSyntaxPatternMap.clear() mSyntaxPatternMap.putAll(syntaxPatterns!!) } fun addSyntaxPattern(pattern: Pattern, @ColorInt Color: Int) { mSyntaxPatternMap[pattern] = Color } fun removeSyntaxPattern(pattern: Pattern) { mSyntaxPatternMap.remove(pattern) } fun getSyntaxPatternsSize(): Int { return mSyntaxPatternMap.size } fun resetSyntaxPatternList() { mSyntaxPatternMap.clear() } fun setAutoIndentCharacterList(characterList: MutableList) { mIndentCharacterList = characterList } fun clearAutoIndentCharacterList() { mIndentCharacterList.clear() } fun getAutoIndentCharacterList(): List { return mIndentCharacterList } fun addErrorLine(lineNum: Int, color: Int) { mErrorHashSet[lineNum] = color hasErrors = true } fun removeErrorLine(lineNum: Int) { mErrorHashSet.remove(lineNum) hasErrors = mErrorHashSet.size > 0 } fun removeAllErrorLines() { mErrorHashSet.clear() hasErrors = false } fun getErrorsSize(): Int { return mErrorHashSet.size } fun getTextWithoutTrailingSpace(): String { return PATTERN_TRAILING_WHITE_SPACE .matcher(text) .replaceAll("") } fun setAutoCompleteTokenizer(tokenizer: Tokenizer?) { mAutoCompleteTokenizer = tokenizer } fun setRemoveErrorsWhenTextChanged(removeErrors: Boolean) { mRemoveErrorsWhenTextChanged = removeErrors } fun reHighlightSyntax() { highlightSyntax(editableText) } fun reHighlightErrors() { highlightErrorLines(editableText) } fun isHasError(): Boolean { return hasErrors } fun setUpdateDelayTime(time: Int) { mUpdateDelayTime = time } fun getUpdateDelayTime(): Int { return mUpdateDelayTime } fun setHighlightWhileTextChanging(updateWhileTextChanging: Boolean) { highlightWhileTextChanging = updateWhileTextChanging } private inner class TabWidthSpan : ReplacementSpan() { override fun getSize( paint: Paint, text: CharSequence, start: Int, end: Int, fm: FontMetricsInt? ): Int { return tabWidth } override fun draw( canvas: Canvas, text: CharSequence, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint ) { } } companion object { private val PATTERN_LINE = Pattern.compile("(^.+$)+", Pattern.MULTILINE) private val PATTERN_TRAILING_WHITE_SPACE = Pattern.compile("[\\t ]+$", Pattern.MULTILINE) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/code/CodeViewExtensions.kt ================================================ @file:Suppress("unused") package io.legado.app.ui.widget.code import android.content.Context import android.widget.ArrayAdapter import io.legado.app.R import splitties.init.appCtx import splitties.resources.color import java.util.regex.Pattern val legadoPattern: Pattern = Pattern.compile("\\|\\||&&|%%|@js:|@Json:|@css:|@@|@XPath:") val jsonPattern: Pattern = Pattern.compile("\"[A-Za-z0-9]*?\"\\:|\"|\\{|\\}|\\[|\\]") val wrapPattern: Pattern = Pattern.compile("\\\\n") val operationPattern: Pattern = Pattern.compile(":|==|>|<|!=|>=|<=|->|=|%|-|-=|%=|\\+|\\-|\\-=|\\+=|\\^|\\&|\\|::|\\?|\\*") val jsPattern: Pattern = Pattern.compile("var") fun CodeView.addLegadoPattern() { addSyntaxPattern(legadoPattern, appCtx.color(R.color.md_orange_900)) } fun CodeView.addJsonPattern() { addSyntaxPattern(jsonPattern, appCtx.color(R.color.md_blue_800)) } fun CodeView.addJsPattern() { addSyntaxPattern(wrapPattern, appCtx.color(R.color.md_blue_grey_500)) addSyntaxPattern(operationPattern, appCtx.color(R.color.md_orange_900)) addSyntaxPattern(jsPattern, appCtx.color(R.color.md_light_blue_600)) } fun Context.arrayAdapter(keywords: Array): ArrayAdapter { return ArrayAdapter(this, R.layout.item_1line_text_and_del, R.id.text_view, keywords) } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/code/KeywordTokenizer.kt ================================================ package io.legado.app.ui.widget.code import android.widget.MultiAutoCompleteTextView import kotlin.math.max class KeywordTokenizer : MultiAutoCompleteTextView.Tokenizer { override fun findTokenStart(charSequence: CharSequence, cursor: Int): Int { var sequenceStr = charSequence.toString() sequenceStr = sequenceStr.substring(0, cursor) val spaceIndex = sequenceStr.lastIndexOf(" ") val lineIndex = sequenceStr.lastIndexOf("\n") val bracketIndex = sequenceStr.lastIndexOf("(") val index = max(0, max(spaceIndex, max(lineIndex, bracketIndex))) if (index == 0) return 0 return if (index + 1 < charSequence.length) index + 1 else index } override fun findTokenEnd(charSequence: CharSequence, cursor: Int): Int { return charSequence.length } override fun terminateToken(charSequence: CharSequence): CharSequence { return charSequence } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/dialog/CodeDialog.kt ================================================ package io.legado.app.ui.widget.dialog import android.os.Bundle import android.view.View import android.view.ViewGroup import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.databinding.DialogCodeViewBinding import io.legado.app.help.IntentData import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.widget.code.addJsPattern import io.legado.app.ui.widget.code.addJsonPattern import io.legado.app.ui.widget.code.addLegadoPattern import io.legado.app.utils.applyTint import io.legado.app.utils.disableEdit import io.legado.app.utils.setLayout import io.legado.app.utils.viewbindingdelegate.viewBinding class CodeDialog() : BaseDialogFragment(R.layout.dialog_code_view) { constructor(code: String, disableEdit: Boolean = true, requestId: String? = null) : this() { arguments = Bundle().apply { putBoolean("disableEdit", disableEdit) putString("code", IntentData.put(code)) putString("requestId", requestId) } } val binding by viewBinding(DialogCodeViewBinding::bind) override fun onStart() { super.onStart() setLayout(1f, ViewGroup.LayoutParams.MATCH_PARENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) if (arguments?.getBoolean("disableEdit") == true) { binding.toolBar.title = "code view" binding.codeView.disableEdit() } else { initMenu() } binding.codeView.addLegadoPattern() binding.codeView.addJsonPattern() binding.codeView.addJsPattern() arguments?.getString("code")?.let { binding.codeView.text = IntentData.get(it) } } private fun initMenu() { binding.toolBar.inflateMenu(R.menu.code_edit) binding.toolBar.menu.applyTint(requireContext()) binding.toolBar.setOnMenuItemClickListener { when (it.itemId) { R.id.menu_save -> { binding.codeView.text?.toString()?.let { code -> val requestId = arguments?.getString("requestId") (parentFragment as? Callback)?.onCodeSave(code, requestId) ?: (activity as? Callback)?.onCodeSave(code, requestId) } dismiss() } } return@setOnMenuItemClickListener true } } interface Callback { fun onCodeSave(code: String, requestId: String?) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/dialog/PhotoDialog.kt ================================================ package io.legado.app.ui.widget.dialog import android.annotation.SuppressLint import android.os.Bundle import android.view.View import android.view.ViewGroup import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy import com.bumptech.glide.request.RequestOptions import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.databinding.DialogPhotoViewBinding import io.legado.app.help.book.BookHelp import io.legado.app.help.glide.ImageLoader import io.legado.app.help.glide.OkHttpModelLoader import io.legado.app.model.BookCover import io.legado.app.model.ImageProvider import io.legado.app.model.ReadBook import io.legado.app.utils.setLayout import io.legado.app.utils.viewbindingdelegate.viewBinding /** * 显示图片 */ class PhotoDialog() : BaseDialogFragment(R.layout.dialog_photo_view) { constructor(src: String, sourceOrigin: String? = null) : this() { arguments = Bundle().apply { putString("src", src) putString("sourceOrigin", sourceOrigin) } } private val binding by viewBinding(DialogPhotoViewBinding::bind) override fun onStart() { super.onStart() setLayout(1f, ViewGroup.LayoutParams.MATCH_PARENT) } @SuppressLint("CheckResult") override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { val arguments = arguments ?: return val src = arguments.getString("src") ?: return ImageProvider.get(src)?.let { binding.photoView.setImageBitmap(it) return } val file = ReadBook.book?.let { book -> BookHelp.getImage(book, src) } if (file?.exists() == true) { ImageLoader.load(requireContext(), file) .error(R.drawable.image_loading_error) .dontTransform() .downsample(DownsampleStrategy.NONE) .diskCacheStrategy(DiskCacheStrategy.NONE) .into(binding.photoView) } else { ImageLoader.load(requireContext(), src).apply { arguments.getString("sourceOrigin")?.let { sourceOrigin -> apply(RequestOptions().set(OkHttpModelLoader.sourceOriginOption, sourceOrigin)) } }.error(BookCover.defaultDrawable) .dontTransform() .downsample(DownsampleStrategy.NONE) .into(binding.photoView) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/dialog/TextDialog.kt ================================================ package io.legado.app.ui.widget.dialog import android.os.Build import android.os.Bundle import android.view.View import android.view.ViewGroup import android.view.textclassifier.TextClassifier import androidx.lifecycle.lifecycleScope import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.databinding.DialogTextViewBinding import io.legado.app.help.IntentData import io.legado.app.lib.theme.primaryColor import io.legado.app.utils.applyTint import io.legado.app.utils.setHtml import io.legado.app.utils.setLayout import io.legado.app.utils.viewbindingdelegate.viewBinding import io.noties.markwon.Markwon import io.noties.markwon.ext.tables.TablePlugin import io.noties.markwon.html.HtmlPlugin import io.noties.markwon.image.glide.GlideImagesPlugin import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class TextDialog() : BaseDialogFragment(R.layout.dialog_text_view) { enum class Mode { MD, HTML, TEXT } constructor( title: String, content: String?, mode: Mode = Mode.TEXT, time: Long = 0, autoClose: Boolean = false ) : this() { arguments = Bundle().apply { putString("title", title) putString("content", IntentData.put(content)) putString("mode", mode.name) putLong("time", time) } isCancelable = false this.autoClose = autoClose } private val binding by viewBinding(DialogTextViewBinding::bind) private var time = 0L private var autoClose: Boolean = false override fun onStart() { super.onStart() setLayout(ViewGroup.LayoutParams.MATCH_PARENT, 0.9f) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) binding.toolBar.inflateMenu(R.menu.dialog_text) binding.toolBar.menu.applyTint(requireContext()) binding.toolBar.setOnMenuItemClickListener { when (it.itemId) { R.id.menu_close -> dismissAllowingStateLoss() } true } arguments?.let { binding.toolBar.title = it.getString("title") val content = IntentData.get(it.getString("content")) ?: "" when (it.getString("mode")) { Mode.MD.name -> viewLifecycleOwner.lifecycleScope.launch { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { binding.textView.setTextClassifier(TextClassifier.NO_OP) } val markwon: Markwon val markdown = withContext(IO) { markwon = Markwon.builder(requireContext()) .usePlugin(GlideImagesPlugin.create(requireContext())) .usePlugin(HtmlPlugin.create()) .usePlugin(TablePlugin.create(requireContext())) .build() markwon.toMarkdown(content) } markwon.setParsedMarkdown(binding.textView, markdown) } Mode.HTML.name -> binding.textView.setHtml(content) else -> { if (content.length >= 32 * 1024) { val truncatedContent = content.substring(0, 32 * 1024) + "\n\n数据太大,无法全部显示…" binding.textView.text = truncatedContent } else { binding.textView.text = content } } } time = it.getLong("time", 0L) } if (time > 0) { binding.badgeView.setBadgeCount((time / 1000).toInt()) lifecycleScope.launch { while (time > 0) { delay(1000) time -= 1000 binding.badgeView.setBadgeCount((time / 1000).toInt()) if (time <= 0) { view.post { dialog?.setCancelable(true) if (autoClose) dialog?.cancel() } } } } } else { view.post { dialog?.setCancelable(true) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/dialog/TextListDialog.kt ================================================ package io.legado.app.ui.widget.dialog import android.content.Context import android.os.Bundle import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.databinding.DialogRecyclerViewBinding import io.legado.app.databinding.ItemLogBinding import io.legado.app.utils.setLayout import io.legado.app.utils.viewbindingdelegate.viewBinding @Suppress("unused") class TextListDialog() : BaseDialogFragment(R.layout.dialog_recycler_view) { constructor(title: String, values: ArrayList) : this() { arguments = Bundle().apply { putString("title", title) putStringArrayList("values", values) } } private val binding by viewBinding(DialogRecyclerViewBinding::bind) private val adapter by lazy { TextAdapter(requireContext()) } private var values: ArrayList? = null override fun onStart() { super.onStart() setLayout(0.9f, 0.9f) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) = binding.run { arguments?.let { toolBar.title = it.getString("title") values = it.getStringArrayList("values") } recyclerView.layoutManager = LinearLayoutManager(requireContext()) recyclerView.adapter = adapter adapter.setItems(values) } class TextAdapter(context: Context) : RecyclerAdapter(context) { override fun getViewBinding(parent: ViewGroup): ItemLogBinding { return ItemLogBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemLogBinding, item: String, payloads: MutableList ) { binding.apply { if (textView.getTag(R.id.tag1) == null) { val listener = object : View.OnAttachStateChangeListener { override fun onViewAttachedToWindow(v: View) { textView.isCursorVisible = false textView.isCursorVisible = true } override fun onViewDetachedFromWindow(v: View) {} } textView.addOnAttachStateChangeListener(listener) textView.setTag(R.id.tag1, listener) } textView.text = item } } override fun registerListener(holder: ItemViewHolder, binding: ItemLogBinding) { //nothing } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/dialog/UrlOptionDialog.kt ================================================ package io.legado.app.ui.widget.dialog import android.app.Dialog import android.content.Context import android.os.Bundle import android.view.ViewGroup import io.legado.app.R import io.legado.app.constant.AppConst import io.legado.app.databinding.DialogUrlOptionEditBinding import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.utils.GSON import io.legado.app.utils.setLayout class UrlOptionDialog(context: Context, private val success: (String) -> Unit) : Dialog(context) { val binding = DialogUrlOptionEditBinding.inflate(layoutInflater) override fun onStart() { super.onStart() setLayout(1f, ViewGroup.LayoutParams.MATCH_PARENT) window?.setBackgroundDrawableResource(R.color.transparent) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) binding.root.setOnClickListener { dismiss() } binding.vwBg.setOnClickListener(null) binding.editMethod.setFilterValues("POST", "GET") binding.editCharset.setFilterValues(AppConst.charsets) binding.tvOk.setOnClickListener { success.invoke(GSON.toJson(getUrlOption())) dismiss() } } private fun getUrlOption(): AnalyzeUrl.UrlOption { val urlOption = AnalyzeUrl.UrlOption() urlOption.useWebView(binding.cbUseWebView.isChecked) urlOption.setMethod(binding.editMethod.text.toString()) urlOption.setCharset(binding.editCharset.text.toString()) urlOption.setHeaders(binding.editHeaders.text.toString()) urlOption.setBody(binding.editBody.text.toString()) urlOption.setRetry(binding.editRetry.text.toString()) urlOption.setType(binding.editType.text.toString()) urlOption.setWebJs(binding.editWebJs.text.toString()) urlOption.setJs(binding.editJs.text.toString()) return urlOption } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/dialog/VariableDialog.kt ================================================ package io.legado.app.ui.widget.dialog import android.app.Application import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.fragment.app.viewModels import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.BaseViewModel import io.legado.app.databinding.DialogVariableBinding import io.legado.app.lib.theme.primaryColor import io.legado.app.utils.applyTint import io.legado.app.utils.setLayout import io.legado.app.utils.viewbindingdelegate.viewBinding class VariableDialog() : BaseDialogFragment(R.layout.dialog_variable, true), Toolbar.OnMenuItemClickListener { private val binding by viewBinding(DialogVariableBinding::bind) private val viewModel by viewModels() constructor(title: String, key: String, variable: String?, comment: String) : this() { arguments = Bundle().apply { putString("title", title) putString("key", key) putString("variable", variable) putString("comment", comment) } } override fun onStart() { super.onStart() setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) arguments?.let { binding.toolBar.title = it.getString("title") viewModel.init(it) { binding.tvComment.text = viewModel.comment binding.tvVariable.setText(viewModel.variable) } } ?: let { dismiss() return } binding.toolBar.inflateMenu(R.menu.save) binding.toolBar.menu.applyTint(requireContext()) binding.toolBar.setOnMenuItemClickListener(this) } override fun onMenuItemClick(item: MenuItem?): Boolean { when (item?.itemId) { R.id.menu_save -> { callback?.setVariable( viewModel.key ?: "", binding.tvVariable.text?.toString() ) dismissAllowingStateLoss() } } return true } val callback get() = (parentFragment as? Callback) ?: (activity as? Callback) class ViewModel(application: Application) : BaseViewModel(application) { var key: String? = null var comment: String? = null var variable: String? = null fun init(arguments: Bundle, onFinally: () -> Unit) { if (key != null) return execute { key = arguments.getString("key") comment = arguments.getString("comment") variable = arguments.getString("variable") }.onFinally { onFinally.invoke() } } } interface Callback { fun setVariable(key: String, variable: String?) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/dialog/WaitDialog.kt ================================================ package io.legado.app.ui.widget.dialog import android.app.Dialog import android.content.Context import io.legado.app.databinding.DialogWaitBinding @Suppress("unused") class WaitDialog(context: Context) : Dialog(context) { val binding = DialogWaitBinding.inflate(layoutInflater) init { setCanceledOnTouchOutside(false) setContentView(binding.root) } fun setText(text: String): WaitDialog { binding.tvMsg.text = text return this } fun setText(res: Int): WaitDialog { binding.tvMsg.setText(res) return this } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/dynamiclayout/DynamicFrameLayout.kt ================================================ package io.legado.app.ui.widget.dynamiclayout import android.content.Context import android.graphics.drawable.Drawable import android.util.AttributeSet import android.view.View import android.view.ViewStub import android.widget.FrameLayout import android.widget.ProgressBar import androidx.appcompat.widget.AppCompatButton import androidx.appcompat.widget.AppCompatImageView import androidx.appcompat.widget.AppCompatTextView import io.legado.app.R @Suppress("unused") class DynamicFrameLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : FrameLayout(context, attrs), ViewSwitcher { private var errorView: View? = null private var errorImage: AppCompatImageView? = null private var errorTextView: AppCompatTextView? = null private var actionBtn: AppCompatButton? = null private var progressView: View? = null private var progressBar: ProgressBar? = null private var contentView: View? = null private var errorIcon: Drawable? = null private var emptyIcon: Drawable? = null private var errorActionDescription: CharSequence? = null private var emptyActionDescription: CharSequence? = null private var emptyDescription: CharSequence? = null private var errorAction: Action? = null private var emptyAction: Action? = null private var changeListener: OnVisibilityChangeListener? = null init { View.inflate(context, R.layout.view_dynamic, this) val a = context.obtainStyledAttributes(attrs, R.styleable.DynamicFrameLayout) errorIcon = a.getDrawable(R.styleable.DynamicFrameLayout_errorSrc) emptyIcon = a.getDrawable(R.styleable.DynamicFrameLayout_emptySrc) emptyActionDescription = a.getText(R.styleable.DynamicFrameLayout_emptyActionDescription) emptyDescription = a.getText(R.styleable.DynamicFrameLayout_emptyDescription) errorActionDescription = a.getText(R.styleable.DynamicFrameLayout_errorActionDescription) if (errorActionDescription == null) { errorActionDescription = context.getString(R.string.dynamic_click_retry) } a.recycle() } override fun onFinishInflate() { super.onFinishInflate() if (childCount > 2) { contentView = getChildAt(2) } } override fun showErrorView(message: CharSequence) { ensureErrorView() setViewVisible(errorView, true) setViewVisible(contentView, false) setViewVisible(progressView, false) errorTextView?.text = message errorImage?.setImageDrawable(errorIcon) actionBtn?.let { it.tag = ACTION_WHEN_ERROR it.visibility = View.VISIBLE if (errorActionDescription != null) { it.text = errorActionDescription } } dispatchVisibilityChanged(ViewSwitcher.SHOW_ERROR_VIEW) } override fun showErrorView(messageId: Int) { showErrorView(resources.getText(messageId)) } override fun showEmptyView() { ensureErrorView() setViewVisible(errorView, true) setViewVisible(contentView, false) setViewVisible(progressView, false) errorTextView?.text = emptyDescription errorImage?.setImageDrawable(emptyIcon) actionBtn?.let { it.tag = ACTION_WHEN_EMPTY if (errorActionDescription != null) { it.visibility = View.VISIBLE it.text = errorActionDescription } else { it.visibility = View.INVISIBLE } } dispatchVisibilityChanged(ViewSwitcher.SHOW_EMPTY_VIEW) } override fun showProgressView() { ensureProgressView() setViewVisible(errorView, false) setViewVisible(contentView, false) setViewVisible(progressView, true) dispatchVisibilityChanged(ViewSwitcher.SHOW_PROGRESS_VIEW) } override fun showContentView() { setViewVisible(errorView, false) setViewVisible(contentView, true) setViewVisible(progressView, false) dispatchVisibilityChanged(ViewSwitcher.SHOW_CONTENT_VIEW) } fun setOnVisibilityChangeListener(listener: OnVisibilityChangeListener) { changeListener = listener } fun setErrorAction(action: Action) { errorAction = action } fun setEmptyAction(action: Action) { emptyAction = action } private fun setViewVisible(view: View?, visible: Boolean) { view?.let { it.visibility = if (visible) View.VISIBLE else View.INVISIBLE } } private fun ensureErrorView() { if (errorView == null) { errorView = findViewById(R.id.error_view_stub).inflate() errorImage = errorView?.findViewById(R.id.iv_error_image) errorTextView = errorView?.findViewById(R.id.tv_error_message) actionBtn = errorView?.findViewById(R.id.btn_error_retry) actionBtn?.setOnClickListener { when (it.tag) { ACTION_WHEN_EMPTY -> emptyAction?.onAction(this@DynamicFrameLayout) ACTION_WHEN_ERROR -> errorAction?.onAction(this@DynamicFrameLayout) } } } } private fun ensureProgressView() { if (progressView == null) { progressView = findViewById(R.id.progress_view_stub).inflate() progressBar = progressView?.findViewById(R.id.loading_progress) } } private fun dispatchVisibilityChanged(@ViewSwitcher.Visibility visibility: Int) { changeListener?.onVisibilityChanged(visibility) } interface Action { fun onAction(switcher: ViewSwitcher) } interface OnVisibilityChangeListener { fun onVisibilityChanged(@ViewSwitcher.Visibility visibility: Int) } companion object { private const val ACTION_WHEN_ERROR = "ACTION_WHEN_ERROR" private const val ACTION_WHEN_EMPTY = "ACTION_WHEN_EMPTY" } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/dynamiclayout/ViewSwitcher.kt ================================================ package io.legado.app.ui.widget.dynamiclayout import androidx.annotation.IntDef import androidx.annotation.StringRes interface ViewSwitcher { companion object { const val SHOW_CONTENT_VIEW = 0 const val SHOW_ERROR_VIEW = 1 const val SHOW_EMPTY_VIEW = 2 const val SHOW_PROGRESS_VIEW = 3 } @Retention(AnnotationRetention.SOURCE) @IntDef(SHOW_CONTENT_VIEW, SHOW_ERROR_VIEW, SHOW_EMPTY_VIEW, SHOW_PROGRESS_VIEW) annotation class Visibility fun showErrorView(message: CharSequence) fun showErrorView(@StringRes messageId: Int) fun showEmptyView() fun showProgressView() fun showContentView() } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/image/ArcView.kt ================================================ package io.legado.app.ui.widget.image import android.content.Context import android.graphics.* import android.util.AttributeSet import android.view.View import io.legado.app.R /** * 弧形View */ class ArcView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : View(context, attrs) { private var mWidth = 0 private var mHeight = 0 //弧形高度 private val mArcHeight: Int //背景颜色 private var mBgColor: Int private val mPaint: Paint = Paint().apply { isAntiAlias = true } private val mDirectionTop: Boolean val rect = Rect() val path = Path() init { val typedArray = context.obtainStyledAttributes(attrs, R.styleable.ArcView) mArcHeight = typedArray.getDimensionPixelSize(R.styleable.ArcView_arcHeight, 0) mBgColor = typedArray.getColor( R.styleable.ArcView_bgColor, Color.parseColor("#303F9F") ) mDirectionTop = typedArray.getBoolean(R.styleable.ArcView_arcDirectionTop, false) typedArray.recycle() } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) mPaint.style = Paint.Style.FILL mPaint.color = mBgColor if (mDirectionTop) { rect.set(0, mArcHeight, mWidth, mHeight) canvas.drawRect(rect, mPaint) path.reset() path.moveTo(0f, mArcHeight.toFloat()) path.quadTo(mWidth / 2.toFloat(), 0f, mWidth.toFloat(), mArcHeight.toFloat()) canvas.drawPath(path, mPaint) } else { rect.set(0, 0, mWidth, mHeight - mArcHeight) canvas.drawRect(rect, mPaint) path.reset() path.moveTo(0f, mHeight - mArcHeight.toFloat()) path.quadTo( mWidth / 2.toFloat(), mHeight.toFloat(), mWidth.toFloat(), mHeight - mArcHeight.toFloat() ) canvas.drawPath(path, mPaint) } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) val widthSize = MeasureSpec.getSize(widthMeasureSpec) val widthMode = MeasureSpec.getMode(widthMeasureSpec) val heightSize = MeasureSpec.getSize(heightMeasureSpec) val heightMode = MeasureSpec.getMode(heightMeasureSpec) if (widthMode == MeasureSpec.EXACTLY) { mWidth = widthSize } if (heightMode == MeasureSpec.EXACTLY) { mHeight = heightSize } setMeasuredDimension(mWidth, mHeight) } fun setBgColor(color: Int) { mBgColor = color invalidate() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/image/CircleImageView.kt ================================================ package io.legado.app.ui.widget.image import android.annotation.SuppressLint import android.content.Context import android.graphics.* import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.net.Uri import android.text.TextPaint import android.util.AttributeSet import android.view.MotionEvent import android.view.View import android.view.ViewOutlineProvider import androidx.annotation.ColorInt import androidx.annotation.ColorRes import androidx.annotation.DrawableRes import androidx.appcompat.widget.AppCompatImageView import io.legado.app.R import io.legado.app.utils.getCompatColor import io.legado.app.utils.printOnDebug import io.legado.app.utils.spToPx import kotlin.math.min import kotlin.math.pow @Suppress("unused", "MemberVisibilityCanBePrivate") class CircleImageView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : AppCompatImageView(context, attrs) { private val mDrawableRect = RectF() private val mBorderRect = RectF() private val mShaderMatrix = Matrix() private val mBitmapPaint = Paint() private val mBorderPaint = Paint() private val mCircleBackgroundPaint = Paint() private val textPaint by lazy { val textPaint = TextPaint() textPaint.isAntiAlias = true textPaint.textAlign = Paint.Align.CENTER textPaint } private var mBorderColor = DEFAULT_BORDER_COLOR private var mBorderWidth = DEFAULT_BORDER_WIDTH private var mCircleBackgroundColor = DEFAULT_CIRCLE_BACKGROUND_COLOR private var mBitmap: Bitmap? = null private var mBitmapShader: BitmapShader? = null private var mBitmapWidth: Int = 0 private var mBitmapHeight: Int = 0 private var mDrawableRadius: Float = 0.toFloat() private var mBorderRadius: Float = 0.toFloat() private var mColorFilter: ColorFilter? = null private var mReady: Boolean = false private var mSetupPending: Boolean = false private var mBorderOverlay: Boolean = false var isDisableCircularTransformation: Boolean = false set(disableCircularTransformation) { if (field == disableCircularTransformation) { return } field = disableCircularTransformation initializeBitmap() } var borderColor: Int get() = mBorderColor set(@ColorInt borderColor) { if (borderColor == mBorderColor) { return } mBorderColor = borderColor mBorderPaint.color = mBorderColor invalidate() } var circleBackgroundColor: Int get() = mCircleBackgroundColor set(@ColorInt circleBackgroundColor) { if (circleBackgroundColor == mCircleBackgroundColor) { return } mCircleBackgroundColor = circleBackgroundColor mCircleBackgroundPaint.color = circleBackgroundColor invalidate() } var borderWidth: Int get() = mBorderWidth set(borderWidth) { if (borderWidth == mBorderWidth) { return } mBorderWidth = borderWidth setup() } var isBorderOverlay: Boolean get() = mBorderOverlay set(borderOverlay) { if (borderOverlay == mBorderOverlay) { return } mBorderOverlay = borderOverlay setup() } private var text: String? = null private var textColor = context.getCompatColor(R.color.primaryText) private var textBold = false var isInView = false init { val a = context.obtainStyledAttributes(attrs, R.styleable.CircleImageView) mBorderWidth = a.getDimensionPixelSize( R.styleable.CircleImageView_civ_border_width, DEFAULT_BORDER_WIDTH ) mBorderColor = a.getColor(R.styleable.CircleImageView_civ_border_color, DEFAULT_BORDER_COLOR) mBorderOverlay = a.getBoolean(R.styleable.CircleImageView_civ_border_overlay, DEFAULT_BORDER_OVERLAY) mCircleBackgroundColor = a.getColor( R.styleable.CircleImageView_civ_circle_background_color, DEFAULT_CIRCLE_BACKGROUND_COLOR ) text = a.getString(R.styleable.CircleImageView_text) contentDescription = text if (a.hasValue(R.styleable.CircleImageView_textColor)) { textColor = a.getColor( R.styleable.CircleImageView_textColor, context.getCompatColor(R.color.primaryText) ) } a.recycle() mReady = true if (mSetupPending) { setup() mSetupPending = false } } override fun setAdjustViewBounds(adjustViewBounds: Boolean) { if (adjustViewBounds) { throw IllegalArgumentException("adjustViewBounds not supported.") } } override fun onDraw(canvas: Canvas) { if (isDisableCircularTransformation) { super.onDraw(canvas) return } if (mBitmap == null) { return } if (mCircleBackgroundColor != Color.TRANSPARENT) { canvas.drawCircle( mDrawableRect.centerX(), mDrawableRect.centerY(), mDrawableRadius, mCircleBackgroundPaint ) } canvas.drawCircle( mDrawableRect.centerX(), mDrawableRect.centerY(), mDrawableRadius, mBitmapPaint ) if (mBorderWidth > 0) { canvas.drawCircle( mBorderRect.centerX(), mBorderRect.centerY(), mBorderRadius, mBorderPaint ) } drawText(canvas) } private fun drawText(canvas: Canvas) { text?.let { textPaint.color = textColor textPaint.isFakeBoldText = textBold textPaint.textSize = 15f.spToPx() val fm = textPaint.fontMetrics canvas.drawText( it, width * 0.5f, (height * 0.5f + (fm.bottom - fm.top) * 0.5f - fm.bottom), textPaint ) } } fun setText(text: String?) { this.text = text contentDescription = text invalidate() } fun setTextColor(@ColorInt textColor: Int) { this.textColor = textColor invalidate() } fun setTextBold(bold: Boolean) { this.textBold = bold invalidate() } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) setup() } override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) { super.setPadding(left, top, right, bottom) setup() } override fun setPaddingRelative(start: Int, top: Int, end: Int, bottom: Int) { super.setPaddingRelative(start, top, end, bottom) setup() } fun setCircleBackgroundColorResource(@ColorRes circleBackgroundRes: Int) { circleBackgroundColor = context.getCompatColor(circleBackgroundRes) } override fun setImageBitmap(bm: Bitmap) { super.setImageBitmap(bm) initializeBitmap() } override fun setImageDrawable(drawable: Drawable?) { super.setImageDrawable(drawable) initializeBitmap() } override fun setImageResource(@DrawableRes resId: Int) { super.setImageResource(resId) initializeBitmap() } override fun setImageURI(uri: Uri?) { super.setImageURI(uri) initializeBitmap() } override fun setColorFilter(cf: ColorFilter) { if (cf === mColorFilter) { return } mColorFilter = cf applyColorFilter() invalidate() } override fun getColorFilter(): ColorFilter? { return mColorFilter } private fun applyColorFilter() { mBitmapPaint.colorFilter = mColorFilter } private fun getBitmapFromDrawable(drawable: Drawable?): Bitmap? { if (drawable == null) { return null } if (drawable is BitmapDrawable) { return drawable.bitmap } return try { val bitmap: Bitmap = if (drawable is ColorDrawable) { Bitmap.createBitmap( COLOR_DRAWABLE_DIMENSION, COLOR_DRAWABLE_DIMENSION, BITMAP_CONFIG ) } else { Bitmap.createBitmap( drawable.intrinsicWidth, drawable.intrinsicHeight, BITMAP_CONFIG ) } val canvas = Canvas(bitmap) drawable.setBounds(0, 0, canvas.width, canvas.height) drawable.draw(canvas) bitmap } catch (e: Exception) { e.printOnDebug() null } } private fun initializeBitmap() { mBitmap = if (isDisableCircularTransformation) { null } else { getBitmapFromDrawable(drawable) } setup() } private fun setup() { if (!mReady) { mSetupPending = true return } if (width == 0 && height == 0) { return } if (mBitmap == null) { invalidate() return } mBitmapShader = BitmapShader(mBitmap!!, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) mBitmapPaint.isAntiAlias = true mBitmapPaint.shader = mBitmapShader mBorderPaint.style = Paint.Style.STROKE mBorderPaint.isAntiAlias = true mBorderPaint.color = mBorderColor mBorderPaint.strokeWidth = mBorderWidth.toFloat() mCircleBackgroundPaint.style = Paint.Style.FILL mCircleBackgroundPaint.isAntiAlias = true mCircleBackgroundPaint.color = mCircleBackgroundColor mBitmapHeight = mBitmap!!.height mBitmapWidth = mBitmap!!.width mBorderRect.set(calculateBounds()) mBorderRadius = min( (mBorderRect.height() - mBorderWidth) / 2.0f, (mBorderRect.width() - mBorderWidth) / 2.0f ) mDrawableRect.set(mBorderRect) if (!mBorderOverlay && mBorderWidth > 0) { mDrawableRect.inset(mBorderWidth - 1.0f, mBorderWidth - 1.0f) } mDrawableRadius = min(mDrawableRect.height() / 2.0f, mDrawableRect.width() / 2.0f) applyColorFilter() updateShaderMatrix() invalidate() } private fun calculateBounds(): RectF { val availableWidth = width - paddingLeft - paddingRight val availableHeight = height - paddingTop - paddingBottom val sideLength = min(availableWidth, availableHeight) val left = paddingLeft + (availableWidth - sideLength) / 2f val top = paddingTop + (availableHeight - sideLength) / 2f return RectF(left, top, left + sideLength, top + sideLength) } private fun updateShaderMatrix() { val scale: Float var dx = 0f var dy = 0f mShaderMatrix.set(null) if (mBitmapWidth * mDrawableRect.height() > mDrawableRect.width() * mBitmapHeight) { scale = mDrawableRect.height() / mBitmapHeight.toFloat() dx = (mDrawableRect.width() - mBitmapWidth * scale) * 0.5f } else { scale = mDrawableRect.width() / mBitmapWidth.toFloat() dy = (mDrawableRect.height() - mBitmapHeight * scale) * 0.5f } mShaderMatrix.setScale(scale, scale) mShaderMatrix.postTranslate( (dx + 0.5f).toInt() + mDrawableRect.left, (dy + 0.5f).toInt() + mDrawableRect.top ) mBitmapShader!!.setLocalMatrix(mShaderMatrix) } @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { when (event.action) { MotionEvent.ACTION_DOWN -> { isInView = (inTouchableArea(event.x, event.y)) } } return super.onTouchEvent(event) } private fun inTouchableArea(x: Float, y: Float): Boolean { return (x - mBorderRect.centerX()).toDouble() .pow(2.0) + (y - mBorderRect.centerY()).toDouble() .pow(2.0) <= mBorderRadius.toDouble().pow(2.0) } private inner class OutlineProvider : ViewOutlineProvider() { override fun getOutline(view: View, outline: Outline) { val bounds = Rect() mBorderRect.roundOut(bounds) outline.setRoundRect(bounds, bounds.width() / 2.0f) } } companion object { private val BITMAP_CONFIG = Bitmap.Config.ARGB_8888 private const val COLOR_DRAWABLE_DIMENSION = 2 private const val DEFAULT_BORDER_WIDTH = 0 private const val DEFAULT_BORDER_COLOR = Color.BLACK private const val DEFAULT_CIRCLE_BACKGROUND_COLOR = Color.TRANSPARENT private const val DEFAULT_BORDER_OVERLAY = false } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/image/CoverImageView.kt ================================================ package io.legado.app.ui.widget.image import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Path import android.graphics.Typeface import android.graphics.drawable.Drawable import android.text.TextPaint import android.util.AttributeSet import android.view.ViewGroup import androidx.appcompat.widget.AppCompatImageView import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.target.Target import io.legado.app.constant.AppPattern import io.legado.app.help.config.AppConfig import io.legado.app.help.glide.ImageLoader import io.legado.app.help.glide.OkHttpModelLoader import io.legado.app.lib.theme.accentColor import io.legado.app.model.BookCover import io.legado.app.utils.textHeight import io.legado.app.utils.toStringArray /** * 封面 */ @Suppress("unused") class CoverImageView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : AppCompatImageView(context, attrs) { private var filletPath = Path() private var viewWidth: Float = 0f private var viewHeight: Float = 0f private var defaultCover = true var bitmapPath: String? = null private set private var name: String? = null private var author: String? = null private var nameHeight = 0f private var authorHeight = 0f private val namePaint by lazy { val textPaint = TextPaint() textPaint.typeface = Typeface.DEFAULT_BOLD textPaint.isAntiAlias = true textPaint.textAlign = Paint.Align.CENTER textPaint } private val authorPaint by lazy { val textPaint = TextPaint() textPaint.typeface = Typeface.DEFAULT textPaint.isAntiAlias = true textPaint.textAlign = Paint.Align.CENTER textPaint } override fun setLayoutParams(params: ViewGroup.LayoutParams?) { if (params != null) { val width = params.width if (width >= 0) { params.height = width * 7 / 5 } else { params.height = ViewGroup.LayoutParams.WRAP_CONTENT } } super.setLayoutParams(params) } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val measuredWidth = MeasureSpec.getSize(widthMeasureSpec) val measuredHeight = measuredWidth * 7 / 5 super.onMeasure( widthMeasureSpec, MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.EXACTLY) ) } override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { super.onLayout(changed, left, top, right, bottom) viewWidth = width.toFloat() viewHeight = height.toFloat() filletPath.reset() if (width > 10 && viewHeight > 10) { filletPath.apply { moveTo(10f, 0f) lineTo(viewWidth - 10, 0f) quadTo(viewWidth, 0f, viewWidth, 10f) lineTo(viewWidth, viewHeight - 10) quadTo(viewWidth, viewHeight, viewWidth - 10, viewHeight) lineTo(10f, viewHeight) quadTo(0f, viewHeight, 0f, viewHeight - 10) lineTo(0f, 10f) quadTo(0f, 0f, 10f, 0f) close() } } } override fun onDraw(canvas: Canvas) { if (!filletPath.isEmpty) { canvas.clipPath(filletPath) } super.onDraw(canvas) if (defaultCover && !isInEditMode) { drawNameAuthor(canvas) } } private fun drawNameAuthor(canvas: Canvas) { if (!BookCover.drawBookName) return var startX = width * 0.2f var startY = viewHeight * 0.2f name?.toStringArray()?.let { name -> namePaint.textSize = viewWidth / 6 namePaint.strokeWidth = namePaint.textSize / 5 name.forEachIndexed { index, char -> namePaint.color = Color.WHITE namePaint.style = Paint.Style.STROKE canvas.drawText(char, startX, startY, namePaint) namePaint.color = context.accentColor namePaint.style = Paint.Style.FILL canvas.drawText(char, startX, startY, namePaint) startY += namePaint.textHeight if (startY > viewHeight * 0.8) { startX += namePaint.textSize namePaint.textSize = viewWidth / 10 startY = (viewHeight - (name.size - index - 1) * namePaint.textHeight) / 2 } } } if (!BookCover.drawBookAuthor) return author?.toStringArray()?.let { author -> authorPaint.textSize = viewWidth / 10 authorPaint.strokeWidth = authorPaint.textSize / 5 startX = width * 0.8f startY = viewHeight * 0.95f - author.size * authorPaint.textHeight startY = maxOf(startY, viewHeight * 0.3f) author.forEach { authorPaint.color = Color.WHITE authorPaint.style = Paint.Style.STROKE canvas.drawText(it, startX, startY, authorPaint) authorPaint.color = context.accentColor authorPaint.style = Paint.Style.FILL canvas.drawText(it, startX, startY, authorPaint) startY += authorPaint.textHeight if (startY > viewHeight * 0.95) { return@let } } } } fun setHeight(height: Int) { val width = height * 5 / 7 minimumWidth = width } private val glideListener by lazy { object : RequestListener { override fun onLoadFailed( e: GlideException?, model: Any?, target: Target, isFirstResource: Boolean ): Boolean { defaultCover = true return false } override fun onResourceReady( resource: Drawable, model: Any, target: Target?, dataSource: DataSource, isFirstResource: Boolean ): Boolean { defaultCover = false return false } } } fun load( path: String? = null, name: String? = null, author: String? = null, loadOnlyWifi: Boolean = false, sourceOrigin: String? = null, fragment: Fragment? = null, lifecycle: Lifecycle? = null, onLoadFinish: (() -> Unit)? = null ) { this.bitmapPath = path this.name = name?.replace(AppPattern.bdRegex, "")?.trim() this.author = author?.replace(AppPattern.bdRegex, "")?.trim() defaultCover = true invalidate() if (AppConfig.useDefaultCover) { ImageLoader.load(context, BookCover.defaultDrawable) .centerCrop() .into(this) } else { var options = RequestOptions().set(OkHttpModelLoader.loadOnlyWifiOption, loadOnlyWifi) if (sourceOrigin != null) { options = options.set(OkHttpModelLoader.sourceOriginOption, sourceOrigin) } var builder = if (fragment != null && lifecycle != null) { ImageLoader.load(fragment, lifecycle, path) } else { ImageLoader.load(context, path)//Glide自动识别http://,content://和file:// } builder = builder.apply(options) .placeholder(BookCover.defaultDrawable) .error(BookCover.defaultDrawable) .listener(glideListener) if (onLoadFinish != null) { builder = builder.addListener(object : RequestListener { override fun onLoadFailed( e: GlideException?, model: Any?, target: Target, isFirstResource: Boolean ): Boolean { onLoadFinish.invoke() return false } override fun onResourceReady( resource: Drawable, model: Any, target: Target?, dataSource: DataSource, isFirstResource: Boolean ): Boolean { onLoadFinish.invoke() return false } }) } builder .centerCrop() .into(this) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/image/FilletImageView.kt ================================================ package io.legado.app.ui.widget.image import android.annotation.SuppressLint import android.content.Context import android.graphics.Canvas import android.graphics.Path import android.util.AttributeSet import androidx.appcompat.widget.AppCompatImageView import io.legado.app.R import io.legado.app.utils.dpToPx import kotlin.math.max class FilletImageView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : AppCompatImageView(context, attrs) { internal var width: Float = 0.toFloat() internal var height: Float = 0.toFloat() private var leftTopRadius: Int = 0 private var rightTopRadius: Int = 0 private var rightBottomRadius: Int = 0 private var leftBottomRadius: Int = 0 init { // 读取配置 val array = context.obtainStyledAttributes(attrs, R.styleable.FilletImageView) val defaultRadius = 5.dpToPx() val radius = array.getDimensionPixelOffset(R.styleable.FilletImageView_radius, defaultRadius) leftTopRadius = array.getDimensionPixelOffset( R.styleable.FilletImageView_left_top_radius, defaultRadius ) rightTopRadius = array.getDimensionPixelOffset( R.styleable.FilletImageView_right_top_radius, defaultRadius ) rightBottomRadius = array.getDimensionPixelOffset( R.styleable.FilletImageView_right_bottom_radius, defaultRadius ) leftBottomRadius = array.getDimensionPixelOffset( R.styleable.FilletImageView_left_bottom_radius, defaultRadius ) //如果四个角的值没有设置,那么就使用通用的radius的值。 if (defaultRadius == leftTopRadius) { leftTopRadius = radius } if (defaultRadius == rightTopRadius) { rightTopRadius = radius } if (defaultRadius == rightBottomRadius) { rightBottomRadius = radius } if (defaultRadius == leftBottomRadius) { leftBottomRadius = radius } array.recycle() } override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { super.onLayout(changed, left, top, right, bottom) width = getWidth().toFloat() height = getHeight().toFloat() } override fun onDraw(canvas: Canvas) { //这里做下判断,只有图片的宽高大于设置的圆角距离的时候才进行裁剪 val maxLeft = max(leftTopRadius, leftBottomRadius) val maxRight = max(rightTopRadius, rightBottomRadius) val minWidth = maxLeft + maxRight val maxTop = max(leftTopRadius, rightTopRadius) val maxBottom = max(leftBottomRadius, rightBottomRadius) val minHeight = maxTop + maxBottom if (width >= minWidth && height > minHeight) { @SuppressLint("DrawAllocation") val path = Path() //四个角:右上,右下,左下,左上 path.moveTo(leftTopRadius.toFloat(), 0f) path.lineTo(width - rightTopRadius, 0f) path.quadTo(width, 0f, width, rightTopRadius.toFloat()) path.lineTo(width, height - rightBottomRadius) path.quadTo(width, height, width - rightBottomRadius, height) path.lineTo(leftBottomRadius.toFloat(), height) path.quadTo(0f, height, 0f, height - leftBottomRadius) path.lineTo(0f, leftTopRadius.toFloat()) path.quadTo(0f, 0f, leftTopRadius.toFloat(), 0f) canvas.clipPath(path) } super.onDraw(canvas) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/image/ImageButton.kt ================================================ package io.legado.app.ui.widget.image import android.content.Context import android.util.AttributeSet import androidx.appcompat.widget.AppCompatImageView class ImageButton @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : AppCompatImageView(context, attrs) { override fun setEnabled(enabled: Boolean) { if (isEnabled != enabled) { imageAlpha = if (enabled) 0xFF else 0x3F } super.setEnabled(enabled) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/image/PhotoView.kt ================================================ package io.legado.app.ui.widget.image import android.annotation.SuppressLint import android.content.Context import android.graphics.Canvas import android.graphics.Matrix import android.graphics.PointF import android.graphics.RectF import android.graphics.drawable.Drawable import android.util.AttributeSet import android.view.GestureDetector import android.view.GestureDetector.SimpleOnGestureListener import android.view.MotionEvent import android.view.ScaleGestureDetector import android.view.ScaleGestureDetector.OnScaleGestureListener import android.view.View import android.view.ViewGroup import android.view.ViewParent import android.view.animation.DecelerateInterpolator import android.view.animation.Interpolator import android.widget.ImageView import android.widget.OverScroller import android.widget.Scroller import androidx.appcompat.widget.AppCompatImageView import io.legado.app.ui.widget.image.photo.Info import io.legado.app.ui.widget.image.photo.OnRotateListener import io.legado.app.ui.widget.image.photo.RotateGestureDetector import kotlin.math.abs import kotlin.math.roundToInt @Suppress("UNUSED_PARAMETER", "unused", "MemberVisibilityCanBePrivate", "PropertyName") class PhotoView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : AppCompatImageView(context, attrs) { val MIN_ROTATE = 35 val ANIMA_DURING = 340 val MAX_SCALE = 2.5f private var mMinRotate = 0 var mAnimaDuring = 0 private var mMaxScale = 0f var MAX_OVER_SCROLL = 0 var MAX_FLING_OVER_SCROLL = 0 var MAX_OVER_RESISTANCE = 0 var MAX_ANIM_FROM_WAITE = 500 private val mBaseMatrix: Matrix = Matrix() private val mAnimMatrix: Matrix = Matrix() private val mSynthesisMatrix: Matrix = Matrix() private val mTmpMatrix: Matrix = Matrix() private val mRotateDetector: RotateGestureDetector private val mDetector: GestureDetector private val mScaleDetector: ScaleGestureDetector private var mClickListener: OnClickListener? = null private var mScaleType: ScaleType? = null private var hasMultiTouch = false private var hasDrawable = false private var isKnowSize = false private var hasOverTranslate = false //缩放 var isEnable = true //旋转 var isRotateEnable = false private var isInit = false private var mAdjustViewBounds = false // 当前是否处于放大状态 private var isZoonUp = false private var canRotate = false private var imgLargeWidth = false private var imgLargeHeight = false private var mRotateFlag = 0f private var mDegrees = 0f private var mScale = 1.0f private var mTranslateX = 0 private var mTranslateY = 0 private var mHalfBaseRectWidth = 0f private var mHalfBaseRectHeight = 0f private val mWidgetRect = RectF() private val mBaseRect = RectF() private val mImgRect = RectF() private val mTmpRect = RectF() private val mCommonRect = RectF() private val mScreenCenter = PointF() private val mScaleCenter = PointF() private val mRotateCenter = PointF() private val mTranslate: Transform = Transform() private var mClip: RectF? = null private var mFromInfo: Info? = null private var mInfoTime: Long = 0 private var mCompleteCallBack: Runnable? = null private var mLongClick: OnLongClickListener? = null private val mRotateListener = RotateListener() private val mGestureListener = GestureListener() private val mScaleListener = ScaleGestureListener() init { super.setScaleType(ScaleType.MATRIX) if (mScaleType == null) mScaleType = ScaleType.CENTER_INSIDE mRotateDetector = RotateGestureDetector(mRotateListener) mDetector = GestureDetector(context, mGestureListener) mScaleDetector = ScaleGestureDetector(context, mScaleListener) val density = resources.displayMetrics.density MAX_OVER_SCROLL = (density * 30).toInt() MAX_FLING_OVER_SCROLL = (density * 30).toInt() MAX_OVER_RESISTANCE = (density * 140).toInt() mMinRotate = MIN_ROTATE mAnimaDuring = ANIMA_DURING mMaxScale = MAX_SCALE } /** * 获取默认的动画持续时间 */ fun getDefaultAnimDuring(): Int { return ANIMA_DURING } override fun setOnClickListener(l: OnClickListener?) { super.setOnClickListener(l) mClickListener = l } override fun setScaleType(scaleType: ScaleType) { if (scaleType == ScaleType.MATRIX) return if (scaleType != mScaleType) { mScaleType = scaleType if (isInit) { initBase() } } } override fun setOnLongClickListener(l: OnLongClickListener?) { mLongClick = l } /** * 设置动画的插入器 */ fun setInterpolator(interpolator: Interpolator?) { mTranslate.setInterpolator(interpolator) } /** * 获取动画持续时间 */ fun getAnimDuring(): Int { return mAnimaDuring } /** * 设置动画的持续时间 */ fun setAnimDuring(during: Int) { mAnimaDuring = during } /** * 设置最大可以缩放的倍数 */ fun setMaxScale(maxScale: Float) { mMaxScale = maxScale } /** * 获取最大可以缩放的倍数 */ fun getMaxScale(): Float { return mMaxScale } /** */ fun setMaxAnimFromWaiteTime(wait: Int) { MAX_ANIM_FROM_WAITE = wait } @SuppressLint("UseCompatLoadingForDrawables") override fun setImageResource(resId: Int) { val drawable: Drawable? = kotlin.runCatching { resources.getDrawable(resId, null) }.getOrNull() setImageDrawable(drawable) } override fun setImageDrawable(drawable: Drawable?) { super.setImageDrawable(drawable) if (drawable == null) { hasDrawable = false return } if (!hasSize(drawable)) return if (!hasDrawable) { hasDrawable = true } initBase() } private fun hasSize(d: Drawable): Boolean { return !((d.intrinsicHeight <= 0 || d.intrinsicWidth <= 0) && (d.minimumWidth <= 0 || d.minimumHeight <= 0) && (d.bounds.width() <= 0 || d.bounds.height() <= 0)) } private fun getDrawableWidth(d: Drawable): Int { var width = d.intrinsicWidth if (width <= 0) width = d.minimumWidth if (width <= 0) width = d.bounds.width() return width } private fun getDrawableHeight(d: Drawable): Int { var height = d.intrinsicHeight if (height <= 0) height = d.minimumHeight if (height <= 0) height = d.bounds.height() return height } private fun initBase() { if (!hasDrawable) return if (!isKnowSize) return mBaseMatrix.reset() mAnimMatrix.reset() isZoonUp = false val img = drawable val w = width val h = height val imgW = getDrawableWidth(img) val imgH = getDrawableHeight(img) mBaseRect[0f, 0f, imgW.toFloat()] = imgH.toFloat() // 以图片中心点居中位移 val tx = (w - imgW) / 2 val ty = (h - imgH) / 2 var sx = 1f var sy = 1f // 缩放,默认不超过屏幕大小 if (imgW > w) { sx = w.toFloat() / imgW } if (imgH > h) { sy = h.toFloat() / imgH } val scale = if (sx < sy) sx else sy mBaseMatrix.reset() mBaseMatrix.postTranslate(tx.toFloat(), ty.toFloat()) mBaseMatrix.postScale(scale, scale, mScreenCenter.x, mScreenCenter.y) mBaseMatrix.mapRect(mBaseRect) mHalfBaseRectWidth = mBaseRect.width() / 2 mHalfBaseRectHeight = mBaseRect.height() / 2 mScaleCenter.set(mScreenCenter) mRotateCenter.set(mScaleCenter) executeTranslate() when (mScaleType) { ScaleType.CENTER -> initCenter() ScaleType.CENTER_CROP -> initCenterCrop() ScaleType.CENTER_INSIDE -> initCenterInside() ScaleType.FIT_CENTER -> initFitCenter() ScaleType.FIT_START -> initFitStart() ScaleType.FIT_END -> initFitEnd() ScaleType.FIT_XY -> initFitXY() else -> { } } isInit = true mFromInfo?.let { if (System.currentTimeMillis() - mInfoTime < MAX_ANIM_FROM_WAITE) { animaFrom(it) } } mFromInfo = null } private fun initCenter() { if (!hasDrawable) return if (!isKnowSize) return val img = drawable val imgW = getDrawableWidth(img) val imgH = getDrawableHeight(img) if (imgW > mWidgetRect.width() || imgH > mWidgetRect.height()) { val scaleX = imgW / mImgRect.width() val scaleY = imgH / mImgRect.height() mScale = if (scaleX > scaleY) scaleX else scaleY mAnimMatrix.postScale(mScale, mScale, mScreenCenter.x, mScreenCenter.y) executeTranslate() resetBase() } } private fun initCenterCrop() { if (mImgRect.width() < mWidgetRect.width() || mImgRect.height() < mWidgetRect.height()) { val scaleX = mWidgetRect.width() / mImgRect.width() val scaleY = mWidgetRect.height() / mImgRect.height() mScale = if (scaleX > scaleY) scaleX else scaleY mAnimMatrix.postScale(mScale, mScale, mScreenCenter.x, mScreenCenter.y) executeTranslate() resetBase() } } private fun initCenterInside() { if (mImgRect.width() > mWidgetRect.width() || mImgRect.height() > mWidgetRect.height()) { val scaleX = mWidgetRect.width() / mImgRect.width() val scaleY = mWidgetRect.height() / mImgRect.height() mScale = if (scaleX < scaleY) scaleX else scaleY mAnimMatrix.postScale(mScale, mScale, mScreenCenter.x, mScreenCenter.y) executeTranslate() resetBase() } } private fun initFitCenter() { if (mImgRect.width() < mWidgetRect.width()) { mScale = mWidgetRect.width() / mImgRect.width() mAnimMatrix.postScale(mScale, mScale, mScreenCenter.x, mScreenCenter.y) executeTranslate() resetBase() } } private fun initFitStart() { initFitCenter() val ty = -mImgRect.top mAnimMatrix.postTranslate(0f, ty) executeTranslate() resetBase() mTranslateY += ty.toInt() } private fun initFitEnd() { initFitCenter() val ty = mWidgetRect.bottom - mImgRect.bottom mTranslateY += ty.toInt() mAnimMatrix.postTranslate(0f, ty) executeTranslate() resetBase() } private fun initFitXY() { val scaleX = mWidgetRect.width() / mImgRect.width() val scaleY = mWidgetRect.height() / mImgRect.height() mAnimMatrix.postScale(scaleX, scaleY, mScreenCenter.x, mScreenCenter.y) executeTranslate() resetBase() } private fun resetBase() { val img = drawable val imgW = getDrawableWidth(img) val imgH = getDrawableHeight(img) mBaseRect[0f, 0f, imgW.toFloat()] = imgH.toFloat() mBaseMatrix.set(mSynthesisMatrix) mBaseMatrix.mapRect(mBaseRect) mHalfBaseRectWidth = mBaseRect.width() / 2 mHalfBaseRectHeight = mBaseRect.height() / 2 mScale = 1f mTranslateX = 0 mTranslateY = 0 mAnimMatrix.reset() } private fun executeTranslate() { mSynthesisMatrix.set(mBaseMatrix) mSynthesisMatrix.postConcat(mAnimMatrix) imageMatrix = mSynthesisMatrix mAnimMatrix.mapRect(mImgRect, mBaseRect) imgLargeWidth = mImgRect.width() > mWidgetRect.width() imgLargeHeight = mImgRect.height() > mWidgetRect.height() } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { if (!hasDrawable) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) return } val d = drawable val drawableW = getDrawableWidth(d) val drawableH = getDrawableHeight(d) val pWidth = MeasureSpec.getSize(widthMeasureSpec) val pHeight = MeasureSpec.getSize(heightMeasureSpec) val widthMode = MeasureSpec.getMode(widthMeasureSpec) val heightMode = MeasureSpec.getMode(heightMeasureSpec) var width: Int var height: Int var p = layoutParams if (p == null) { p = ViewGroup.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT ) } width = if (p.width == ViewGroup.LayoutParams.MATCH_PARENT) { if (widthMode == MeasureSpec.UNSPECIFIED) { drawableW } else { pWidth } } else { if (widthMode == MeasureSpec.EXACTLY) { pWidth } else if (widthMode == MeasureSpec.AT_MOST) { if (drawableW > pWidth) pWidth else drawableW } else { drawableW } } height = if (p.height == ViewGroup.LayoutParams.MATCH_PARENT) { if (heightMode == MeasureSpec.UNSPECIFIED) { drawableH } else { pHeight } } else { if (heightMode == MeasureSpec.EXACTLY) { pHeight } else if (heightMode == MeasureSpec.AT_MOST) { if (drawableH > pHeight) pHeight else drawableH } else { drawableH } } if (mAdjustViewBounds && drawableW.toFloat() / drawableH != width.toFloat() / height) { val hScale = height.toFloat() / drawableH val wScale = width.toFloat() / drawableW val scale = if (hScale < wScale) hScale else wScale width = if (p.width == ViewGroup.LayoutParams.MATCH_PARENT) width else (drawableW * scale).toInt() height = if (p.height == ViewGroup.LayoutParams.MATCH_PARENT) height else (drawableH * scale).toInt() } setMeasuredDimension(width, height) } override fun setAdjustViewBounds(adjustViewBounds: Boolean) { super.setAdjustViewBounds(adjustViewBounds) mAdjustViewBounds = adjustViewBounds } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) mWidgetRect[0f, 0f, w.toFloat()] = h.toFloat() mScreenCenter[w / 2.toFloat()] = h / 2.toFloat() if (!isKnowSize) { isKnowSize = true initBase() } } override fun draw(canvas: Canvas) { mClip?.let { canvas.clipRect(it) mClip = null } super.draw(canvas) } override fun dispatchTouchEvent(event: MotionEvent): Boolean { return if (isEnable) { val action = event.actionMasked if (event.pointerCount >= 2) hasMultiTouch = true mDetector.onTouchEvent(event) if (isRotateEnable) { mRotateDetector.onTouchEvent(event) } mScaleDetector.onTouchEvent(event) if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) onUp() true } else { super.dispatchTouchEvent(event) } } private fun onUp() { if (mTranslate.isRunning) return if (canRotate || mDegrees % 90 != 0f) { var toDegrees = (mDegrees / 90).toInt() * 90.toFloat() val remainder = mDegrees % 90 if (remainder > 45) toDegrees += 90f else if (remainder < -45) toDegrees -= 90f mTranslate.withRotate(mDegrees.toInt(), toDegrees.toInt()) mDegrees = toDegrees } var scale = mScale if (mScale < 1) { scale = 1f mTranslate.withScale(mScale, 1F) } else if (mScale > mMaxScale) { scale = mMaxScale mTranslate.withScale(mScale, mMaxScale) } val cx = mImgRect.left + mImgRect.width() / 2 val cy = mImgRect.top + mImgRect.height() / 2 mScaleCenter[cx] = cy mRotateCenter[cx] = cy mTranslateX = 0 mTranslateY = 0 mTmpMatrix.reset() mTmpMatrix.postTranslate(-mBaseRect.left, -mBaseRect.top) mTmpMatrix.postTranslate(cx - mHalfBaseRectWidth, cy - mHalfBaseRectHeight) mTmpMatrix.postScale(scale, scale, cx, cy) mTmpMatrix.postRotate(mDegrees, cx, cy) mTmpMatrix.mapRect(mTmpRect, mBaseRect) doTranslateReset(mTmpRect) mTranslate.start() } private fun doTranslateReset(imgRect: RectF) { var tx = 0 var ty = 0 if (imgRect.width() <= mWidgetRect.width()) { if (!isImageCenterWidth(imgRect)) tx = (-((mWidgetRect.width() - imgRect.width()) / 2 - imgRect.left)).toInt() } else { if (imgRect.left > mWidgetRect.left) { tx = (imgRect.left - mWidgetRect.left).toInt() } else if (imgRect.right < mWidgetRect.right) { tx = (imgRect.right - mWidgetRect.right).toInt() } } if (imgRect.height() <= mWidgetRect.height()) { if (!isImageCenterHeight(imgRect)) ty = (-((mWidgetRect.height() - imgRect.height()) / 2 - imgRect.top)).toInt() } else { if (imgRect.top > mWidgetRect.top) { ty = (imgRect.top - mWidgetRect.top).toInt() } else if (imgRect.bottom < mWidgetRect.bottom) { ty = (imgRect.bottom - mWidgetRect.bottom).toInt() } } if (tx != 0 || ty != 0) { if (!mTranslate.mFlingScroller.isFinished) mTranslate.mFlingScroller.abortAnimation() mTranslate.withTranslate(mTranslateX, mTranslateY, -tx, -ty) } } private fun isImageCenterHeight(rect: RectF): Boolean { return abs(rect.top.roundToInt() - (mWidgetRect.height() - rect.height()) / 2) < 1 } private fun isImageCenterWidth(rect: RectF): Boolean { return abs(rect.left.roundToInt() - (mWidgetRect.width() - rect.width()) / 2) < 1 } private fun resistanceScrollByX( overScroll: Float, detalX: Float ): Float { return detalX * (abs(abs(overScroll) - MAX_OVER_RESISTANCE) / MAX_OVER_RESISTANCE.toFloat()) } private fun resistanceScrollByY( overScroll: Float, detalY: Float ): Float { return detalY * (abs(abs(overScroll) - MAX_OVER_RESISTANCE) / MAX_OVER_RESISTANCE.toFloat()) } /** * 匹配两个Rect的共同部分输出到out,若无共同部分则输出0,0,0,0 */ private fun mapRect(r1: RectF, r2: RectF, out: RectF) { val l: Float = if (r1.left > r2.left) r1.left else r2.left val r: Float = if (r1.right < r2.right) r1.right else r2.right if (l > r) { out[0f, 0f, 0f] = 0f return } val t: Float = if (r1.top > r2.top) r1.top else r2.top val b: Float = if (r1.bottom < r2.bottom) r1.bottom else r2.bottom if (t > b) { out[0f, 0f, 0f] = 0f return } out[l, t, r] = b } private fun checkRect() { if (!hasOverTranslate) { mapRect(mWidgetRect, mImgRect, mCommonRect) } } private val mClickRunnable = Runnable { mClickListener?.onClick(this) } fun canScrollHorizontallySelf(direction: Float): Boolean { if (mImgRect.width() <= mWidgetRect.width()) return false if (direction < 0 && mImgRect.left.roundToInt() - direction >= mWidgetRect.left) return false return !(direction > 0 && mImgRect.right.roundToInt() - direction <= mWidgetRect.right) } fun canScrollVerticallySelf(direction: Float): Boolean { if (mImgRect.height() <= mWidgetRect.height()) return false if (direction < 0 && mImgRect.top.roundToInt() - direction >= mWidgetRect.top) return false return !(direction > 0 && mImgRect.bottom.roundToInt() - direction <= mWidgetRect.bottom) } override fun canScrollHorizontally(direction: Int): Boolean { return if (hasMultiTouch) true else canScrollHorizontallySelf(direction.toFloat()) } override fun canScrollVertically(direction: Int): Boolean { return if (hasMultiTouch) true else canScrollVerticallySelf(direction.toFloat()) } private inner class InterpolatorProxy : Interpolator { private var mTarget: Interpolator? init { mTarget = DecelerateInterpolator() } fun setTargetInterpolator(interpolator: Interpolator?) { mTarget = interpolator } override fun getInterpolation(input: Float): Float { return mTarget?.getInterpolation(input) ?: input } } private inner class Transform : Runnable { var isRunning = false var mTranslateScroller: OverScroller var mFlingScroller: OverScroller var mScaleScroller: Scroller var mClipScroller: Scroller var mRotateScroller: Scroller var c: ClipCalculate? = null var mLastFlingX = 0 var mLastFlingY = 0 var mLastTranslateX = 0 var mLastTranslateY = 0 var mClipRect = RectF() var mInterpolatorProxy = InterpolatorProxy() fun setInterpolator(interpolator: Interpolator?) { mInterpolatorProxy.setTargetInterpolator(interpolator) } init { val ctx: Context = context mTranslateScroller = OverScroller(ctx, mInterpolatorProxy) mScaleScroller = Scroller(ctx, mInterpolatorProxy) mFlingScroller = OverScroller(ctx, mInterpolatorProxy) mClipScroller = Scroller(ctx, mInterpolatorProxy) mRotateScroller = Scroller(ctx, mInterpolatorProxy) } fun withTranslate(startX: Int, startY: Int, deltaX: Int, deltaY: Int) { mLastTranslateX = 0 mLastTranslateY = 0 mTranslateScroller.startScroll(0, 0, deltaX, deltaY, mAnimaDuring) } fun withScale(form: Float, to: Float) { mScaleScroller.startScroll( (form * 10000).toInt(), 0, ((to - form) * 10000).toInt(), 0, mAnimaDuring ) } fun withClip( fromX: Float, fromY: Float, deltaX: Float, deltaY: Float, d: Int, c: ClipCalculate? ) { mClipScroller.startScroll( (fromX * 10000).toInt(), (fromY * 10000).toInt(), (deltaX * 10000).toInt(), (deltaY * 10000).toInt(), d ) this.c = c } fun withRotate(fromDegrees: Int, toDegrees: Int) { mRotateScroller.startScroll(fromDegrees, 0, toDegrees - fromDegrees, 0, mAnimaDuring) } fun withRotate(fromDegrees: Int, toDegrees: Int, during: Int) { mRotateScroller.startScroll(fromDegrees, 0, toDegrees - fromDegrees, 0, during) } fun withFling(velocityX: Float, velocityY: Float) { mLastFlingX = if (velocityX < 0) Int.MAX_VALUE else 0 var distanceX = (if (velocityX > 0) abs(mImgRect.left) else mImgRect.right - mWidgetRect.right).toInt() distanceX = if (velocityX < 0) Int.MAX_VALUE - distanceX else distanceX var minX = if (velocityX < 0) distanceX else 0 var maxX = if (velocityX < 0) Int.MAX_VALUE else distanceX val overX = if (velocityX < 0) Int.MAX_VALUE - minX else distanceX mLastFlingY = if (velocityY < 0) Int.MAX_VALUE else 0 var distanceY = (if (velocityY > 0) abs(mImgRect.top) else mImgRect.bottom - mWidgetRect.bottom).toInt() distanceY = if (velocityY < 0) Int.MAX_VALUE - distanceY else distanceY var minY = if (velocityY < 0) distanceY else 0 var maxY = if (velocityY < 0) Int.MAX_VALUE else distanceY val overY = if (velocityY < 0) Int.MAX_VALUE - minY else distanceY if (velocityX == 0f) { maxX = 0 minX = 0 } if (velocityY == 0f) { maxY = 0 minY = 0 } mFlingScroller.fling( mLastFlingX, mLastFlingY, velocityX.toInt(), velocityY.toInt(), minX, maxX, minY, maxY, if (abs(overX) < MAX_FLING_OVER_SCROLL * 2) 0 else MAX_FLING_OVER_SCROLL, if (abs(overY) < MAX_FLING_OVER_SCROLL * 2) 0 else MAX_FLING_OVER_SCROLL ) } fun start() { isRunning = true postExecute() } fun stop() { removeCallbacks(this) mTranslateScroller.abortAnimation() mScaleScroller.abortAnimation() mFlingScroller.abortAnimation() mRotateScroller.abortAnimation() isRunning = false } override fun run() { // if (!isRuning) return; var endAnima = true if (mScaleScroller.computeScrollOffset()) { mScale = mScaleScroller.currX / 10000f endAnima = false } if (mTranslateScroller.computeScrollOffset()) { val tx = mTranslateScroller.currX - mLastTranslateX val ty = mTranslateScroller.currY - mLastTranslateY mTranslateX += tx mTranslateY += ty mLastTranslateX = mTranslateScroller.currX mLastTranslateY = mTranslateScroller.currY endAnima = false } if (mFlingScroller.computeScrollOffset()) { val x = mFlingScroller.currX - mLastFlingX val y = mFlingScroller.currY - mLastFlingY mLastFlingX = mFlingScroller.currX mLastFlingY = mFlingScroller.currY mTranslateX += x mTranslateY += y endAnima = false } if (mRotateScroller.computeScrollOffset()) { mDegrees = mRotateScroller.currX.toFloat() endAnima = false } if (mClipScroller.computeScrollOffset() || mClip != null) { val sx = mClipScroller.currX / 10000f val sy = mClipScroller.currY / 10000f mTmpMatrix.setScale( sx, sy, (mImgRect.left + mImgRect.right) / 2, c!!.calculateTop() ) mTmpMatrix.mapRect(mClipRect, mImgRect) if (sx == 1f) { mClipRect.left = mWidgetRect.left mClipRect.right = mWidgetRect.right } if (sy == 1f) { mClipRect.top = mWidgetRect.top mClipRect.bottom = mWidgetRect.bottom } mClip = mClipRect } if (!endAnima) { applyAnima() postExecute() } else { isRunning = false // 修复动画结束后边距有些空隙, var needFix = false if (imgLargeWidth) { if (mImgRect.left > 0) { mTranslateX -= mImgRect.left.toInt() } else if (mImgRect.right < mWidgetRect.width()) { mTranslateX -= (mWidgetRect.width() - mImgRect.right).toInt() } needFix = true } if (imgLargeHeight) { if (mImgRect.top > 0) { mTranslateY -= mImgRect.top.toInt() } else if (mImgRect.bottom < mWidgetRect.height()) { mTranslateY -= (mWidgetRect.height() - mImgRect.bottom).toInt() } needFix = true } if (needFix) { applyAnima() } invalidate() mCompleteCallBack?.let { it.run() mCompleteCallBack = null } } } private fun applyAnima() { mAnimMatrix.reset() mAnimMatrix.postTranslate(-mBaseRect.left, -mBaseRect.top) mAnimMatrix.postTranslate(mRotateCenter.x, mRotateCenter.y) mAnimMatrix.postTranslate(-mHalfBaseRectWidth, -mHalfBaseRectHeight) mAnimMatrix.postRotate(mDegrees, mRotateCenter.x, mRotateCenter.y) mAnimMatrix.postScale(mScale, mScale, mScaleCenter.x, mScaleCenter.y) mAnimMatrix.postTranslate(mTranslateX.toFloat(), mTranslateY.toFloat()) executeTranslate() } private fun postExecute() { if (isRunning) post(this) } } fun getInfo(): Info { val rect = RectF() val p = IntArray(2) getLocation(this, p) rect[p[0] + mImgRect.left, p[1] + mImgRect.top, p[0] + mImgRect.right] = p[1] + mImgRect.bottom return Info( rect, mImgRect, mWidgetRect, mBaseRect, mScreenCenter, mScale, mDegrees, mScaleType ) } fun getImageViewInfo(imgView: ImageView): Info { val p = IntArray(2) getLocation(imgView, p) val drawable: Drawable = imgView.drawable val matrix: Matrix = imgView.imageMatrix val width = getDrawableWidth(drawable) val height = getDrawableHeight(drawable) val imgRect = RectF(0F, 0F, width.toFloat(), height.toFloat()) matrix.mapRect(imgRect) val rect = RectF( p[0] + imgRect.left, p[1] + imgRect.top, p[0] + imgRect.right, p[1] + imgRect.bottom ) val widgetRect = RectF(0F, 0F, imgView.width.toFloat(), imgView.height.toFloat()) val baseRect = RectF(widgetRect) val screenCenter = PointF(widgetRect.width() / 2, widgetRect.height() / 2) return Info( rect, imgRect, widgetRect, baseRect, screenCenter, 1F, 0F, imgView.scaleType ) } private fun getLocation(target: View, position: IntArray) { position[0] += target.left position[1] += target.top var viewParent: ViewParent = target.parent while (viewParent is View) { val view: View = viewParent if (view.id == android.R.id.content) return position[0] -= view.scrollX position[1] -= view.scrollY position[0] += view.left position[1] += view.top viewParent = view.parent } position[0] = (position[0] + 0.5f).toInt() position[1] = (position[1] + 0.5f).toInt() } private fun reset() { mAnimMatrix.reset() executeTranslate() mScale = 1f mTranslateX = 0 mTranslateY = 0 } interface ClipCalculate { fun calculateTop(): Float } inner class START : ClipCalculate { override fun calculateTop(): Float { return mImgRect.top } } inner class END : ClipCalculate { override fun calculateTop(): Float { return mImgRect.bottom } } inner class OTHER : ClipCalculate { override fun calculateTop(): Float { return (mImgRect.top + mImgRect.bottom) / 2 } } /** * 在PhotoView内部还没有图片的时候同样可以调用该方法 * * * 此时并不会播放动画,当给PhotoView设置图片后会自动播放动画。 * * * 若等待时间过长也没有给控件设置图片,则会忽略该动画,若要再次播放动画则需要重新调用该方法 * (等待的时间默认500毫秒,可以通过setMaxAnimFromWaiteTime(int)设置最大等待时间) */ fun animaFrom(info: Info) { if (isInit) { reset() val mine = getInfo() val scaleX = info.mImgRect.width() / mine.mImgRect.width() val scaleY = info.mImgRect.height() / mine.mImgRect.height() val scale = if (scaleX < scaleY) scaleX else scaleY val ocx = info.mRect.left + info.mRect.width() / 2 val ocy = info.mRect.top + info.mRect.height() / 2 val mcx = mine.mRect.left + mine.mRect.width() / 2 val mcy = mine.mRect.top + mine.mRect.height() / 2 mAnimMatrix.reset() // mAnimaMatrix.postTranslate(-mBaseRect.left, -mBaseRect.top); mAnimMatrix.postTranslate(ocx - mcx, ocy - mcy) mAnimMatrix.postScale(scale, scale, ocx, ocy) mAnimMatrix.postRotate(info.mDegrees, ocx, ocy) executeTranslate() mScaleCenter[ocx] = ocy mRotateCenter[ocx] = ocy mTranslate.withTranslate(0, 0, (-(ocx - mcx)).toInt(), (-(ocy - mcy)).toInt()) mTranslate.withScale(scale, 1F) mTranslate.withRotate(info.mDegrees.toInt(), 0) if (info.mWidgetRect.width() < info.mImgRect.width() || info.mWidgetRect.height() < info.mImgRect.height()) { var clipX = info.mWidgetRect.width() / info.mImgRect.width() var clipY = info.mWidgetRect.height() / info.mImgRect.height() clipX = if (clipX > 1) 1F else clipX clipY = if (clipY > 1) 1F else clipY val c = if (info.mScaleType == ScaleType.FIT_START) START() else if (info.mScaleType == ScaleType.FIT_END) END() else OTHER() mTranslate.withClip(clipX, clipY, 1 - clipX, 1 - clipY, mAnimaDuring / 3, c) mTmpMatrix.setScale( clipX, clipY, (mImgRect.left + mImgRect.right) / 2, c.calculateTop() ) mTmpMatrix.mapRect(mTranslate.mClipRect, mImgRect) mClip = mTranslate.mClipRect } mTranslate.start() } else { mFromInfo = info mInfoTime = System.currentTimeMillis() } } fun animaTo( info: Info, completeCallBack: Runnable ) { if (isInit) { mTranslate.stop() mTranslateX = 0 mTranslateY = 0 val tcx = info.mRect.left + info.mRect.width() / 2 val tcy = info.mRect.top + info.mRect.height() / 2 mScaleCenter[mImgRect.left + mImgRect.width() / 2] = mImgRect.top + mImgRect.height() / 2 mRotateCenter.set(mScaleCenter) // 将图片旋转回正常位置,用以计算 mAnimMatrix.postRotate(-mDegrees, mScaleCenter.x, mScaleCenter.y) mAnimMatrix.mapRect(mImgRect, mBaseRect) // 缩放 val scaleX = info.mImgRect.width() / mBaseRect.width() val scaleY = info.mImgRect.height() / mBaseRect.height() val scale = if (scaleX > scaleY) scaleX else scaleY mAnimMatrix.postRotate(mDegrees, mScaleCenter.x, mScaleCenter.y) mAnimMatrix.mapRect(mImgRect, mBaseRect) mDegrees %= 360 mTranslate.withTranslate( 0, 0, (tcx - mScaleCenter.x).toInt(), (tcy - mScaleCenter.y).toInt() ) mTranslate.withScale(mScale, scale) mTranslate.withRotate(mDegrees.toInt(), info.mDegrees.toInt(), mAnimaDuring * 2 / 3) if (info.mWidgetRect.width() < info.mRect.width() || info.mWidgetRect.height() < info.mRect.height()) { var clipX = info.mWidgetRect.width() / info.mRect.width() var clipY = info.mWidgetRect.height() / info.mRect.height() clipX = if (clipX > 1) 1F else clipX clipY = if (clipY > 1) 1F else clipY val cx = clipX val cy = clipY val c = if (info.mScaleType == ScaleType.FIT_START) START() else if (info.mScaleType == ScaleType.FIT_END) END() else OTHER() postDelayed( { mTranslate.withClip(1F, 1F, -1 + cx, -1 + cy, mAnimaDuring / 2, c) }, mAnimaDuring / 2.toLong() ) } mCompleteCallBack = completeCallBack mTranslate.start() } } fun rotate(degrees: Float) { mDegrees += degrees val centerX = (mWidgetRect.left + mWidgetRect.width() / 2).toInt() val centerY = (mWidgetRect.top + mWidgetRect.height() / 2).toInt() mAnimMatrix.postRotate(degrees, centerX.toFloat(), centerY.toFloat()) executeTranslate() } inner class RotateListener : OnRotateListener { override fun onRotate( degrees: Float, focusX: Float, focusY: Float ) { mRotateFlag += degrees if (canRotate) { mDegrees += degrees mAnimMatrix.postRotate(degrees, focusX, focusY) } else { if (abs(mRotateFlag) >= mMinRotate) { canRotate = true mRotateFlag = 0f } } } } inner class GestureListener : SimpleOnGestureListener() { override fun onLongPress(e: MotionEvent) { mLongClick?.onLongClick(this@PhotoView) } override fun onDown(e: MotionEvent): Boolean { hasOverTranslate = false hasMultiTouch = false canRotate = false removeCallbacks(mClickRunnable) return false } override fun onFling( e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float ): Boolean { if (hasMultiTouch) return false if (!imgLargeWidth && !imgLargeHeight) return false if (mTranslate.isRunning) return false var vx = velocityX var vy = velocityY if (mImgRect.left.roundToInt() >= mWidgetRect.left || mImgRect.right.roundToInt() <= mWidgetRect.right ) { vx = 0f } if (mImgRect.top.roundToInt() >= mWidgetRect.top || mImgRect.bottom.roundToInt() <= mWidgetRect.bottom ) { vy = 0f } if (canRotate || mDegrees % 90 != 0f) { var toDegrees = (mDegrees / 90).toInt() * 90.toFloat() val remainder = mDegrees % 90 if (remainder > 45) toDegrees += 90f else if (remainder < -45) toDegrees -= 90f mTranslate.withRotate(mDegrees.toInt(), toDegrees.toInt()) mDegrees = toDegrees } doTranslateReset(mImgRect) mTranslate.withFling(vx, vy) mTranslate.start() // onUp(e2); return super.onFling(e1, e2, velocityX, velocityY) } override fun onScroll( e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float ): Boolean { var x = distanceX var y = distanceY if (mTranslate.isRunning) { mTranslate.stop() } if (canScrollHorizontallySelf(x)) { if (x < 0 && mImgRect.left - x > mWidgetRect.left) x = mImgRect.left if (x > 0 && mImgRect.right - x < mWidgetRect.right) x = mImgRect.right - mWidgetRect.right mAnimMatrix.postTranslate(-x, 0f) mTranslateX -= x.toInt() } else if (imgLargeWidth || hasMultiTouch || hasOverTranslate) { checkRect() if (!hasMultiTouch) { if (x < 0 && mImgRect.left - x > mCommonRect.left) x = resistanceScrollByX(mImgRect.left - mCommonRect.left, x) if (x > 0 && mImgRect.right - x < mCommonRect.right) x = resistanceScrollByX(mImgRect.right - mCommonRect.right, x) } mTranslateX -= x.toInt() mAnimMatrix.postTranslate(-x, 0f) hasOverTranslate = true } if (canScrollVerticallySelf(y)) { if (y < 0 && mImgRect.top - y > mWidgetRect.top) y = mImgRect.top if (y > 0 && mImgRect.bottom - y < mWidgetRect.bottom) y = mImgRect.bottom - mWidgetRect.bottom mAnimMatrix.postTranslate(0f, -y) mTranslateY -= y.toInt() } else if (imgLargeHeight || hasOverTranslate || hasMultiTouch) { checkRect() if (!hasMultiTouch) { if (y < 0 && mImgRect.top - y > mCommonRect.top) y = resistanceScrollByY(mImgRect.top - mCommonRect.top, y) if (y > 0 && mImgRect.bottom - y < mCommonRect.bottom) y = resistanceScrollByY(mImgRect.bottom - mCommonRect.bottom, y) } mAnimMatrix.postTranslate(0f, -y) mTranslateY -= y.toInt() hasOverTranslate = true } executeTranslate() return true } override fun onSingleTapUp(e: MotionEvent): Boolean { postDelayed(mClickRunnable, 250) return false } override fun onDoubleTap(e: MotionEvent): Boolean { mTranslate.stop() val from: Float val to: Float val imgCx = mImgRect.left + mImgRect.width() / 2 val imgCy = mImgRect.top + mImgRect.height() / 2 mScaleCenter[imgCx] = imgCy mRotateCenter[imgCx] = imgCy mTranslateX = 0 mTranslateY = 0 if (isZoonUp) { from = mScale to = 1f } else { from = mScale to = mMaxScale mScaleCenter[e.x] = e.y } mTmpMatrix.reset() mTmpMatrix.postTranslate(-mBaseRect.left, -mBaseRect.top) mTmpMatrix.postTranslate(mRotateCenter.x, mRotateCenter.y) mTmpMatrix.postTranslate(-mHalfBaseRectWidth, -mHalfBaseRectHeight) mTmpMatrix.postRotate(mDegrees, mRotateCenter.x, mRotateCenter.y) mTmpMatrix.postScale(to, to, mScaleCenter.x, mScaleCenter.y) mTmpMatrix.postTranslate(mTranslateX.toFloat(), mTranslateY.toFloat()) mTmpMatrix.mapRect(mTmpRect, mBaseRect) doTranslateReset(mTmpRect) isZoonUp = !isZoonUp mTranslate.withScale(from, to) mTranslate.start() return false } } inner class ScaleGestureListener : OnScaleGestureListener { override fun onScale(detector: ScaleGestureDetector): Boolean { val scaleFactor = detector.scaleFactor if (java.lang.Float.isNaN(scaleFactor) || java.lang.Float.isInfinite(scaleFactor)) return false mScale *= scaleFactor //mScaleCenter.set(detector.getFocusX(), detector.getFocusY()); mAnimMatrix.postScale( scaleFactor, scaleFactor, detector.focusX, detector.focusY ) executeTranslate() return true } override fun onScaleBegin(detector: ScaleGestureDetector): Boolean { return true } override fun onScaleEnd(detector: ScaleGestureDetector) {} } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/image/photo/Info.kt ================================================ package io.legado.app.ui.widget.image.photo import android.graphics.PointF import android.graphics.RectF import android.widget.ImageView @Suppress("MemberVisibilityCanBePrivate") class Info( rect: RectF, img: RectF, widget: RectF, base: RectF, screenCenter: PointF, scale: Float, degrees: Float, scaleType: ImageView.ScaleType? ) { // 内部图片在整个手机界面的位置 var mRect = RectF() // 控件在窗口的位置 var mImgRect = RectF() var mWidgetRect = RectF() var mBaseRect = RectF() var mScreenCenter = PointF() var mScale = 0f var mDegrees = 0f var mScaleType: ImageView.ScaleType? = null init { mRect.set(rect) mImgRect.set(img) mWidgetRect.set(widget) mScale = scale mScaleType = scaleType mDegrees = degrees mBaseRect.set(base) mScreenCenter.set(screenCenter) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/image/photo/RotateGestureDetector.kt ================================================ package io.legado.app.ui.widget.image.photo import android.view.MotionEvent import kotlin.math.abs import kotlin.math.atan class RotateGestureDetector(private val mListener: OnRotateListener) { private val MAX_DEGREES_STEP = 120 private var mPrevSlope = 0f private var mCurrSlope = 0f private val x1 = 0f private val y1 = 0f private val x2 = 0f private val y2 = 0f fun onTouchEvent(event: MotionEvent) { when (event.actionMasked) { MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_POINTER_UP -> { if (event.pointerCount == 2) mPrevSlope = calculateSlope(event) } MotionEvent.ACTION_MOVE -> if (event.pointerCount > 1) { mCurrSlope = calculateSlope(event) val currDegrees = Math.toDegrees(atan(mCurrSlope.toDouble())) val prevDegrees = Math.toDegrees(atan(mPrevSlope.toDouble())) val deltaSlope = currDegrees - prevDegrees if (abs(deltaSlope) <= MAX_DEGREES_STEP) { mListener.onRotate(deltaSlope.toFloat(), (x2 + x1) / 2, (y2 + y1) / 2) } mPrevSlope = mCurrSlope } } } private fun calculateSlope(event: MotionEvent): Float { val x1 = event.getX(0) val y1 = event.getY(0) val x2 = event.getX(1) val y2 = event.getY(1) return (y2 - y1) / (x2 - x1) } } interface OnRotateListener { fun onRotate(degrees: Float, focusX: Float, focusY: Float) } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/keyboard/KeyboardAssistsConfig.kt ================================================ package io.legado.app.ui.widget.keyboard import android.content.Context import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.core.view.setPadding import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import io.legado.app.R import io.legado.app.base.BaseDialogFragment import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.KeyboardAssist import io.legado.app.databinding.DialogMultipleEditTextBinding import io.legado.app.databinding.DialogRecyclerViewBinding import io.legado.app.databinding.Item1lineTextAndDelBinding import io.legado.app.lib.dialogs.alert import io.legado.app.lib.theme.backgroundColor import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.widget.recycler.ItemTouchCallback import io.legado.app.ui.widget.recycler.VerticalDivider import io.legado.app.utils.applyTint import io.legado.app.utils.dpToPx import io.legado.app.utils.setLayout import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch /** * 辅助按键配置 */ class KeyboardAssistsConfig : BaseDialogFragment(R.layout.dialog_recycler_view), Toolbar.OnMenuItemClickListener { private val binding by viewBinding(DialogRecyclerViewBinding::bind) private val adapter by lazy { KeyAdapter(requireContext()) } override fun onStart() { super.onStart() setLayout(0.9f, 0.9f) } override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { binding.toolBar.setBackgroundColor(primaryColor) binding.toolBar.setTitle(R.string.assists_key_config) initView() initMenu() initData() } private fun initView() { binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.addItemDecoration(VerticalDivider(requireContext())) binding.recyclerView.adapter = adapter val itemTouchCallback = ItemTouchCallback(adapter) itemTouchCallback.isCanDrag = true ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView) } private fun initMenu() { binding.toolBar.setOnMenuItemClickListener(this) binding.toolBar.inflateMenu(R.menu.keyboard_assists_config) binding.toolBar.menu.applyTint(requireContext()) } private fun initData() { lifecycleScope.launch { appDb.keyboardAssistsDao.flowAll.catch { AppLog.put("辅助按键配置获取数据失败\n${it.localizedMessage}", it) }.flowOn(IO).collect { adapter.setItems(it) } } } override fun onMenuItemClick(item: MenuItem?): Boolean { when (item?.itemId) { R.id.menu_add -> editKey(null) } return false } private fun editKey(keyboardAssist: KeyboardAssist?) { alert { setTitle("辅助按键") val alertBinding = DialogMultipleEditTextBinding.inflate(layoutInflater).apply { layout1.hint = "key" edit1.setText(keyboardAssist?.key) layout2.hint = "value" layout2.visible() edit2.setText(keyboardAssist?.value) } setCustomView(alertBinding.root) cancelButton() okButton { lifecycleScope.launch(IO) { val newKeyboardAssist = KeyboardAssist( key = alertBinding.edit1.text.toString(), value = alertBinding.edit2.text.toString() ) if (keyboardAssist == null) { newKeyboardAssist.serialNo = appDb.keyboardAssistsDao.maxSerialNo + 1 appDb.keyboardAssistsDao.insert(newKeyboardAssist) } else { newKeyboardAssist.serialNo = keyboardAssist.serialNo appDb.keyboardAssistsDao.delete(keyboardAssist) appDb.keyboardAssistsDao.insert(newKeyboardAssist) } } } } } private inner class KeyAdapter(context: Context) : RecyclerAdapter(context), ItemTouchCallback.Callback { private var isMoved = false override fun getViewBinding(parent: ViewGroup): Item1lineTextAndDelBinding { return Item1lineTextAndDelBinding.inflate(inflater, parent, false).apply { root.setPadding(16.dpToPx()) ivDelete.visible() } } override fun convert( holder: ItemViewHolder, binding: Item1lineTextAndDelBinding, item: KeyboardAssist, payloads: MutableList ) { binding.root.setBackgroundColor(context.backgroundColor) binding.textView.text = item.key } override fun registerListener(holder: ItemViewHolder, binding: Item1lineTextAndDelBinding) { binding.root.setOnClickListener { getItemByLayoutPosition(holder.layoutPosition)?.let { keyboardAssist -> editKey(keyboardAssist) } } binding.ivDelete.setOnClickListener { getItemByLayoutPosition(holder.layoutPosition)?.let { keyboardAssist -> lifecycleScope.launch(IO) { appDb.keyboardAssistsDao.delete(keyboardAssist) } } } } override fun swap(srcPosition: Int, targetPosition: Int): Boolean { swapItem(srcPosition, targetPosition) isMoved = true return true } override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { if (isMoved) { for ((index, item) in getItems().withIndex()) { item.serialNo = index + 1 } lifecycleScope.launch(IO) { appDb.keyboardAssistsDao.update(*getItems().toTypedArray()) } } isMoved = false } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/keyboard/KeyboardToolPop.kt ================================================ package io.legado.app.ui.widget.keyboard import android.content.Context import android.graphics.Rect import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.ViewTreeObserver import android.view.Window import android.widget.PopupWindow import io.legado.app.R import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.KeyboardAssist import io.legado.app.databinding.ItemFilletTextBinding import io.legado.app.databinding.PopupKeyboardToolBinding import io.legado.app.lib.dialogs.SelectItem import io.legado.app.lib.dialogs.selector import io.legado.app.utils.activity import io.legado.app.utils.showDialogFragment import io.legado.app.utils.windowSize import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch import splitties.systemservices.layoutInflater import splitties.systemservices.windowManager import kotlin.math.abs /** * 键盘帮助浮窗 */ class KeyboardToolPop( private val context: Context, private val scope: CoroutineScope, private val rootView: View, private val callBack: CallBack ) : PopupWindow(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT), ViewTreeObserver.OnGlobalLayoutListener { private val helpChar = "❓" private val binding = PopupKeyboardToolBinding.inflate(LayoutInflater.from(context)) private val adapter = Adapter(context) private var mIsSoftKeyBoardShowing = false var initialPadding = 0 init { contentView = binding.root isTouchable = true isOutsideTouchable = false isFocusable = false inputMethodMode = INPUT_METHOD_NEEDED //解决遮盖输入法 initRecyclerView() upAdapterData() } fun attachToWindow(window: Window) { window.decorView.viewTreeObserver.addOnGlobalLayoutListener(this) contentView.measure( View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED, ) } override fun onGlobalLayout() { val rect = Rect() // 获取当前页面窗口的显示范围 rootView.getWindowVisibleDisplayFrame(rect) val screenHeight = windowManager.windowSize.heightPixels val keyboardHeight = screenHeight - rect.bottom // 输入法的高度 val preShowing = mIsSoftKeyBoardShowing if (abs(keyboardHeight) > screenHeight / 5) { mIsSoftKeyBoardShowing = true // 超过屏幕五分之一则表示弹出了输入法 rootView.setPadding(0, 0, 0, initialPadding + contentView.measuredHeight) if (!isShowing) { showAtLocation(rootView, Gravity.BOTTOM, 0, 0) } } else { mIsSoftKeyBoardShowing = false rootView.setPadding(0, 0, 0, 0) if (preShowing) { dismiss() } } } private fun initRecyclerView() { binding.recyclerView.adapter = adapter adapter.addHeaderView { ItemFilletTextBinding.inflate(context.layoutInflater, it, false).apply { textView.text = helpChar root.setOnClickListener { helpAlert() } } } } @Suppress("MemberVisibilityCanBePrivate") fun upAdapterData() { scope.launch { appDb.keyboardAssistsDao.flowByType(0).catch { AppLog.put("键盘帮助浮窗获取数据失败\n${it.localizedMessage}", it) }.flowOn(IO).collect { adapter.setItems(it) } } } private fun helpAlert() { val items = arrayListOf( SelectItem(context.getString(R.string.assists_key_config), "keyConfig") ) items.addAll(callBack.helpActions()) context.selector(context.getString(R.string.help), items) { _, selectItem, _ -> when (selectItem.value) { "keyConfig" -> config() else -> callBack.onHelpActionSelect(selectItem.value) } } } private fun config() { contentView.activity?.showDialogFragment() } inner class Adapter(context: Context) : RecyclerAdapter(context) { override fun getViewBinding(parent: ViewGroup): ItemFilletTextBinding { return ItemFilletTextBinding.inflate(inflater, parent, false) } override fun convert( holder: ItemViewHolder, binding: ItemFilletTextBinding, item: KeyboardAssist, payloads: MutableList ) { binding.run { textView.text = item.key } } override fun registerListener(holder: ItemViewHolder, binding: ItemFilletTextBinding) { holder.itemView.apply { setOnClickListener { getItemByLayoutPosition(holder.layoutPosition)?.let { callBack.sendText(it.value) } } } } } interface CallBack { fun helpActions(): List> = arrayListOf() fun onHelpActionSelect(action: String) fun sendText(text: String) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/number/NumberPickerDialog.kt ================================================ package io.legado.app.ui.widget.number import android.content.Context import android.widget.NumberPicker import androidx.appcompat.app.AlertDialog import io.legado.app.R import io.legado.app.utils.applyTint import io.legado.app.utils.hideSoftInput class NumberPickerDialog(context: Context) { private val builder = AlertDialog.Builder(context) private var numberPicker: NumberPicker? = null private var maxValue: Int? = null private var minValue: Int? = null private var value: Int? = null init { builder.setView(R.layout.dialog_number_picker) } fun setTitle(title: String): NumberPickerDialog { builder.setTitle(title) return this } fun setMaxValue(value: Int): NumberPickerDialog { maxValue = value return this } fun setMinValue(value: Int): NumberPickerDialog { minValue = value return this } fun setValue(value: Int): NumberPickerDialog { this.value = value return this } fun setCustomButton(textId: Int, listener: (() -> Unit)?): NumberPickerDialog { builder.setNeutralButton(textId) { _, _ -> numberPicker?.let { it.clearFocus() it.hideSoftInput() listener?.invoke() } } return this } fun show(callBack: ((value: Int) -> Unit)?) { builder.setPositiveButton(R.string.ok) { _, _ -> numberPicker?.let { it.clearFocus() it.hideSoftInput() callBack?.invoke(it.value) } } builder.setNegativeButton(R.string.cancel, null) val dialog = builder.show().applyTint() numberPicker = dialog.findViewById(R.id.number_picker) numberPicker?.let { np -> minValue?.let { np.minValue = it } maxValue?.let { np.maxValue = it } value?.let { np.value = it } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/recycler/DividerNoLast.kt ================================================ package io.legado.app.ui.widget.recycler import android.content.Context import android.graphics.Canvas import android.graphics.Rect import android.graphics.drawable.Drawable import android.view.View import android.widget.LinearLayout import androidx.recyclerview.widget.RecyclerView import io.legado.app.utils.DebugLog import kotlin.math.roundToInt /** * 不画最后一条分隔线 */ @Suppress("MemberVisibilityCanBePrivate", "RedundantRequireNotNullCall", "unused") class DividerNoLast(context: Context, orientation: Int) : RecyclerView.ItemDecoration() { companion object { const val HORIZONTAL = LinearLayout.HORIZONTAL const val VERTICAL = LinearLayout.VERTICAL } private val attrs = intArrayOf(android.R.attr.listDivider) private var mDivider: Drawable? = null /** * Current orientation. Either [.HORIZONTAL] or [.VERTICAL]. */ private var mOrientation = 0 private val mBounds = Rect() init { val a = context.obtainStyledAttributes(attrs) mDivider = a.getDrawable(0) if (mDivider == null) { DebugLog.w( javaClass.name, "@android:attr/listDivider was not set in the theme used for this DividerItemDecoration. Please set that attribute all call setDrawable()" ) } a.recycle() setOrientation(orientation) } /** * Sets the orientation for this divider. This should be called if * [RecyclerView.LayoutManager] changes orientation. * * @param orientation [.HORIZONTAL] or [.VERTICAL] */ fun setOrientation(orientation: Int) { require(!(orientation != HORIZONTAL && orientation != VERTICAL)) { "Invalid orientation. It should be either HORIZONTAL or VERTICAL" } mOrientation = orientation } /** * Sets the [Drawable] for this divider. * * @param drawable Drawable that should be used as a divider. */ fun setDrawable(drawable: Drawable) { requireNotNull(drawable) { "Drawable cannot be null." } mDivider = drawable } /** * @return the [Drawable] for this divider. */ fun getDrawable(): Drawable? { return mDivider } override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { if (parent.layoutManager == null || mDivider == null) { return } if (mOrientation == VERTICAL) { drawVertical(c, parent) } else { drawHorizontal(c, parent) } } private fun drawVertical( canvas: Canvas, parent: RecyclerView ) { canvas.save() val left: Int val right: Int if (parent.clipToPadding) { left = parent.paddingLeft right = parent.width - parent.paddingRight canvas.clipRect( left, parent.paddingTop, right, parent.height - parent.paddingBottom ) } else { left = 0 right = parent.width } val childCount = parent.childCount for (i in 0 until childCount - 1) { val child = parent.getChildAt(i) parent.getDecoratedBoundsWithMargins(child, mBounds) val bottom = mBounds.bottom + child.translationY.roundToInt() val top = bottom - mDivider!!.intrinsicHeight mDivider!!.setBounds(left, top, right, bottom) mDivider!!.draw(canvas) } canvas.restore() } private fun drawHorizontal(canvas: Canvas, parent: RecyclerView) { canvas.save() val top: Int val bottom: Int if (parent.clipToPadding) { top = parent.paddingTop bottom = parent.height - parent.paddingBottom canvas.clipRect( parent.paddingLeft, top, parent.width - parent.paddingRight, bottom ) } else { top = 0 bottom = parent.height } val childCount = parent.childCount for (i in 0 until childCount - 1) { val child = parent.getChildAt(i) parent.layoutManager!!.getDecoratedBoundsWithMargins(child, mBounds) val right = mBounds.right + child.translationX.roundToInt() val left = right - mDivider!!.intrinsicWidth mDivider!!.setBounds(left, top, right, bottom) mDivider!!.draw(canvas) } canvas.restore() } override fun getItemOffsets( outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State ) { if (mDivider == null) { outRect[0, 0, 0] = 0 return } if (mOrientation == VERTICAL) { outRect[0, 0, 0] = mDivider!!.intrinsicHeight } else { val childAdapterPosition = parent.getChildAdapterPosition(view) val lastCount = parent.adapter!!.itemCount - 1 //如果不是最后一条 正常赋值 如果是最后一条 赋值为0 if (childAdapterPosition != lastCount) { outRect[0, 0, mDivider!!.intrinsicWidth] = 0 } else { outRect[0, 0, 0] = 0 } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/recycler/DragSelectTouchHelper.kt ================================================ /* * Copyright 2020 Mupceet * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package io.legado.app.ui.widget.recycler import android.content.res.Resources import android.text.TextUtils import android.util.DisplayMetrics import android.util.TypedValue import android.view.MotionEvent import android.view.View import androidx.core.view.ViewCompat import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener import io.legado.app.BuildConfig import io.legado.app.ui.widget.recycler.DragSelectTouchHelper.AdvanceCallback.Mode import io.legado.app.utils.DebugLog import java.util.Locale import kotlin.math.max import kotlin.math.min /** * @author mupceet * !autoChangeMode +-------------------+ inactiveSelect() * +------------------------------------> | | <--------------------+ * | | Normal | | * | activeDragSelect(position) | | activeSlideSelect() | * | +------------------------------ | | ----------+ | * | v +-------------------+ v | * +-------------------+ autoChangeMode +-----------------------+ * | Drag From Disable | ----------------------------------------------> | | * +-------------------+ | | * | | | | * | | activeDragSelect(position) && allowDragInSlide | Slide | * | | <---------------------------------------------- | | * | Drag From Slide | | | * | | | | * | | ----------------------------------------------> | | * +-------------------+ +-----------------------+ */ @Suppress("unused", "MemberVisibilityCanBePrivate", "DEPRECATION") class DragSelectTouchHelper( /** * Developer callback which controls the behavior of DragSelectTouchHelper. */ private val mCallback: Callback, ) { companion object { private const val TAG = "DSTH" private const val MAX_HOTSPOT_RATIO = 0.5f private val DEFAULT_EDGE_TYPE = EdgeType.INSIDE_EXTEND private const val DEFAULT_HOTSPOT_RATIO = 0.2f private const val DEFAULT_HOTSPOT_OFFSET = 0 private const val DEFAULT_MAX_SCROLL_VELOCITY = 10 private const val SELECT_STATE_NORMAL = 0x00 private const val SELECT_STATE_SLIDE = 0x01 private const val SELECT_STATE_DRAG_FROM_NORMAL = 0x10 private const val SELECT_STATE_DRAG_FROM_SLIDE = 0x11 } private val mDisplayMetrics: DisplayMetrics = Resources.getSystem().displayMetrics /** * Start of the slide area. */ private var mSlideAreaLeft = 0f /** * End of the slide area. */ private var mSlideAreaRight = 0f /** * The hotspot height by the ratio of RecyclerView. */ private var mHotspotHeightRatio = 0f /** * The hotspot height. */ private var mHotspotHeight = 0f /** * The hotspot offset. */ private var mHotspotOffset = 0f /** * Whether should continue scrolling when move outside top hotspot region. */ private var mScrollAboveTopRegion = false /** * Whether should continue scrolling when move outside bottom hotspot region. */ private var mScrollBelowBottomRegion = false /** * The maximum velocity of auto scrolling. */ private var mMaximumVelocity = 0 /** * Whether should auto enter slide mode after drag select finished. */ private var mShouldAutoChangeState = false /** * Whether can drag selection in slide select mode. */ private var mIsAllowDragInSlideState = false private var mRecyclerView: RecyclerView? = null /** * The coordinate of hotspot area. */ private var mTopRegionFrom = -1f private var mTopRegionTo = -1f private var mBottomRegionFrom = -1f private var mBottomRegionTo = -1f private val mOnLayoutChangeListener = View.OnLayoutChangeListener { v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom -> if (oldLeft != left || oldRight != right || oldTop != top || oldBottom != bottom) { if (v === mRecyclerView) { Logger.i( "onLayoutChange:new: " + left + " " + top + " " + right + " " + bottom ) Logger.i( "onLayoutChange:old: " + oldLeft + " " + oldTop + " " + oldRight + " " + oldBottom ) init(bottom - top) } } } /** * The current mode of selection. */ private var mSelectState = SELECT_STATE_NORMAL /** * Whether is in top hotspot area. */ private var mIsInTopHotspot = false /** * Whether is in bottom hotspot area. */ private var mIsInBottomHotspot = false /** * Indicates automatically scroll. */ private var mIsScrolling = false /** * The actual speed of the current moment. */ private var mScrollDistance = 0 /** * The reference coordinate for the action start, used to avoid reverse scrolling. */ private var mDownY = Float.MIN_VALUE /** * The reference coordinates for the last action. */ private var mLastX = Float.MIN_VALUE private var mLastY = Float.MIN_VALUE /** * The selected items position. */ private var mStart = RecyclerView.NO_POSITION private var mEnd = RecyclerView.NO_POSITION private var mLastRealStart = RecyclerView.NO_POSITION private var mLastRealEnd = RecyclerView.NO_POSITION private var mSlideStateStartPosition = RecyclerView.NO_POSITION private var mHaveCalledSelectStart = false private val mScrollRunnable: Runnable by lazy { object : Runnable { override fun run() { if (mIsScrolling) { scrollBy(mScrollDistance) ViewCompat.postOnAnimation(mRecyclerView!!, this) } } } } private val mOnItemTouchListener: OnItemTouchListener by lazy { object : OnItemTouchListener { override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean { Logger.d( "onInterceptTouchEvent: x:" + e.x + ",y:" + e.y + ", " + MotionEvent.actionToString(e.action) ) val adapter = rv.adapter if (adapter == null || adapter.itemCount == 0) { return false } var intercept = false val action = e.action when (action and MotionEvent.ACTION_MASK) { MotionEvent.ACTION_DOWN -> { mDownY = e.y // call the selection start's callback before moving if (mSelectState == SELECT_STATE_SLIDE && isInSlideArea(e)) { mSlideStateStartPosition = getItemPosition(rv, e) if (mSlideStateStartPosition != RecyclerView.NO_POSITION) { mCallback.onSelectStart(mSlideStateStartPosition) mHaveCalledSelectStart = true } intercept = true } } MotionEvent.ACTION_MOVE -> if (mSelectState == SELECT_STATE_DRAG_FROM_NORMAL || mSelectState == SELECT_STATE_DRAG_FROM_SLIDE ) { Logger.i("onInterceptTouchEvent: drag mode move") intercept = true } MotionEvent.ACTION_UP -> { if (mSelectState == SELECT_STATE_DRAG_FROM_NORMAL || mSelectState == SELECT_STATE_DRAG_FROM_SLIDE ) { intercept = true } // finger is lifted before moving if (mSlideStateStartPosition != RecyclerView.NO_POSITION) { selectFinished(mSlideStateStartPosition) mSlideStateStartPosition = RecyclerView.NO_POSITION } // selection has triggered if (mStart != RecyclerView.NO_POSITION) { selectFinished(mEnd) } } MotionEvent.ACTION_CANCEL -> { if (mSlideStateStartPosition != RecyclerView.NO_POSITION) { selectFinished(mSlideStateStartPosition) mSlideStateStartPosition = RecyclerView.NO_POSITION } if (mStart != RecyclerView.NO_POSITION) { selectFinished(mEnd) } } else -> { } } // Intercept only when the selection is triggered Logger.d("intercept result: $intercept") return intercept } override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) { if (!isActivated) { return } Logger.d( "onTouchEvent: x:" + e.x + ",y:" + e.y + ", " + MotionEvent.actionToString(e.action) ) val action = e.action when (action and MotionEvent.ACTION_MASK) { MotionEvent.ACTION_MOVE -> { if (mSlideStateStartPosition != RecyclerView.NO_POSITION) { selectFirstItem(mSlideStateStartPosition) // selection is triggered mSlideStateStartPosition = RecyclerView.NO_POSITION Logger.i("onTouchEvent: after slide mode down") } processAutoScroll(e) if (!mIsInTopHotspot && !mIsInBottomHotspot) { updateSelectedRange(rv, e) } } MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { if (mSlideStateStartPosition != RecyclerView.NO_POSITION) { selectFirstItem(mSlideStateStartPosition) // selection is triggered mSlideStateStartPosition = RecyclerView.NO_POSITION Logger.i("onTouchEvent: after slide mode down") } if (!mIsInTopHotspot && !mIsInBottomHotspot) { updateSelectedRange(rv, e) } selectFinished(mEnd) } } } override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { if (disallowIntercept) { inactiveSelect() } } } } init { setHotspotRatio(DEFAULT_HOTSPOT_RATIO) setHotspotOffset(DEFAULT_HOTSPOT_OFFSET) setMaximumVelocity(DEFAULT_MAX_SCROLL_VELOCITY) setEdgeType(DEFAULT_EDGE_TYPE) setAutoEnterSlideState(false) setAllowDragInSlideState(false) setSlideArea(0, 0) } /** * Attaches the DragSelectTouchHelper to the provided RecyclerView. If TouchHelper is already * attached to a RecyclerView, it will first detach from the previous one. You can call this * method with `null` to detach it from the current RecyclerView. * * @param recyclerView The RecyclerView instance to which you want to add this helper or * `null` if you want to remove DragSelectTouchHelper from the * current RecyclerView. */ fun attachToRecyclerView(recyclerView: RecyclerView?) { if (mRecyclerView === recyclerView) { return // nothing to do } mRecyclerView?.removeOnItemTouchListener(mOnItemTouchListener) mRecyclerView = recyclerView mRecyclerView?.let { it.addOnItemTouchListener(mOnItemTouchListener) it.addOnLayoutChangeListener(mOnLayoutChangeListener) } } /** * Activate the slide selection mode. */ fun activeSlideSelect() { activeSelectInternal(RecyclerView.NO_POSITION) } /** * Activate the selection mode with selected item position. Normally called on long press. * * @param position Indicates the position of selected item. */ fun activeDragSelect(position: Int) { activeSelectInternal(position) } /** * Exit the selection mode. */ fun inactiveSelect() { if (isActivated) { selectFinished(mEnd) } else { selectFinished(RecyclerView.NO_POSITION) } Logger.logSelectStateChange(mSelectState, SELECT_STATE_NORMAL) mSelectState = SELECT_STATE_NORMAL } /** * To determine whether it is in the selection mode. * * @return true if is in the selection mode. */ val isActivated: Boolean get() = mSelectState != SELECT_STATE_NORMAL /** * Sets hotspot height by ratio of RecyclerView. * * @param ratio range (0, 0.5). * @return The select helper, which may used to chain setter calls. */ fun setHotspotRatio(ratio: Float): DragSelectTouchHelper { mHotspotHeightRatio = ratio return this } /** * Sets hotspot height. * * @param hotspotHeight hotspot height which unit is dp. * @return The select helper, which may used to chain setter calls. */ fun setHotspotHeight(hotspotHeight: Int): DragSelectTouchHelper { mHotspotHeight = dp2px(hotspotHeight.toFloat()).toFloat() return this } /** * Sets hotspot offset. It don't need to be set if no special requirement. * * @param hotspotOffset hotspot offset which unit is dp. * @return The select helper, which may used to chain setter calls. */ fun setHotspotOffset(hotspotOffset: Int): DragSelectTouchHelper { mHotspotOffset = dp2px(hotspotOffset.toFloat()).toFloat() return this } /** * Sets the activation edge type, one of: * * * [EdgeType.INSIDE] for edges that respond to touches inside * the bounds of the host view. If touch moves outside the bounds, scrolling * will stop. * * [EdgeType.INSIDE_EXTEND] for inside edges that continued to * scroll when touch moves outside the bounds of the host view. * * * @param type The type of edge to use. * @return The select helper, which may used to chain setter calls. */ fun setEdgeType(type: EdgeType?): DragSelectTouchHelper { when (type) { EdgeType.INSIDE -> { mScrollAboveTopRegion = false mScrollBelowBottomRegion = false } EdgeType.INSIDE_EXTEND -> { mScrollAboveTopRegion = true mScrollBelowBottomRegion = true } else -> { mScrollAboveTopRegion = true mScrollBelowBottomRegion = true } } return this } /** * Sets sliding area's start and end, has been considered RTL situation * * @param startDp The start of the sliding area * @param endDp The end of the sliding area * @return The select helper, which may used to chain setter calls. */ fun setSlideArea(startDp: Int, endDp: Int): DragSelectTouchHelper { if (!isRtl) { mSlideAreaLeft = dp2px(startDp.toFloat()).toFloat() mSlideAreaRight = dp2px(endDp.toFloat()).toFloat() } else { val displayWidth = mDisplayMetrics.widthPixels mSlideAreaLeft = displayWidth - dp2px(endDp.toFloat()).toFloat() mSlideAreaRight = displayWidth - dp2px(startDp.toFloat()).toFloat() } return this } /** * Sets the maximum velocity for scrolling * * @param velocity maximum velocity * @return The select helper, which may used to chain setter calls. */ fun setMaximumVelocity(velocity: Int): DragSelectTouchHelper { mMaximumVelocity = (velocity * mDisplayMetrics.density + 0.5f).toInt() return this } /** * Sets whether should auto enter slide mode after drag select finished. * It's usefully for LinearLayout RecyclerView. * * @param autoEnterSlideState should auto enter slide mode * @return The select helper, which may used to chain setter calls. */ fun setAutoEnterSlideState(autoEnterSlideState: Boolean): DragSelectTouchHelper { mShouldAutoChangeState = autoEnterSlideState return this } /** * Sets whether can drag selection in slide select mode. * It's usefully for LinearLayout RecyclerView. * * @param allowDragInSlideState allow drag selection in slide select mode * @return The select helper, which may used to chain setter calls. */ fun setAllowDragInSlideState(allowDragInSlideState: Boolean): DragSelectTouchHelper { mIsAllowDragInSlideState = allowDragInSlideState return this } private fun init(rvHeight: Int) { if (mHotspotOffset >= rvHeight * MAX_HOTSPOT_RATIO) { mHotspotOffset = rvHeight * MAX_HOTSPOT_RATIO } // The height of hotspot area is not set, using (RV height x ratio) if (mHotspotHeight <= 0) { if (mHotspotHeightRatio <= 0 || mHotspotHeightRatio >= MAX_HOTSPOT_RATIO) { mHotspotHeightRatio = DEFAULT_HOTSPOT_RATIO } mHotspotHeight = rvHeight * mHotspotHeightRatio } else { if (mHotspotHeight >= rvHeight * MAX_HOTSPOT_RATIO) { mHotspotHeight = rvHeight * MAX_HOTSPOT_RATIO } } mTopRegionFrom = mHotspotOffset mTopRegionTo = mTopRegionFrom + mHotspotHeight mBottomRegionTo = rvHeight - mHotspotOffset mBottomRegionFrom = mBottomRegionTo - mHotspotHeight if (mTopRegionTo > mBottomRegionFrom) { mBottomRegionFrom = (rvHeight shr 1.toFloat().toInt()).toFloat() mTopRegionTo = mBottomRegionFrom } Logger.d( "Hotspot: [" + mTopRegionFrom + ", " + mTopRegionTo + "], [" + mBottomRegionFrom + ", " + mBottomRegionTo + "]" ) } private fun activeSelectInternal(position: Int) { // We should initialize the hotspot here, because its data may be delayed load mRecyclerView?.let { init(it.height) } if (position == RecyclerView.NO_POSITION) { Logger.logSelectStateChange(mSelectState, SELECT_STATE_SLIDE) mSelectState = SELECT_STATE_SLIDE } else { if (!mHaveCalledSelectStart) { mCallback.onSelectStart(position) mHaveCalledSelectStart = true } if (mSelectState == SELECT_STATE_SLIDE) { if (mIsAllowDragInSlideState && selectFirstItem(position)) { Logger.logSelectStateChange(mSelectState, SELECT_STATE_DRAG_FROM_SLIDE) mSelectState = SELECT_STATE_DRAG_FROM_SLIDE } } else if (mSelectState == SELECT_STATE_NORMAL) { if (selectFirstItem(position)) { Logger.logSelectStateChange(mSelectState, SELECT_STATE_DRAG_FROM_NORMAL) mSelectState = SELECT_STATE_DRAG_FROM_NORMAL } } else { Logger.e("activeSelect in unexpected state: $mSelectState") } } } private fun selectFirstItem(position: Int): Boolean { val selectFirstItemSucceed = mCallback.onSelectChange(position, true) // The drag select feature is only available if the first item is available for selection if (selectFirstItemSucceed) { mStart = position mEnd = position mLastRealStart = position mLastRealEnd = position } return selectFirstItemSucceed } private fun updateSelectedRange(rv: RecyclerView, e: MotionEvent) { updateSelectedRange(rv, e.x, e.y) } private fun updateSelectedRange(rv: RecyclerView, x: Float, y: Float) { val position = getItemPosition(rv, x, y) if (position != RecyclerView.NO_POSITION && mEnd != position) { mEnd = position notifySelectRangeChange() } } private fun notifySelectRangeChange() { if (mStart == RecyclerView.NO_POSITION || mEnd == RecyclerView.NO_POSITION) { return } val newStart: Int = min(mStart, mEnd) val newEnd: Int = max(mStart, mEnd) if (mLastRealStart == RecyclerView.NO_POSITION || mLastRealEnd == RecyclerView.NO_POSITION) { if (newEnd - newStart == 1) { notifySelectChange(newStart, newStart, true) } else { notifySelectChange(newStart, newEnd, true) } } else { if (newStart > mLastRealStart) { notifySelectChange(mLastRealStart, newStart - 1, false) } else if (newStart < mLastRealStart) { notifySelectChange(newStart, mLastRealStart - 1, true) } if (newEnd > mLastRealEnd) { notifySelectChange(mLastRealEnd + 1, newEnd, true) } else if (newEnd < mLastRealEnd) { notifySelectChange(newEnd + 1, mLastRealEnd, false) } } mLastRealStart = newStart mLastRealEnd = newEnd } private fun notifySelectChange(start: Int, end: Int, newState: Boolean) { for (i in start..end) { mCallback.onSelectChange(i, newState) } } private fun selectFinished(lastItem: Int) { if (lastItem != RecyclerView.NO_POSITION) { mCallback.onSelectEnd(lastItem) } mStart = RecyclerView.NO_POSITION mEnd = RecyclerView.NO_POSITION mLastRealStart = RecyclerView.NO_POSITION mLastRealEnd = RecyclerView.NO_POSITION mHaveCalledSelectStart = false mIsInTopHotspot = false mIsInBottomHotspot = false stopAutoScroll() when (mSelectState) { SELECT_STATE_DRAG_FROM_NORMAL -> mSelectState = if (mShouldAutoChangeState) { Logger.logSelectStateChange( mSelectState, SELECT_STATE_SLIDE ) SELECT_STATE_SLIDE } else { Logger.logSelectStateChange( mSelectState, SELECT_STATE_NORMAL ) SELECT_STATE_NORMAL } SELECT_STATE_DRAG_FROM_SLIDE -> { Logger.logSelectStateChange(mSelectState, SELECT_STATE_SLIDE) mSelectState = SELECT_STATE_SLIDE } else -> { } } } /** * Process motion event, according to the location to determine whether to scroll */ private fun processAutoScroll(e: MotionEvent) { val y = e.y if (y in mTopRegionFrom..mTopRegionTo && y < mDownY) { mLastX = e.x mLastY = e.y val scrollDistanceFactor = (y - mTopRegionTo) / mHotspotHeight mScrollDistance = (mMaximumVelocity * scrollDistanceFactor).toInt() if (!mIsInTopHotspot) { mIsInTopHotspot = true startAutoScroll() mDownY = mTopRegionTo } } else if (mScrollAboveTopRegion && y < mTopRegionFrom && mIsInTopHotspot) { mLastX = e.x mLastY = mTopRegionFrom // Use the maximum speed mScrollDistance = mMaximumVelocity * -1 startAutoScroll() } else if (y in mBottomRegionFrom..mBottomRegionTo && y > mDownY) { mLastX = e.x mLastY = e.y val scrollDistanceFactor = (y - mBottomRegionFrom) / mHotspotHeight mScrollDistance = (mMaximumVelocity * scrollDistanceFactor).toInt() if (!mIsInBottomHotspot) { mIsInBottomHotspot = true startAutoScroll() mDownY = mBottomRegionFrom } } else if (mScrollBelowBottomRegion && y > mBottomRegionTo && mIsInBottomHotspot) { mLastX = e.x mLastY = mBottomRegionTo // Use the maximum speed mScrollDistance = mMaximumVelocity startAutoScroll() } else { mIsInTopHotspot = false mIsInBottomHotspot = false mLastX = Float.MIN_VALUE mLastY = Float.MIN_VALUE stopAutoScroll() } } private fun startAutoScroll() { if (!mIsScrolling) { mIsScrolling = true mRecyclerView!!.removeCallbacks(mScrollRunnable) ViewCompat.postOnAnimation(mRecyclerView!!, mScrollRunnable) } } private fun stopAutoScroll() { if (mIsScrolling) { mIsScrolling = false mRecyclerView?.removeCallbacks(mScrollRunnable) } } private fun scrollBy(distance: Int) { val scrollDistance: Int = if (distance > 0) { min(distance, mMaximumVelocity) } else { max(distance, -mMaximumVelocity) } mRecyclerView!!.scrollBy(0, scrollDistance) if (mLastX != Float.MIN_VALUE && mLastY != Float.MIN_VALUE) { updateSelectedRange(mRecyclerView!!, mLastX, mLastY) } } private fun dp2px(dpVal: Float): Int { return TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, dpVal, mDisplayMetrics ).toInt() } private val isRtl: Boolean get() = (TextUtils.getLayoutDirectionFromLocale(Locale.getDefault()) == View.LAYOUT_DIRECTION_RTL) private fun isInSlideArea(e: MotionEvent): Boolean { val x = e.x return x > mSlideAreaLeft && x < mSlideAreaRight } private fun getItemPosition(rv: RecyclerView, e: MotionEvent): Int { return getItemPosition(rv, e.x, e.y) } private fun getItemPosition(rv: RecyclerView, x: Float, y: Float): Int { val v = rv.findChildViewUnder(x, y) if (v == null) { val layoutManager = rv.layoutManager if (layoutManager is GridLayoutManager) { val lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition() val lastItemPosition = layoutManager.getItemCount() - 1 if (lastItemPosition == lastVisibleItemPosition) { return lastItemPosition } } return RecyclerView.NO_POSITION } return rv.getChildAdapterPosition(v) } /** * Edge type that specifies an activation area starting at the view bounds and extending inward. */ enum class EdgeType { /** * After activation begins, moving outside the view bounds will stop scrolling. */ INSIDE, /** * After activation begins, moving outside the view bounds will continue scrolling. */ INSIDE_EXTEND } /** * This class is the contract between DragSelectTouchHelper and your application. It lets you * update adapter when selection start/end and state changed. */ abstract class Callback { /** * Called when changing item state. * * @param position this item want to change the state to new state. * @param isSelected true if the position should be selected, false otherwise. * @return Whether to set the new state successfully. */ abstract fun onSelectChange(position: Int, isSelected: Boolean): Boolean /** * Called when selection start. * * @param start the first selected item. */ open fun onSelectStart(start: Int) {} /** * Called when selection end. * * @param end the last selected item. */ open fun onSelectEnd(end: Int) {} } /** * An advance Callback which provide 4 useful selection modes [Mode]. * * * Note: Since the state of item may be repeatedly set, in order to improve efficiency, * please process it in the Adapter */ abstract class AdvanceCallback : Callback { private var mMode: Mode? = null private var mOriginalSelection: MutableSet = mutableSetOf() private var mFirstWasSelected = false /** * Creates a SimpleCallback with default [Mode.SelectAndReverse]# mode. * * @see Mode */ constructor() { setMode(Mode.SelectAndReverse) } /** * Creates a SimpleCallback with select mode. * * @param mode the initial select mode * @see Mode */ constructor(mode: Mode?) { setMode(mode) } /** * Sets the select mode. * * @param mode The type of select mode. * @see Mode */ fun setMode(mode: Mode?) { mMode = mode } override fun onSelectStart(start: Int) { mOriginalSelection.clear() val selected = currentSelectedId() if (selected != null) { mOriginalSelection.addAll(selected) } mFirstWasSelected = mOriginalSelection.contains(getItemId(start)) } override fun onSelectEnd(end: Int) { mOriginalSelection.clear() } override fun onSelectChange(position: Int, isSelected: Boolean): Boolean { return when (mMode) { Mode.SelectAndKeep -> { updateSelectState(position, true) } Mode.SelectAndReverse -> { updateSelectState(position, isSelected) } Mode.SelectAndUndo -> { if (isSelected) { updateSelectState(position, true) } else { updateSelectState( position, mOriginalSelection.contains(getItemId(position)) ) } } Mode.ToggleAndKeep -> { updateSelectState(position, !mFirstWasSelected) } Mode.ToggleAndReverse -> { if (isSelected) { updateSelectState(position, !mFirstWasSelected) } else { updateSelectState(position, mFirstWasSelected) } } Mode.ToggleAndUndo -> { if (isSelected) { updateSelectState(position, !mFirstWasSelected) } else { updateSelectState( position, mOriginalSelection.contains(getItemId(position)) ) } } else -> // SelectAndReverse Mode updateSelectState(position, isSelected) } } /** * Get the currently selected items when selecting first item. * * @return the currently selected item's id set. */ abstract fun currentSelectedId(): Set? /** * Get the ID of the item. * * @param position item position to be judged. * @return item's identity. */ abstract fun getItemId(position: Int): T /** * Update the selection status of the position. * * @param position the position who's selection state changed. * @param isSelected true if the position should be selected, false otherwise. * @return Whether to set the state successfully. */ abstract fun updateSelectState(position: Int, isSelected: Boolean): Boolean /** * Different existing selection modes */ enum class Mode { /** * Selects the first item and applies the same state to each item you go by * and keep the state on move back */ SelectAndKeep, /** * Selects the first item and applies the same state to each item you go by * and applies inverted state on move back */ SelectAndReverse, /** * Selects the first item and applies the same state to each item you go by * and reverts to the original state on move back */ SelectAndUndo, /** * Toggles the first item and applies the same state to each item you go by * and keep the state on move back */ ToggleAndKeep, /** * Toggles the first item and applies the same state to each item you go by * and applies inverted state on move back */ ToggleAndReverse, /** * Toggles the first item and applies the same state to each item you go by * and reverts to the original state on move back */ ToggleAndUndo } } private object Logger { private val DEBUG = BuildConfig.DEBUG fun d(msg: String) { DebugLog.d(javaClass.name, msg) } fun e(msg: String) { DebugLog.e(javaClass.name, msg) } fun i(msg: String) { DebugLog.i(javaClass.name, msg) } fun logSelectStateChange(before: Int, after: Int) { i("Select state changed: " + stateName(before) + " --> " + stateName(after)) } private fun stateName(state: Int): String { return when (state) { SELECT_STATE_NORMAL -> "NormalState" SELECT_STATE_SLIDE -> "SlideState" SELECT_STATE_DRAG_FROM_NORMAL -> "DragFromNormal" SELECT_STATE_DRAG_FROM_SLIDE -> "DragFromSlide" else -> "Unknown" } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/recycler/HeaderAdapterDataObserver.kt ================================================ package io.legado.app.ui.widget.recycler import androidx.recyclerview.widget.RecyclerView @Suppress("unused") internal class HeaderAdapterDataObserver( private var adapterDataObserver: RecyclerView.AdapterDataObserver, private var headerCount: Int ) : RecyclerView.AdapterDataObserver() { override fun onChanged() { adapterDataObserver.onChanged() } override fun onItemRangeChanged(positionStart: Int, itemCount: Int) { adapterDataObserver.onItemRangeChanged(positionStart + headerCount, itemCount) } override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) { adapterDataObserver.onItemRangeChanged(positionStart + headerCount, itemCount, payload) } // 当第n个数据被获取,更新第n+1个position override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { adapterDataObserver.onItemRangeInserted(positionStart + headerCount, itemCount) } override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) { adapterDataObserver.onItemRangeRemoved(positionStart + headerCount, itemCount) } override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) { super.onItemRangeMoved(fromPosition + headerCount, toPosition + headerCount, itemCount) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/recycler/ItemTouchCallback.kt ================================================ package io.legado.app.ui.widget.recycler import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout /** * Created by GKF on 2018/3/16. */ @Suppress("MemberVisibilityCanBePrivate") class ItemTouchCallback(private val callback: Callback) : ItemTouchHelper.Callback() { private var swipeRefreshLayout: SwipeRefreshLayout? = null /** * 是否可以拖拽 */ var isCanDrag = false /** * 是否可以被滑动 */ var isCanSwipe = false /** * 当Item被长按的时候是否可以被拖拽 */ override fun isLongPressDragEnabled(): Boolean { return isCanDrag } /** * Item是否可以被滑动(H:左右滑动,V:上下滑动) */ override fun isItemViewSwipeEnabled(): Boolean { return isCanSwipe } /** * 当用户拖拽或者滑动Item的时候需要我们告诉系统滑动或者拖拽的方向 */ override fun getMovementFlags( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder ): Int { val layoutManager = recyclerView.layoutManager if (layoutManager is GridLayoutManager) {// GridLayoutManager // flag如果值是0,相当于这个功能被关闭 val dragFlag = ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT or ItemTouchHelper.UP or ItemTouchHelper.DOWN val swipeFlag = 0 // create make return makeMovementFlags(dragFlag, swipeFlag) } else if (layoutManager is LinearLayoutManager) {// linearLayoutManager val linearLayoutManager = layoutManager as LinearLayoutManager? val orientation = linearLayoutManager!!.orientation var dragFlag = 0 var swipeFlag = 0 // 为了方便理解,相当于分为横着的ListView和竖着的ListView if (orientation == LinearLayoutManager.HORIZONTAL) {// 如果是横向的布局 swipeFlag = ItemTouchHelper.UP or ItemTouchHelper.DOWN dragFlag = ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT } else if (orientation == LinearLayoutManager.VERTICAL) {// 如果是竖向的布局,相当于ListView dragFlag = ItemTouchHelper.UP or ItemTouchHelper.DOWN swipeFlag = ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT } return makeMovementFlags(dragFlag, swipeFlag) } return 0 } /** * 当Item被拖拽的时候被回调 * * @param recyclerView recyclerView * @param srcViewHolder 拖拽的ViewHolder * @param targetViewHolder 目的地的viewHolder */ override fun onMove( recyclerView: RecyclerView, srcViewHolder: RecyclerView.ViewHolder, targetViewHolder: RecyclerView.ViewHolder ): Boolean { val fromPosition: Int = srcViewHolder.bindingAdapterPosition val toPosition: Int = targetViewHolder.bindingAdapterPosition if (fromPosition < toPosition) { for (i in fromPosition until toPosition) { callback.swap(i, i + 1) } } else { for (i in fromPosition downTo toPosition + 1) { callback.swap(i, i - 1) } } return true } override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { callback.onSwiped(viewHolder.bindingAdapterPosition) } override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { super.onSelectedChanged(viewHolder, actionState) val swiping = actionState == ItemTouchHelper.ACTION_STATE_DRAG swipeRefreshLayout?.isEnabled = !swiping } override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { super.clearView(recyclerView, viewHolder) callback.onClearView(recyclerView, viewHolder) } interface Callback { /** * 当某个Item被滑动删除的时候 * * @param adapterPosition item的position */ fun onSwiped(adapterPosition: Int) { } /** * 当两个Item位置互换的时候被回调 * * @param srcPosition 拖拽的item的position * @param targetPosition 目的地的Item的position * @return 开发者处理了操作应该返回true,开发者没有处理就返回false */ fun swap(srcPosition: Int, targetPosition: Int): Boolean { return true } /** * 手指松开 */ fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/recycler/LoadMoreView.kt ================================================ package io.legado.app.ui.widget.recycler import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.View import android.widget.FrameLayout import androidx.annotation.ColorRes import io.legado.app.R import io.legado.app.databinding.ViewLoadMoreBinding import io.legado.app.lib.dialogs.alert import io.legado.app.utils.getCompatColor import io.legado.app.utils.invisible import io.legado.app.utils.visible @Suppress("unused") class LoadMoreView(context: Context, attrs: AttributeSet? = null) : FrameLayout(context, attrs) { private val binding = ViewLoadMoreBinding.inflate(LayoutInflater.from(context), this) private var errorMsg = "" private var onClickListener: OnClickListener? = null var isLoading = false private set var hasMore = true private set init { super.setOnClickListener { if (!showErrorDialog(it)) { onClickListener?.onClick(it) } } } override fun setOnClickListener(l: OnClickListener?) { this.onClickListener = l } override fun onAttachedToWindow() { super.onAttachedToWindow() layoutParams.width = LayoutParams.MATCH_PARENT } fun startLoad() { isLoading = true binding.tvText.invisible() binding.rotateLoading.visible() } fun stopLoad() { isLoading = false binding.rotateLoading.inVisible() } fun hasMore() { errorMsg = "" hasMore = true startLoad() } fun noMore(msg: String? = null) { stopLoad() errorMsg = "" hasMore = false if (msg != null) { binding.tvText.text = msg } else { binding.tvText.setText(R.string.bottom_line) } binding.tvText.visible() } fun error(msg: String?, text: String = "") { stopLoad() hasMore = false errorMsg = msg ?: "" binding.tvText.text = text.ifEmpty { context.getString(R.string.error_load_msg, "点击查看详情") } binding.tvText.visible() } fun setLoadingColor(@ColorRes color: Int) { binding.rotateLoading.loadingColor = context.getCompatColor(color) } fun setLoadingTextColor(@ColorRes color: Int) { binding.tvText.setTextColor(context.getCompatColor(color)) } private fun showErrorDialog(view: View): Boolean { if (errorMsg.isBlank()) { return false } context.alert(R.string.error) { setMessage(errorMsg) if (onClickListener != null) { neutralButton(R.string.retry) { onClickListener?.onClick(view) } } } return true } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/recycler/NoChildScrollLinearLayoutManager.kt ================================================ package io.legado.app.ui.widget.recycler import android.content.Context import android.util.AttributeSet import android.view.View import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView class NoChildScrollLinearLayoutManager @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, defStyleRes: Int = 0 ) : LinearLayoutManager(context, attrs, defStyleAttr, defStyleRes) { override fun onRequestChildFocus( parent: RecyclerView, state: RecyclerView.State, child: View, focused: View? ): Boolean { return true } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/recycler/RecyclerViewAtPager2.kt ================================================ package io.legado.app.ui.widget.recycler import android.content.Context import android.util.AttributeSet import android.view.MotionEvent import androidx.recyclerview.widget.RecyclerView import kotlin.math.abs class RecyclerViewAtPager2 : RecyclerView { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( context, attrs, defStyleAttr ) private var startX = 0 private var startY = 0 override fun dispatchTouchEvent(ev: MotionEvent): Boolean { when (ev.action) { MotionEvent.ACTION_DOWN -> { startX = ev.x.toInt() startY = ev.y.toInt() parent.requestDisallowInterceptTouchEvent(true) } MotionEvent.ACTION_MOVE -> { val endX = ev.x.toInt() val endY = ev.y.toInt() val disX = abs(endX - startX) val disY = abs(endY - startY) if (disX > disY) { if (disX > 50) { parent.requestDisallowInterceptTouchEvent(false) } } else { parent.requestDisallowInterceptTouchEvent(true) } } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> parent.requestDisallowInterceptTouchEvent(false) } return super.dispatchTouchEvent(ev) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/recycler/UpLinearLayoutManager.kt ================================================ package io.legado.app.ui.widget.recycler import android.content.Context import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearSmoothScroller @Suppress("MemberVisibilityCanBePrivate", "unused") class UpLinearLayoutManager(val context: Context) : LinearLayoutManager(context) { fun smoothScrollToPosition(position: Int) { smoothScrollToPosition(position, 0) } fun smoothScrollToPosition(position: Int, offset: Int) { val scroller = UpLinearSmoothScroller(context) scroller.targetPosition = position scroller.offset = offset startSmoothScroll(scroller) } class UpLinearSmoothScroller(context: Context?) : LinearSmoothScroller(context) { var offset = 0 override fun getVerticalSnapPreference(): Int { return SNAP_TO_START } override fun getHorizontalSnapPreference(): Int { return SNAP_TO_START } override fun calculateDtToFit( viewStart: Int, viewEnd: Int, boxStart: Int, boxEnd: Int, snapPreference: Int ): Int { if (snapPreference == SNAP_TO_START) { return boxStart - viewStart + offset } throw IllegalArgumentException("snap preference should be SNAP_TO_START") } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/recycler/VerticalDivider.kt ================================================ package io.legado.app.ui.widget.recycler import android.content.Context import androidx.core.content.ContextCompat import androidx.recyclerview.widget.DividerItemDecoration import io.legado.app.R class VerticalDivider(context: Context) : DividerItemDecoration(context, VERTICAL) { init { ContextCompat.getDrawable(context, R.drawable.ic_divider)?.let { this.setDrawable(it) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/recycler/ViewPager2Container.kt ================================================ package io.legado.app.ui.widget.recycler import android.content.Context import android.util.AttributeSet import android.view.MotionEvent import android.widget.RelativeLayout import androidx.viewpager2.widget.ViewPager2 import kotlin.math.abs @Suppress("unused") class ViewPager2Container @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : RelativeLayout(context, attrs, defStyleAttr) { private var mViewPager2: ViewPager2? = null private var disallowParentInterceptDownEvent = true private var startX = 0 private var startY = 0 override fun onFinishInflate() { super.onFinishInflate() for (i in 0 until childCount) { val childView = getChildAt(i) if (childView is ViewPager2) { mViewPager2 = childView break } } if (mViewPager2 == null) { throw IllegalStateException("The root child of ViewPager2Container must contains a ViewPager2") } } override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { val doNotNeedIntercept = (!mViewPager2!!.isUserInputEnabled || (mViewPager2?.adapter != null && mViewPager2?.adapter!!.itemCount <= 1)) if (doNotNeedIntercept) { return super.onInterceptTouchEvent(ev) } when (ev.action) { MotionEvent.ACTION_DOWN -> { startX = ev.x.toInt() startY = ev.y.toInt() parent.requestDisallowInterceptTouchEvent(!disallowParentInterceptDownEvent) } MotionEvent.ACTION_MOVE -> { val endX = ev.x.toInt() val endY = ev.y.toInt() val disX = abs(endX - startX) val disY = abs(endY - startY) if (mViewPager2!!.orientation == ViewPager2.ORIENTATION_VERTICAL) { onVerticalActionMove(endY, disX, disY) } else if (mViewPager2!!.orientation == ViewPager2.ORIENTATION_HORIZONTAL) { onHorizontalActionMove(endX, disX, disY) } } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> parent.requestDisallowInterceptTouchEvent( false ) } return super.onInterceptTouchEvent(ev) } private fun onHorizontalActionMove(endX: Int, disX: Int, disY: Int) { if (mViewPager2?.adapter == null) { return } if (disX > disY) { val currentItem = mViewPager2?.currentItem val itemCount = mViewPager2?.adapter!!.itemCount if (currentItem == 0 && endX - startX > 0) { parent.requestDisallowInterceptTouchEvent(false) } else { parent.requestDisallowInterceptTouchEvent( currentItem != itemCount - 1 || endX - startX >= 0 ) } } else if (disY > disX) { parent.requestDisallowInterceptTouchEvent(false) } } private fun onVerticalActionMove(endY: Int, disX: Int, disY: Int) { if (mViewPager2?.adapter == null) { return } val currentItem = mViewPager2?.currentItem val itemCount = mViewPager2?.adapter!!.itemCount if (disY > disX) { if (currentItem == 0 && endY - startY > 0) { parent.requestDisallowInterceptTouchEvent(false) } else { parent.requestDisallowInterceptTouchEvent( currentItem != itemCount - 1 || endY - startY >= 0 ) } } else if (disX > disY) { parent.requestDisallowInterceptTouchEvent(false) } } /** * 设置是否允许在当前View的{@link MotionEvent#ACTION_DOWN}事件中禁止父View对事件的拦截,该方法 * 用于解决CoordinatorLayout+CollapsingToolbarLayout在嵌套ViewPager2Container时引起的滑动冲突问题。 * * 设置是否允许在ViewPager2Container的{@link MotionEvent#ACTION_DOWN}事件中禁止父View对事件的拦截,该方法 * 用于解决CoordinatorLayout+CollapsingToolbarLayout在嵌套ViewPager2Container时引起的滑动冲突问题。 * * @param disallowParentInterceptDownEvent 是否允许ViewPager2Container在{@link MotionEvent#ACTION_DOWN}事件中禁止父View拦截事件,默认值为false * true 不允许ViewPager2Container在{@link MotionEvent#ACTION_DOWN}时间中禁止父View的时间拦截, * 设置disallowIntercept为true可以解决CoordinatorLayout+CollapsingToolbarLayout的滑动冲突 * false 允许ViewPager2Container在{@link MotionEvent#ACTION_DOWN}时间中禁止父View的时间拦截, */ fun disallowParentInterceptDownEvent(disallowParentInterceptDownEvent: Boolean) { this.disallowParentInterceptDownEvent = disallowParentInterceptDownEvent } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/recycler/scroller/FastScrollRecyclerView.kt ================================================ package io.legado.app.ui.widget.recycler.scroller import android.content.Context import android.util.AttributeSet import android.view.ViewGroup import android.widget.FrameLayout import android.widget.RelativeLayout import androidx.annotation.ColorInt import androidx.constraintlayout.widget.ConstraintLayout import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.recyclerview.widget.RecyclerView import io.legado.app.R @Suppress("MemberVisibilityCanBePrivate", "unused") class FastScrollRecyclerView : RecyclerView { private lateinit var mFastScroller: FastScroller constructor(context: Context) : super(context) { layout(context, null) layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) } @JvmOverloads constructor( context: Context, attrs: AttributeSet, defStyleAttr: Int = 0 ) : super(context, attrs, defStyleAttr) { layout(context, attrs) } private fun layout(context: Context, attrs: AttributeSet?) { mFastScroller = FastScroller(context, attrs) mFastScroller.id = R.id.fast_scroller } override fun setAdapter(adapter: Adapter<*>?) { super.setAdapter(adapter) if (adapter is FastScroller.SectionIndexer) { setSectionIndexer(adapter as FastScroller.SectionIndexer?) } else if (adapter == null) { setSectionIndexer(null) } } override fun setVisibility(visibility: Int) { super.setVisibility(visibility) mFastScroller.visibility = visibility } /** * Set the [FastScroller.SectionIndexer] for the [FastScroller]. * * @param sectionIndexer The SectionIndexer that provides section text for the FastScroller */ fun setSectionIndexer(sectionIndexer: FastScroller.SectionIndexer?) { mFastScroller.setSectionIndexer(sectionIndexer) } /** * Set the enabled state of fast scrolling. * * @param enabled True to enable fast scrolling, false otherwise */ fun setFastScrollEnabled(enabled: Boolean) { mFastScroller.isEnabled = enabled } /** * Hide the scrollbar when not scrolling. * * @param hideScrollbar True to hide the scrollbar, false to show */ fun setHideScrollbar(hideScrollbar: Boolean) { mFastScroller.setFadeScrollbar(hideScrollbar) } /** * Display a scroll track while scrolling. * * @param visible True to show scroll track, false to hide */ fun setTrackVisible(visible: Boolean) { mFastScroller.setTrackVisible(visible) } /** * Set the color of the scroll track. * * @param color The color for the scroll track */ fun setTrackColor(@ColorInt color: Int) { mFastScroller.setTrackColor(color) } /** * Set the color for the scroll handle. * * @param color The color for the scroll handle */ fun setHandleColor(@ColorInt color: Int) { mFastScroller.setHandleColor(color) } /** * Show the section bubble while scrolling. * * @param visible True to show the bubble, false to hide */ fun setBubbleVisible(visible: Boolean) { mFastScroller.setBubbleVisible(visible) } /** * Set the background color of the index bubble. * * @param color The background color for the index bubble */ fun setBubbleColor(@ColorInt color: Int) { mFastScroller.setBubbleColor(color) } /** * Set the text color of the index bubble. * * @param color The text color for the index bubble */ fun setBubbleTextColor(@ColorInt color: Int) { mFastScroller.setBubbleTextColor(color) } /** * Set the fast scroll state change listener. * * @param fastScrollStateChangeListener The interface that will listen to fastscroll state change events */ fun setFastScrollStateChangeListener(fastScrollStateChangeListener: FastScrollStateChangeListener) { mFastScroller.setFastScrollStateChangeListener(fastScrollStateChangeListener) } override fun onAttachedToWindow() { super.onAttachedToWindow() mFastScroller.attachRecyclerView(this) var parent = parent while (parent != null) { when (parent) { is ConstraintLayout, is CoordinatorLayout, is FrameLayout, is RelativeLayout -> break else -> parent = parent.parent } } if (parent is ViewGroup && parent.indexOfChild(mFastScroller) == -1) { parent.addView(mFastScroller) mFastScroller.setLayoutParams(parent) } } override fun onDetachedFromWindow() { mFastScroller.detachRecyclerView() super.onDetachedFromWindow() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/recycler/scroller/FastScrollStateChangeListener.kt ================================================ package io.legado.app.ui.widget.recycler.scroller interface FastScrollStateChangeListener { /** * Called when fast scrolling begins */ fun onFastScrollStart(fastScroller: FastScroller) /** * Called when fast scrolling ends */ fun onFastScrollStop(fastScroller: FastScroller) } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/recycler/scroller/FastScroller.kt ================================================ package io.legado.app.ui.widget.recycler.scroller import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.annotation.SuppressLint import android.content.Context import android.graphics.Color import android.graphics.drawable.Drawable import android.util.AttributeSet import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.view.ViewPropertyAnimator import android.widget.* import androidx.annotation.ColorInt import androidx.annotation.IdRes import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintSet import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.DrawableCompat import androidx.core.view.GravityCompat import androidx.core.view.ViewCompat import androidx.core.view.isVisible import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.StaggeredGridLayoutManager import io.legado.app.R import io.legado.app.lib.theme.accentColor import io.legado.app.utils.ColorUtils import io.legado.app.utils.getCompatColor import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt @Suppress("SameParameterValue") class FastScroller : LinearLayout { @ColorInt private var mBubbleColor: Int = 0 @ColorInt private var mHandleColor: Int = 0 private var mBubbleHeight: Int = 0 private var mHandleHeight: Int = 0 private var mViewHeight: Int = 0 private var mFadeScrollbar: Boolean = false private var mShowBubble: Boolean = false private var mSectionIndexer: SectionIndexer? = null private var mScrollbarAnimator: ViewPropertyAnimator? = null private var mBubbleAnimator: ViewPropertyAnimator? = null private var mRecyclerView: RecyclerView? = null private lateinit var mBubbleView: TextView private lateinit var mHandleView: ImageView private lateinit var mTrackView: ImageView private lateinit var mScrollbar: View private var mBubbleImage: Drawable? = null private var mHandleImage: Drawable? = null private var mTrackImage: Drawable? = null private var mFastScrollStateChangeListener: FastScrollStateChangeListener? = null private val mScrollbarHider = Runnable { this.hideScrollbar() } private val mScrollListener = object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { if (!mHandleView.isSelected && isEnabled) { setViewPositions(getScrollProportion(recyclerView)) } } override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { super.onScrollStateChanged(recyclerView, newState) if (isEnabled) { when (newState) { RecyclerView.SCROLL_STATE_DRAGGING -> { handler.removeCallbacks(mScrollbarHider) cancelAnimation(mScrollbarAnimator) if (!isViewVisible(mScrollbar)) { showScrollbar() } } RecyclerView.SCROLL_STATE_IDLE -> if (mFadeScrollbar && !mHandleView.isSelected) { handler.postDelayed(mScrollbarHider, sScrollbarHideDelay.toLong()) } } } } } constructor(context: Context) : super(context) { layout(context, null) layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT) } @JvmOverloads constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int = 0) : super( context, attrs, defStyleAttr ) { layout(context, attrs) layoutParams = generateLayoutParams(attrs) } override fun setLayoutParams(params: ViewGroup.LayoutParams) { params.width = LayoutParams.WRAP_CONTENT super.setLayoutParams(params) } fun setLayoutParams(viewGroup: ViewGroup) { @IdRes val recyclerViewId = mRecyclerView?.id ?: View.NO_ID val marginTop = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_top) val marginBottom = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_bottom) require(recyclerViewId != View.NO_ID) { "RecyclerView must have a view ID" } when (viewGroup) { is ConstraintLayout -> { val constraintSet = ConstraintSet() @IdRes val layoutId = id constraintSet.clone(viewGroup) constraintSet.connect( layoutId, ConstraintSet.TOP, recyclerViewId, ConstraintSet.TOP ) constraintSet.connect( layoutId, ConstraintSet.BOTTOM, recyclerViewId, ConstraintSet.BOTTOM ) constraintSet.connect( layoutId, ConstraintSet.END, recyclerViewId, ConstraintSet.END ) constraintSet.applyTo(viewGroup) val layoutParams = layoutParams as ConstraintLayout.LayoutParams layoutParams.setMargins(0, marginTop, 0, marginBottom) setLayoutParams(layoutParams) } is CoordinatorLayout -> { val layoutParams = layoutParams as CoordinatorLayout.LayoutParams layoutParams.anchorId = recyclerViewId layoutParams.anchorGravity = GravityCompat.END layoutParams.setMargins(0, marginTop, 0, marginBottom) setLayoutParams(layoutParams) } is FrameLayout -> { val layoutParams = layoutParams as FrameLayout.LayoutParams layoutParams.gravity = GravityCompat.END layoutParams.setMargins(0, marginTop, 0, marginBottom) setLayoutParams(layoutParams) } is RelativeLayout -> { val layoutParams = layoutParams as RelativeLayout.LayoutParams val endRule = RelativeLayout.ALIGN_END layoutParams.addRule(RelativeLayout.ALIGN_TOP, recyclerViewId) layoutParams.addRule(RelativeLayout.ALIGN_BOTTOM, recyclerViewId) layoutParams.addRule(endRule, recyclerViewId) layoutParams.setMargins(0, marginTop, 0, marginBottom) setLayoutParams(layoutParams) } else -> throw IllegalArgumentException("Parent ViewGroup must be a ConstraintLayout, CoordinatorLayout, FrameLayout, or RelativeLayout") } updateViewHeights() } fun setSectionIndexer(sectionIndexer: SectionIndexer?) { mSectionIndexer = sectionIndexer } fun attachRecyclerView(recyclerView: RecyclerView) { mRecyclerView = recyclerView mRecyclerView!!.addOnScrollListener(mScrollListener) post { // set initial positions for bubble and handle setViewPositions(getScrollProportion(mRecyclerView)) } } fun detachRecyclerView() { if (mRecyclerView != null) { mRecyclerView!!.removeOnScrollListener(mScrollListener) mRecyclerView = null } } /** * Hide the scrollbar when not scrolling. * * @param fadeScrollbar True to hide the scrollbar, false to show */ fun setFadeScrollbar(fadeScrollbar: Boolean) { mFadeScrollbar = fadeScrollbar mScrollbar.visibility = if (fadeScrollbar) View.INVISIBLE else View.VISIBLE } /** * Show the section bubble while scrolling. * * @param visible True to show the bubble, false to hide */ fun setBubbleVisible(visible: Boolean) { mShowBubble = visible } /** * Display a scroll track while scrolling. * * @param visible True to show scroll track, false to hide */ fun setTrackVisible(visible: Boolean) { mTrackView.visibility = if (visible) View.VISIBLE else View.INVISIBLE } /** * Set the color of the scroll track. * * @param color The color for the scroll track */ fun setTrackColor(@ColorInt color: Int) { if (mTrackImage == null) { val drawable = ContextCompat.getDrawable(context, R.drawable.fastscroll_track) if (drawable != null) { mTrackImage = DrawableCompat.wrap(drawable) } } DrawableCompat.setTint(mTrackImage!!, color) mTrackView.setImageDrawable(mTrackImage) } /** * Set the color for the scroll handle. * * @param color The color for the scroll handle */ fun setHandleColor(@ColorInt color: Int) { mHandleColor = color if (mHandleImage == null) { val drawable = ContextCompat.getDrawable(context, R.drawable.fastscroll_handle) if (drawable != null) { mHandleImage = DrawableCompat.wrap(drawable) } } DrawableCompat.setTint(mHandleImage!!, mHandleColor) mHandleView.setImageDrawable(mHandleImage) } /** * Set the background color of the index bubble. * * @param color The background color for the index bubble */ fun setBubbleColor(@ColorInt color: Int) { mBubbleColor = color if (mBubbleImage == null) { val drawable = ContextCompat.getDrawable(context, R.drawable.fastscroll_bubble) if (drawable != null) { mBubbleImage = DrawableCompat.wrap(drawable) } } DrawableCompat.setTint(mBubbleImage!!, mBubbleColor) mBubbleView.background = mBubbleImage } /** * Set the text color of the index bubble. * * @param color The text color for the index bubble */ fun setBubbleTextColor(@ColorInt color: Int) { mBubbleView.setTextColor(color) } /** * Set the fast scroll state change listener. * * @param fastScrollStateChangeListener The interface that will listen to fastscroll state change events */ fun setFastScrollStateChangeListener(fastScrollStateChangeListener: FastScrollStateChangeListener) { mFastScrollStateChangeListener = fastScrollStateChangeListener } override fun setEnabled(enabled: Boolean) { super.setEnabled(enabled) visibility = if (enabled) View.VISIBLE else View.INVISIBLE } @Suppress("DEPRECATION") @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { when (event.action) { MotionEvent.ACTION_DOWN -> { if (event.x < mHandleView.x - ViewCompat.getPaddingStart(mHandleView)) { return false } if (!mScrollbar.isVisible) { return false } requestDisallowInterceptTouchEvent(true) setHandleSelected(true) handler.removeCallbacks(mScrollbarHider) cancelAnimation(mScrollbarAnimator) cancelAnimation(mBubbleAnimator) if (mShowBubble && mSectionIndexer != null) { showBubble() } if (mFastScrollStateChangeListener != null) { mFastScrollStateChangeListener!!.onFastScrollStart(this) } val y = event.y setViewPositions(y) setRecyclerViewPosition(y) return true } MotionEvent.ACTION_MOVE -> { val y = event.y setViewPositions(y) setRecyclerViewPosition(y) return true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { requestDisallowInterceptTouchEvent(false) setHandleSelected(false) if (mFadeScrollbar) { handler.postDelayed(mScrollbarHider, sScrollbarHideDelay.toLong()) } hideBubble() if (mFastScrollStateChangeListener != null) { mFastScrollStateChangeListener!!.onFastScrollStop(this) } return true } } return super.onTouchEvent(event) } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) mViewHeight = h } private fun setRecyclerViewPosition(y: Float) { mRecyclerView?.adapter?.let { adapter -> val itemCount = adapter.itemCount val proportion: Float = when { mHandleView.y == 0f -> 0f mHandleView.y + mHandleHeight >= mViewHeight - sTrackSnapRange -> 1f else -> y / mViewHeight.toFloat() } var scrolledItemCount = (proportion * itemCount).roundToInt() if (isLayoutReversed(mRecyclerView?.layoutManager)) { scrolledItemCount = itemCount - scrolledItemCount } val targetPos = getValueInRange(0, itemCount - 1, scrolledItemCount) mRecyclerView?.layoutManager?.scrollToPosition(targetPos) mSectionIndexer?.let { sectionIndexer -> if (mShowBubble) { mBubbleView.text = sectionIndexer.getSectionText(targetPos) } } } } private fun getScrollProportion(recyclerView: RecyclerView?): Float { recyclerView ?: return 0f val verticalScrollOffset = recyclerView.computeVerticalScrollOffset() val verticalScrollRange = recyclerView.computeVerticalScrollRange() val rangeDiff = (verticalScrollRange - mViewHeight).toFloat() val proportion = verticalScrollOffset.toFloat() / if (rangeDiff > 0) rangeDiff else 1f return mViewHeight * proportion } private fun getValueInRange(min: Int, max: Int, value: Int): Int { val minimum = max(min, value) return min(minimum, max) } private fun setViewPositions(y: Float) { mBubbleHeight = mBubbleView.height mHandleHeight = mHandleView.height val bubbleY = getValueInRange( 0, mViewHeight - mBubbleHeight - mHandleHeight / 2, (y - mBubbleHeight).toInt() ) val handleY = getValueInRange(0, mViewHeight - mHandleHeight, (y - mHandleHeight / 2).toInt()) if (mShowBubble) { mBubbleView.y = bubbleY.toFloat() } mHandleView.y = handleY.toFloat() } private fun updateViewHeights() { val measureSpec = MeasureSpec.makeMeasureSpec(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED) mBubbleView.measure(measureSpec, measureSpec) mBubbleHeight = mBubbleView.measuredHeight mHandleView.measure(measureSpec, measureSpec) mHandleHeight = mHandleView.measuredHeight } private fun isLayoutReversed(layoutManager: RecyclerView.LayoutManager?): Boolean { if (layoutManager is LinearLayoutManager) { return layoutManager.reverseLayout } else if (layoutManager is StaggeredGridLayoutManager) { return layoutManager.reverseLayout } return false } private fun isViewVisible(view: View?): Boolean { return view != null && view.visibility == View.VISIBLE } private fun cancelAnimation(animator: ViewPropertyAnimator?) { animator?.cancel() } private fun showBubble() { if (!isViewVisible(mBubbleView)) { mBubbleView.visibility = View.VISIBLE mBubbleAnimator = mBubbleView.animate().alpha(1f) .setDuration(sBubbleAnimDuration.toLong()) .setListener(object : AnimatorListenerAdapter() { // adapter required for new alpha value to stick }) } } private fun hideBubble() { if (isViewVisible(mBubbleView)) { mBubbleAnimator = mBubbleView.animate().alpha(0f) .setDuration(sBubbleAnimDuration.toLong()) .setListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { super.onAnimationEnd(animation) mBubbleView.visibility = View.INVISIBLE mBubbleAnimator = null } override fun onAnimationCancel(animation: Animator) { super.onAnimationCancel(animation) mBubbleView.visibility = View.INVISIBLE mBubbleAnimator = null } }) } } private fun showScrollbar() { mRecyclerView?.let { mRecyclerView -> if (mRecyclerView.computeVerticalScrollRange() - mViewHeight > 0) { val transX = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_padding_end) .toFloat() mScrollbar.translationX = transX mScrollbar.visibility = View.VISIBLE mScrollbarAnimator = mScrollbar.animate().translationX(0f).alpha(1f) .setDuration(sScrollbarAnimDuration.toLong()) .setListener(object : AnimatorListenerAdapter() { // adapter required for new alpha value to stick }) } } } private fun hideScrollbar() { val transX = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_padding_end).toFloat() mScrollbarAnimator = mScrollbar.animate().translationX(transX).alpha(0f) .setDuration(sScrollbarAnimDuration.toLong()) .setListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { super.onAnimationEnd(animation) mScrollbar.visibility = View.INVISIBLE mScrollbarAnimator = null } override fun onAnimationCancel(animation: Animator) { super.onAnimationCancel(animation) mScrollbar.visibility = View.INVISIBLE mScrollbarAnimator = null } }) } private fun setHandleSelected(selected: Boolean) { mHandleView.isSelected = selected DrawableCompat.setTint(mHandleImage!!, if (selected) mBubbleColor else mHandleColor) } private fun layout(context: Context, attrs: AttributeSet?) { View.inflate(context, R.layout.view_fastscroller, this) clipChildren = false orientation = HORIZONTAL mBubbleView = findViewById(R.id.fastscroll_bubble) mHandleView = findViewById(R.id.fastscroll_handle) mTrackView = findViewById(R.id.fastscroll_track) mScrollbar = findViewById(R.id.fastscroll_scrollbar) @ColorInt var bubbleColor = ColorUtils.adjustAlpha(context.accentColor, 0.8f) @ColorInt var handleColor = context.accentColor @ColorInt var trackColor = context.getCompatColor(R.color.transparent30) @ColorInt var textColor = if (ColorUtils.isColorLight(bubbleColor)) Color.BLACK else Color.WHITE var fadeScrollbar = true var showBubble = false var showTrack = true if (attrs != null) { val typedArray = context.obtainStyledAttributes(attrs, R.styleable.FastScroller, 0, 0) try { bubbleColor = typedArray.getColor(R.styleable.FastScroller_bubbleColor, bubbleColor) handleColor = typedArray.getColor(R.styleable.FastScroller_handleColor, handleColor) trackColor = typedArray.getColor(R.styleable.FastScroller_trackColor, trackColor) textColor = typedArray.getColor(R.styleable.FastScroller_bubbleTextColor, textColor) fadeScrollbar = typedArray.getBoolean(R.styleable.FastScroller_fadeScrollbar, fadeScrollbar) showBubble = typedArray.getBoolean(R.styleable.FastScroller_showBubble, showBubble) showTrack = typedArray.getBoolean(R.styleable.FastScroller_showTrack, showTrack) } finally { typedArray.recycle() } } setTrackColor(trackColor) setHandleColor(handleColor) setBubbleColor(bubbleColor) setBubbleTextColor(textColor) setFadeScrollbar(fadeScrollbar) setBubbleVisible(showBubble) setTrackVisible(showTrack) } interface SectionIndexer { fun getSectionText(position: Int): String } companion object { private const val sBubbleAnimDuration = 100 private const val sScrollbarAnimDuration = 300 private const val sScrollbarHideDelay = 1000 private const val sTrackSnapRange = 5 } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/seekbar/SeekBarChangeListener.kt ================================================ package io.legado.app.ui.widget.seekbar import android.widget.SeekBar interface SeekBarChangeListener : SeekBar.OnSeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { } override fun onStartTrackingTouch(seekBar: SeekBar) { } override fun onStopTrackingTouch(seekBar: SeekBar) { } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/seekbar/VerticalSeekBar.kt ================================================ package io.legado.app.ui.widget.seekbar import android.annotation.SuppressLint import android.content.Context import android.graphics.Canvas import android.graphics.drawable.Drawable import android.util.AttributeSet import android.view.KeyEvent import android.view.MotionEvent import android.widget.ProgressBar import androidx.appcompat.widget.AppCompatSeekBar import androidx.core.view.ViewCompat import io.legado.app.R import io.legado.app.lib.theme.accentColor import io.legado.app.utils.applyTint import java.lang.reflect.InvocationTargetException import java.lang.reflect.Method @Suppress("SameParameterValue") class VerticalSeekBar @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : AppCompatSeekBar(context, attrs) { private var mIsDragging: Boolean = false private var mThumb: Drawable? = null private var mMethodSetProgressFromUser: Method? = null private var mRotationAngle = ROTATION_ANGLE_CW_90 var rotationAngle: Int get() = mRotationAngle set(angle) { require(isValidRotationAngle(angle)) { "Invalid angle specified :$angle" } if (mRotationAngle == angle) { return } mRotationAngle = angle if (useViewRotation()) { val wrapper = wrapper wrapper?.applyViewRotation() } else { requestLayout() } } private val wrapper: VerticalSeekBarWrapper? get() { val parent = parent return if (parent is VerticalSeekBarWrapper) { parent } else { null } } init { if (!isInEditMode) { applyTint(context.accentColor) } @Suppress("DEPRECATION") ViewCompat.setLayoutDirection(this, ViewCompat.LAYOUT_DIRECTION_LTR) if (attrs != null) { val a = context.obtainStyledAttributes(attrs, R.styleable.VerticalSeekBar) val rotationAngle = a.getInteger(R.styleable.VerticalSeekBar_seekBarRotation, 0) if (isValidRotationAngle(rotationAngle)) { mRotationAngle = rotationAngle } a.recycle() } } override fun setThumb(thumb: Drawable?) { mThumb = thumb super.setThumb(thumb) } @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { return if (useViewRotation()) { onTouchEventUseViewRotation(event) } else { onTouchEventTraditionalRotation(event) } } private fun onTouchEventTraditionalRotation(event: MotionEvent): Boolean { if (!isEnabled) { return false } when (event.action) { MotionEvent.ACTION_DOWN -> { isPressed = true onStartTrackingTouch() trackTouchEvent(event) attemptClaimDrag(true) invalidate() } MotionEvent.ACTION_MOVE -> if (mIsDragging) { trackTouchEvent(event) } MotionEvent.ACTION_UP -> { if (mIsDragging) { trackTouchEvent(event) onStopTrackingTouch() isPressed = false } else { // Touch up when we never crossed the touch slop threshold // should // be interpreted as a tap-seek to that location. onStartTrackingTouch() trackTouchEvent(event) onStopTrackingTouch() attemptClaimDrag(false) } // ProgressBar doesn't know to repaint the thumb drawable // in its inactive state when the touch stops (because the // value has not apparently changed) invalidate() } MotionEvent.ACTION_CANCEL -> { if (mIsDragging) { onStopTrackingTouch() isPressed = false } invalidate() // see above explanation } } return true } private fun onTouchEventUseViewRotation(event: MotionEvent): Boolean { val handled = super.onTouchEvent(event) if (handled) { when (event.action) { MotionEvent.ACTION_DOWN -> attemptClaimDrag(true) MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> attemptClaimDrag(false) } } return handled } private fun trackTouchEvent(event: MotionEvent) { val paddingLeft = super.getPaddingLeft() val paddingRight = super.getPaddingRight() val height = height val available = height - paddingLeft - paddingRight val y = event.y.toInt() val scale: Float var value = 0f when (mRotationAngle) { ROTATION_ANGLE_CW_90 -> value = (y - paddingLeft).toFloat() ROTATION_ANGLE_CW_270 -> value = (height - paddingLeft - y).toFloat() } scale = if (value < 0 || available == 0) { 0.0f } else if (value > available) { 1.0f } else { value / available.toFloat() } val max = max val progress = scale * max setProgressFromUser(progress.toInt(), true) } /** * Tries to claim the user's drag motion, and requests disallowing any * ancestors from stealing events in the drag. */ private fun attemptClaimDrag(active: Boolean) { val parent = parent parent?.requestDisallowInterceptTouchEvent(active) } /** * This is called when the user has started touching this widget. */ private fun onStartTrackingTouch() { mIsDragging = true } /** * This is called when the user either releases his touch or the touch is * canceled. */ private fun onStopTrackingTouch() { mIsDragging = false } override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { if (isEnabled) { val handled: Boolean var direction = 0 when (keyCode) { KeyEvent.KEYCODE_DPAD_DOWN -> { direction = if (mRotationAngle == ROTATION_ANGLE_CW_90) 1 else -1 handled = true } KeyEvent.KEYCODE_DPAD_UP -> { direction = if (mRotationAngle == ROTATION_ANGLE_CW_270) 1 else -1 handled = true } KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT -> // move view focus to previous/next view return false else -> handled = false } if (handled) { val keyProgressIncrement = keyProgressIncrement var progress = progress progress += direction * keyProgressIncrement if (progress in 0..max) { setProgressFromUser(progress, true) } return true } } return super.onKeyDown(keyCode, event) } @Synchronized override fun setProgress(progress: Int) { super.setProgress(progress) if (!useViewRotation()) { refreshThumb() } } @Synchronized private fun setProgressFromUser(progress: Int, fromUser: Boolean) { if (mMethodSetProgressFromUser == null) { try { val m: Method = ProgressBar::class.java.getDeclaredMethod( "setProgress", Int::class.javaPrimitiveType, Boolean::class.javaPrimitiveType ) m.isAccessible = true mMethodSetProgressFromUser = m } catch (_: NoSuchMethodException) { } } if (mMethodSetProgressFromUser != null) { try { mMethodSetProgressFromUser!!.invoke(this, progress, fromUser) } catch (_: IllegalArgumentException) { } catch (_: IllegalAccessException) { } catch (_: InvocationTargetException) { } } else { super.setProgress(progress) } refreshThumb() } @Synchronized override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { if (useViewRotation()) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) } else { super.onMeasure(heightMeasureSpec, widthMeasureSpec) val lp = layoutParams if (isInEditMode && lp != null && lp.height >= 0) { setMeasuredDimension(super.getMeasuredHeight(), lp.height) } else { setMeasuredDimension(super.getMeasuredHeight(), super.getMeasuredWidth()) } } } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { if (useViewRotation()) { super.onSizeChanged(w, h, oldw, oldh) } else { super.onSizeChanged(h, w, oldh, oldw) } } @Synchronized override fun onDraw(canvas: Canvas) { if (!useViewRotation()) { when (mRotationAngle) { ROTATION_ANGLE_CW_90 -> { canvas.rotate(90f) canvas.translate(0f, (-super.getWidth()).toFloat()) } ROTATION_ANGLE_CW_270 -> { canvas.rotate(-90f) canvas.translate((-super.getHeight()).toFloat(), 0f) } } } super.onDraw(canvas) } // refresh thumb position private fun refreshThumb() { onSizeChanged(super.getWidth(), super.getHeight(), 0, 0) } /*package*/ internal fun useViewRotation(): Boolean { return !isInEditMode } companion object { const val ROTATION_ANGLE_CW_90 = 90 const val ROTATION_ANGLE_CW_270 = 270 private fun isValidRotationAngle(angle: Int): Boolean { return angle == ROTATION_ANGLE_CW_90 || angle == ROTATION_ANGLE_CW_270 } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/seekbar/VerticalSeekBarWrapper.kt ================================================ package io.legado.app.ui.widget.seekbar import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet import android.view.Gravity import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import androidx.core.view.ViewCompat import kotlin.math.max class VerticalSeekBarWrapper @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : FrameLayout(context, attrs) { private val childSeekBar: VerticalSeekBar? get() { val child = if (childCount > 0) getChildAt(0) else null return if (child is VerticalSeekBar) child else null } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { if (useViewRotation()) { onSizeChangedUseViewRotation(w, h, oldw, oldh) } else { onSizeChangedTraditionalRotation(w, h, oldw, oldh) } } @SuppressLint("RtlHardcoded") private fun onSizeChangedTraditionalRotation(w: Int, h: Int, oldw: Int, oldh: Int) { val seekBar = childSeekBar if (seekBar != null) { val hPadding = paddingLeft + paddingRight val vPadding = paddingTop + paddingBottom val lp = seekBar.layoutParams as LayoutParams lp.width = ViewGroup.LayoutParams.WRAP_CONTENT lp.height = max(0, h - vPadding) seekBar.layoutParams = lp seekBar.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED) val seekBarMeasuredWidth = seekBar.measuredWidth seekBar.measure( MeasureSpec.makeMeasureSpec( max(0, w - hPadding), MeasureSpec.AT_MOST ), MeasureSpec.makeMeasureSpec( max(0, h - vPadding), MeasureSpec.EXACTLY ) ) lp.gravity = Gravity.TOP or Gravity.LEFT lp.leftMargin = (max(0, w - hPadding) - seekBarMeasuredWidth) / 2 seekBar.layoutParams = lp } super.onSizeChanged(w, h, oldw, oldh) } private fun onSizeChangedUseViewRotation(w: Int, h: Int, oldw: Int, oldh: Int) { val seekBar = childSeekBar if (seekBar != null) { val hPadding = paddingLeft + paddingRight val vPadding = paddingTop + paddingBottom seekBar.measure( MeasureSpec.makeMeasureSpec( max(0, h - vPadding), MeasureSpec.EXACTLY ), MeasureSpec.makeMeasureSpec( max(0, w - hPadding), MeasureSpec.AT_MOST ) ) } applyViewRotation(w, h) super.onSizeChanged(w, h, oldw, oldh) } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val seekBar = childSeekBar val widthMode = MeasureSpec.getMode(widthMeasureSpec) val heightMode = MeasureSpec.getMode(heightMeasureSpec) val widthSize = MeasureSpec.getSize(widthMeasureSpec) val heightSize = MeasureSpec.getSize(heightMeasureSpec) if (seekBar != null && widthMode != MeasureSpec.EXACTLY) { val seekBarWidth: Int val seekBarHeight: Int val hPadding = paddingLeft + paddingRight val vPadding = paddingTop + paddingBottom val innerContentWidthMeasureSpec = MeasureSpec.makeMeasureSpec(max(0, widthSize - hPadding), widthMode) val innerContentHeightMeasureSpec = MeasureSpec.makeMeasureSpec(max(0, heightSize - vPadding), heightMode) if (useViewRotation()) { seekBar.measure(innerContentHeightMeasureSpec, innerContentWidthMeasureSpec) seekBarWidth = seekBar.measuredHeight seekBarHeight = seekBar.measuredWidth } else { seekBar.measure(innerContentWidthMeasureSpec, innerContentHeightMeasureSpec) seekBarWidth = seekBar.measuredWidth seekBarHeight = seekBar.measuredHeight } val measuredWidth = View.resolveSizeAndState(seekBarWidth + hPadding, widthMeasureSpec, 0) val measuredHeight = View.resolveSizeAndState(seekBarHeight + vPadding, heightMeasureSpec, 0) setMeasuredDimension(measuredWidth, measuredHeight) } else { super.onMeasure(widthMeasureSpec, heightMeasureSpec) } } /*package*/ internal fun applyViewRotation() { applyViewRotation(width, height) } @Suppress("DEPRECATION") private fun applyViewRotation(w: Int, h: Int) { val seekBar = childSeekBar if (seekBar != null) { val isLTR = ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_LTR val rotationAngle = seekBar.rotationAngle val seekBarMeasuredWidth = seekBar.measuredWidth val seekBarMeasuredHeight = seekBar.measuredHeight val hPadding = paddingLeft + paddingRight val vPadding = paddingTop + paddingBottom val hOffset = (max(0, w - hPadding) - seekBarMeasuredHeight) * 0.5f val lp = seekBar.layoutParams lp.width = max(0, h - vPadding) lp.height = ViewGroup.LayoutParams.WRAP_CONTENT seekBar.layoutParams = lp seekBar.pivotX = (if (isLTR) 0 else max(0, h - vPadding)).toFloat() seekBar.pivotY = 0f when (rotationAngle) { VerticalSeekBar.ROTATION_ANGLE_CW_90 -> { seekBar.rotation = 90f if (isLTR) { seekBar.translationX = seekBarMeasuredHeight + hOffset seekBar.translationY = 0f } else { seekBar.translationX = -hOffset seekBar.translationY = seekBarMeasuredWidth.toFloat() } } VerticalSeekBar.ROTATION_ANGLE_CW_270 -> { seekBar.rotation = 270f if (isLTR) { seekBar.translationX = hOffset seekBar.translationY = seekBarMeasuredWidth.toFloat() } else { seekBar.translationX = -(seekBarMeasuredHeight + hOffset) seekBar.translationY = 0f } } } } } private fun useViewRotation(): Boolean { val seekBar = childSeekBar return seekBar?.useViewRotation() ?: false } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/text/AccentBgTextView.kt ================================================ package io.legado.app.ui.widget.text import android.content.Context import android.graphics.Color import android.util.AttributeSet import androidx.appcompat.widget.AppCompatTextView import io.legado.app.R import io.legado.app.lib.theme.Selector import io.legado.app.lib.theme.ThemeStore import io.legado.app.utils.ColorUtils import io.legado.app.utils.dpToPx import io.legado.app.utils.getCompatColor class AccentBgTextView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : AppCompatTextView(context, attrs) { private var radius = 0 init { val typedArray = context.obtainStyledAttributes(attrs, R.styleable.AccentBgTextView) radius = typedArray.getDimensionPixelOffset(R.styleable.AccentBgTextView_radius, radius) typedArray.recycle() upBackground() } fun setRadius(radius: Int) { this.radius = radius.dpToPx() upBackground() } private fun upBackground() { val accentColor = if (isInEditMode) { context.getCompatColor(R.color.accent) } else { ThemeStore.accentColor(context) } background = Selector.shapeBuild() .setCornerRadius(radius) .setDefaultBgColor(accentColor) .setPressedBgColor(ColorUtils.darkenColor(accentColor)) .create() setTextColor( if (ColorUtils.isColorLight(accentColor)) { Color.BLACK } else { Color.WHITE } ) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/text/AccentStrokeTextView.kt ================================================ package io.legado.app.ui.widget.text import android.content.Context import android.util.AttributeSet import androidx.appcompat.widget.AppCompatTextView import io.legado.app.R import io.legado.app.lib.theme.Selector import io.legado.app.lib.theme.ThemeStore import io.legado.app.lib.theme.bottomBackground import io.legado.app.utils.ColorUtils import io.legado.app.utils.dpToPx import io.legado.app.utils.getCompatColor class AccentStrokeTextView(context: Context, attrs: AttributeSet) : AppCompatTextView(context, attrs) { private var radius = 3.dpToPx() private val isBottomBackground: Boolean init { val typedArray = context.obtainStyledAttributes(attrs, R.styleable.AccentStrokeTextView) radius = typedArray.getDimensionPixelOffset(R.styleable.StrokeTextView_radius, radius) isBottomBackground = typedArray.getBoolean(R.styleable.StrokeTextView_isBottomBackground, false) typedArray.recycle() upStyle() } private fun upStyle() { val isLight = ColorUtils.isColorLight(context.bottomBackground) val disableColor = if (isBottomBackground) { if (isLight) { context.getCompatColor(R.color.md_light_disabled) } else { context.getCompatColor(R.color.md_dark_disabled) } } else { context.getCompatColor(R.color.disabled) } val accentColor = if (isInEditMode) { context.getCompatColor(R.color.accent) } else { ThemeStore.accentColor(context) } background = Selector.shapeBuild() .setCornerRadius(radius) .setStrokeWidth(1.dpToPx()) .setDisabledStrokeColor(disableColor) .setDefaultStrokeColor(accentColor) .setPressedBgColor(context.getCompatColor(R.color.transparent30)) .create() setTextColor( Selector.colorBuild() .setDefaultColor(accentColor) .setDisabledColor(disableColor) .create() ) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/text/AccentTextView.kt ================================================ package io.legado.app.ui.widget.text import android.content.Context import android.util.AttributeSet import androidx.appcompat.widget.AppCompatTextView import io.legado.app.R import io.legado.app.lib.theme.accentColor import io.legado.app.utils.getCompatColor class AccentTextView(context: Context, attrs: AttributeSet?) : AppCompatTextView(context, attrs) { init { if (!isInEditMode) { setTextColor(context.accentColor) } else { setTextColor(context.getCompatColor(R.color.accent)) } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/text/AutoCompleteTextView.kt ================================================ package io.legado.app.ui.widget.text import android.annotation.SuppressLint import android.content.Context import android.os.Build import android.util.AttributeSet import android.view.LayoutInflater import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter import android.widget.ImageView import android.widget.TextView import androidx.appcompat.widget.AppCompatAutoCompleteTextView import io.legado.app.R import io.legado.app.lib.theme.accentColor import io.legado.app.utils.applyTint import io.legado.app.utils.gone import io.legado.app.utils.visible @Suppress("unused") class AutoCompleteTextView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : AppCompatAutoCompleteTextView(context, attrs) { var delCallBack: ((value: String) -> Unit)? = null init { applyTint(context.accentColor) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { isLocalePreferredLineHeightForMinimumUsed = false } } override fun enoughToFilter(): Boolean { return true } @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent?): Boolean { if (event?.action == MotionEvent.ACTION_DOWN) { showDropDown() } return super.onTouchEvent(event) } fun setFilterValues(values: List?) { values?.let { setAdapter(MyAdapter(context, values)) } } fun setFilterValues(vararg value: String) { setAdapter(MyAdapter(context, value.toMutableList())) } inner class MyAdapter(context: Context, values: List) : ArrayAdapter(context, android.R.layout.simple_dropdown_item_1line, values) { override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { val view = convertView ?: LayoutInflater.from(context) .inflate(R.layout.item_1line_text_and_del, parent, false) val textView = view.findViewById(R.id.text_view) textView.text = getItem(position) val ivDelete = view.findViewById(R.id.iv_delete) if (delCallBack != null) ivDelete.visible() else ivDelete.gone() ivDelete.setOnClickListener { getItem(position)?.let { remove(it) delCallBack?.invoke(it) showDropDown() } } return view } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/text/BadgeView.kt ================================================ package io.legado.app.ui.widget.text import android.content.Context import android.graphics.Color import android.graphics.drawable.ShapeDrawable import android.graphics.drawable.shapes.RoundRectShape import android.text.TextUtils import android.util.AttributeSet import android.util.TypedValue import android.view.Gravity import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import android.widget.FrameLayout.LayoutParams import androidx.appcompat.widget.AppCompatTextView import io.legado.app.R import io.legado.app.lib.theme.accentColor import io.legado.app.utils.ColorUtils import io.legado.app.utils.getCompatColor import io.legado.app.utils.invisible import io.legado.app.utils.visible /** * Created by milad heydari on 5/6/2016. */ @Suppress("MemberVisibilityCanBePrivate", "unused") class BadgeView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : AppCompatTextView(context, attrs) { var isHideOnNull = true set(hideOnNull) { field = hideOnNull text = text } private var radius: Float = 0.toFloat() private var flatangle: Boolean val badgeCount: Int? get() { if (text == null) { return null } val text = text.toString() return kotlin.runCatching { Integer.parseInt(text) }.getOrNull() } var badgeGravity: Int get() { val params = layoutParams as LayoutParams return params.gravity } set(gravity) { val params = layoutParams as LayoutParams params.gravity = gravity layoutParams = params } val badgeMargin: IntArray get() { val params = layoutParams as LayoutParams return intArrayOf( params.leftMargin, params.topMargin, params.rightMargin, params.bottomMargin ) } init { val typedArray = context.obtainStyledAttributes(attrs, R.styleable.BadgeView) val radios = typedArray.getDimensionPixelOffset(R.styleable.BadgeView_radius, 8) flatangle = typedArray.getBoolean(R.styleable.BadgeView_up_flat_angle, false) typedArray.recycle() if (layoutParams !is LayoutParams) { val layoutParams = LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER ) setLayoutParams(layoutParams) } //setTypeface(Typeface.DEFAULT_BOLD); setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f) setPadding(dip2Px(5f), dip2Px(1f), dip2Px(5f), dip2Px(1f)) radius = radios.toFloat() // set default background setBackground(radius, context.accentColor) gravity = Gravity.CENTER // default values isHideOnNull = true setBadgeCount(0) minWidth = dip2Px(16f) minHeight = dip2Px(16f) } override fun setBackgroundColor(color: Int) { val background = background if (background is ShapeDrawable && background.paint.color == color) { return } setBackground(radius, color) } fun setBackground(dipRadius: Float, badgeColor: Int) { val radius = dip2Px(dipRadius).toFloat() val radiusArray = floatArrayOf(radius, radius, radius, radius, radius, radius, radius, radius) if (flatangle) { radiusArray.fill(0f, 0, 3) } val roundRect = RoundRectShape(radiusArray, null, null) val bgDrawable = ShapeDrawable(roundRect) bgDrawable.paint.color = badgeColor background = bgDrawable setTextColor( if (ColorUtils.isColorLight(badgeColor)) { Color.BLACK } else { Color.WHITE } ) } /** * @see android.widget.TextView.setText */ override fun setText(text: CharSequence, type: BufferType) { if (isHideOnNull && TextUtils.isEmpty(text)) { invisible() } else { visible() } super.setText(text, type) } fun setBadgeCount(count: Int) { text = if (count == 0) "" else count.toString() } fun setHighlight(highlight: Boolean) { if (highlight) { setBackgroundColor(context.accentColor) } else { setBackgroundColor(context.getCompatColor(R.color.darker_gray)) } } fun setBadgeMargin(dipMargin: Int) { setBadgeMargin(dipMargin, dipMargin, dipMargin, dipMargin) } fun setBadgeMargin( leftDipMargin: Int, topDipMargin: Int, rightDipMargin: Int, bottomDipMargin: Int ) { val params = layoutParams as LayoutParams params.leftMargin = dip2Px(leftDipMargin.toFloat()) params.topMargin = dip2Px(topDipMargin.toFloat()) params.rightMargin = dip2Px(rightDipMargin.toFloat()) params.bottomMargin = dip2Px(bottomDipMargin.toFloat()) layoutParams = params } fun incrementBadgeCount(increment: Int) { val count = badgeCount if (count == null) { setBadgeCount(increment) } else { setBadgeCount(increment + count) } } fun decrementBadgeCount(decrement: Int) { incrementBadgeCount(-decrement) } /** * Attach the BadgeView to the target view * @param target the view to attach the BadgeView */ fun setTargetView(target: View?) { if (parent != null) { (parent as ViewGroup).removeView(this) } if (target == null) { return } if (target.parent is FrameLayout) { (target.parent as FrameLayout).addView(this) } else if (target.parent is ViewGroup) { // use a new FrameLayout container for adding badge val parentContainer = target.parent as ViewGroup val groupIndex = parentContainer.indexOfChild(target) parentContainer.removeView(target) val badgeContainer = FrameLayout(context) val parentLayoutParams = target.layoutParams badgeContainer.layoutParams = parentLayoutParams target.layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) parentContainer.addView(badgeContainer, groupIndex, parentLayoutParams) badgeContainer.addView(target) badgeContainer.addView(this) } } /** * converts dip to px */ private fun dip2Px(dip: Float): Int { return (dip * context.resources.displayMetrics.density + 0.5f).toInt() } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/text/BevelLabelView.kt ================================================ package io.legado.app.ui.widget.text import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Path import android.util.AttributeSet import android.util.TypedValue import android.view.View import androidx.annotation.ColorInt import androidx.annotation.IntDef import io.legado.app.R import io.legado.app.lib.theme.accentColor /** * 斜角标签 */ @Suppress("unused") class BevelLabelView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : View(context, attrs) { companion object { const val MODE_LEFT_TOP = 0 const val MODE_RIGHT_TOP = 1 const val MODE_LEFT_BOTTOM = 2 const val MODE_RIGHT_BOTTOM = 3 const val MODE_LEFT_TOP_FILL = 4 const val MODE_RIGHT_TOP_FILL = 5 const val MODE_LEFT_BOTTOM_FILL = 6 const val MODE_RIGHT_BOTTOM_FILL = 7 } private var mBgColor: Int private var mText: String private var mTextSize: Int private var mTextColor: Int private var mLength: Int private var mCorner: Int private var mMode: Int private var mPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG) private var path: Path = Path() private var mWidth = 0 private var mHeight: Int = 0 private var mRotate = 45 //因为默认模式是1,所以这时是45度 private var mX: Int = 0 private var mY: Int = 0 init { val typedArray = context.obtainStyledAttributes(attrs, R.styleable.BevelLabelView) mBgColor = typedArray.getColor( R.styleable.BevelLabelView_label_bg_color, context.accentColor ) //默认红色 mText = typedArray.getString(R.styleable.BevelLabelView_label_text) ?: "" mTextSize = typedArray.getDimensionPixelOffset( R.styleable.BevelLabelView_label_text_size, sp2px(11) ) mTextColor = typedArray.getColor(R.styleable.BevelLabelView_label_text_color, Color.WHITE) mLength = typedArray.getDimensionPixelOffset(R.styleable.BevelLabelView_label_length, dip2px(40)) mCorner = typedArray.getDimensionPixelOffset(R.styleable.BevelLabelView_label_corner, 0) mMode = typedArray.getInt(R.styleable.BevelLabelView_label_mode, 1) mPaint.isAntiAlias = true typedArray.recycle() } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) mWidth = MeasureSpec.getSize(widthMeasureSpec) mHeight = mWidth } override fun onDraw(canvas: Canvas) { mPaint.color = mBgColor drawBackgroundText(canvas) } fun setMode(@BevelLabelMode mode: Int) { mMode = mode invalidate() } fun setTextColor(@ColorInt color: Int) { mTextColor = color invalidate() } fun setBgColor(@ColorInt color: Int) { mBgColor = color invalidate() } private fun drawBackgroundText(canvas: Canvas) { check(mWidth == mHeight) { "width must equal to height" //标签view 是一个正方形, } when (mMode) { MODE_LEFT_TOP -> { mCorner = 0 //没有铺满的时候mCorner要归零; leftTopMeasure() getLeftTop() } MODE_RIGHT_TOP -> { mCorner = 0 rightTopMeasure() getRightTop() } MODE_LEFT_BOTTOM -> { mCorner = 0 leftBottomMeasure() getLeftBottom() } MODE_RIGHT_BOTTOM -> { mCorner = 0 rightBottomMeasure() getRightBottom() } MODE_LEFT_TOP_FILL -> { leftTopMeasure() getLeftTopFill() if (mCorner != 0) { canvas.drawPath(path, mPaint) getLeftTop() } } MODE_RIGHT_TOP_FILL -> { rightTopMeasure() getRightTopFill() if (mCorner != 0) { canvas.drawPath(path, mPaint) getRightTop() } } MODE_LEFT_BOTTOM_FILL -> { leftBottomMeasure() getLeftBottomFill() if (mCorner != 0) { canvas.drawPath(path, mPaint) getLeftBottom() } } MODE_RIGHT_BOTTOM_FILL -> { rightBottomMeasure() getRightBottomFill() if (mCorner != 0) { canvas.drawPath(path, mPaint) getRightBottom() } } else -> {} } canvas.drawPath(path, mPaint) mPaint.textSize = mTextSize.toFloat() mPaint.textAlign = Paint.Align.CENTER mPaint.color = mTextColor canvas.translate(mX.toFloat(), mY.toFloat()) canvas.rotate(mRotate.toFloat()) val baseLineY = (-(mPaint.descent() + mPaint.ascent())).toInt() / 2 //基线中间点的y轴计算公式 canvas.drawText(mText, 0f, baseLineY.toFloat(), mPaint) } private fun rightBottomMeasure() { mRotate = -45 mX = mWidth / 2 + mLength / 4 mY = mX } private fun leftBottomMeasure() { mRotate = 45 mX = mWidth / 2 - mLength / 4 mY = mHeight / 2 + mLength / 4 } private fun rightTopMeasure() { mRotate = 45 mX = mWidth / 2 + mLength / 4 mY = mHeight / 2 - mLength / 4 } private fun leftTopMeasure() { mRotate = -45 mX = mWidth / 2 - mLength / 4 mY = mX } //左上角铺满 private fun getLeftTopFill() { if (mCorner != 0) { path.addRoundRect( 0f, 0f, (mWidth / 2).toFloat(), (mHeight / 2).toFloat(), floatArrayOf(mCorner.toFloat(), mCorner.toFloat(), 0f, 0f, 0f, 0f, 0f, 0f), Path.Direction.CW ) } else { path.moveTo(0f, 0f) path.lineTo(mWidth.toFloat(), 0f) path.lineTo(0f, mHeight.toFloat()) path.close() } } //左上角不铺满 private fun getLeftTop() { path.moveTo(if (mCorner != 0) mCorner.toFloat() else (mWidth - mLength).toFloat(), 0f) path.lineTo(mWidth.toFloat(), 0f) path.lineTo(0f, mHeight.toFloat()) path.lineTo(0f, if (mCorner != 0) mCorner.toFloat() else (mHeight - mLength).toFloat()) path.close() } //左下角铺满 private fun getLeftBottomFill() { if (mCorner != 0) { path.addRoundRect( 0f, (mHeight / 2).toFloat(), (mWidth / 2).toFloat(), mHeight.toFloat(), floatArrayOf(0f, 0f, 0f, 0f, 0f, 0f, mCorner.toFloat(), mCorner.toFloat()), Path.Direction.CW ) } else { path.moveTo(0f, 0f) path.lineTo(mWidth.toFloat(), mHeight.toFloat()) path.lineTo(0f, mHeight.toFloat()) path.close() } } //左下角不铺满 private fun getLeftBottom() { path.moveTo(0f, 0f) path.lineTo(mWidth.toFloat(), mHeight.toFloat()) path.lineTo( if (mCorner != 0) mCorner.toFloat() else (mWidth - mLength).toFloat(), mHeight.toFloat() ) path.lineTo(0f, if (mCorner != 0) (mHeight - mCorner).toFloat() else mLength.toFloat()) path.close() } //右上角铺满 private fun getRightTopFill() { if (mCorner != 0) { path.addRoundRect( (mWidth / 2).toFloat(), 0f, mWidth.toFloat(), (mHeight / 2).toFloat(), floatArrayOf(0f, 0f, mCorner.toFloat(), mCorner.toFloat(), 0f, 0f, 0f, 0f), Path.Direction.CW ) } else { path.moveTo(0f, 0f) path.lineTo(mWidth.toFloat(), 0f) path.lineTo(mWidth.toFloat(), mHeight.toFloat()) path.close() } } //右上角不铺满 private fun getRightTop() { path.moveTo(0f, 0f) path.lineTo(if (mCorner != 0) (mWidth - mCorner).toFloat() else mLength.toFloat(), 0f) path.lineTo( mWidth.toFloat(), if (mCorner != 0) mCorner.toFloat() else (mHeight - mLength).toFloat() ) path.lineTo(mWidth.toFloat(), mHeight.toFloat()) path.close() } //右下角铺满 private fun getRightBottomFill() { if (mCorner != 0) { path.addRoundRect( (mWidth / 2).toFloat(), (mHeight / 2).toFloat(), mWidth.toFloat(), mHeight.toFloat(), floatArrayOf(0f, 0f, 0f, 0f, mCorner.toFloat(), mCorner.toFloat(), 0f, 0f), Path.Direction.CW ) } else { path.moveTo(mWidth.toFloat(), 0f) path.lineTo(mWidth.toFloat(), mHeight.toFloat()) path.lineTo(0f, mHeight.toFloat()) path.close() } } //右下角不铺满 private fun getRightBottom() { path.moveTo(mWidth.toFloat(), 0f) path.lineTo( mWidth.toFloat(), if (mCorner != 0) (mHeight - mCorner).toFloat() else mLength.toFloat() ) path.lineTo( if (mCorner != 0) (mWidth - mCorner).toFloat() else mLength.toFloat(), mHeight.toFloat() ) path.lineTo(0f, mHeight.toFloat()) path.close() } /** * @param sp 转换大小 */ @Suppress("SameParameterValue") private fun sp2px(sp: Int): Int { return TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_SP, sp.toFloat(), resources.displayMetrics ) .toInt() } /** * @param dip 转换大小 */ @Suppress("SameParameterValue") private fun dip2px(dip: Int): Int { return TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, dip.toFloat(), resources.displayMetrics ) .toInt() } @Target(AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.SOURCE) @IntDef( MODE_LEFT_BOTTOM, MODE_LEFT_BOTTOM_FILL, MODE_LEFT_TOP, MODE_LEFT_TOP_FILL, MODE_RIGHT_BOTTOM, MODE_RIGHT_BOTTOM_FILL, MODE_RIGHT_TOP, MODE_RIGHT_TOP_FILL ) annotation class BevelLabelMode } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/text/EditEntity.kt ================================================ package io.legado.app.ui.widget.text import splitties.init.appCtx data class EditEntity( var key: String, var value: String?, var hint: String, val viewType: Int = 0 ) { constructor( key: String, value: String?, hint: Int, viewType: Int = 0 ) : this( key, value, appCtx.getString(hint), viewType ) @Suppress("unused") object ViewType { const val checkBox = 1 } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/text/MultilineTextView.kt ================================================ package io.legado.app.ui.widget.text import android.content.Context import android.os.Build import android.util.AttributeSet import androidx.appcompat.widget.AppCompatTextView class MultilineTextView(context: Context, attrs: AttributeSet?) : AppCompatTextView(context, attrs) { init { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { isFallbackLineSpacing = false } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val heightSize = MeasureSpec.getSize(heightMeasureSpec) calculateLines(heightSize) super.onMeasure(widthMeasureSpec, heightMeasureSpec) } private fun calculateLines(measuredHeight: Int) { val lHeight = lineHeight val lines = measuredHeight / lHeight setLines(lines) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/text/PrimaryTextView.kt ================================================ package io.legado.app.ui.widget.text import android.content.Context import android.util.AttributeSet import androidx.appcompat.widget.AppCompatTextView import io.legado.app.lib.theme.ThemeStore /** * @author Aidan Follestad (afollestad) */ @Suppress("unused") class PrimaryTextView(context: Context, attrs: AttributeSet) : AppCompatTextView(context, attrs) { init { setTextColor(ThemeStore.textColorPrimary(context)) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/text/ScrollMultiAutoCompleteTextView.kt ================================================ package io.legado.app.ui.widget.text import android.annotation.SuppressLint import android.content.Context import android.os.Build import android.util.AttributeSet import android.view.GestureDetector import android.view.MotionEvent import android.view.VelocityTracker import android.view.ViewConfiguration import android.view.animation.Interpolator import android.widget.OverScroller import androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView import androidx.core.view.ViewCompat import kotlin.math.abs import kotlin.math.max import kotlin.math.min /** * 嵌套惯性滚动 MultiAutoCompleteTextView */ open class ScrollMultiAutoCompleteTextView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : AppCompatMultiAutoCompleteTextView(context, attrs) { //是否到顶或者到底的标志 private var disallowIntercept = true private val scrollStateIdle = 0 private val scrollStateDragging = 1 val scrollStateSettling = 2 private val mViewFling: ViewFling by lazy { ViewFling() } private val velocityTracker: VelocityTracker by lazy { VelocityTracker.obtain() } private var mScrollState = scrollStateIdle private var mLastTouchY: Int = 0 private var mTouchSlop: Int = 0 private var mMinFlingVelocity: Int = 0 private var mMaxFlingVelocity: Int = 0 //滑动距离的最大边界 private var mOffsetHeight: Int = 0 //f(x) = (x-1)^5 + 1 private val sQuinticInterpolator = Interpolator { var t = it t -= 1.0f t * t * t * t * t + 1.0f } private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { override fun onDown(e: MotionEvent): Boolean { disallowIntercept = true return super.onDown(e) } override fun onScroll( e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float ): Boolean { val y = scrollY + distanceY if (y < 0 || y > mOffsetHeight) { disallowIntercept = false //这里触发父布局或祖父布局的滑动事件 parent.requestDisallowInterceptTouchEvent(false) } else { disallowIntercept = true } return true } }) init { val vc = ViewConfiguration.get(context) mTouchSlop = vc.scaledTouchSlop mMinFlingVelocity = vc.scaledMinimumFlingVelocity mMaxFlingVelocity = vc.scaledMaximumFlingVelocity if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { isLocalePreferredLineHeightForMinimumUsed = false } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) initOffsetHeight() } override fun onTextChanged( text: CharSequence, start: Int, lengthBefore: Int, lengthAfter: Int ) { super.onTextChanged(text, start, lengthBefore, lengthAfter) initOffsetHeight() } override fun dispatchTouchEvent(event: MotionEvent): Boolean { if (lineCount > maxLines) { gestureDetector.onTouchEvent(event) } velocityTracker.addMovement(event) when (event.action) { MotionEvent.ACTION_DOWN -> { setScrollState(scrollStateIdle) mLastTouchY = (event.y + 0.5f).toInt() } MotionEvent.ACTION_MOVE -> { val y = (event.y + 0.5f).toInt() var dy = mLastTouchY - y if (mScrollState != scrollStateDragging) { var startScroll = false if (abs(dy) > mTouchSlop) { if (dy > 0) { dy -= mTouchSlop } else { dy += mTouchSlop } startScroll = true } if (startScroll) { setScrollState(scrollStateDragging) } } if (mScrollState == scrollStateDragging) { mLastTouchY = y } } MotionEvent.ACTION_UP -> { velocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity.toFloat()) val yVelocity = velocityTracker.yVelocity if (abs(yVelocity) > mMinFlingVelocity) { mViewFling.fling(-yVelocity.toInt()) } else { setScrollState(scrollStateIdle) } resetTouch() } MotionEvent.ACTION_CANCEL -> { resetTouch() } } return super.dispatchTouchEvent(event) } @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { val result = super.onTouchEvent(event) //如果是需要拦截,则再拦截,这个方法会在onScrollChanged方法之后再调用一次 if (disallowIntercept && lineCount > maxLines) { parent.requestDisallowInterceptTouchEvent(true) } return result } override fun scrollTo(x: Int, y: Int) { super.scrollTo(x, min(y, mOffsetHeight)) } private fun initOffsetHeight() { val mLayoutHeight: Int //获得内容面板 val mLayout = layout ?: return //获得内容面板的高度 mLayoutHeight = mLayout.height //获取上内边距 val paddingTop: Int = totalPaddingTop //获取下内边距 val paddingBottom: Int = totalPaddingBottom //获得控件的实际高度 val mHeight: Int = measuredHeight //计算滑动距离的边界 mOffsetHeight = mLayoutHeight + paddingTop + paddingBottom - mHeight if (mOffsetHeight <= 0) { scrollTo(0, 0) } } private fun resetTouch() { velocityTracker.clear() } private fun setScrollState(state: Int) { if (state == mScrollState) { return } mScrollState = state if (state != scrollStateSettling) { mViewFling.stop() } } /** * 惯性滚动 */ private inner class ViewFling : Runnable { private var mLastFlingY = 0 private val mScroller: OverScroller = OverScroller(context, sQuinticInterpolator) private var mEatRunOnAnimationRequest = false private var mReSchedulePostAnimationCallback = false override fun run() { disableRunOnAnimationRequests() val scroller = mScroller if (scroller.computeScrollOffset()) { val y = scroller.currY val dy = y - mLastFlingY mLastFlingY = y if (dy < 0 && scrollY > 0) { scrollBy(0, max(dy, -scrollY)) } else if (dy > 0 && scrollY < mOffsetHeight) { scrollBy(0, min(dy, mOffsetHeight - scrollY)) } postOnAnimation() } enableRunOnAnimationRequests() } fun fling(velocityY: Int) { mLastFlingY = 0 setScrollState(scrollStateSettling) mScroller.fling( 0, 0, 0, velocityY, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE ) postOnAnimation() } fun stop() { removeCallbacks(this) mScroller.abortAnimation() } private fun disableRunOnAnimationRequests() { mReSchedulePostAnimationCallback = false mEatRunOnAnimationRequest = true } private fun enableRunOnAnimationRequests() { mEatRunOnAnimationRequest = false if (mReSchedulePostAnimationCallback) { postOnAnimation() } } @Suppress("DEPRECATION") fun postOnAnimation() { if (mEatRunOnAnimationRequest) { mReSchedulePostAnimationCallback = true } else { removeCallbacks(this) ViewCompat.postOnAnimation(this@ScrollMultiAutoCompleteTextView, this) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/text/ScrollTextView.kt ================================================ package io.legado.app.ui.widget.text import android.annotation.SuppressLint import android.content.Context import android.text.method.LinkMovementMethod import android.util.AttributeSet import android.view.GestureDetector import android.view.MotionEvent import android.view.VelocityTracker import android.view.ViewConfiguration import android.view.animation.Interpolator import android.widget.OverScroller import androidx.appcompat.widget.AppCompatTextView import androidx.core.view.ViewCompat import kotlin.math.abs import kotlin.math.max import kotlin.math.min /** * 嵌套惯性滚动 TextView */ class ScrollTextView(context: Context, attrs: AttributeSet?) : AppCompatTextView(context, attrs) { //是否到顶或者到底的标志 private var disallowIntercept = true private val scrollStateIdle = 0 private val scrollStateDragging = 1 val scrollStateSettling = 2 private val mViewFling: ViewFling by lazy { ViewFling() } private val velocityTracker: VelocityTracker by lazy { VelocityTracker.obtain() } private var mScrollState = scrollStateIdle private var mLastTouchY: Int = 0 private var mTouchSlop: Int = 0 private var mMinFlingVelocity: Int = 0 private var mMaxFlingVelocity: Int = 0 //滑动距离的最大边界 private var mOffsetHeight: Int = 0 //f(x) = (x-1)^5 + 1 private val sQuinticInterpolator = Interpolator { var t = it t -= 1.0f t * t * t * t * t + 1.0f } private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { override fun onDown(e: MotionEvent): Boolean { disallowIntercept = true return super.onDown(e) } override fun onScroll( e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float ): Boolean { val y = scrollY + distanceY if (y < 0 || y > mOffsetHeight) { disallowIntercept = false //这里触发父布局或祖父布局的滑动事件 parent.requestDisallowInterceptTouchEvent(false) } else { disallowIntercept = true } return true } }) init { val vc = ViewConfiguration.get(context) mTouchSlop = vc.scaledTouchSlop mMinFlingVelocity = vc.scaledMinimumFlingVelocity mMaxFlingVelocity = vc.scaledMaximumFlingVelocity movementMethod = LinkMovementMethod.getInstance() } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) initOffsetHeight() } override fun onTextChanged( text: CharSequence, start: Int, lengthBefore: Int, lengthAfter: Int ) { super.onTextChanged(text, start, lengthBefore, lengthAfter) initOffsetHeight() } override fun dispatchTouchEvent(event: MotionEvent): Boolean { if (lineCount > maxLines) { gestureDetector.onTouchEvent(event) } velocityTracker.addMovement(event) when (event.action) { MotionEvent.ACTION_DOWN -> { setScrollState(scrollStateIdle) mLastTouchY = (event.y + 0.5f).toInt() } MotionEvent.ACTION_MOVE -> { val y = (event.y + 0.5f).toInt() var dy = mLastTouchY - y if (mScrollState != scrollStateDragging) { var startScroll = false if (abs(dy) > mTouchSlop) { if (dy > 0) { dy -= mTouchSlop } else { dy += mTouchSlop } startScroll = true } if (startScroll) { setScrollState(scrollStateDragging) } } if (mScrollState == scrollStateDragging) { mLastTouchY = y } } MotionEvent.ACTION_UP -> { velocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity.toFloat()) val yVelocity = velocityTracker.yVelocity if (abs(yVelocity) > mMinFlingVelocity) { mViewFling.fling(-yVelocity.toInt()) } else { setScrollState(scrollStateIdle) } resetTouch() } MotionEvent.ACTION_CANCEL -> { resetTouch() } } return super.dispatchTouchEvent(event) } @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { val result = super.onTouchEvent(event) //如果是需要拦截,则再拦截,这个方法会在onScrollChanged方法之后再调用一次 if (disallowIntercept && lineCount > maxLines) { parent.requestDisallowInterceptTouchEvent(true) } return result } override fun scrollTo(x: Int, y: Int) { super.scrollTo(x, min(y, mOffsetHeight)) } private fun initOffsetHeight() { val mLayoutHeight: Int //获得内容面板 val mLayout = layout ?: return //获得内容面板的高度 mLayoutHeight = mLayout.height //获取上内边距 val paddingTop: Int = totalPaddingTop //获取下内边距 val paddingBottom: Int = totalPaddingBottom //获得控件的实际高度 val mHeight: Int = measuredHeight //计算滑动距离的边界 mOffsetHeight = mLayoutHeight + paddingTop + paddingBottom - mHeight if (mOffsetHeight <= 0) { scrollTo(0, 0) } } private fun resetTouch() { velocityTracker.clear() } private fun setScrollState(state: Int) { if (state == mScrollState) { return } mScrollState = state if (state != scrollStateSettling) { mViewFling.stop() } } /** * 惯性滚动 */ private inner class ViewFling : Runnable { private var mLastFlingY = 0 private val mScroller: OverScroller = OverScroller(context, sQuinticInterpolator) private var mEatRunOnAnimationRequest = false private var mReSchedulePostAnimationCallback = false override fun run() { disableRunOnAnimationRequests() val scroller = mScroller if (scroller.computeScrollOffset()) { val y = scroller.currY val dy = y - mLastFlingY mLastFlingY = y if (dy < 0 && scrollY > 0) { scrollBy(0, max(dy, -scrollY)) } else if (dy > 0 && scrollY < mOffsetHeight) { scrollBy(0, min(dy, mOffsetHeight - scrollY)) } postOnAnimation() } enableRunOnAnimationRequests() } fun fling(velocityY: Int) { mLastFlingY = 0 setScrollState(scrollStateSettling) mScroller.fling( 0, 0, 0, velocityY, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE ) postOnAnimation() } fun stop() { removeCallbacks(this) mScroller.abortAnimation() } private fun disableRunOnAnimationRequests() { mReSchedulePostAnimationCallback = false mEatRunOnAnimationRequest = true } private fun enableRunOnAnimationRequests() { mEatRunOnAnimationRequest = false if (mReSchedulePostAnimationCallback) { postOnAnimation() } } @Suppress("DEPRECATION") fun postOnAnimation() { if (mEatRunOnAnimationRequest) { mReSchedulePostAnimationCallback = true } else { removeCallbacks(this) ViewCompat.postOnAnimation(this@ScrollTextView, this) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/text/SecondaryTextView.kt ================================================ package io.legado.app.ui.widget.text import android.content.Context import android.util.AttributeSet import androidx.appcompat.widget.AppCompatTextView import io.legado.app.lib.theme.secondaryTextColor /** * @author Aidan Follestad (afollestad) */ @Suppress("unused") class SecondaryTextView(context: Context, attrs: AttributeSet) : AppCompatTextView(context, attrs) { init { setTextColor(context.secondaryTextColor) } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/text/StrokeTextView.kt ================================================ package io.legado.app.ui.widget.text import android.content.Context import android.util.AttributeSet import androidx.appcompat.widget.AppCompatTextView import io.legado.app.R import io.legado.app.lib.theme.* import io.legado.app.utils.ColorUtils import io.legado.app.utils.dpToPx import io.legado.app.utils.getCompatColor @Suppress("unused") open class StrokeTextView(context: Context, attrs: AttributeSet?) : AppCompatTextView(context, attrs) { private var radius = 1.dpToPx() private val isBottomBackground: Boolean init { val typedArray = context.obtainStyledAttributes(attrs, R.styleable.StrokeTextView) radius = typedArray.getDimensionPixelOffset(R.styleable.StrokeTextView_radius, radius) isBottomBackground = typedArray.getBoolean(R.styleable.StrokeTextView_isBottomBackground, false) typedArray.recycle() upBackground() } fun setRadius(radius: Int) { this.radius = radius.dpToPx() upBackground() } private fun upBackground() { when { isInEditMode -> { background = Selector.shapeBuild() .setCornerRadius(radius) .setStrokeWidth(1.dpToPx()) .setDisabledStrokeColor(context.getCompatColor(R.color.md_grey_500)) .setDefaultStrokeColor(context.getCompatColor(R.color.secondaryText)) .setSelectedStrokeColor(context.getCompatColor(R.color.accent)) .setPressedBgColor(context.getCompatColor(R.color.transparent30)) .create() setTextColor( Selector.colorBuild() .setDefaultColor(context.getCompatColor(R.color.secondaryText)) .setSelectedColor(context.getCompatColor(R.color.accent)) .setDisabledColor(context.getCompatColor(R.color.md_grey_500)) .create() ) } isBottomBackground -> { val isLight = ColorUtils.isColorLight(context.bottomBackground) background = Selector.shapeBuild() .setCornerRadius(radius) .setStrokeWidth(1.dpToPx()) .setDisabledStrokeColor(context.getCompatColor(R.color.md_grey_500)) .setDefaultStrokeColor(context.getPrimaryTextColor(isLight)) .setSelectedStrokeColor(context.accentColor) .setPressedBgColor(context.getCompatColor(R.color.transparent30)) .create() setTextColor( Selector.colorBuild() .setDefaultColor(context.getPrimaryTextColor(isLight)) .setSelectedColor(context.accentColor) .setDisabledColor(context.getCompatColor(R.color.md_grey_500)) .create() ) } else -> { background = Selector.shapeBuild() .setCornerRadius(radius) .setStrokeWidth(1.dpToPx()) .setDisabledStrokeColor(context.getCompatColor(R.color.md_grey_500)) .setDefaultStrokeColor(ThemeStore.textColorSecondary(context)) .setSelectedStrokeColor(ThemeStore.accentColor(context)) .setPressedBgColor(context.getCompatColor(R.color.transparent30)) .create() setTextColor( Selector.colorBuild() .setDefaultColor(ThemeStore.textColorSecondary(context)) .setSelectedColor(ThemeStore.accentColor(context)) .setDisabledColor(context.getCompatColor(R.color.md_grey_500)) .create() ) } } } } ================================================ FILE: app/src/main/java/io/legado/app/ui/widget/text/TextInputLayout.kt ================================================ package io.legado.app.ui.widget.text import android.content.Context import android.util.AttributeSet import com.google.android.material.textfield.TextInputLayout import io.legado.app.lib.theme.Selector import io.legado.app.lib.theme.ThemeStore class TextInputLayout(context: Context, attrs: AttributeSet?) : TextInputLayout(context, attrs) { init { if (!isInEditMode) { defaultHintTextColor = Selector.colorBuild().setDefaultColor(ThemeStore.accentColor(context)).create() } } } ================================================ FILE: app/src/main/java/io/legado/app/utils/ACache.kt ================================================ //Copyright (c) 2017. 章钦豪. All rights reserved. package io.legado.app.utils import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Canvas import android.graphics.PixelFormat import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import org.json.JSONArray import org.json.JSONObject import splitties.init.appCtx import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.File import java.io.IOException import java.io.ObjectInputStream import java.io.ObjectOutputStream import java.io.Serializable import java.util.Collections import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong import kotlin.math.min /** * 本地缓存 */ @Suppress("unused", "MemberVisibilityCanBePrivate") class ACache private constructor(cacheDir: File, max_size: Long, max_count: Int) { companion object { const val TIME_HOUR = 60 * 60 const val TIME_DAY = TIME_HOUR * 24 private const val MAX_SIZE = 1000 * 1000 * 50 // 50 mb private const val MAX_COUNT = Integer.MAX_VALUE // 不限制存放数据的数量 private val mInstanceMap = HashMap() @JvmOverloads fun get( cacheName: String = "ACache", maxSize: Long = MAX_SIZE.toLong(), maxCount: Int = MAX_COUNT, cacheDir: Boolean = true ): ACache { val f = if (cacheDir) File(appCtx.cacheDir, cacheName) else File(appCtx.filesDir, cacheName) return get(f, maxSize, maxCount) } @JvmOverloads fun get( cacheDir: File, maxSize: Long = MAX_SIZE.toLong(), maxCount: Int = MAX_COUNT ): ACache { synchronized(this) { var manager = mInstanceMap[cacheDir.absoluteFile.toString() + myPid()] if (manager == null) { manager = ACache(cacheDir, maxSize, maxCount) mInstanceMap[cacheDir.absolutePath + myPid()] = manager } return manager } } private fun myPid(): String { return "_" + android.os.Process.myPid() } } private var mCache: ACacheManager? = null init { try { if (!cacheDir.exists() && !cacheDir.mkdirs()) { DebugLog.i(javaClass.name, "can't make dirs in %s" + cacheDir.absolutePath) } mCache = ACacheManager(cacheDir, max_size, max_count) } catch (e: Exception) { e.printOnDebug() } } // ======================================= // ============ String数据 读写 ============== // ======================================= /** * 保存 String数据 到 缓存中 * * @param key 保存的key * @param value 保存的String数据 */ fun put(key: String, value: String) { mCache?.let { mCache -> try { val file = mCache.newFile(key) file.writeText(value) mCache.put(file) } catch (e: Exception) { e.printOnDebug() } } } /** * 保存 String数据 到 缓存中 * * @param key 保存的key * @param value 保存的String数据 * @param saveTime 保存的时间,单位:秒 */ fun put(key: String, value: String, saveTime: Int) { if (saveTime == 0) put(key, value) else put( key, Utils.newStringWithDateInfo(saveTime, value) ) } /** * 读取 String数据 * * @return String 数据 */ fun getAsString(key: String): String? { mCache?.let { mCache -> val file = mCache[key] if (!file.exists()) return null var removeFile = false try { val text = file.readText() if (!Utils.isDue(text)) { return Utils.clearDateInfo(text) } else { removeFile = true } } catch (e: IOException) { e.printOnDebug() } finally { if (removeFile) remove(key) } } return null } // ======================================= // ========== JSONObject 数据 读写 ========= // ======================================= /** * 保存 JSONObject数据 到 缓存中 * * @param key 保存的key * @param value 保存的JSON数据 */ fun put(key: String, value: JSONObject) { put(key, value.toString()) } /** * 保存 JSONObject数据 到 缓存中 * * @param key 保存的key * @param value 保存的JSONObject数据 * @param saveTime 保存的时间,单位:秒 */ fun put(key: String, value: JSONObject, saveTime: Int) { put(key, value.toString(), saveTime) } /** * 读取JSONObject数据 * * @return JSONObject数据 */ fun getAsJSONObject(key: String): JSONObject? { val json = getAsString(key) ?: return null return try { JSONObject(json) } catch (e: Exception) { null } } // ======================================= // ============ JSONArray 数据 读写 ============= // ======================================= /** * 保存 JSONArray数据 到 缓存中 * * @param key 保存的key * @param value 保存的JSONArray数据 */ fun put(key: String, value: JSONArray) { put(key, value.toString()) } /** * 保存 JSONArray数据 到 缓存中 * * @param key 保存的key * @param value 保存的JSONArray数据 * @param saveTime 保存的时间,单位:秒 */ fun put(key: String, value: JSONArray, saveTime: Int) { put(key, value.toString(), saveTime) } /** * 读取JSONArray数据 * * @return JSONArray数据 */ fun getAsJSONArray(key: String): JSONArray? { val json = getAsString(key) return try { JSONArray(json) } catch (e: Exception) { null } } // ======================================= // ============== byte 数据 读写 ============= // ======================================= /** * 保存 byte数据 到 缓存中 * * @param key 保存的key * @param value 保存的数据 */ fun put(key: String, value: ByteArray) { mCache?.let { mCache -> val file = mCache.newFile(key) file.writeBytes(value) mCache.put(file) } } /** * 保存 byte数据 到 缓存中 * * @param key 保存的key * @param value 保存的数据 * @param saveTime 保存的时间,单位:秒 */ fun put(key: String, value: ByteArray, saveTime: Int) { if (saveTime == 0) put(key, value) else put(key, Utils.newByteArrayWithDateInfo(saveTime, value)) } /** * 获取 byte 数据 * * @return byte 数据 */ fun getAsBinary(key: String): ByteArray? { mCache?.let { mCache -> var removeFile = false try { val file = mCache[key] if (!file.exists()) return null val byteArray = file.readBytes() return if (!Utils.isDue(byteArray)) { Utils.clearDateInfo(byteArray) } else { removeFile = true null } } catch (e: Exception) { e.printOnDebug() } finally { if (removeFile) remove(key) } } return null } /** * 保存 Serializable数据到 缓存中 * * @param key 保存的key * @param value 保存的value * @param saveTime 保存的时间,单位:秒 */ @JvmOverloads fun put(key: String, value: Serializable, saveTime: Int = -1) { try { val byteArrayOutputStream = ByteArrayOutputStream() ObjectOutputStream(byteArrayOutputStream).use { oos -> oos.writeObject(value) val data = byteArrayOutputStream.toByteArray() if (saveTime != -1) { put(key, data, saveTime) } else { put(key, data) } } } catch (e: Exception) { e.printOnDebug() } } /** * 读取 Serializable数据 * * @return Serializable 数据 */ fun getAsObject(key: String): Any? { val data = getAsBinary(key) if (data != null) { var bis: ByteArrayInputStream? = null var ois: ObjectInputStream? = null try { bis = ByteArrayInputStream(data) ois = ObjectInputStream(bis) return ois.readObject() } catch (e: Exception) { e.printOnDebug() } finally { try { bis?.close() } catch (e: IOException) { e.printOnDebug() } try { ois?.close() } catch (e: IOException) { e.printOnDebug() } } } return null } // ======================================= // ============== bitmap 数据 读写 ============= // ======================================= /** * 保存 bitmap 到 缓存中 * * @param key 保存的key * @param value 保存的bitmap数据 */ fun put(key: String, value: Bitmap) { put(key, Utils.bitmap2Bytes(value)) } /** * 保存 bitmap 到 缓存中 * * @param key 保存的key * @param value 保存的 bitmap 数据 * @param saveTime 保存的时间,单位:秒 */ fun put(key: String, value: Bitmap, saveTime: Int) { put(key, Utils.bitmap2Bytes(value), saveTime) } /** * 读取 bitmap 数据 * * @return bitmap 数据 */ fun getAsBitmap(key: String): Bitmap? { return if (getAsBinary(key) == null) { null } else Utils.bytes2Bitmap(getAsBinary(key)!!) } // ======================================= // ============= drawable 数据 读写 ============= // ======================================= /** * 保存 drawable 到 缓存中 * * @param key 保存的key * @param value 保存的drawable数据 */ fun put(key: String, value: Drawable) { put(key, Utils.drawable2Bitmap(value)) } /** * 保存 drawable 到 缓存中 * * @param key 保存的key * @param value 保存的 drawable 数据 * @param saveTime 保存的时间,单位:秒 */ fun put(key: String, value: Drawable, saveTime: Int) { put(key, Utils.drawable2Bitmap(value), saveTime) } /** * 读取 Drawable 数据 * * @return Drawable 数据 */ fun getAsDrawable(key: String): Drawable? { return if (getAsBinary(key) == null) { null } else Utils.bitmap2Drawable( Utils.bytes2Bitmap( getAsBinary(key)!! ) ) } /** * 获取缓存文件 * * @return value 缓存的文件 */ fun file(key: String): File? { mCache?.let { mCache -> try { val f = mCache.newFile(key) if (f.exists()) { return f } } catch (e: Exception) { e.printOnDebug() } } return null } /** * 移除某个key * * @return 是否移除成功 */ fun remove(key: String): Boolean { return mCache?.remove(key) == true } /** * 清除所有数据 */ fun clear() { mCache?.clear() } /** * @author 杨福海(michael) www.yangfuhai.com * @version 1.0 * title 时间计算工具类 */ private object Utils { @Suppress("ConstPropertyName") private const val mSeparator = ' ' /** * 判断缓存的String数据是否到期 * * @return true:到期了 false:还没有到期 */ fun isDue(str: String): Boolean { return isDue(str.toByteArray()) } /** * 判断缓存的byte数据是否到期 * * @return true:到期了 false:还没有到期 */ fun isDue(data: ByteArray): Boolean { try { val text = getDateInfoFromDate(data) if (text != null && text.size == 2) { var saveTimeStr = text[0] while (saveTimeStr.startsWith("0")) { saveTimeStr = saveTimeStr .substring(1) } val saveTime = java.lang.Long.valueOf(saveTimeStr) val deleteAfter = java.lang.Long.valueOf(text[1]) if (System.currentTimeMillis() > saveTime + deleteAfter * 1000) { return true } } } catch (e: Exception) { e.printOnDebug() } return false } fun newStringWithDateInfo(second: Int, strInfo: String): String { return createDateInfo(second) + strInfo } fun newByteArrayWithDateInfo(second: Int, data2: ByteArray): ByteArray { val data1 = createDateInfo(second).toByteArray() val retData = ByteArray(data1.size + data2.size) System.arraycopy(data1, 0, retData, 0, data1.size) System.arraycopy(data2, 0, retData, data1.size, data2.size) return retData } fun clearDateInfo(strInfo: String?): String? { strInfo?.let { if (hasDateInfo(strInfo.toByteArray())) { return strInfo.substring(strInfo.indexOf(mSeparator) + 1) } } return strInfo } fun clearDateInfo(data: ByteArray): ByteArray { return if (hasDateInfo(data)) { copyOfRange( data, indexOf(data, mSeparator) + 1, data.size ) } else data } fun hasDateInfo(data: ByteArray?): Boolean { return (data != null && data.size > 15 && data[13] == '-'.code.toByte() && indexOf(data, mSeparator) > 14) } fun getDateInfoFromDate(data: ByteArray): Array? { if (hasDateInfo(data)) { val saveDate = String(copyOfRange(data, 0, 13)) val deleteAfter = String( copyOfRange( data, 14, indexOf(data, mSeparator) ) ) return arrayOf(saveDate, deleteAfter) } return null } @Suppress("SameParameterValue") private fun indexOf(data: ByteArray, c: Char): Int { for (i in data.indices) { if (data[i] == c.code.toByte()) { return i } } return -1 } private fun copyOfRange(original: ByteArray, from: Int, to: Int): ByteArray { val newLength = to - from require(newLength >= 0) { "$from > $to" } val copy = ByteArray(newLength) System.arraycopy( original, from, copy, 0, min(original.size - from, newLength) ) return copy } private fun createDateInfo(second: Int): String { val currentTime = StringBuilder(System.currentTimeMillis().toString() + "") while (currentTime.length < 13) { currentTime.insert(0, "0") } return "$currentTime-$second$mSeparator" } /* * Bitmap → byte[] */ fun bitmap2Bytes(bm: Bitmap): ByteArray { val byteArrayOutputStream = ByteArrayOutputStream() bm.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream) return byteArrayOutputStream.toByteArray() } /* * byte[] → Bitmap */ fun bytes2Bitmap(b: ByteArray): Bitmap? { return if (b.isEmpty()) { null } else BitmapFactory.decodeByteArray(b, 0, b.size) } /* * Drawable → Bitmap */ fun drawable2Bitmap(drawable: Drawable): Bitmap { // 取 drawable 的长宽 val w = drawable.intrinsicWidth val h = drawable.intrinsicHeight // 取 drawable 的颜色格式 @Suppress("DEPRECATION") val config = if (drawable.opacity != PixelFormat.OPAQUE) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565 // 建立对应 bitmap val bitmap = Bitmap.createBitmap(w, h, config) // 建立对应 bitmap 的画布 val canvas = Canvas(bitmap) drawable.setBounds(0, 0, w, h) // 把 drawable 内容画到画布中 drawable.draw(canvas) return bitmap } /* * Bitmap → Drawable */ fun bitmap2Drawable(bm: Bitmap?): Drawable? { return if (bm == null) { null } else BitmapDrawable(appCtx.resources, bm) } } /** * @author 杨福海(michael) www.yangfuhai.com * @version 1.0 * title 缓存管理器 */ open inner class ACacheManager( private var cacheDir: File, private val sizeLimit: Long, private val countLimit: Int ) { private val cacheSize: AtomicLong = AtomicLong() private val cacheCount: AtomicInteger = AtomicInteger() private val lastUsageDates = Collections .synchronizedMap(HashMap()) init { calculateCacheSizeAndCacheCount() } /** * 计算 cacheSize和cacheCount */ private fun calculateCacheSizeAndCacheCount() { Thread { try { var size = 0 var count = 0 val cachedFiles = cacheDir.listFiles() if (cachedFiles != null) { for (cachedFile in cachedFiles) { size += calculateSize(cachedFile).toInt() count += 1 lastUsageDates[cachedFile] = cachedFile.lastModified() } cacheSize.set(size.toLong()) cacheCount.set(count) } } catch (e: Exception) { e.printOnDebug() } }.start() } fun put(file: File) { try { var curCacheCount = cacheCount.get() while (curCacheCount + 1 > countLimit) { val freedSize = removeNext() cacheSize.addAndGet(-freedSize) curCacheCount = cacheCount.addAndGet(-1) } cacheCount.addAndGet(1) val valueSize = calculateSize(file) var curCacheSize = cacheSize.get() while (curCacheSize + valueSize > sizeLimit) { val freedSize = removeNext() curCacheSize = cacheSize.addAndGet(-freedSize) } cacheSize.addAndGet(valueSize) val currentTime = System.currentTimeMillis() file.setLastModified(currentTime) lastUsageDates[file] = currentTime } catch (e: Exception) { e.printOnDebug() } } operator fun get(key: String): File { val file = newFile(key) val currentTime = System.currentTimeMillis() file.setLastModified(currentTime) lastUsageDates[file] = currentTime return file } fun newFile(key: String): File { return File(cacheDir, key.hashCode().toString() + "") } fun remove(key: String): Boolean { val image = get(key) return image.delete() } fun clear() { try { lastUsageDates.clear() cacheSize.set(0) val files = cacheDir.listFiles() if (files != null) { for (f in files) { f.delete() } } } catch (e: Exception) { e.printOnDebug() } } /** * 移除旧的文件 */ private fun removeNext(): Long { try { if (lastUsageDates.isEmpty()) { return 0 } var oldestUsage: Long? = null var mostLongUsedFile: File? = null val entries = lastUsageDates.entries synchronized(lastUsageDates) { for ((key, lastValueUsage) in entries) { if (mostLongUsedFile == null) { mostLongUsedFile = key oldestUsage = lastValueUsage } else { if (lastValueUsage < oldestUsage!!) { oldestUsage = lastValueUsage mostLongUsedFile = key } } } } var fileSize: Long = 0 if (mostLongUsedFile != null) { fileSize = calculateSize(mostLongUsedFile) if (mostLongUsedFile.delete()) { lastUsageDates.remove(mostLongUsedFile) } } return fileSize } catch (e: Exception) { e.printOnDebug() return 0 } } private fun calculateSize(file: File): Long { return file.length() } } } ================================================ FILE: app/src/main/java/io/legado/app/utils/ActivityExtensions.kt ================================================ package io.legado.app.utils import android.annotation.SuppressLint import android.app.Activity import android.graphics.Color import android.os.Build import android.os.Bundle import android.util.DisplayMetrics import android.view.Gravity import android.view.View import android.view.ViewGroup import android.view.WindowInsets import android.view.WindowInsetsController import android.view.WindowManager import android.view.WindowMetrics import android.widget.FrameLayout import androidx.annotation.ColorInt import androidx.appcompat.app.AppCompatActivity import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE import androidx.fragment.app.DialogFragment import io.legado.app.R import io.legado.app.ui.widget.dialog.TextDialog inline fun AppCompatActivity.showDialogFragment( arguments: Bundle.() -> Unit = {} ) { @Suppress("DEPRECATION") val dialog = T::class.java.newInstance() val bundle = Bundle() bundle.apply(arguments) dialog.arguments = bundle dialog.show(supportFragmentManager, T::class.simpleName) } inline fun AppCompatActivity.dismissDialogFragment() { supportFragmentManager.fragments.forEach { if (it is T) { it.dismissAllowingStateLoss() } } } fun AppCompatActivity.showDialogFragment(dialogFragment: DialogFragment) { dialogFragment.show(supportFragmentManager, dialogFragment::class.simpleName) } val WindowManager.windowSize: DisplayMetrics get() { val displayMetrics = DisplayMetrics() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val windowMetrics: WindowMetrics = currentWindowMetrics val insets = windowMetrics.windowInsets .getInsetsIgnoringVisibility( WindowInsets.Type.systemBars() or WindowInsets.Type.displayCutout() ) val windowWidth = windowMetrics.bounds.width() val windowHeight = windowMetrics.bounds.height() var insetsWidth = insets.left + insets.right var insetsHeight = insets.top + insets.bottom if (windowWidth > windowHeight) { val tmp = insetsWidth insetsWidth = insetsHeight insetsHeight = tmp } displayMetrics.widthPixels = windowWidth - insetsWidth displayMetrics.heightPixels = windowHeight - insetsHeight } else { @Suppress("DEPRECATION") defaultDisplay.getMetrics(displayMetrics) } return displayMetrics } @Suppress("DEPRECATION") fun Activity.fullScreen() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { window.setDecorFitsSystemWindows(true) } window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_STABLE window.clearFlags( WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS or WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION ) window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) } /** * 设置状态栏颜色 */ @Suppress("DEPRECATION") fun Activity.setStatusBarColorAuto( @ColorInt color: Int, isTransparent: Boolean, fullScreen: Boolean ) { val isLightBar = ColorUtils.isColorLight(color) if (fullScreen) { if (isTransparent) { window.statusBarColor = Color.TRANSPARENT } else { window.statusBarColor = getCompatColor(R.color.status_bar_bag) } } else { window.statusBarColor = color } setLightStatusBar(isLightBar) } @SuppressLint("ObsoleteSdkInt") fun Activity.setLightStatusBar(isLightBar: Boolean) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { window.insetsController?.let { if (isLightBar) { it.setSystemBarsAppearance( WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS, WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS ) } else { it.setSystemBarsAppearance( 0, WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS ) } } } @Suppress("DEPRECATION") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val decorView = window.decorView val systemUiVisibility = decorView.systemUiVisibility if (isLightBar) { decorView.systemUiVisibility = systemUiVisibility or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR } else { decorView.systemUiVisibility = systemUiVisibility and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv() } } } /** * 设置导航栏颜色 */ @Suppress("DEPRECATION") fun Activity.setNavigationBarColorAuto(@ColorInt color: Int) { val isLightBor = ColorUtils.isColorLight(color) window.navigationBarColor = color if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { window.insetsController?.let { if (isLightBor) { it.setSystemBarsAppearance( WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS, WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS ) } else { it.setSystemBarsAppearance( 0, WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS ) } } } @Suppress("DEPRECATION") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val decorView = window.decorView var systemUiVisibility = decorView.systemUiVisibility systemUiVisibility = if (isLightBor) { systemUiVisibility or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR } else { systemUiVisibility and View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR.inv() } decorView.systemUiVisibility = systemUiVisibility } } fun Activity.keepScreenOn(on: Boolean) { val isScreenOn = (window.attributes.flags and WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) != 0 if (on == isScreenOn) return if (on) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } else { window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } } fun Activity.toggleSystemBar(show: Boolean) { WindowCompat.getInsetsController(window, window.decorView).run { if (show) { show(WindowInsetsCompat.Type.systemBars()) } else { hide(WindowInsetsCompat.Type.systemBars()) systemBarsBehavior = BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } } } /////以下方法需要在View完全被绘制出来之后调用,否则判断不了,在比如 onWindowFocusChanged()方法中可以得到正确的结果///// /** * 返回NavigationBar */ val Activity.navigationBar: View? get() { val viewGroup = (window.decorView as? ViewGroup) ?: return null for (i in 0 until viewGroup.childCount) { val child = viewGroup.getChildAt(i) val childId = child.id if (childId != View.NO_ID && resources.getResourceEntryName(childId) == "navigationBarBackground" ) { return child } } return null } /** * 返回NavigationBar是否存在 */ val Activity.isNavigationBarExist: Boolean get() = navigationBar != null /** * 返回NavigationBar高度 */ val Activity.navigationBarHeight: Int @SuppressLint("InternalInsetResource", "DiscouragedApi") get() { if (isNavigationBarExist) { val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android") return resources.getDimensionPixelSize(resourceId) } return 0 } /** * 返回navigationBar位置 */ val Activity.navigationBarGravity: Int get() { val gravity = (navigationBar?.layoutParams as? FrameLayout.LayoutParams)?.gravity return gravity ?: Gravity.BOTTOM } /** * 显示目录help下的帮助文档 */ fun AppCompatActivity.showHelp(fileName: String) { val mdText = String(assets.open("web/help/md/${fileName}.md").readBytes()) showDialogFragment(TextDialog(getString(R.string.help), mdText, TextDialog.Mode.MD)) } ================================================ FILE: app/src/main/java/io/legado/app/utils/ActivityResult.kt ================================================ package io.legado.app.utils import androidx.activity.result.contract.ActivityResultContract import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityOptionsCompat import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume fun AppCompatActivity.registerForActivityResult(contract: ActivityResultContract): ActivityResultLauncherAwait { lateinit var cout: CancellableContinuation val launcher = registerForActivityResult(contract) { if (cout.isActive) { cout.resume(it) } } return object : ActivityResultLauncherAwait() { override suspend fun launch(input: I, options: ActivityOptionsCompat?): O { return suspendCancellableCoroutine { cout = it launcher.launch(input, options) } } override fun unregister() { launcher.unregister() } override fun getContract(): ActivityResultContract { return launcher.contract } } } abstract class ActivityResultLauncherAwait { suspend fun launch(input: I): O { return launch(input, null) } abstract suspend fun launch(input: I, options: ActivityOptionsCompat?): O abstract fun unregister() abstract fun getContract(): ActivityResultContract } ================================================ FILE: app/src/main/java/io/legado/app/utils/ActivityResultContracts.kt ================================================ package io.legado.app.utils import android.app.Activity.RESULT_OK import android.content.Context import android.content.Intent import android.net.Uri import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContracts import splitties.init.appCtx fun ActivityResultLauncher.launch() { launch(null) } class SelectImageContract : ActivityResultContract() { private val delegate = ActivityResultContracts.PickVisualMedia() private var requestCode: Int? = null private var useFallback = false override fun createIntent(context: Context, input: Int?): Intent { requestCode = input val intent = Intent(Intent.ACTION_GET_CONTENT) .addCategory(Intent.CATEGORY_OPENABLE) .setType("image/*") if (intent.resolveActivity(appCtx.packageManager) == null) { useFallback = true val request = PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) return delegate.createIntent(context, request) } return intent } override fun parseResult(resultCode: Int, intent: Intent?): Result { val uri = if (useFallback) { delegate.parseResult(resultCode, intent) } else if (resultCode == RESULT_OK) { intent?.data } else { null } return Result(requestCode, uri) } data class Result( val requestCode: Int?, val uri: Uri? = null ) } class StartActivityContract(private val cls: Class<*>) : ActivityResultContract<(Intent.() -> Unit)?, ActivityResult>() { override fun createIntent(context: Context, input: (Intent.() -> Unit)?): Intent { val intent = Intent(context, cls) input?.let { intent.apply(input) } return intent } override fun parseResult( resultCode: Int, intent: Intent? ): ActivityResult { return ActivityResult(resultCode, intent) } } ================================================ FILE: app/src/main/java/io/legado/app/utils/AlphanumComparator.kt ================================================ package io.legado.app.utils /** * 排序比较 */ object AlphanumComparator : Comparator { override fun compare(s1: String, s2: String): Int { var thisMarker = 0 var thatMarker = 0 val s1Length = s1.length val s2Length = s2.length while (thisMarker < s1Length && thatMarker < s2Length) { val thisChunk = getChunk(s1, s1Length, thisMarker) thisMarker += thisChunk.length val thatChunk = getChunk(s2, s2Length, thatMarker) thatMarker += thatChunk.length // If both chunks contain numeric characters, sort them numerically. var result: Int if (isDigit(thisChunk[0]) && isDigit(thatChunk[0])) { // Simple chunk comparison by length. val thisChunkLength = thisChunk.length result = thisChunkLength - thatChunk.length // If equal, the first different number counts. if (result == 0) { for (i in 0 until thisChunkLength) { result = thisChunk[i] - thatChunk[i] if (result != 0) { return result } } } } else { result = thisChunk.compareTo(thatChunk) } if (result != 0) { return result } } return s1Length - s2Length } private fun getChunk(string: String, length: Int, marker: Int): String { var current = marker val chunk = StringBuilder() var c = string[current] chunk.append(c) current++ if (isDigit(c)) { while (current < length) { c = string[current] if (!isDigit(c)) { break } chunk.append(c) current++ } } else { while (current < length) { c = string[current] if (isDigit(c)) { break } chunk.append(c) current++ } } return chunk.toString() } private fun isDigit(ch: Char): Boolean { return ch in '0'..'9' } } ================================================ FILE: app/src/main/java/io/legado/app/utils/AnimationExtensions.kt ================================================ package io.legado.app.utils import android.content.Context import android.view.animation.Animation import android.view.animation.AnimationUtils import androidx.annotation.AnimRes import io.legado.app.help.config.AppConfig fun loadAnimation(context: Context, @AnimRes id: Int): Animation { val animation = AnimationUtils.loadAnimation(context, id) if (AppConfig.isEInkMode) { animation.duration = 0 } return animation } ================================================ FILE: app/src/main/java/io/legado/app/utils/ArchiveUtils.kt ================================================ package io.legado.app.utils import android.net.Uri import androidx.documentfile.provider.DocumentFile import io.legado.app.constant.AppPattern.archiveFileRegex import io.legado.app.utils.compress.LibArchiveUtils import splitties.init.appCtx import java.io.File /* 自动判断压缩文件后缀 然后再调用具体的实现 */ @Suppress("unused", "MemberVisibilityCanBePrivate") object ArchiveUtils { const val TEMP_FOLDER_NAME = "ArchiveTemp" // 临时目录 下次启动自动删除 val TEMP_PATH: String by lazy { appCtx.externalCache.getFile(TEMP_FOLDER_NAME).createFolderReplace().absolutePath } fun deCompress( archiveUri: Uri, path: String = TEMP_PATH, filter: ((String) -> Boolean)? = null ): List { return deCompress(FileDoc.fromUri(archiveUri, false), path, filter) } fun deCompress( archivePath: String, path: String = TEMP_PATH, filter: ((String) -> Boolean)? = null ): List { return deCompress(Uri.parse(archivePath), path, filter) } fun deCompress( archiveFile: File, path: String = TEMP_PATH, filter: ((String) -> Boolean)? = null ): List { return deCompress(FileDoc.fromFile(archiveFile), path, filter) } fun deCompress( archiveDoc: DocumentFile, path: String = TEMP_PATH, filter: ((String) -> Boolean)? = null ): List { return deCompress(FileDoc.fromDocumentFile(archiveDoc), path, filter) } fun deCompress( archiveFileDoc: FileDoc, path: String = TEMP_PATH, filter: ((String) -> Boolean)? = null ): List { if (archiveFileDoc.isDir) throw IllegalArgumentException("Unexpected Folder input") val name = archiveFileDoc.name checkAchieve(name) val workPathFileDoc = getCacheFolderFileDoc(name, path) val workPath = workPathFileDoc.toString() return archiveFileDoc.openReadPfd().getOrThrow().use { LibArchiveUtils.unArchive(it, File(workPath), filter) } } /* 遍历目录获取文件名 */ fun getArchiveFilesName(fileUri: Uri, filter: ((String) -> Boolean)? = null): List = getArchiveFilesName(FileDoc.fromUri(fileUri, false), filter) fun getArchiveFilesName( fileDoc: FileDoc, filter: ((String) -> Boolean)? = null ): List { val name = fileDoc.name checkAchieve(name) return fileDoc.openReadPfd().getOrThrow().use { try { LibArchiveUtils.getFilesName(it, filter) } catch (e: Exception) { emptyList() } } } fun isArchive(name: String): Boolean { return archiveFileRegex.matches(name) } private fun checkAchieve(name: String) { if (!isArchive(name)) throw IllegalArgumentException("Unexpected file suffix: Only 7z rar zip Accepted") } private fun getCacheFolderFileDoc( archiveName: String, workPath: String ): FileDoc { return FileDoc.fromUri(Uri.parse(workPath), true) .createFolderIfNotExist(MD5Utils.md5Encode16(archiveName)) } } ================================================ FILE: app/src/main/java/io/legado/app/utils/AsyncFileHandler.kt ================================================ package io.legado.app.utils import io.legado.app.help.globalExecutor import java.util.logging.FileHandler import java.util.logging.LogRecord class AsyncFileHandler(pattern: String) : FileHandler(pattern) { override fun publish(record: LogRecord?) { if (!isLoggable(record)) { return } globalExecutor.execute { super.publish(record) } } } ================================================ FILE: app/src/main/java/io/legado/app/utils/BitmapUtils.kt ================================================ @file:Suppress("unused") package io.legado.app.utils import android.content.Context import android.graphics.Bitmap import android.graphics.Bitmap.Config import android.graphics.BitmapFactory import android.graphics.Color import com.google.android.renderscript.Toolkit import java.io.* import kotlin.math.* @Suppress("WeakerAccess", "MemberVisibilityCanBePrivate") object BitmapUtils { /** * 从path中获取图片信息,在通过BitmapFactory.decodeFile(String path)方法将突破转成Bitmap时, * 遇到大一些的图片,我们经常会遇到OOM(Out Of Memory)的问题。所以用到了我们上面提到的BitmapFactory.Options这个类。 * * @param path 文件路径 * @param width 想要显示的图片的宽度 * @param height 想要显示的图片的高度 * @return */ @Throws(IOException::class) fun decodeBitmap(path: String, width: Int, height: Int? = null): Bitmap? { val fis = FileInputStream(path) return fis.use { val op = BitmapFactory.Options() // inJustDecodeBounds如果设置为true,仅仅返回图片实际的宽和高,宽和高是赋值给opts.outWidth,opts.outHeight; op.inJustDecodeBounds = true BitmapFactory.decodeFileDescriptor(fis.fd, null, op) op.inSampleSize = calculateInSampleSize(op, width, height) op.inJustDecodeBounds = false BitmapFactory.decodeFileDescriptor(fis.fd, null, op) } } /** *计算 InSampleSize。缺省返回1 * @param options BitmapFactory.Options, * @param width 想要显示的图片的宽度 * @param height 想要显示的图片的高度 * @return */ private fun calculateInSampleSize( options: BitmapFactory.Options, width: Int? = null, height: Int? = null ): Int { //获取比例大小 val wRatio = width?.let { options.outWidth / it } ?: -1 val hRatio = height?.let { options.outHeight / it } ?: -1 //如果超出指定大小,则缩小相应的比例 return when { wRatio > 1 && hRatio > 1 -> max(wRatio, hRatio) wRatio > 1 -> wRatio hRatio > 1 -> hRatio else -> 1 } } /** 从path中获取Bitmap图片 * @param path 图片路径 * @return */ @Throws(IOException::class) fun decodeBitmap(path: String): Bitmap? { val fis = FileInputStream(path) return fis.use { val opts = BitmapFactory.Options() opts.inJustDecodeBounds = true BitmapFactory.decodeFileDescriptor(fis.fd, null, opts) opts.inSampleSize = computeSampleSize(opts, -1, 128 * 128) opts.inJustDecodeBounds = false BitmapFactory.decodeFileDescriptor(fis.fd, null, opts) } } /** * 以最省内存的方式读取本地资源的图片 * @param context 设备上下文 * @param resId 资源ID * @return */ fun decodeBitmap(context: Context, resId: Int): Bitmap? { val opt = BitmapFactory.Options() opt.inPreferredConfig = Config.RGB_565 return BitmapFactory.decodeResource(context.resources, resId, opt) } /** * @param context 设备上下文 * @param resId 资源ID * @param width * @param height * @return */ fun decodeBitmap(context: Context, resId: Int, width: Int, height: Int): Bitmap? { val op = BitmapFactory.Options() // inJustDecodeBounds如果设置为true,仅仅返回图片实际的宽和高,宽和高是赋值给opts.outWidth,opts.outHeight; op.inJustDecodeBounds = true BitmapFactory.decodeResource(context.resources, resId, op) //获取尺寸信息 op.inSampleSize = calculateInSampleSize(op, width, height) op.inJustDecodeBounds = false return BitmapFactory.decodeResource(context.resources, resId, op) } /** * @param context 设备上下文 * @param fileNameInAssets Assets里面文件的名称 * @param width 图片的宽度 * @param height 图片的高度 * @return Bitmap * @throws IOException */ @Throws(IOException::class) fun decodeAssetsBitmap( context: Context, fileNameInAssets: String, width: Int, height: Int ): Bitmap? { var inputStream = context.assets.open(fileNameInAssets) return inputStream.use { val op = BitmapFactory.Options() // inJustDecodeBounds如果设置为true,仅仅返回图片实际的宽和高,宽和高是赋值给opts.outWidth,opts.outHeight; op.inJustDecodeBounds = true BitmapFactory.decodeStream(inputStream, null, op) //获取尺寸信息 op.inSampleSize = calculateInSampleSize(op, width, height) inputStream = context.assets.open(fileNameInAssets) op.inJustDecodeBounds = false BitmapFactory.decodeStream(inputStream, null, op) } } /** * @param options * @param minSideLength * @param maxNumOfPixels * @return * 设置恰当的inSampleSize是解决该问题的关键之一。BitmapFactory.Options提供了另一个成员inJustDecodeBounds。 * 设置inJustDecodeBounds为true后,decodeFile并不分配空间,但可计算出原始图片的长度和宽度,即opts.width和opts.height。 * 有了这两个参数,再通过一定的算法,即可得到一个恰当的inSampleSize。 * 查看Android源码,Android提供了下面这种动态计算的方法。 */ fun computeSampleSize( options: BitmapFactory.Options, minSideLength: Int, maxNumOfPixels: Int ): Int { val initialSize = computeInitialSampleSize(options, minSideLength, maxNumOfPixels) var roundedSize: Int if (initialSize <= 8) { roundedSize = 1 while (roundedSize < initialSize) { roundedSize = roundedSize shl 1 } } else { roundedSize = (initialSize + 7) / 8 * 8 } return roundedSize } private fun computeInitialSampleSize( options: BitmapFactory.Options, minSideLength: Int, maxNumOfPixels: Int ): Int { val w = options.outWidth.toDouble() val h = options.outHeight.toDouble() val lowerBound = when (maxNumOfPixels) { -1 -> 1 else -> ceil(sqrt(w * h / maxNumOfPixels)).toInt() } val upperBound = when (minSideLength) { -1 -> 128 else -> min( floor(w / minSideLength), floor(h / minSideLength) ).toInt() } if (upperBound < lowerBound) { // return the larger one when there is no overlapping zone. return lowerBound } return when { maxNumOfPixels == -1 && minSideLength == -1 -> { 1 } minSideLength == -1 -> { lowerBound } else -> { upperBound } } } /** * 将Bitmap转换成InputStream * * @param bitmap * @return */ fun toInputStream(bitmap: Bitmap): InputStream { val bos = ByteArrayOutputStream() bitmap.compress(Bitmap.CompressFormat.JPEG, 90 /*ignored for PNG*/, bos) return ByteArrayInputStream(bos.toByteArray()).also { bos.close() } } } /** * 获取指定宽高的图片 */ fun Bitmap.resizeAndRecycle(newWidth: Int, newHeight: Int): Bitmap { //获取新的bitmap val bitmap = Toolkit.resize(this, newWidth, newHeight) recycle() return bitmap } /** * 高斯模糊 */ fun Bitmap.stackBlur(radius: Int = 8): Bitmap { return Toolkit.blur(this, radius) } /** * 取平均色 */ fun Bitmap.getMeanColor(): Int { val width: Int = this.width val height: Int = this.height var pixel: Int var pixelSumRed = 0 var pixelSumBlue = 0 var pixelSumGreen = 0 for (i in 0..99) { for (j in 70..99) { pixel = this.getPixel( (i * width / 100.toFloat()).roundToInt(), (j * height / 100.toFloat()).roundToInt() ) pixelSumRed += Color.red(pixel) pixelSumGreen += Color.green(pixel) pixelSumBlue += Color.blue(pixel) } } val averagePixelRed = pixelSumRed / 3000 val averagePixelBlue = pixelSumBlue / 3000 val averagePixelGreen = pixelSumGreen / 3000 return Color.rgb( averagePixelRed + 3, averagePixelGreen + 3, averagePixelBlue + 3 ) } ================================================ FILE: app/src/main/java/io/legado/app/utils/BookChapterExtensions.kt ================================================ package io.legado.app.utils import io.legado.app.data.entities.BookChapter fun BookChapter.internString() { title = title.intern() bookUrl = bookUrl.intern() } ================================================ FILE: app/src/main/java/io/legado/app/utils/ByteArrayExtensions.kt ================================================ package io.legado.app.utils /** * Search the data byte array for the first occurrence * of the byte array pattern. */ fun ByteArray.indexOf(pattern: ByteArray, start: Int = 0, stop: Int = size): Int { val data = this val failure: IntArray = computeFailure(pattern) var j = 0 for (i in start until stop) { while (j > 0 && pattern[j] != data[i]) { j = failure[j - 1] } if (pattern[j] == data[i]) { j++ } if (j == pattern.size) { return i - pattern.size + 1 } } return -1 } /** * Computes the failure function using a boot-strapping process, * where the pattern is matched against itself. */ private fun computeFailure(pattern: ByteArray): IntArray { val failure = IntArray(pattern.size) var j = 0 for (i in 1 until pattern.size) { while (j > 0 && pattern[j] != pattern[i]) { j = failure[j - 1] } if (pattern[j] == pattern[i]) { j++ } failure[i] = j } return failure } ================================================ FILE: app/src/main/java/io/legado/app/utils/ChineseUtils.kt ================================================ package io.legado.app.utils import com.github.liuyueyi.quick.transfer.ChineseUtils import com.github.liuyueyi.quick.transfer.constants.TransType object ChineseUtils { private var fixed = false fun s2t(content: String): String { return ChineseUtils.s2t(content) } fun t2s(content: String): String { if (!fixed) { fixT2sDict() } return ChineseUtils.t2s(content) } fun preLoad(async: Boolean, vararg transType: TransType) { ChineseUtils.preLoad(async, *transType) } fun unLoad(vararg transType: TransType) { ChineseUtils.unLoad(*transType) } fun fixT2sDict() { fixed = true val excludeList = listOf( "槃", "划槳", "列根", "雪梨", "雪糕", "多士", "起司", "芝士", "沙芬", "母音", "华乐", "民乐", "晶元", "晶片", "映像", "明覆", "明瞭", "新力", "新喻", "零錢", "零钱", "離線", "碟片", "模組", "桌球", "案頭", "機車", "電漿", "鳳梨", "魔戒", "載入", "菲林", "整合", "變數", "解碼", "散钱", "插水", "房屋", "房价", "快取", "德士", "建立", "常式", "席丹", "布殊", "布希", "巴哈", "巨集", "夜学", "向量", "半形", "加彭", "列印", "函式", "全形", "光碟", "介面", "乳酪", "沈船", "永珍", "演化", "牛油", "相容", "磁碟", "菲林", "規則", "酵素", "雷根", "饭盒", "路易斯", "非同步", "出租车", "周杰倫", "马铃薯", "馬鈴薯", "機械人", "電單車", "電扶梯", "音效卡", "飆車族", "點陣圖", "個入球", "顆進球", "沃尓沃", "晶片集", "斯瓦巴", "斜角巷", "战列舰", "快速面", "希特拉", "太空梭", "吐瓦魯", "吉布堤", "吉布地", "史太林", "南冰洋", "区域网", "波札那", "解析度", "酷洛米", "金夏沙", "魔獸紀元", "高空彈跳", "铁达尼号", "太空战士", "埃及妖后", "吉里巴斯", "附加元件", "魔鬼終結者", "純文字檔案", "奇幻魔法Melody", "列支敦斯登" ) ChineseUtils.loadExcludeDict(TransType.TRADITIONAL_TO_SIMPLE, excludeList) } } ================================================ FILE: app/src/main/java/io/legado/app/utils/CollectionExtensions.kt ================================================ package io.legado.app.utils fun List.fastSum(): Float { var sum = 0f for (i in indices) { sum += this[i] } return sum } inline fun List.fastBinarySearch( fromIndex: Int = 0, toIndex: Int = size, comparison: (T) -> Int ): Int { when { fromIndex > toIndex -> throw IllegalArgumentException( "fromIndex ($fromIndex) is greater than toIndex ($toIndex)." ) fromIndex < 0 -> throw IndexOutOfBoundsException("fromIndex ($fromIndex) is less than zero.") toIndex > size -> throw IndexOutOfBoundsException("toIndex ($toIndex) is greater than size ($size).") } var low = fromIndex var high = toIndex - 1 while (low <= high) { val mid = (low + high).ushr(1) // safe from overflows val midVal = get(mid) val cmp = comparison(midVal) if (cmp < 0) low = mid + 1 else if (cmp > 0) high = mid - 1 else return mid // key found } return -(low + 1) // key not found } inline fun > List.fastBinarySearchBy( key: K?, fromIndex: Int = 0, toIndex: Int = size, crossinline selector: (T) -> K? ): Int = fastBinarySearch(fromIndex, toIndex) { compareValues(selector(it), key) } fun MutableList.removeLastElement(): T { return if (isEmpty()) { throw NoSuchElementException("List is empty.") } else { removeAt(lastIndex) } } ================================================ FILE: app/src/main/java/io/legado/app/utils/ColorUtils.kt ================================================ package io.legado.app.utils import android.graphics.Color import androidx.annotation.ColorInt import androidx.annotation.FloatRange import androidx.core.graphics.ColorUtils import kotlin.math.max import kotlin.math.min import kotlin.math.pow import kotlin.math.roundToInt import kotlin.math.sqrt @Suppress("unused", "MemberVisibilityCanBePrivate") object ColorUtils { fun isColorLight(@ColorInt color: Int): Boolean { return ColorUtils.calculateLuminance(color) >= 0.5 } fun intToString(intColor: Int): String { return String.format("#%06X", 0xFFFFFF and intColor) } fun stripAlpha(@ColorInt color: Int): Int { return -0x1000000 or color } @ColorInt fun shiftColor(@ColorInt color: Int, @FloatRange(from = 0.0, to = 2.0) by: Float): Int { if (by == 1f) return color val alpha = Color.alpha(color) val hsv = FloatArray(3) Color.colorToHSV(color, hsv) hsv[2] *= by // value component return (alpha shl 24) + (0x00ffffff and Color.HSVToColor(hsv)) } @ColorInt fun darkenColor(@ColorInt color: Int): Int { return shiftColor(color, 0.9f) } @ColorInt fun lightenColor(@ColorInt color: Int): Int { return shiftColor(color, 1.1f) } @ColorInt fun invertColor(@ColorInt color: Int): Int { val r = 255 - Color.red(color) val g = 255 - Color.green(color) val b = 255 - Color.blue(color) return Color.argb(Color.alpha(color), r, g, b) } @ColorInt fun adjustAlpha(@ColorInt color: Int, @FloatRange(from = 0.0, to = 1.0) factor: Float): Int { val alpha = (Color.alpha(color) * factor).roundToInt() val red = Color.red(color) val green = Color.green(color) val blue = Color.blue(color) return Color.argb(alpha, red, green, blue) } @ColorInt fun withAlpha(@ColorInt baseColor: Int, @FloatRange(from = 0.0, to = 1.0) alpha: Float): Int { val a = min(255, max(0, (alpha * 255).toInt())) shl 24 val rgb = 0x00ffffff and baseColor return a + rgb } /** * Taken from CollapsingToolbarLayout's CollapsingTextHelper class. */ fun blendColors(color1: Int, color2: Int, @FloatRange(from = 0.0, to = 1.0) ratio: Float): Int { val inverseRatio = 1f - ratio val a = Color.alpha(color1) * inverseRatio + Color.alpha(color2) * ratio val r = Color.red(color1) * inverseRatio + Color.red(color2) * ratio val g = Color.green(color1) * inverseRatio + Color.green(color2) * ratio val b = Color.blue(color1) * inverseRatio + Color.blue(color2) * ratio return Color.argb(a.toInt(), r.toInt(), g.toInt(), b.toInt()) } fun argb(r: Int, g: Int, b: Int): Int { return argb(Byte.MAX_VALUE.toInt(), r, g, b) } fun argb(alpha: Int, r: Int, g: Int, b: Int): Int { val colorByteArr = byteArrayOf(alpha.toByte(), r.toByte(), g.toByte(), b.toByte()) return byteArrToInt(colorByteArr) } fun rgb(argb: Int): IntArray { return intArrayOf(argb shr 16 and 0xFF, argb shr 8 and 0xFF, argb and 0xFF) } fun byteArrToInt(colorByteArr: ByteArray): Int { return ((colorByteArr[0].toInt() shl 24) + (colorByteArr[1].toInt() and 0xFF shl 16) + (colorByteArr[2].toInt() and 0xFF shl 8) + (colorByteArr[3].toInt() and 0xFF)) } /** * Computes the difference between two RGB colors by converting them to the L*a*b scale and * comparing them using the CIE76 algorithm { http://en.wikipedia.org/wiki/Color_difference#CIE76} */ fun getColorDifference(a: Int, b: Int): Double { val lab1 = DoubleArray(3) val lab2 = DoubleArray(3) ColorUtils.colorToLAB(a, lab1) ColorUtils.colorToLAB(b, lab2) return sqrt( (lab2[0] - lab1[0]) .pow(2.0) + (lab2[1] - lab1[1]) .pow(2.0) + (lab2[2] - lab1[2]) .pow(2.0) ) } } ================================================ FILE: app/src/main/java/io/legado/app/utils/ConfigurationExtensions.kt ================================================ @file:Suppress("unused") package io.legado.app.utils import android.content.res.Configuration import android.content.res.Resources val sysConfiguration: Configuration = Resources.getSystem().configuration val Configuration.isNightMode: Boolean get() { val mode = uiMode and Configuration.UI_MODE_NIGHT_MASK return mode == Configuration.UI_MODE_NIGHT_YES } ================================================ FILE: app/src/main/java/io/legado/app/utils/ConflateLiveData.kt ================================================ package io.legado.app.utils import android.os.Handler import android.os.Looper import androidx.lifecycle.LiveData /** * 合并发送,只发送最新数据 * @param delay 发送时间间隔 */ class ConflateLiveData(val delay: Int) : LiveData() { private val handler = Handler(Looper.getMainLooper()) private val sendRunnable = Runnable { sendData() } private var postTime = 0L private var data: T? = null private fun sendData() { data?.let { super.postValue(it) } } @Synchronized public override fun postValue(value: T) { data = value val postDelay = postTime + delay - System.currentTimeMillis() if (postDelay > 0) { handler.removeCallbacks(sendRunnable) handler.postDelayed(sendRunnable, postDelay) } else { handler.removeCallbacks(sendRunnable) postTime = System.currentTimeMillis() super.postValue(value) } } } ================================================ FILE: app/src/main/java/io/legado/app/utils/ConstraintModify.kt ================================================ package io.legado.app.utils import androidx.annotation.IdRes import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintSet import androidx.transition.TransitionManager @Suppress("unused") fun ConstraintLayout.modifyBegin(withAnim: Boolean = false): ConstraintModify.ConstraintBegin { val begin = ConstraintModify(this).begin if (withAnim) { TransitionManager.beginDelayedTransition(this) } return begin } @Suppress("MemberVisibilityCanBePrivate", "unused") class ConstraintModify(private val constraintLayout: ConstraintLayout) { val begin: ConstraintBegin by lazy { applyConstraintSet.clone(constraintLayout) ConstraintBegin(constraintLayout, applyConstraintSet) } private val applyConstraintSet = ConstraintSet() private val resetConstraintSet = ConstraintSet() init { resetConstraintSet.clone(constraintLayout) } /** * 带动画的修改 * @return */ fun beginWithAnim(): ConstraintBegin { TransitionManager.beginDelayedTransition(constraintLayout) return begin } /** * 重置 */ fun reSet() { resetConstraintSet.applyTo(constraintLayout) } /** * 带动画的重置 */ fun reSetWidthAnim() { TransitionManager.beginDelayedTransition(constraintLayout) resetConstraintSet.applyTo(constraintLayout) } @Suppress("unused", "MemberVisibilityCanBePrivate") class ConstraintBegin( private val constraintLayout: ConstraintLayout, private val applyConstraintSet: ConstraintSet ) { /** * 清除关系,这里不仅仅会清除关系,还会清除对应控件的宽高为 w:0,h:0 * @param viewId 视图ID * @return */ fun clear(viewId: Int): ConstraintBegin { applyConstraintSet.clear(viewId) return this } /** * 清除某个控件的,某个关系 * @param viewId 控件ID * @param anchor 要解除的关系 * @return */ fun clear(viewId: Int, anchor: Anchor): ConstraintBegin { applyConstraintSet.clear(viewId, anchor.toInt()) return this } fun setHorizontalWeight(viewId: Int, weight: Float): ConstraintBegin { applyConstraintSet.setHorizontalWeight(viewId, weight) return this } fun setVerticalWeight(viewId: Int, weight: Float): ConstraintBegin { applyConstraintSet.setVerticalWeight(viewId, weight) return this } /** * 为某个控件设置 margin * @param viewId 某个控件ID * @param left marginLeft * @param top marginTop * @param right marginRight * @param bottom marginBottom * @return */ fun setMargin( @IdRes viewId: Int, left: Int, top: Int, right: Int, bottom: Int ): ConstraintBegin { setMarginLeft(viewId, left) setMarginTop(viewId, top) setMarginRight(viewId, right) setMarginBottom(viewId, bottom) return this } /** * 为某个控件设置 marginLeft * @param viewId 某个控件ID * @param left marginLeft * @return */ fun setMarginLeft(@IdRes viewId: Int, left: Int): ConstraintBegin { applyConstraintSet.setMargin(viewId, ConstraintSet.LEFT, left) return this } /** * 为某个控件设置 marginRight * @param viewId 某个控件ID * @param right marginRight * @return */ fun setMarginRight(@IdRes viewId: Int, right: Int): ConstraintBegin { applyConstraintSet.setMargin(viewId, ConstraintSet.RIGHT, right) return this } /** * 为某个控件设置 marginTop * @param viewId 某个控件ID * @param top marginTop * @return */ fun setMarginTop(@IdRes viewId: Int, top: Int): ConstraintBegin { applyConstraintSet.setMargin(viewId, ConstraintSet.TOP, top) return this } /** * 为某个控件设置marginBottom * @param viewId 某个控件ID * @param bottom marginBottom * @return */ fun setMarginBottom(@IdRes viewId: Int, bottom: Int): ConstraintBegin { applyConstraintSet.setMargin(viewId, ConstraintSet.BOTTOM, bottom) return this } /** * 为某个控件设置关联关系 left_to_left_of * @param startId * @param endId * @return */ fun leftToLeftOf(@IdRes startId: Int, @IdRes endId: Int): ConstraintBegin { applyConstraintSet.connect(startId, ConstraintSet.LEFT, endId, ConstraintSet.LEFT) return this } /** * 为某个控件设置关联关系 left_to_right_of * @param startId * @param endId * @return */ fun leftToRightOf(@IdRes startId: Int, @IdRes endId: Int): ConstraintBegin { applyConstraintSet.connect(startId, ConstraintSet.LEFT, endId, ConstraintSet.RIGHT) return this } /** * 为某个控件设置关联关系 top_to_top_of * @param startId * @param endId * @return */ fun topToTopOf(@IdRes startId: Int, @IdRes endId: Int): ConstraintBegin { applyConstraintSet.connect(startId, ConstraintSet.TOP, endId, ConstraintSet.TOP) return this } /** * 为某个控件设置关联关系 top_to_bottom_of * @param startId * @param endId * @return */ fun topToBottomOf(@IdRes startId: Int, @IdRes endId: Int): ConstraintBegin { applyConstraintSet.connect(startId, ConstraintSet.TOP, endId, ConstraintSet.BOTTOM) return this } /** * 为某个控件设置关联关系 right_to_left_of * @param startId * @param endId * @return */ fun rightToLeftOf(@IdRes startId: Int, @IdRes endId: Int): ConstraintBegin { applyConstraintSet.connect(startId, ConstraintSet.RIGHT, endId, ConstraintSet.LEFT) return this } /** * 为某个控件设置关联关系 right_to_right_of * @param startId * @param endId * @return */ fun rightToRightOf(@IdRes startId: Int, @IdRes endId: Int): ConstraintBegin { applyConstraintSet.connect(startId, ConstraintSet.RIGHT, endId, ConstraintSet.RIGHT) return this } /** * 为某个控件设置关联关系 bottom_to_bottom_of * @param startId * @param endId * @return */ fun bottomToBottomOf(@IdRes startId: Int, @IdRes endId: Int): ConstraintBegin { applyConstraintSet.connect(startId, ConstraintSet.BOTTOM, endId, ConstraintSet.BOTTOM) return this } /** * 为某个控件设置关联关系 bottom_to_top_of * @param startId * @param endId * @return */ fun bottomToTopOf(@IdRes startId: Int, @IdRes endId: Int): ConstraintBegin { applyConstraintSet.connect(startId, ConstraintSet.BOTTOM, endId, ConstraintSet.TOP) return this } /** * 为某个控件设置宽度 * @param viewId * @param width * @return */ fun setWidth(@IdRes viewId: Int, width: Int): ConstraintBegin { applyConstraintSet.constrainWidth(viewId, width) return this } /** * 某个控件设置高度 * @param viewId * @param height * @return */ fun setHeight(@IdRes viewId: Int, height: Int): ConstraintBegin { applyConstraintSet.constrainHeight(viewId, height) return this } /** * 提交应用生效 */ fun commit() { constraintLayout.post { applyConstraintSet.applyTo(constraintLayout) } } } enum class Anchor { LEFT, RIGHT, TOP, BOTTOM, BASELINE, START, END, CIRCLE_REFERENCE; fun toInt(): Int { return when (this) { LEFT -> ConstraintSet.LEFT RIGHT -> ConstraintSet.RIGHT TOP -> ConstraintSet.TOP BOTTOM -> ConstraintSet.BOTTOM BASELINE -> ConstraintSet.BASELINE START -> ConstraintSet.START END -> ConstraintSet.END CIRCLE_REFERENCE -> ConstraintSet.CIRCLE_REFERENCE } } } } ================================================ FILE: app/src/main/java/io/legado/app/utils/ContextExtensions.kt ================================================ @file:Suppress("unused", "UnusedReceiverParameter") package io.legado.app.utils import android.annotation.SuppressLint import android.app.Activity import android.app.PendingIntent import android.app.PendingIntent.FLAG_MUTABLE import android.app.PendingIntent.FLAG_UPDATE_CURRENT import android.app.PendingIntent.getActivity import android.app.PendingIntent.getBroadcast import android.app.PendingIntent.getService import android.app.Service import android.content.BroadcastReceiver import android.content.ClipData import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.SharedPreferences import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.content.res.ColorStateList import android.content.res.Configuration import android.graphics.Bitmap import android.graphics.drawable.Drawable import android.net.ConnectivityManager import android.net.Uri import android.os.BatteryManager import android.os.Build import android.os.Process import android.provider.Settings import androidx.annotation.ColorRes import androidx.annotation.DrawableRes import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.core.content.edit import androidx.preference.PreferenceManager import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel import io.legado.app.R import io.legado.app.constant.AppConst import io.legado.app.data.entities.Book import io.legado.app.help.IntentHelp import io.legado.app.help.book.isAudio import io.legado.app.help.book.isImage import io.legado.app.help.book.isLocal import io.legado.app.help.config.AppConfig import io.legado.app.ui.book.audio.AudioPlayActivity import io.legado.app.ui.book.manga.ReadMangaActivity import io.legado.app.ui.book.read.ReadBookActivity import splitties.systemservices.clipboardManager import splitties.systemservices.connectivityManager import splitties.systemservices.uiModeManager import java.io.File import java.io.FileOutputStream import kotlin.system.exitProcess inline fun Context.startActivity(configIntent: Intent.() -> Unit = {}) { val intent = Intent(this, A::class.java) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) intent.apply(configIntent) startActivity(intent) } fun Context.startActivityForBook( book: Book, configIntent: Intent.() -> Unit = {}, ) { val cls = when { book.isAudio -> AudioPlayActivity::class.java !book.isLocal && book.isImage && AppConfig.showMangaUi -> ReadMangaActivity::class.java else -> ReadBookActivity::class.java } val intent = Intent(this, cls) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) intent.putExtra("bookUrl", book.bookUrl) intent.apply(configIntent) startActivity(intent) } inline fun Context.startService(configIntent: Intent.() -> Unit = {}) { startService(Intent(this, T::class.java).apply(configIntent)) } inline fun Context.stopService() { stopService(Intent(this, T::class.java)) } @SuppressLint("UnspecifiedImmutableFlag") inline fun Context.servicePendingIntent( action: String, requestCode: Int = 0, configIntent: Intent.() -> Unit = {}, ): PendingIntent? { val intent = Intent(this, T::class.java) intent.action = action configIntent.invoke(intent) val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { FLAG_UPDATE_CURRENT or FLAG_MUTABLE } else { FLAG_UPDATE_CURRENT } return getService(this, requestCode, intent, flags) } @SuppressLint("UnspecifiedImmutableFlag") fun Context.activityPendingIntent( intent: Intent, action: String, ): PendingIntent? { intent.action = action val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { FLAG_UPDATE_CURRENT or FLAG_MUTABLE } else { FLAG_UPDATE_CURRENT } return getActivity(this, 0, intent, flags) } @SuppressLint("UnspecifiedImmutableFlag") inline fun Context.activityPendingIntent( action: String, configIntent: Intent.() -> Unit = {}, ): PendingIntent? { val intent = Intent(this, T::class.java) intent.action = action configIntent.invoke(intent) val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { FLAG_UPDATE_CURRENT or FLAG_MUTABLE } else { FLAG_UPDATE_CURRENT } return getActivity(this, 0, intent, flags) } @SuppressLint("UnspecifiedImmutableFlag") inline fun Context.broadcastPendingIntent( action: String, configIntent: Intent.() -> Unit = {}, ): PendingIntent? { val intent = Intent(this, T::class.java) intent.action = action configIntent.invoke(intent) val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { FLAG_UPDATE_CURRENT or FLAG_MUTABLE } else { FLAG_UPDATE_CURRENT } return getBroadcast(this, 0, intent, flags) } fun Context.startForegroundServiceCompat(intent: Intent) { try { startService(intent) } catch (e: IllegalStateException) { ContextCompat.startForegroundService(this, intent) } } val Context.defaultSharedPreferences: SharedPreferences get() = PreferenceManager.getDefaultSharedPreferences(this) fun Context.getPrefBoolean(key: String, defValue: Boolean = false) = defaultSharedPreferences.getBoolean(key, defValue) fun Context.putPrefBoolean(key: String, value: Boolean = false) = defaultSharedPreferences.edit { putBoolean(key, value) } fun Context.getPrefInt(key: String, defValue: Int = 0) = defaultSharedPreferences.getInt(key, defValue) fun Context.putPrefInt(key: String, value: Int) = defaultSharedPreferences.edit { putInt(key, value) } fun Context.getPrefLong(key: String, defValue: Long = 0L) = defaultSharedPreferences.getLong(key, defValue) fun Context.putPrefLong(key: String, value: Long) = defaultSharedPreferences.edit { putLong(key, value) } fun Context.getPrefString(key: String, defValue: String? = null) = defaultSharedPreferences.getString(key, defValue) fun Context.putPrefString(key: String, value: String?) = defaultSharedPreferences.edit { putString(key, value) } fun Context.getPrefStringSet( key: String, defValue: MutableSet? = null, ): MutableSet? = defaultSharedPreferences.getStringSet(key, defValue) fun Context.putPrefStringSet(key: String, value: MutableSet) = defaultSharedPreferences.edit { putStringSet(key, value) } fun Context.removePref(key: String) = defaultSharedPreferences.edit { remove(key) } fun Context.getCompatColor(@ColorRes id: Int): Int = ContextCompat.getColor(this, id) fun Context.getCompatDrawable(@DrawableRes id: Int): Drawable? = ContextCompat.getDrawable(this, id) fun Context.getCompatColorStateList(@ColorRes id: Int): ColorStateList? = ContextCompat.getColorStateList(this, id) fun Context.checkSelfUriPermission(uri: Uri, modeFlags: Int): Int = checkUriPermission(uri, Process.myPid(), Process.myUid(), modeFlags) fun Context.restart() { val intent: Intent? = packageManager.getLaunchIntentForPackage(packageName) intent?.let { intent.addFlags( Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP ) startActivity(intent) //杀掉以前进程 Process.killProcess(Process.myPid()) exitProcess(0) } } /** * 系统息屏时间 */ val Context.sysScreenOffTime: Int get() { return kotlin.runCatching { Settings.System.getInt(contentResolver, Settings.System.SCREEN_OFF_TIMEOUT) }.onFailure { it.printOnDebug() }.getOrDefault(0) } val Context.statusBarHeight: Int @SuppressLint("DiscouragedApi", "InternalInsetResource") get() { if (Build.BOARD == "windows") { return 0 } val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android") return resources.getDimensionPixelSize(resourceId) } val Context.navigationBarHeight: Int @SuppressLint("DiscouragedApi", "InternalInsetResource") get() { val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android") return resources.getDimensionPixelSize(resourceId) } fun Context.share(text: String, title: String = getString(R.string.share)) { kotlin.runCatching { val intent = Intent(Intent.ACTION_SEND) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) intent.putExtra(Intent.EXTRA_SUBJECT, title) intent.putExtra(Intent.EXTRA_TEXT, text) intent.type = "text/plain" startActivity(Intent.createChooser(intent, title)) } } fun Context.share(file: File, type: String = "text/*") { val fileUri = FileProvider.getUriForFile(this, AppConst.authority, file) val intent = Intent(Intent.ACTION_SEND) intent.type = type intent.putExtra(Intent.EXTRA_STREAM, fileUri) intent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) startActivity( Intent.createChooser( intent, getString(R.string.share_selected_source) ) ) } @SuppressLint("SetWorldReadable") fun Context.shareWithQr( text: String, title: String = getString(R.string.share), errorCorrectionLevel: ErrorCorrectionLevel = ErrorCorrectionLevel.H, ) { val bitmap = QRCodeUtils.createQRCode(text, errorCorrectionLevel = errorCorrectionLevel) if (bitmap == null) { toastOnUi(R.string.text_too_long_qr_error) } else { try { val file = File(externalCacheDir, "qr.png") val fOut = FileOutputStream(file) bitmap.compress(Bitmap.CompressFormat.PNG, 100, fOut) fOut.flush() fOut.close() file.setReadable(true, false) val contentUri = FileProvider.getUriForFile(this, AppConst.authority, file) val intent = Intent(Intent.ACTION_SEND) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) intent.putExtra(Intent.EXTRA_STREAM, contentUri) intent.type = "image/png" startActivity(Intent.createChooser(intent, title)) } catch (e: Exception) { toastOnUi(e.localizedMessage ?: "ERROR") } } } fun Context.sendToClip(text: String) { val clipData = ClipData.newPlainText(null, text) clipboardManager.setPrimaryClip(clipData) longToastOnUi(R.string.copy_complete) } fun Context.getClipText(): String? { clipboardManager.primaryClip?.let { if (it.itemCount > 0) { return it.getItemAt(0).text.toString().trim() } } return null } fun Context.sendMail(mail: String) { try { val intent = Intent(Intent.ACTION_SENDTO) intent.data = Uri.parse("mailto:$mail") intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) startActivity(intent) } catch (e: Exception) { toastOnUi(e.localizedMessage ?: "Error") } } /** * 获取电量 */ val Context.sysBattery: Int get() { val iFilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED) val batteryStatus = registerReceiver(null, iFilter) return batteryStatus?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1 } val Context.externalFiles: File get() = this.getExternalFilesDir(null) ?: this.filesDir val Context.externalCache: File get() = this.externalCacheDir ?: this.cacheDir fun Context.openUrl(url: String) { try { startActivity(IntentHelp.getBrowserIntent(url)) } catch (e: Exception) { toastOnUi(e.localizedMessage ?: "open url error") e.printOnDebug() } } fun Context.openUrl(uri: Uri) { try { startActivity(IntentHelp.getBrowserIntent(uri)) } catch (e: Exception) { toastOnUi(e.localizedMessage ?: "open url error") e.printOnDebug() } } @SuppressLint("ObsoleteSdkInt") fun Context.openFileUri(uri: Uri, type: String? = null) { val intent = Intent() intent.action = Intent.ACTION_VIEW intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { //7.0版本以上 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } val uri = if (uri.isContentScheme()) uri else FileProvider.getUriForFile(this, AppConst.authority, File(uri.path!!)) intent.setDataAndType(uri, type ?: IntentType.from(uri)) try { startActivity(intent) } catch (e: Exception) { toastOnUi(e.stackTraceStr) e.printOnDebug() } } @Suppress("DEPRECATION") val Context.isWifiConnect: Boolean @SuppressLint("MissingPermission") get() { val info = connectivityManager.getNetworkInfo(ConnectivityManager.TYPE_WIFI) return info?.isConnected == true } val Context.isPad: Boolean get() { return (resources.configuration.screenLayout and Configuration.SCREENLAYOUT_SIZE_MASK) >= Configuration.SCREENLAYOUT_SIZE_LARGE } val Context.isTv: Boolean get() = uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION val Context.channel: String get() { try { val pm = packageManager val appInfo = pm.getApplicationInfo(packageName, PackageManager.GET_META_DATA) return appInfo.metaData.getString("channel") ?: "" } catch (e: Exception) { e.printOnDebug() } return "" } val Context.isDebuggable: Boolean get() = applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 ================================================ FILE: app/src/main/java/io/legado/app/utils/ConvertExtensions.kt ================================================ @file:Suppress("unused") package io.legado.app.utils import android.content.res.Resources import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import java.io.BufferedReader import java.io.InputStream import java.io.InputStreamReader import java.text.DecimalFormat import kotlin.math.log10 import kotlin.math.pow /** * 数据类型转换、单位转换 * * @author 李玉江[QQ:1023694760] * @since 2014-4-18 */ @Suppress("MemberVisibilityCanBePrivate") object ConvertUtils { const val GB: Long = 1073741824 const val MB: Long = 1048576 const val KB: Long = 1024 fun toInt(obj: Any): Int { return kotlin.runCatching { Integer.parseInt(obj.toString()) }.getOrDefault(-1) } fun toInt(bytes: ByteArray): Int { var result = 0 var byte: Byte for (i in bytes.indices) { byte = bytes[i] result += (byte.toInt() and 0xFF).shl(8 * i) } return result } fun toFloat(obj: Any): Float { return kotlin.runCatching { java.lang.Float.parseFloat(obj.toString()) }.getOrDefault(-1f) } fun toString(objects: Array, tag: String): String { val sb = StringBuilder() for (`object` in objects) { sb.append(`object`) sb.append(tag) } return sb.toString() } @JvmOverloads fun toBitmap(bytes: ByteArray, width: Int = -1, height: Int = -1): Bitmap? { var bitmap: Bitmap? = null if (bytes.isNotEmpty()) { kotlin.runCatching { val options = BitmapFactory.Options() // 设置让解码器以最佳方式解码 options.inPreferredConfig = null if (width > 0 && height > 0) { options.outWidth = width options.outHeight = height } bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) bitmap!!.density = 96// 96 dpi } } return bitmap } private fun toDrawable(bitmap: Bitmap?): Drawable? { return if (bitmap == null) null else BitmapDrawable(Resources.getSystem(), bitmap) } fun toDrawable(bytes: ByteArray): Drawable? { return toDrawable(toBitmap(bytes)) } fun formatFileSize(length: Long): String { if (length <= 0) return "0" val units = arrayOf("b", "kb", "M", "G", "T") //计算单位的,原理是利用lg,公式是 lg(1024^n) = nlg(1024),最后 nlg(1024)/lg(1024) = n。 val digitGroups = (log10(length.toDouble()) / log10(1024.0)).toInt() //计算原理是,size/单位值。单位值指的是:比如说b = 1024,KB = 1024^2 return DecimalFormat("#,##0.##").format(length / 1024.0.pow(digitGroups.toDouble())) + " " + units[digitGroups] } @JvmOverloads fun toString(`is`: InputStream, charset: String = "utf-8"): String { val sb = StringBuilder() kotlin.runCatching { val reader = BufferedReader(InputStreamReader(`is`, charset)) while (true) { val line = reader.readLine() if (line == null) { break } else { sb.append(line).append("\n") } } reader.close() `is`.close() } return sb.toString() } } val Int.hexString: String get() = Integer.toHexString(this) fun Int.dpToPx(): Int = this.toFloat().dpToPx().toInt() fun Int.spToPx(): Int = this.toFloat().spToPx().toInt() fun Float.dpToPx(): Float = android.util.TypedValue.applyDimension( android.util.TypedValue.COMPLEX_UNIT_DIP, this, Resources.getSystem().displayMetrics ) fun Float.spToPx(): Float = android.util.TypedValue.applyDimension( android.util.TypedValue.COMPLEX_UNIT_SP, this, Resources.getSystem().displayMetrics ) ================================================ FILE: app/src/main/java/io/legado/app/utils/CookieManagerExtensions.kt ================================================ @file:Suppress("UnusedReceiverParameter") package io.legado.app.utils import android.webkit.CookieManager @Suppress("unused") fun CookieManager.removeCookie(url: String) { val cm = CookieManager.getInstance() val domains = arrayOf( NetworkUtils.getDomain(url), NetworkUtils.getSubDomain(url) ) domains.forEach { dm -> val cookieGlob: String? = cm.getCookie(dm) cookieGlob?.splitNotBlank(";")?.forEach { val cookieName = it.substringBefore("=") cm.setCookie(dm, "$cookieName=; Expires=Wed, 31 Dec 2000 23:59:59 GMT") } } } ================================================ FILE: app/src/main/java/io/legado/app/utils/CoroutineExtensions.kt ================================================ package io.legado.app.utils import io.legado.app.help.coroutine.Coroutine import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException class TimeoutCancellationException(msg: String) : CancellationException(msg) suspend fun withTimeoutAsync(delayMillis: Long, block: suspend CoroutineScope.() -> T): T { return suspendCancellableCoroutine { cout -> Coroutine.async(context = cout.context) { launch { delay(delayMillis) if (!cout.isCompleted) { cout.resumeWithException(TimeoutCancellationException("Timed out waiting for $delayMillis ms")) } } val result = block() if (!cout.isCompleted) { cout.resume(result) } } } } suspend fun withTimeoutOrNullAsync(delayMillis: Long, block: suspend CoroutineScope.() -> T): T? { return try { withTimeoutAsync(delayMillis, block) } catch (e: TimeoutCancellationException) { null } } ================================================ FILE: app/src/main/java/io/legado/app/utils/CustomExportUtils.kt ================================================ package io.legado.app.utils import io.legado.app.help.config.AppConfig // 匹配待“输入的章节”字符串 private val regexEpisode = Regex("\\d+(-\\d+)?(,\\d+(-\\d+)?)*") /** * 是否启用自定义导出 * * @author Discut */ fun enableCustomExport(): Boolean { return AppConfig.enableCustomExport && AppConfig.exportType == 1 } /** * 验证 输入的范围 是否正确 * * @since 1.0.0 * @author Discut * @param text 输入的范围 字符串 * @return 是否正确 */ fun verificationField(text: String): Boolean { return text.matches(regexEpisode) } ================================================ FILE: app/src/main/java/io/legado/app/utils/Debounce.kt ================================================ package io.legado.app.utils import android.os.SystemClock import kotlin.math.max @Suppress("MemberVisibilityCanBePrivate") open class Debounce( var wait: Long = 0L, var maxWait: Long = -1L, var leading: Boolean = false, var trailing: Boolean = true, private val func: () -> T ) { companion object { private val handler by lazy { buildMainHandler() } } private var lastCallTime = -1L private var lastInvokeTime = 0L private val maxing get() = maxWait != -1L private var result: T? = null private var hasTimer = false private val timerExpiredRunnable = Runnable { timerExpired() } init { maxWait = if (maxing) max(maxWait, wait) else maxWait } private fun invokeFunc(time: Long): T { lastInvokeTime = time return func.invoke().also { result = it } } private fun startTimer(wait: Long) { hasTimer = true handler.postDelayed(timerExpiredRunnable, wait) } private fun cancelTimer() { handler.removeCallbacks(timerExpiredRunnable) } private fun leadingEdge(time: Long): T? { lastInvokeTime = time startTimer(wait) return if (leading) invokeFunc(time) else result } private fun trailingEdge(time: Long): T? { hasTimer = false return if (trailing) invokeFunc(time) else result } private fun remainingWait(time: Long): Long { val timeSinceLastCall = time - lastCallTime val timeSinceLastInvoke = time - lastInvokeTime val timeWaiting = wait - timeSinceLastCall return if (maxing) timeWaiting.coerceAtMost(maxWait - timeSinceLastInvoke) else timeWaiting } private fun shouldInvoke(time: Long): Boolean { val timeSinceLastCall = time - lastCallTime val timeSinceLastInvoke = time - lastInvokeTime return lastCallTime == -1L || timeSinceLastCall >= wait || timeSinceLastCall < 0 || maxing && timeSinceLastInvoke >= maxWait } private fun timerExpired() { val time = SystemClock.uptimeMillis() if (shouldInvoke(time)) { trailingEdge(time) } else { startTimer(remainingWait(time)) } } fun cancel() { if (hasTimer) { cancelTimer() } lastInvokeTime = 0 lastCallTime = -1L hasTimer = false } fun flush(): T? { return if (hasTimer) trailingEdge(SystemClock.uptimeMillis()) else result } fun pending(): Boolean = hasTimer operator fun invoke(): T? { val time = SystemClock.uptimeMillis() val isInvoking = shouldInvoke(time) lastCallTime = time if (isInvoking) { if (!hasTimer) { return leadingEdge(lastCallTime) } if (maxing) { startTimer(wait) return invokeFunc(lastCallTime) } } if (!hasTimer) { startTimer(wait) } return result } } fun debounce( wait: Long = 0L, maxWait: Long = -1L, leading: Boolean = false, trailing: Boolean = true, func: () -> T ) = Debounce(wait, maxWait, leading, trailing, func) ================================================ FILE: app/src/main/java/io/legado/app/utils/DebugLog.kt ================================================ package io.legado.app.utils import android.util.Log import io.legado.app.BuildConfig object DebugLog { fun e(tag: String, throwable: Throwable) { if (BuildConfig.DEBUG) { Log.e(tag, throwable.stackTraceToString()) } } fun e(tag: String, msg: String, throwable: Throwable? = null) { if (BuildConfig.DEBUG) { if (throwable == null) { Log.e(tag, msg) } else { Log.e(tag, msg, throwable) } } } fun d(tag: String, msg: String, throwable: Throwable? = null) { if (BuildConfig.DEBUG) { if (throwable == null) { Log.d(tag, msg) } else { Log.d(tag, msg, throwable) } } } fun i(tag: String, msg: String, throwable: Throwable? = null) { if (BuildConfig.DEBUG) { if (throwable == null) { Log.i(tag, msg) } else { Log.i(tag, msg, throwable) } } } fun w(tag: String, msg: String, throwable: Throwable? = null) { if (BuildConfig.DEBUG) { if (throwable == null) { Log.w(tag, msg) } else { Log.w(tag, msg, throwable) } } } } ================================================ FILE: app/src/main/java/io/legado/app/utils/DialogExtensions.kt ================================================ package io.legado.app.utils import android.app.Dialog import android.view.WindowManager import androidx.appcompat.app.AlertDialog import androidx.core.view.forEach import androidx.fragment.app.DialogFragment import io.legado.app.lib.theme.Selector import io.legado.app.lib.theme.ThemeStore import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.filletBackground import splitties.systemservices.windowManager fun AlertDialog.applyTint(): AlertDialog { window?.setBackgroundDrawable(context.filletBackground) val colorStateList = Selector.colorBuild() .setDefaultColor(ThemeStore.accentColor(context)) .setPressedColor(ColorUtils.darkenColor(ThemeStore.accentColor(context))) .create() if (getButton(AlertDialog.BUTTON_NEGATIVE) != null) { getButton(AlertDialog.BUTTON_NEGATIVE).setTextColor(colorStateList) } if (getButton(AlertDialog.BUTTON_POSITIVE) != null) { getButton(AlertDialog.BUTTON_POSITIVE).setTextColor(colorStateList) } if (getButton(AlertDialog.BUTTON_NEUTRAL) != null) { getButton(AlertDialog.BUTTON_NEUTRAL).setTextColor(colorStateList) } window?.decorView?.post { listView?.forEach { it.applyTint(context.accentColor) } } return this } fun AlertDialog.requestInputMethod() { window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) } fun DialogFragment.setLayout(widthMix: Float, heightMix: Float) { dialog?.setLayout(widthMix, heightMix) } fun Dialog.setLayout(widthMix: Float, heightMix: Float) { val dm = context.windowManager.windowSize window?.setLayout( (dm.widthPixels * widthMix).toInt(), (dm.heightPixels * heightMix).toInt() ) } fun DialogFragment.setLayout(width: Int, heightMix: Float) { dialog?.setLayout(width, heightMix) } fun Dialog.setLayout(width: Int, heightMix: Float) { val dm = context.windowManager.windowSize window?.setLayout( width, (dm.heightPixels * heightMix).toInt() ) } fun DialogFragment.setLayout(widthMix: Float, height: Int) { dialog?.setLayout(widthMix, height) } fun Dialog.setLayout(widthMix: Float, height: Int) { val dm = context.windowManager.windowSize window?.setLayout( (dm.widthPixels * widthMix).toInt(), height ) } fun DialogFragment.setLayout(width: Int, height: Int) { dialog?.setLayout(width, height) } fun Dialog.setLayout(width: Int, height: Int) { window?.setLayout(width, height) } ================================================ FILE: app/src/main/java/io/legado/app/utils/DocumentUtils.kt ================================================ package io.legado.app.utils import androidx.documentfile.provider.DocumentFile @Suppress("MemberVisibilityCanBePrivate") object DocumentUtils { fun exists(root: DocumentFile, fileName: String, vararg subDirs: String): Boolean { val parent = getDirDocument(root, *subDirs) ?: return false return parent.findFile(fileName)?.exists() ?: false } fun delete(root: DocumentFile, fileName: String, vararg subDirs: String) { val parent: DocumentFile? = createFolderIfNotExist(root, *subDirs) parent?.findFile(fileName)?.delete() } fun createFileIfNotExist( root: DocumentFile, fileName: String, vararg subDirs: String ): DocumentFile? { val parent: DocumentFile? = createFolderIfNotExist(root, *subDirs) return parent?.findFile(fileName) ?: parent?.createFile("", fileName) } fun createFolderIfNotExist(root: DocumentFile, vararg subDirs: String): DocumentFile? { var parent: DocumentFile? = root for (subDirName in subDirs) { val subDir = parent?.findFile(subDirName) ?: parent?.createDirectory(subDirName) parent = subDir } return parent } fun getDirDocument(root: DocumentFile, vararg subDirs: String): DocumentFile? { var parent = root for (subDirName in subDirs) { val subDir = parent.findFile(subDirName) parent = subDir ?: return null } return parent } } ================================================ FILE: app/src/main/java/io/legado/app/utils/DrawableUtils.kt ================================================ @file:Suppress("unused") package io.legado.app.utils import android.content.res.ColorStateList import android.graphics.PorterDuff import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.TransitionDrawable import androidx.annotation.ColorInt import androidx.core.graphics.drawable.DrawableCompat /** * @author Karim Abou Zeid (kabouzeid) */ @Suppress("unused") object DrawableUtils { fun createTransitionDrawable( @ColorInt startColor: Int, @ColorInt endColor: Int ): TransitionDrawable { return createTransitionDrawable(ColorDrawable(startColor), ColorDrawable(endColor)) } fun createTransitionDrawable(start: Drawable, end: Drawable): TransitionDrawable { val drawables = arrayOfNulls(2) drawables[0] = start drawables[1] = end return TransitionDrawable(drawables) } } fun Drawable.setTintListMutate( tint: ColorStateList, tintMode: PorterDuff.Mode = PorterDuff.Mode.SRC_ATOP ) { val wrappedDrawable = DrawableCompat.wrap(this) wrappedDrawable.mutate() DrawableCompat.setTintMode(wrappedDrawable, tintMode) DrawableCompat.setTintList(wrappedDrawable, tint) } fun Drawable.setTintMutate( @ColorInt tint: Int, tintMode: PorterDuff.Mode = PorterDuff.Mode.SRC_ATOP ) { val wrappedDrawable = DrawableCompat.wrap(this) wrappedDrawable.mutate() DrawableCompat.setTintMode(wrappedDrawable, tintMode) DrawableCompat.setTint(wrappedDrawable, tint) } ================================================ FILE: app/src/main/java/io/legado/app/utils/EncoderUtils.kt ================================================ package io.legado.app.utils import android.util.Base64 /** * 编码工具 escape base64 */ @Suppress("unused") object EncoderUtils { fun escape(src: String): String { val tmp = StringBuilder() for (char in src) { val charCode = char.code if (charCode in 48..57 || charCode in 65..90 || charCode in 97..122) { tmp.append(char) continue } val prefix = when { charCode < 16 -> "%0" charCode < 256 -> "%" else -> "%u" } tmp.append(prefix).append(charCode.toString(16)) } return tmp.toString() } @JvmOverloads fun base64Decode(str: String, flags: Int = Base64.DEFAULT): String { val bytes = Base64.decode(str, flags) return String(bytes) } @JvmOverloads fun base64Encode(str: String, flags: Int = Base64.NO_WRAP): String? { return Base64.encodeToString(str.toByteArray(), flags) } @JvmOverloads fun base64Encode(bytes: ByteArray, flags: Int = Base64.NO_WRAP): String { return Base64.encodeToString(bytes, flags) } @JvmOverloads fun base64DecodeToByteArray(str: String, flags: Int = Base64.DEFAULT): ByteArray { return Base64.decode(str, flags) } } ================================================ FILE: app/src/main/java/io/legado/app/utils/EncodingDetect.kt ================================================ package io.legado.app.utils import android.text.TextUtils import io.legado.app.lib.icu4j.CharsetDetector import org.jsoup.Jsoup import java.io.File /** * 自动获取文件的编码 * */ @Suppress("MemberVisibilityCanBePrivate", "unused") object EncodingDetect { private val headTagRegex = "(?i)[\\s\\S]*?".toRegex() private val headOpenBytes = "".toByteArray() private val headCloseBytes = "".toByteArray() fun getHtmlEncode(bytes: ByteArray): String { try { var head: String? = null val startIndex = bytes.indexOf(headOpenBytes) if (startIndex > -1) { val endIndex = bytes.indexOf(headCloseBytes, startIndex) if (endIndex > -1) { head = String(bytes.copyOfRange(startIndex, endIndex + headCloseBytes.size)) } } val doc = Jsoup.parseBodyFragment(head ?: headTagRegex.find(String(bytes))!!.value) val metaTags = doc.getElementsByTag("meta") var charsetStr: String for (metaTag in metaTags) { charsetStr = metaTag.attr("charset") if (!TextUtils.isEmpty(charsetStr)) { return charsetStr } val httpEquiv = metaTag.attr("http-equiv") if (httpEquiv.equals("content-type", true)) { val content = metaTag.attr("content") val idx = content.indexOf("charset=", ignoreCase = true) charsetStr = if (idx > -1) { content.substring(idx + "charset=".length) } else { content.substringAfter(";") } if (!TextUtils.isEmpty(charsetStr)) { return charsetStr } } } } catch (ignored: Exception) { } return getEncode(bytes) } fun getEncode(bytes: ByteArray): String { val match = CharsetDetector().setText(bytes).detect() return match?.name ?: "UTF-8" } /** * 得到文件的编码 */ fun getEncode(filePath: String): String { return getEncode(File(filePath)) } /** * 得到文件的编码 */ fun getEncode(file: File): String { val tempByte = getFileBytes(file) if (tempByte.isEmpty()) { return "UTF-8" } return getEncode(tempByte) } private fun getFileBytes(file: File): ByteArray { val byteArray = ByteArray(8000) var pos = 0 try { file.inputStream().buffered().use { while (pos < byteArray.size) { val n = it.read(byteArray, pos, 1) if (n == -1) { break } if (byteArray[pos] < 0) { pos++ } } } } catch (e: Exception) { System.err.println("Error: $e") } return byteArray.copyOf(pos) } } ================================================ FILE: app/src/main/java/io/legado/app/utils/EventBusExtensions.kt ================================================ @file:Suppress("unused") package io.legado.app.utils import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import androidx.lifecycle.LifecycleService import androidx.lifecycle.Observer import com.jeremyliao.liveeventbus.LiveEventBus import com.jeremyliao.liveeventbus.core.Observable inline fun eventObservable(tag: String): Observable { return LiveEventBus.get(tag, EVENT::class.java) } inline fun postEvent(tag: String, event: EVENT) { LiveEventBus.get(tag).post(event) } inline fun postEventDelay(tag: String, event: EVENT, delay: Long) { LiveEventBus.get(tag).postDelay(event, delay) } inline fun postEventOrderly(tag: String, event: EVENT) { LiveEventBus.get(tag).postOrderly(event) } inline fun AppCompatActivity.observeEvent( vararg tags: String, noinline observer: (EVENT) -> Unit ) { val o = Observer { observer(it) } tags.forEach { eventObservable(it).observe(this, o) } } inline fun AppCompatActivity.observeEventSticky( vararg tags: String, noinline observer: (EVENT) -> Unit ) { val o = Observer { observer(it) } tags.forEach { eventObservable(it).observeSticky(this, o) } } inline fun Fragment.observeEvent( vararg tags: String, noinline observer: (EVENT) -> Unit ) { val o = Observer { observer(it) } tags.forEach { eventObservable(it).observe(this, o) } } inline fun Fragment.observeEventSticky( vararg tags: String, noinline observer: (EVENT) -> Unit ) { val o = Observer { observer(it) } tags.forEach { eventObservable(it).observeSticky(this, o) } } inline fun LifecycleService.observeEvent( vararg tags: String, noinline observer: (EVENT) -> Unit ) { val o = Observer { observer(it) } tags.forEach { eventObservable(it).observe(this, o) } } inline fun LifecycleService.observeEventSticky( vararg tags: String, noinline observer: (EVENT) -> Unit ) { val o = Observer { observer(it) } tags.forEach { eventObservable(it).observeSticky(this, o) } } ================================================ FILE: app/src/main/java/io/legado/app/utils/FileDocExtensions.kt ================================================ @file:Suppress("unused") package io.legado.app.utils import android.app.DownloadManager import android.content.Context import android.database.Cursor import android.net.Uri import android.os.ParcelFileDescriptor import android.provider.DocumentsContract import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import io.legado.app.exception.NoStackTraceException import splitties.init.appCtx import splitties.systemservices.downloadManager import java.io.File import java.io.InputStream import java.io.OutputStream import java.nio.charset.Charset import java.util.concurrent.atomic.AtomicInteger data class FileDoc( val name: String, val isDir: Boolean, val size: Long, val lastModified: Long, val uri: Uri ) { override fun toString(): String { return if (uri.isContentScheme()) uri.toString() else uri.path!! } val isContentScheme get() = uri.isContentScheme() fun readBytes(): ByteArray { return uri.readBytes(appCtx) } fun readText(): String { return uri.readText(appCtx) } fun asDocumentFile(): DocumentFile? { if (isContentScheme) { return if (isDir) { treeDocumentFileConstructor.newInstance(null, appCtx, uri) as DocumentFile } else { DocumentFile.fromSingleUri(appCtx, uri) } } return null } fun asFile(): File? { if (isContentScheme) { return null } return File(uri.path!!) } companion object { private val treeDocumentFileConstructor by lazy { Class.forName("androidx.documentfile.provider.TreeDocumentFile") .getDeclaredConstructor( DocumentFile::class.java, Context::class.java, Uri::class.java ).apply { isAccessible = true } } fun fromDir(path: String): FileDoc { return fromUri(path.toUri(), true) } fun fromFile(path: String): FileDoc { return fromUri(path.toUri(), false) } fun fromDir(uri: Uri): FileDoc { return fromUri(uri, true) } fun fromUri(uri: Uri, isDir: Boolean): FileDoc { if (uri.isContentScheme()) { val doc = if (isDir) { DocumentFile.fromTreeUri(appCtx, uri)!! } else if (uri.host == "downloads") { val query = DownloadManager.Query() query.setFilterById(uri.lastPathSegment!!.toLong()) downloadManager.query(query).use { if (it.moveToFirst()) { val lUriColum = it.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI) val lUri = it.getString(lUriColum) DocumentFile.fromSingleUri(appCtx, Uri.parse(lUri))!! } else { DocumentFile.fromSingleUri(appCtx, uri)!! } } } else { DocumentFile.fromSingleUri(appCtx, uri)!! } return FileDoc(doc.name ?: "", isDir, doc.length(), doc.lastModified(), doc.uri) } val file = File(uri.path!!) return FileDoc(file.name, isDir, file.length(), file.lastModified(), uri) } fun fromDocumentFile(doc: DocumentFile): FileDoc { return FileDoc( name = doc.name ?: "", isDir = doc.isDirectory, size = doc.length(), lastModified = doc.lastModified(), uri = doc.uri ) } fun fromFile(file: File): FileDoc { return FileDoc( name = file.name, isDir = file.isDirectory, size = file.length(), lastModified = file.lastModified(), uri = Uri.fromFile(file) ) } } } /** * 过滤器 */ typealias FileDocFilter = (file: FileDoc) -> Boolean private val projection by lazy { arrayOf( DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME, DocumentsContract.Document.COLUMN_LAST_MODIFIED, DocumentsContract.Document.COLUMN_SIZE, DocumentsContract.Document.COLUMN_MIME_TYPE ) } /** * 返回子文件列表,如果不是文件夹则返回null */ fun FileDoc.list(filter: FileDocFilter? = null): ArrayList? { if (isDir) { if (uri.isContentScheme()) { /** * DocumentFile 的 listFiles() 非常的慢,所以这里直接从数据库查询 */ val childrenUri = DocumentsContract .buildChildDocumentsUriUsingTree(uri, DocumentsContract.getDocumentId(uri)) val docList = arrayListOf() var cursor: Cursor? = null try { cursor = appCtx.contentResolver.query( childrenUri, projection, null, null, DocumentsContract.Document.COLUMN_DISPLAY_NAME ) cursor?.let { val ici = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID) val nci = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME) val sci = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE) val mci = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE) val dci = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_LAST_MODIFIED) if (cursor.moveToFirst()) { do { val item = FileDoc( name = cursor.getString(nci), isDir = cursor.getString(mci) == DocumentsContract.Document.MIME_TYPE_DIR, size = cursor.getLong(sci), lastModified = cursor.getLong(dci), uri = DocumentsContract.buildDocumentUriUsingTree( uri, cursor.getString(ici) ) ) if (filter == null || filter.invoke(item)) { docList.add(item) } } while (cursor.moveToNext()) } } } finally { cursor?.close() } return docList } else { return File(uri.path!!).listFileDocs(filter) } } return null } /** * 查找文档, 如果存在则返回文档,如果不存在返回空 * @param name 文件名 * @param depth 查找文件夹深度 */ fun FileDoc.find(name: String, depth: Int = 0): FileDoc? { val list = list() list?.forEach { if (it.name == name) { return it } } if (depth > 0) { list?.forEach { if (it.isDir) { val fileDoc = it.find(name, depth - 1) if (fileDoc != null) { return fileDoc } } } } return null } /** * 查找文档, 如果存在则返回文档,如果不存在返回空 * @param name 文件名 * @param depth 查找文件夹深度 * @param maxFinds 最大查找文件夹数量 */ fun FileDoc.find(name: String, depth: Int = 0, maxFinds: Int = Int.MAX_VALUE): FileDoc? { return find(name, depth, AtomicInteger(maxFinds)) } private fun FileDoc.find(name: String, depth: Int, maxFinds: AtomicInteger): FileDoc? { if (maxFinds.getAndDecrement() <= 0) { return null } val list = list() list?.forEach { if (it.name == name) { return it } } if (depth > 0) { list?.forEach { if (it.isDir) { val fileDoc = it.find(name, depth - 1, maxFinds) if (fileDoc != null) { return fileDoc } } } } return null } fun FileDoc.createFileIfNotExist( fileName: String, vararg subDirs: String ): FileDoc { return if (uri.isContentScheme()) { val documentFile = asDocumentFile()!! val tmp = DocumentUtils.createFileIfNotExist(documentFile, fileName, *subDirs)!! FileDoc.fromDocumentFile(tmp) } else { val path = FileUtils.getPath(uri.path!!, *subDirs) + File.separator + fileName val tmp = FileUtils.createFileIfNotExist(path) FileDoc.fromFile(tmp) } } fun FileDoc.createFolderIfNotExist( vararg subDirs: String ): FileDoc { return if (uri.isContentScheme()) { val documentFile = asDocumentFile()!! val tmp = DocumentUtils.createFolderIfNotExist(documentFile, *subDirs)!! FileDoc.fromDocumentFile(tmp) } else { val path = FileUtils.getPath(uri.path!!, *subDirs) val tmp = FileUtils.createFolderIfNotExist(path) FileDoc.fromFile(tmp) } } fun FileDoc.openInputStream(): Result { return uri.inputStream(appCtx) } fun FileDoc.openOutputStream(): Result { return uri.outputStream(appCtx) } fun FileDoc.openReadPfd(): Result { return uri.toReadPfd(appCtx) } fun FileDoc.openWritePfd(): Result { return uri.toWritePfd(appCtx) } fun FileDoc.exists( fileName: String, vararg subDirs: String ): Boolean { return if (uri.isContentScheme()) { DocumentUtils.exists(asDocumentFile()!!, fileName, *subDirs) } else { val path = FileUtils.getPath(uri.path!!, *subDirs) + File.separator + fileName FileUtils.exist(path) } } fun FileDoc.exists(): Boolean { return if (uri.isContentScheme()) { asDocumentFile()!!.exists() } else { FileUtils.exist(uri.path!!) } } fun FileDoc.writeText(text: String) { if (uri.isContentScheme()) { uri.writeText(appCtx, text) } else { File(uri.path!!).writeText(text) } } fun FileDoc.writeFile(file: File) { openOutputStream().getOrThrow().use { out -> file.inputStream().use { it.copyTo(out) } } } fun FileDoc.delete() { asFile()?.let { FileUtils.delete(it, true) } asDocumentFile()?.delete() } fun FileDoc.checkWrite(): Boolean { if (!isDir) { throw NoStackTraceException("只能检查目录") } asFile()?.let { return it.checkWrite() } return asDocumentFile()!!.checkWrite() } /** * DocumentFile 的 listFiles() 非常的慢,尽量不要使用 */ fun DocumentFile.listFileDocs(filter: FileDocFilter? = null): ArrayList? { return FileDoc.fromDocumentFile(this).list(filter) } @Throws(Exception::class) fun DocumentFile.openInputStream(): InputStream? { return appCtx.contentResolver.openInputStream(uri) } @Throws(Exception::class) fun DocumentFile.openOutputStream(): OutputStream? { return appCtx.contentResolver.openOutputStream(uri) } @Throws(Exception::class) fun DocumentFile.writeText(context: Context, data: String, charset: Charset = Charsets.UTF_8) { uri.writeText(context, data, charset) } @Throws(Exception::class) fun DocumentFile.writeBytes(context: Context, data: ByteArray) { uri.writeBytes(context, data) } @Throws(Exception::class) fun DocumentFile.readText(context: Context): String { return String(readBytes(context)) } @Throws(Exception::class) fun DocumentFile.readBytes(context: Context): ByteArray { return context.contentResolver.openInputStream(uri)?.let { val len: Int = it.available() val buffer = ByteArray(len) it.read(buffer) it.close() return buffer } ?: throw NoStackTraceException("打开文件失败\n${uri}") } fun DocumentFile.checkWrite(): Boolean { var file: DocumentFile? = null return try { val filename = System.currentTimeMillis().toString() file = createFile(FileUtils.getMimeType(filename), filename) file?.openOutputStream()?.let { out -> out.bufferedWriter().use { it.write(filename) } file.openInputStream()?.let { input -> input.bufferedReader().use { return it.readText() == filename } } } false } catch (e: Exception) { false } finally { file?.delete() } } ================================================ FILE: app/src/main/java/io/legado/app/utils/FileExtensions.kt ================================================ @file:Suppress("unused") package io.legado.app.utils import android.net.Uri import java.io.File import java.io.FileOutputStream fun File.getFile(vararg subDirFiles: String): File { val path = FileUtils.getPath(this, *subDirFiles) return File(path) } fun File.exists(vararg subDirFiles: String): Boolean { return getFile(*subDirFiles).exists() } @Throws(Exception::class) fun File.listFileDocs(filter: FileDocFilter? = null): ArrayList { val docList = arrayListOf() listFiles()?.forEach { val item = FileDoc( it.name, it.isDirectory, it.length(), it.lastModified(), Uri.fromFile(it) ) if (filter == null || filter.invoke(item)) { docList.add(item) } } return docList } fun File.createFileIfNotExist(): File { if (!exists()) { parentFile?.createFolderIfNotExist() createNewFile() } return this } fun File.createFileReplace(): File { if (!exists()) { parent?.let { File(it).mkdirs() } createNewFile() } else { delete() createNewFile() } return this } fun File.createFolderIfNotExist(): File { if (!exists()) { mkdirs() } return this } fun File.createFolderReplace(): File { if (exists()) { FileUtils.delete(this, true) } mkdirs() return this } fun File.checkWrite(): Boolean { var file: File? = null return try { val filename = System.currentTimeMillis().toString() file = FileUtils.createFileIfNotExist(this, filename) file.outputStream().bufferedWriter().use { it.write(filename) } file.inputStream().bufferedReader().use { it.readText() == filename } } catch (e: Exception) { false } finally { file?.delete() } } fun File.outputStream(append: Boolean = false): FileOutputStream { return FileOutputStream(this, append) } ================================================ FILE: app/src/main/java/io/legado/app/utils/FileUtils.kt ================================================ package io.legado.app.utils import android.os.Environment import android.webkit.MimeTypeMap import androidx.annotation.IntDef import splitties.init.appCtx import java.io.* import java.nio.charset.Charset import java.text.SimpleDateFormat import java.util.* import java.util.regex.Pattern @Suppress("unused", "MemberVisibilityCanBePrivate") object FileUtils { fun createFileIfNotExist(root: File, vararg subDirFiles: String): File { val filePath = getPath(root, *subDirFiles) return createFileIfNotExist(filePath) } fun createFolderIfNotExist(root: File, vararg subDirs: String): File { val filePath = getPath(root, *subDirs) return createFolderIfNotExist(filePath) } fun createFolderIfNotExist(filePath: String): File { val file = File(filePath) //如果文件夹不存在,就创建它 if (!file.exists()) { file.mkdirs() } return file } @Synchronized fun createFileIfNotExist(filePath: String): File { val file = File(filePath) try { if (!file.exists()) { //创建父类文件夹 file.parent?.let { createFolderIfNotExist(it) } //创建文件 file.createNewFile() } } catch (e: IOException) { e.printOnDebug() } return file } fun createFileWithReplace(filePath: String): File { val file = File(filePath) if (!file.exists()) { //创建父类文件夹 file.parent?.let { createFolderIfNotExist(it) } //创建文件 file.createNewFile() } else { file.delete() file.createNewFile() } return file } fun getPath(rootPath: String, vararg subDirFiles: String): String { val path = StringBuilder(rootPath) subDirFiles.forEach { if (it.isNotEmpty()) { if (!path.endsWith(File.separator)) { path.append(File.separator) } path.append(it) } } return path.toString() } fun getPath(root: File, vararg subDirFiles: String): String { val path = StringBuilder(root.absolutePath) subDirFiles.forEach { if (it.isNotEmpty()) { path.append(File.separator).append(it) } } return path.toString() } fun getCachePath(): String { return appCtx.externalCache.absolutePath } fun getSdCardPath(): String { var sdCardDirectory = Environment.getExternalStorageDirectory().absolutePath try { sdCardDirectory = File(sdCardDirectory).canonicalPath } catch (e: IOException) { e.printOnDebug() } return sdCardDirectory } const val BY_NAME_ASC = 0 const val BY_NAME_DESC = 1 const val BY_TIME_ASC = 2 const val BY_TIME_DESC = 3 const val BY_SIZE_ASC = 4 const val BY_SIZE_DESC = 5 const val BY_EXTENSION_ASC = 6 const val BY_EXTENSION_DESC = 7 @IntDef(value = [BY_NAME_ASC, BY_NAME_DESC, BY_TIME_ASC, BY_TIME_DESC, BY_SIZE_ASC, BY_SIZE_DESC, BY_EXTENSION_ASC, BY_EXTENSION_DESC]) @Retention(AnnotationRetention.SOURCE) annotation class SortType /** * 将目录分隔符统一为平台默认的分隔符,并为目录结尾添加分隔符 */ fun separator(path: String): String { var path1 = path val separator = File.separator path1 = path1.replace("\\", separator) if (!path1.endsWith(separator)) { path1 += separator } return path1 } fun closeSilently(c: Closeable?) { if (c == null) { return } try { c.close() } catch (ignored: IOException) { } } /** * 列出指定目录下的所有子目录 */ @JvmOverloads fun listDirs( startDirPath: String, excludeDirs: Array? = null, @SortType sortType: Int = BY_NAME_ASC ): Array { var excludeDirs1 = excludeDirs val dirList = ArrayList() val startDir = File(startDirPath) if (!startDir.isDirectory) { return arrayOf() } val dirs = startDir.listFiles(FileFilter { f -> if (f == null) { return@FileFilter false } f.isDirectory }) ?: return arrayOf() if (excludeDirs1 == null) { excludeDirs1 = arrayOf() } for (dir in dirs) { val file = dir.absoluteFile if (!excludeDirs1.contentDeepToString().contains(file.name)) { dirList.add(file) } } when (sortType) { BY_NAME_ASC -> Collections.sort(dirList, SortByName()) BY_NAME_DESC -> { Collections.sort(dirList, SortByName()) dirList.reverse() } BY_TIME_ASC -> Collections.sort(dirList, SortByTime()) BY_TIME_DESC -> { Collections.sort(dirList, SortByTime()) dirList.reverse() } BY_SIZE_ASC -> Collections.sort(dirList, SortBySize()) BY_SIZE_DESC -> { Collections.sort(dirList, SortBySize()) dirList.reverse() } BY_EXTENSION_ASC -> Collections.sort(dirList, SortByExtension()) BY_EXTENSION_DESC -> { Collections.sort(dirList, SortByExtension()) dirList.reverse() } } return dirList.toTypedArray() } /** * 列出指定目录下的所有子目录及所有文件 */ @JvmOverloads fun listDirsAndFiles( startDirPath: String, allowExtensions: Array? = null ): Array? { val dirs: Array? val files: Array? = if (allowExtensions == null) { listFiles(startDirPath) } else { listFiles(startDirPath, allowExtensions) } dirs = listDirs(startDirPath) if (files == null) { return null } return dirs + files } /** * 列出指定目录下的所有文件 */ @JvmOverloads fun listFiles( startDirPath: String, filterPattern: Pattern? = null, @SortType sortType: Int = BY_NAME_ASC ): Array { val fileList = ArrayList() val f = File(startDirPath) if (!f.isDirectory) { return arrayOf() } val files = f.listFiles(FileFilter { file -> if (file == null) { return@FileFilter false } if (file.isDirectory) { return@FileFilter false } filterPattern?.matcher(file.name)?.find() ?: true }) ?: return arrayOf() for (file in files) { fileList.add(file.absoluteFile) } when (sortType) { BY_NAME_ASC -> Collections.sort(fileList, SortByName()) BY_NAME_DESC -> { Collections.sort(fileList, SortByName()) fileList.reverse() } BY_TIME_ASC -> Collections.sort(fileList, SortByTime()) BY_TIME_DESC -> { Collections.sort(fileList, SortByTime()) fileList.reverse() } BY_SIZE_ASC -> Collections.sort(fileList, SortBySize()) BY_SIZE_DESC -> { Collections.sort(fileList, SortBySize()) fileList.reverse() } BY_EXTENSION_ASC -> Collections.sort(fileList, SortByExtension()) BY_EXTENSION_DESC -> { Collections.sort(fileList, SortByExtension()) fileList.reverse() } } return fileList.toTypedArray() } /** * 列出指定目录下的所有文件 */ fun listFiles(startDirPath: String, allowExtensions: Array?): Array? { val file = File(startDirPath) return file.listFiles { _, name -> //返回当前目录所有以某些扩展名结尾的文件 val extension = getExtension(name) allowExtensions?.contentDeepToString()?.contains(extension) == true || allowExtensions == null } } /** * 列出指定目录下的所有文件 */ fun listFiles(startDirPath: String, allowExtension: String?): Array? { return if (allowExtension == null) listFiles(startDirPath, allowExtension = null) else listFiles(startDirPath, arrayOf(allowExtension)) } /** * 判断文件或目录是否存在 */ fun exist(path: String): Boolean { val file = File(path) return file.exists() } /** * 删除文件或目录 */ @JvmOverloads fun delete(file: File, deleteRootDir: Boolean = false): Boolean { var result = false if (file.isFile) { //是文件 result = deleteResolveEBUSY(file) } else { //是目录 val files = file.listFiles() ?: return false if (files.isEmpty()) { result = deleteRootDir && deleteResolveEBUSY(file) } else { for (f in files) { delete(f, deleteRootDir) result = deleteResolveEBUSY(f) } } if (deleteRootDir) { result = deleteResolveEBUSY(file) } } return result } /** * bug: open failed: EBUSY (Device or resource busy) * fix: http://stackoverflow.com/questions/11539657/open-failed-ebusy-device-or-resource-busy */ private fun deleteResolveEBUSY(file: File): Boolean { // Before you delete a Directory or File: rename it! val to = File(file.absolutePath + System.currentTimeMillis()) file.renameTo(to) return to.delete() } /** * 删除文件或目录 */ @JvmOverloads fun delete(path: String, deleteRootDir: Boolean = true): Boolean { val file = File(path) return if (file.exists()) { delete(file, deleteRootDir) } else false } /** * 复制文件为另一个文件,或复制某目录下的所有文件及目录到另一个目录下 */ fun copy(src: String, tar: String): Boolean { val srcFile = File(src) return srcFile.exists() && copy(srcFile, File(tar)) } /** * 复制文件或目录 */ fun copy(src: File, tar: File): Boolean { try { if (src.isFile) { val inputStream = FileInputStream(src) val outputStream = FileOutputStream(tar) inputStream.use { outputStream.use { inputStream.copyTo(outputStream) outputStream.flush() } } } else if (src.isDirectory) { tar.mkdirs() src.listFiles()?.forEach { file -> copy(file.absoluteFile, File(tar.absoluteFile, file.name)) } } return true } catch (e: Exception) { return false } } /** * 移动文件或目录 */ fun move(src: String, tar: String): Boolean { return move(File(src), File(tar)) } /** * 移动文件或目录 */ fun move(src: File, tar: File): Boolean { return rename(src, tar) } /** * 文件重命名 */ fun rename(oldPath: String, newPath: String): Boolean { return rename(File(oldPath), File(newPath)) } /** * 文件重命名 */ fun rename(src: File, tar: File): Boolean { return src.renameTo(tar) } /** * 读取文本文件, 失败将返回空串 */ @JvmOverloads fun readText(filepath: String, charset: String = "utf-8"): String { try { val data = readBytes(filepath) if (data != null) { return String(data, Charset.forName(charset)).trim { it <= ' ' } } } catch (ignored: UnsupportedEncodingException) { } return "" } /** * 读取文件内容, 失败将返回空串 */ fun readBytes(filepath: String): ByteArray? { var fis: FileInputStream? = null try { fis = FileInputStream(filepath) val outputStream = ByteArrayOutputStream() val buffer = ByteArray(1024) while (true) { val len = fis.read(buffer, 0, buffer.size) if (len == -1) { break } else { outputStream.write(buffer, 0, len) } } val data = outputStream.toByteArray() outputStream.close() return data } catch (e: IOException) { return null } finally { closeSilently(fis) } } /** * 保存文本内容 */ @JvmOverloads fun writeText(filepath: String, content: String, charset: String = "utf-8"): Boolean { return try { writeBytes(filepath, content.toByteArray(charset(charset))) } catch (e: UnsupportedEncodingException) { false } } /** * 保存文件内容 */ fun writeBytes(filepath: String, data: ByteArray): Boolean { val file = File(filepath) var fos: FileOutputStream? = null return try { if (!file.exists()) { file.parentFile?.mkdirs() file.createNewFile() } fos = FileOutputStream(filepath) fos.write(data) true } catch (e: IOException) { false } finally { closeSilently(fos) } } /** * 保存文件内容 */ fun writeInputStream(filepath: String, data: InputStream): Boolean { val file = File(filepath) return writeInputStream(file, data) } /** * 保存文件内容 */ fun writeInputStream(file: File, data: InputStream): Boolean { return try { if (!file.exists()) { file.parentFile?.mkdirs() file.createNewFile() } data.use { FileOutputStream(file).use { fos -> data.copyTo(fos) fos.flush() } } true } catch (e: IOException) { false } } /** * 追加文本内容 */ fun appendText(path: String, content: String): Boolean { val file = File(path) var writer: FileWriter? = null return try { if (!file.exists()) { file.createNewFile() } writer = FileWriter(file, true) writer.write(content) true } catch (e: IOException) { false } finally { closeSilently(writer) } } /** * 获取文件大小 */ fun getLength(path: String): Long { val file = File(path) return if (!file.isFile || !file.exists()) { 0 } else file.length() } /** * 获取文件或网址的名称(包括后缀) */ fun getName(path: String?): String { if (path == null) { return "" } val pos = path.lastIndexOf(File.separator) return if (0 <= pos) { path.substring(pos + 1) } else { path } } /** * 获取文件名(不包括扩展名) */ fun getNameExcludeExtension(path: String): String { return try { var fileName = File(path).name val lastIndexOf = fileName.lastIndexOf(".") if (lastIndexOf != -1) { fileName = fileName.substring(0, lastIndexOf) } fileName } catch (e: Exception) { "" } } /** * 获取格式化后的文件大小 */ fun getSize(path: String): String { val fileSize = getLength(path) return ConvertUtils.formatFileSize(fileSize) } /** * 获取文件后缀,不包括“.” */ fun getExtension(pathOrUrl: String): String { val dotPos = pathOrUrl.lastIndexOf('.') return if (0 <= dotPos) { pathOrUrl.substring(dotPos + 1) } else { "ext" } } /** * 获取文件的MIME类型 */ fun getMimeType(pathOrUrl: String): String { val ext = getExtension(pathOrUrl) val map = MimeTypeMap.getSingleton() return map.getMimeTypeFromExtension(ext) ?: "*/*" } /** * 获取格式化后的文件/目录创建或最后修改时间 */ @JvmOverloads fun getDateTime(path: String, format: String = "yyyy年MM月dd日HH:mm"): String { val file = File(path) return getDateTime(file, format) } /** * 获取格式化后的文件/目录创建或最后修改时间 */ fun getDateTime(file: File, format: String): String { val cal = Calendar.getInstance() cal.timeInMillis = file.lastModified() return SimpleDateFormat(format, Locale.PRC).format(cal.time) } /** * 比较两个文件的最后修改时间 */ fun compareLastModified(path1: String, path2: String): Int { val stamp1 = File(path1).lastModified() val stamp2 = File(path2).lastModified() return when { stamp1 > stamp2 -> 1 stamp1 < stamp2 -> -1 else -> 0 } } /** * 创建多级别的目录 */ fun makeDirs(path: String): Boolean { return makeDirs(File(path)) } /** * 创建多级别的目录 */ fun makeDirs(file: File): Boolean { return file.mkdirs() } class SortByExtension : Comparator { override fun compare(f1: File?, f2: File?): Int { return if (f1 == null || f2 == null) { if (f1 == null) -1 else 1 } else { if (f1.isDirectory && f2.isFile) { -1 } else if (f1.isFile && f2.isDirectory) { 1 } else { f1.name.compareTo(f2.name, ignoreCase = true) } } } } class SortByName : Comparator { private var caseSensitive: Boolean = false constructor(caseSensitive: Boolean) { this.caseSensitive = caseSensitive } constructor() { this.caseSensitive = false } override fun compare(f1: File?, f2: File?): Int { if (f1 == null || f2 == null) { return if (f1 == null) { -1 } else { 1 } } else { return if (f1.isDirectory && f2.isFile) { -1 } else if (f1.isFile && f2.isDirectory) { 1 } else { val s1 = f1.name val s2 = f2.name if (caseSensitive) { s1.cnCompare(s2) } else { s1.compareTo(s2, ignoreCase = true) } } } } } class SortBySize : Comparator { override fun compare(f1: File?, f2: File?): Int { return if (f1 == null || f2 == null) { if (f1 == null) { -1 } else { 1 } } else { if (f1.isDirectory && f2.isFile) { -1 } else if (f1.isFile && f2.isDirectory) { 1 } else { if (f1.length() < f2.length()) { -1 } else { 1 } } } } } class SortByTime : Comparator { override fun compare(f1: File?, f2: File?): Int { return if (f1 == null || f2 == null) { if (f1 == null) { -1 } else { 1 } } else { if (f1.isDirectory && f2.isFile) { -1 } else if (f1.isFile && f2.isDirectory) { 1 } else { if (f1.lastModified() > f2.lastModified()) { -1 } else { 1 } } } } } } ================================================ FILE: app/src/main/java/io/legado/app/utils/FlowExtensions.kt ================================================ package io.legado.app.utils import androidx.lifecycle.Lifecycle import androidx.lifecycle.repeatOnLifecycle import io.legado.app.data.appDb import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapMerge import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.produceIn import kotlinx.coroutines.sync.Semaphore @OptIn(ExperimentalCoroutinesApi::class) inline fun Flow.onEachParallel( concurrency: Int, crossinline action: suspend (T) -> Unit ): Flow = flatMapMerge(concurrency) { value -> flow { action(value) emit(value) } }.buffer(0) @OptIn(ExperimentalCoroutinesApi::class) inline fun Flow.onEachParallelSafe( concurrency: Int, crossinline action: suspend (T) -> Unit ): Flow = flatMapMerge(concurrency) { value -> flow { try { action(value) } catch (e: Throwable) { currentCoroutineContext().ensureActive() } emit(value) } }.buffer(0) @OptIn(ExperimentalCoroutinesApi::class) inline fun Flow.mapParallel( concurrency: Int, crossinline transform: suspend (T) -> R, ): Flow = flatMapMerge(concurrency) { value -> flow { emit(transform(value)) } }.buffer(0) @OptIn(ExperimentalCoroutinesApi::class) inline fun Flow.mapParallelSafe( concurrency: Int, crossinline transform: suspend (T) -> R, ): Flow = flatMapMerge(concurrency) { value -> flow { try { emit(transform(value)) } catch (_: Throwable) { currentCoroutineContext().ensureActive() } } }.buffer(0) @OptIn(ExperimentalCoroutinesApi::class) inline fun Flow.transformParallelSafe( concurrency: Int, crossinline transform: suspend FlowCollector.(T) -> R, ): Flow = flatMapMerge(concurrency) { value -> flow { try { transform(value) } catch (e: Throwable) { currentCoroutineContext().ensureActive() } } }.buffer(0) inline fun Flow.mapNotNullParallel( concurrency: Int, crossinline transform: suspend (T) -> R?, ): Flow = mapParallel(concurrency, transform).filterNotNull() inline fun Flow.onEachIndexed( crossinline action: suspend (index: Int, T) -> Unit, ): Flow = flow { var index = 0 collect { value -> action(index++, value) emit(value) } } inline fun Flow.mapIndexed( crossinline action: suspend (index: Int, T) -> R, ): Flow = flow { var index = 0 collect { value -> emit(action(index++, value)) } } inline fun Flow.mapAsync( concurrency: Int, crossinline transform: suspend (T) -> R ): Flow = if (concurrency == 1) { map { transform(it) } } else { Semaphore(concurrency).let { semaphore -> channelFlow { collect { semaphore.acquire() send(async { transform(it) }) } }.map { it.await() }.onEach { semaphore.release() } }.buffer(0) } inline fun Flow.mapAsyncIndexed( concurrency: Int, crossinline transform: suspend (index: Int, T) -> R ): Flow = if (concurrency == 1) { mapIndexed { index, value -> transform(index, value) } } else { Semaphore(concurrency).let { semaphore -> channelFlow { var index = 0 collect { semaphore.acquire() val i = index++ send(async { transform(i, it) }) } }.map { it.await() }.onEach { semaphore.release() } }.buffer(0) } inline fun Flow.onEachAsync( concurrency: Int, crossinline action: suspend (T) -> Unit ): Flow = if (concurrency == 1) { onEach { action(it) } } else { Semaphore(concurrency).let { semaphore -> channelFlow { collect { semaphore.acquire() send(async { action(it) it }) } }.map { it.await() }.onEach { semaphore.release() } }.buffer(0) } inline fun Flow.onEachAsyncIndexed( concurrency: Int, crossinline action: suspend (index: Int, T) -> Unit ): Flow = if (concurrency == 1) { onEachIndexed { index, value -> action(index, value) } } else { Semaphore(concurrency).let { semaphore -> channelFlow { var index = 0 collect { semaphore.acquire() val i = index++ send(async { action(i, it) it }) } }.map { it.await() }.onEach { semaphore.release() } }.buffer(0) } fun Flow.flowWithLifecycleFirst( lifecycle: Lifecycle, minActiveState: Lifecycle.State = Lifecycle.State.STARTED ): Flow = callbackFlow { if (!lifecycle.currentState.isAtLeast(minActiveState)) { firstOrNull()?.let { send(it) } } lifecycle.repeatOnLifecycle(minActiveState) { this@flowWithLifecycleFirst.collect { send(it) } } close() } fun Flow.flowWithLifecycleAndDatabaseChange( lifecycle: Lifecycle, minActiveState: Lifecycle.State = Lifecycle.State.STARTED, table: String ): Flow = callbackFlow { var update = 0 val channel = appDb.invalidationTracker .createFlow(table) .conflate() .onEach { update++ } .produceIn(this) lifecycle.repeatOnLifecycle(minActiveState) { if (update == 0) { channel.receive() } this@flowWithLifecycleAndDatabaseChange.collect { update = 0 send(it) } } close() } fun Flow.flowWithLifecycleAndDatabaseChangeFirst( lifecycle: Lifecycle, minActiveState: Lifecycle.State = Lifecycle.State.STARTED, table: String ): Flow = callbackFlow { var update = 0 val isActive = lifecycle.currentState.isAtLeast(minActiveState) val channel = appDb.invalidationTracker .createFlow(table, emitInitialState = isActive) .conflate() .onEach { update++ } .produceIn(this) if (!isActive) { firstOrNull()?.let { send(it) } } lifecycle.repeatOnLifecycle(minActiveState) { if (update == 0) { channel.receive() } this@flowWithLifecycleAndDatabaseChangeFirst.collect { update = 0 send(it) } } close() } ================================================ FILE: app/src/main/java/io/legado/app/utils/FragmentExtensions.kt ================================================ @file:Suppress("unused") package io.legado.app.utils import android.app.Activity import android.content.Intent import android.content.res.ColorStateList import android.graphics.drawable.Drawable import android.os.Bundle import androidx.annotation.ColorRes import androidx.annotation.DrawableRes import androidx.core.content.edit import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import io.legado.app.R import io.legado.app.data.entities.Book import io.legado.app.help.book.isAudio import io.legado.app.help.book.isImage import io.legado.app.help.book.isLocal import io.legado.app.help.config.AppConfig import io.legado.app.ui.book.audio.AudioPlayActivity import io.legado.app.ui.book.manga.ReadMangaActivity import io.legado.app.ui.book.read.ReadBookActivity import io.legado.app.ui.widget.dialog.TextDialog inline fun Fragment.showDialogFragment( arguments: Bundle.() -> Unit = {} ) { @Suppress("DEPRECATION") val dialog = T::class.java.newInstance() val bundle = Bundle() bundle.apply(arguments) dialog.arguments = bundle dialog.show(childFragmentManager, T::class.simpleName) } fun Fragment.showDialogFragment(dialogFragment: DialogFragment) { dialogFragment.show(childFragmentManager, dialogFragment::class.simpleName) } fun Fragment.getPrefBoolean(key: String, defValue: Boolean = false) = requireContext().defaultSharedPreferences.getBoolean(key, defValue) fun Fragment.putPrefBoolean(key: String, value: Boolean = false) = requireContext().defaultSharedPreferences.edit { putBoolean(key, value) } fun Fragment.getPrefInt(key: String, defValue: Int = 0) = requireContext().defaultSharedPreferences.getInt(key, defValue) fun Fragment.putPrefInt(key: String, value: Int) = requireContext().defaultSharedPreferences.edit { putInt(key, value) } fun Fragment.getPrefLong(key: String, defValue: Long = 0L) = requireContext().defaultSharedPreferences.getLong(key, defValue) fun Fragment.putPrefLong(key: String, value: Long) = requireContext().defaultSharedPreferences.edit { putLong(key, value) } fun Fragment.getPrefString(key: String, defValue: String? = null) = requireContext().defaultSharedPreferences.getString(key, defValue) fun Fragment.putPrefString(key: String, value: String) = requireContext().defaultSharedPreferences.edit { putString(key, value) } fun Fragment.getPrefStringSet( key: String, defValue: MutableSet? = null ): MutableSet? = requireContext().defaultSharedPreferences.getStringSet(key, defValue) fun Fragment.putPrefStringSet(key: String, value: MutableSet) = requireContext().defaultSharedPreferences.edit { putStringSet(key, value) } fun Fragment.removePref(key: String) = requireContext().defaultSharedPreferences.edit { remove(key) } fun Fragment.getCompatColor(@ColorRes id: Int): Int = requireContext().getCompatColor(id) fun Fragment.getCompatDrawable(@DrawableRes id: Int): Drawable? = requireContext().getCompatDrawable(id) fun Fragment.getCompatColorStateList(@ColorRes id: Int): ColorStateList? = requireContext().getCompatColorStateList(id) inline fun Fragment.startActivity( configIntent: Intent.() -> Unit = {} ) { startActivity(Intent(requireContext(), T::class.java).apply(configIntent)) } fun Fragment.startActivityForBook( book: Book, configIntent: Intent.() -> Unit = {}, ) { val cls = when { book.isAudio -> AudioPlayActivity::class.java !book.isLocal && book.isImage && AppConfig.showMangaUi -> ReadMangaActivity::class.java else -> ReadBookActivity::class.java } val intent = Intent(requireActivity(), cls) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) intent.putExtra("bookUrl", book.bookUrl) intent.apply(configIntent) startActivity(intent) } fun Fragment.showHelp(fileName: String) { val mdText = String(requireContext().assets.open("web/help/md/${fileName}.md").readBytes()) showDialogFragment(TextDialog(getString(R.string.help), mdText, TextDialog.Mode.MD)) } val Fragment.isCreated get() = lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED) ================================================ FILE: app/src/main/java/io/legado/app/utils/GsonExtensions.kt ================================================ package io.legado.app.utils import com.google.gson.Gson import com.google.gson.GsonBuilder import com.google.gson.JsonDeserializationContext import com.google.gson.JsonDeserializer import com.google.gson.JsonElement import com.google.gson.JsonParseException import com.google.gson.JsonSyntaxException import com.google.gson.Strictness import com.google.gson.ToNumberPolicy import com.google.gson.internal.LinkedTreeMap import com.google.gson.reflect.TypeToken import com.google.gson.stream.JsonWriter import io.legado.app.data.entities.rule.BookInfoRule import io.legado.app.data.entities.rule.ContentRule import io.legado.app.data.entities.rule.ExploreRule import io.legado.app.data.entities.rule.ReviewRule import io.legado.app.data.entities.rule.SearchRule import io.legado.app.data.entities.rule.TocRule import java.io.InputStream import java.io.InputStreamReader import java.io.OutputStream import java.io.OutputStreamWriter import java.lang.reflect.Type import kotlin.math.ceil val INITIAL_GSON: Gson by lazy { GsonBuilder() .registerTypeAdapter( object : TypeToken?>() {}.type, MapDeserializerDoubleAsIntFix() ) .registerTypeAdapter(Int::class.java, IntJsonDeserializer()) .registerTypeAdapter(String::class.java, StringJsonDeserializer()) .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) .disableHtmlEscaping() .setPrettyPrinting() .create() } val GSON: Gson by lazy { INITIAL_GSON.newBuilder() .registerTypeAdapter(ExploreRule::class.java, ExploreRule.jsonDeserializer) .registerTypeAdapter(SearchRule::class.java, SearchRule.jsonDeserializer) .registerTypeAdapter(BookInfoRule::class.java, BookInfoRule.jsonDeserializer) .registerTypeAdapter(TocRule::class.java, TocRule.jsonDeserializer) .registerTypeAdapter(ContentRule::class.java, ContentRule.jsonDeserializer) .registerTypeAdapter(ReviewRule::class.java, ReviewRule.jsonDeserializer) .create() } val GSONStrict: Gson by lazy { GSON.newBuilder() .setStrictness(Strictness.STRICT) .create() } inline fun genericType(): Type = object : TypeToken() {}.type inline fun Gson.fromJsonObject(json: String?): Result { return kotlin.runCatching { if (json == null) { throw JsonSyntaxException("解析字符串为空") } fromJson(json, genericType()) as T } } inline fun Gson.fromJsonArray(json: String?): Result> { return kotlin.runCatching { if (json == null) { throw JsonSyntaxException("解析字符串为空") } val type = TypeToken.getParameterized(List::class.java, T::class.java).type val list = fromJson(json, type) as List if (list.contains(null)) { throw JsonSyntaxException( "列表不能存在null元素,可能是json格式错误,通常为列表存在多余的逗号所致" ) } @Suppress("UNCHECKED_CAST") list as List } } inline fun Gson.fromJsonObject(inputStream: InputStream?): Result { return kotlin.runCatching { if (inputStream == null) { throw JsonSyntaxException("解析流为空") } val reader = InputStreamReader(inputStream) fromJson(reader, genericType()) as T } } inline fun Gson.fromJsonArray(inputStream: InputStream?): Result> { return kotlin.runCatching { if (inputStream == null) { throw JsonSyntaxException("解析流为空") } val reader = InputStreamReader(inputStream) val type = TypeToken.getParameterized(List::class.java, T::class.java).type val list = fromJson(reader, type) as List if (list.contains(null)) { throw JsonSyntaxException( "列表不能存在null元素,可能是json格式错误,通常为列表存在多余的逗号所致" ) } @Suppress("UNCHECKED_CAST") list as List } } fun Gson.writeToOutputStream(out: OutputStream, any: Any) { val writer = JsonWriter(OutputStreamWriter(out, "UTF-8")) writer.setIndent(" ") if (any is List<*>) { writer.beginArray() any.forEach { it?.let { toJson(it, it::class.java, writer) } } writer.endArray() } else { toJson(any, any::class.java, writer) } writer.close() } /** * */ class StringJsonDeserializer : JsonDeserializer { override fun deserialize( json: JsonElement, typeOfT: Type, context: JsonDeserializationContext? ): String? { return when { json.isJsonPrimitive -> json.asString json.isJsonNull -> null else -> json.toString() } } } /** * int类型转化失败时跳过 */ class IntJsonDeserializer : JsonDeserializer { override fun deserialize( json: JsonElement, typeOfT: Type?, context: JsonDeserializationContext? ): Int? { return when { json.isJsonPrimitive -> { val prim = json.asJsonPrimitive if (prim.isNumber) { prim.asNumber.toInt() } else { null } } else -> null } } } /** * 修复Int变为Double的问题 */ class MapDeserializerDoubleAsIntFix : JsonDeserializer?> { @Throws(JsonParseException::class) override fun deserialize( jsonElement: JsonElement, type: Type, jsonDeserializationContext: JsonDeserializationContext ): Map? { @Suppress("unchecked_cast") return read(jsonElement) as? Map } fun read(json: JsonElement): Any? { when { json.isJsonArray -> { val list: MutableList = ArrayList() val arr = json.asJsonArray for (anArr in arr) { list.add(read(anArr)) } return list } json.isJsonObject -> { val map: MutableMap = LinkedTreeMap() val obj = json.asJsonObject val entitySet = obj.entrySet() for ((key, value) in entitySet) { map[key] = read(value) } return map } json.isJsonPrimitive -> { val prim = json.asJsonPrimitive when { prim.isBoolean -> { return prim.asBoolean } prim.isString -> { return prim.asString } prim.isNumber -> { val num: Number = prim.asNumber // here you can handle double int/long values // and return any type you want // this solution will transform 3.0 float to long values return if (ceil(num.toDouble()) == num.toLong().toDouble()) { num.toLong() } else { num.toDouble() } } } } } return null } } ================================================ FILE: app/src/main/java/io/legado/app/utils/HandlerUtils.kt ================================================ @file:Suppress("unused") package io.legado.app.utils import android.os.Build.VERSION.SDK_INT import android.os.Handler import android.os.Looper import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.launch /** This main looper cache avoids synchronization overhead when accessed repeatedly. */ private val mainLooper: Looper = Looper.getMainLooper() private val mainThread: Thread = mainLooper.thread val isMainThread: Boolean get() = mainThread === Thread.currentThread() fun buildMainHandler(): Handler { return if (SDK_INT >= 28) Handler.createAsync(mainLooper) else try { Handler::class.java.getDeclaredConstructor( Looper::class.java, Handler.Callback::class.java, Boolean::class.javaPrimitiveType // async ).newInstance(mainLooper, null, true) } catch (ignored: NoSuchMethodException) { // Hidden constructor absent. Fall back to non-async constructor. Handler(mainLooper) } } private val mainHandler by lazy { buildMainHandler() } fun runOnUI(function: () -> Unit) { if (isMainThread) { function() } else { mainHandler.post(function) } } fun CoroutineScope.runOnIO(function: () -> Unit) { if (isMainThread) { launch(IO) { function() } } else { function() } } ================================================ FILE: app/src/main/java/io/legado/app/utils/HtmlFormatter.kt ================================================ package io.legado.app.utils import io.legado.app.model.analyzeRule.AnalyzeUrl import java.net.URL import java.util.regex.Pattern @Suppress("RegExpRedundantEscape") object HtmlFormatter { private val nbspRegex = "( )+".toRegex() private val espRegex = "( | )".toRegex() private val noPrintRegex = "( |‌|‍|\u2009|\u200C|\u200D)".toRegex() private val wrapHtmlRegex = "]*>".toRegex() private val commentRegex = "".toRegex() //注释 private val notImgHtmlRegex = "])[^<>]*>".toRegex() private val otherHtmlRegex = "])[^<>]*>".toRegex() private val formatImagePattern = Pattern.compile( "]*\\ssrc\\s*=\\s*['\"]([^'\"{>]*\\{(?:[^{}]|\\{[^}>]+\\})+\\})['\"][^>]*>|]*\\s(?:data-src|src)\\s*=\\s*['\"]([^'\">]+)['\"][^>]*>|]*\\sdata-[^=>]*=\\s*['\"]([^'\">]*)['\"][^>]*>", Pattern.CASE_INSENSITIVE ) private val indent1Regex = "\\s*\\n+\\s*".toRegex() private val indent2Regex = "^[\\n\\s]+".toRegex() private val lastRegex = "[\\n\\s]+$".toRegex() fun format(html: String?, otherRegex: Regex = otherHtmlRegex): String { html ?: return "" return html.replace(nbspRegex, " ") .replace(espRegex, " ") .replace(noPrintRegex, "") .replace(wrapHtmlRegex, "\n") .replace(commentRegex, "") .replace(otherRegex, "") .replace(indent1Regex, "\n  ") .replace(indent2Regex, "  ") .replace(lastRegex, "") } fun formatKeepImg(html: String?, redirectUrl: URL? = null): String { html ?: return "" val keepImgHtml = format(html, notImgHtmlRegex) //正则的“|”处于顶端而不处于()中时,具有类似||的熔断效果,故以此机制简化原来的代码 val matcher = formatImagePattern.matcher(keepImgHtml) var appendPos = 0 val sb = StringBuilder() while (matcher.find()) { var param = "" sb.append( keepImgHtml.substring(appendPos, matcher.start()), "" ) appendPos = matcher.end() } if (appendPos < keepImgHtml.length) sb.append( keepImgHtml.substring( appendPos, keepImgHtml.length ) ) return sb.toString() } } ================================================ FILE: app/src/main/java/io/legado/app/utils/ImageUtils.kt ================================================ package io.legado.app.utils import io.legado.app.constant.AppLog import io.legado.app.data.entities.BaseSource import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.RssSource import java.io.ByteArrayInputStream import java.io.InputStream /** * 加密图片解密工具 */ object ImageUtils { /** * @param isCover 根据这个执行书源中不同的解密规则 * @return 解密失败返回Null 解密规则为空不处理 */ fun decode( src: String, bytes: ByteArray, isCover: Boolean, source: BaseSource?, book: Book? = null ): ByteArray? { val ruleJs = getRuleJs(source, isCover) if (ruleJs.isNullOrBlank()) return bytes //解密库hutool.crypto ByteArray|InputStream -> ByteArray return kotlin.runCatching { source?.evalJS(ruleJs) { put("book", book) put("result", bytes) put("src", src) } as ByteArray }.onFailure { AppLog.putDebug("${src}解密错误", it) }.getOrNull() } fun decode( src: String, inputStream: InputStream, isCover: Boolean, source: BaseSource?, book: Book? = null ): InputStream? { val ruleJs = getRuleJs(source, isCover) if (ruleJs.isNullOrBlank()) return inputStream //解密库hutool.crypto ByteArray|InputStream -> ByteArray return kotlin.runCatching { val bytes = source?.evalJS(ruleJs) { put("book", book) put("result", inputStream) put("src", src) } as ByteArray ByteArrayInputStream(bytes) }.onFailure { AppLog.putDebug("${src}解密错误", it) }.getOrNull() } fun skipDecode(source: BaseSource?, isCover: Boolean): Boolean { return getRuleJs(source, isCover).isNullOrBlank() } private fun getRuleJs( source: BaseSource?, isCover: Boolean ): String? { return when (source) { is BookSource -> if (isCover) source.coverDecodeJs else source.getContentRule().imageDecode is RssSource -> source.coverDecodeJs else -> null } } } ================================================ FILE: app/src/main/java/io/legado/app/utils/InputStreamExtensions.kt ================================================ package io.legado.app.utils import java.io.InputStream import java.util.* fun InputStream?.isJson(): Boolean { this ?: return false this.use { val byteArray = ByteArray(128) it.read(byteArray) val a = String(byteArray).trim() it.skip(it.available() - 128L) it.read(byteArray) val b = String(byteArray).trim() return (a + b).isJson() } } fun InputStream?.contains(str: String): Boolean { this ?: return false this.use { val scanner = Scanner(it) return scanner.findWithinHorizon(str, 0) != null } } ================================================ FILE: app/src/main/java/io/legado/app/utils/IntentExtensions.kt ================================================ @file:Suppress("unused") package io.legado.app.utils import android.content.Intent fun Intent.putJson(key: String, any: Any?) { any?.let { putExtra(key, GSON.toJson(any)) } } inline fun Intent.getJsonObject(key: String): T? { val value = getStringExtra(key) return GSON.fromJsonObject(value).getOrNull() } inline fun Intent.getJsonArray(key: String): List? { val value = getStringExtra(key) return GSON.fromJsonArray(value).getOrNull() } ================================================ FILE: app/src/main/java/io/legado/app/utils/IntentType.kt ================================================ package io.legado.app.utils import android.net.Uri import java.io.File object IntentType { fun from(uri: Uri): String { return from(uri.toString()) } fun from(file: File): String { return from(file.absolutePath) } fun from(path: String?): String { val suffix = path ?.substringAfterLast(File.separator) ?.substringAfterLast(".", "") ?.lowercase() return when (suffix) { "m4a", "mp3", "mid", "xmf", "ogg", "wav" -> "video/*" "3gp", "mp4" -> "audio/*" "jpg", "gif", "png", "jpeg", "bmp" -> "image/*" "", "txt", "json", "log" -> "text/plain" "apk" -> "application/vnd.android.package-archive" else -> "*/*" } } } ================================================ FILE: app/src/main/java/io/legado/app/utils/JsURL.kt ================================================ package io.legado.app.utils import androidx.annotation.Keep import java.net.URL import java.net.URLDecoder @Keep @Suppress("MemberVisibilityCanBePrivate") class JsURL(url: String, baseUrl: String? = null) { val searchParams: Map? val host: String val origin: String val pathname: String init { val mUrl = if (!baseUrl.isNullOrEmpty()) { val base = URL(baseUrl) URL(base, url) } else { URL(url) } host = mUrl.host origin = if (mUrl.port > 0) { "${mUrl.protocol}://$host:${mUrl.port}" } else { "${mUrl.protocol}://$host" } pathname = mUrl.path val query = mUrl.query searchParams = query?.let { _ -> val map = hashMapOf() query.split("&").forEach { val x = it.split("=", limit = 2) if (x.size == 2) { map[x[0]] = URLDecoder.decode(x[1], "utf-8") } } map } } } ================================================ FILE: app/src/main/java/io/legado/app/utils/JsonExtensions.kt ================================================ package io.legado.app.utils import com.jayway.jsonpath.* val jsonPath: ParseContext by lazy { JsonPath.using( Configuration.builder() .options(Option.SUPPRESS_EXCEPTIONS) .build() ) } fun ReadContext.readString(path: String): String? = this.read(path, String::class.java) fun ReadContext.readBool(path: String): Boolean? = this.read(path, Boolean::class.java) fun ReadContext.readInt(path: String): Int? = this.read(path, Int::class.java) fun ReadContext.readLong(path: String): Long? = this.read(path, Long::class.java) ================================================ FILE: app/src/main/java/io/legado/app/utils/JsoupExtensions.kt ================================================ package io.legado.app.utils import org.jsoup.internal.StringUtil import org.jsoup.nodes.CDataNode import org.jsoup.nodes.Element import org.jsoup.nodes.Node import org.jsoup.nodes.TextNode import org.jsoup.select.Elements import org.jsoup.select.NodeTraversor import org.jsoup.select.NodeVisitor fun Element.textArray(): Array { val sb = StringUtil.borrowBuilder() NodeTraversor.traverse(object : NodeVisitor { override fun head(node: Node, depth: Int) { if (node is TextNode) { appendNormalisedText(sb, node) } else if (node is Element) { if (sb.isNotEmpty() && (node.isBlock || node.tag().name == "br") && !lastCharIsWhitespace(sb) ) sb.append("\n") } } override fun tail(node: Node, depth: Int) { if (node is Element) { if (node.isBlock && node.nextSibling() is TextNode && !lastCharIsWhitespace(sb) ) { sb.append("\n") } } } }, this) val text = StringUtil.releaseBuilder(sb).trim { it <= ' ' } return text.splitNotBlank("\n") } fun Element.findNS(tag: String, namespace: HashSet): Elements { return select("*|$tag").filter { el -> namespace.contains(el.tagName().substringBefore(":")) }.toElements() } fun Element.findNSPrefix(namespaceURI: String): HashSet { return select("[^xmlns:]").map { element -> element.attributes().filter { it.value == namespaceURI }.map { it.key.substring(6) } }.flatten().toHashSet() } fun List.toElements() = Elements(this) private fun appendNormalisedText(sb: StringBuilder, textNode: TextNode) { val text = textNode.wholeText if (preserveWhitespace(textNode.parentNode()) || textNode is CDataNode) sb.append(text) else StringUtil.appendNormalisedWhitespace(sb, text, lastCharIsWhitespace(sb)) } private fun preserveWhitespace(node: Node?): Boolean { if (node is Element) { var el = node as Element? var i = 0 do { if (el!!.tag().preserveWhitespace()) return true el = el.parent() i++ } while (i < 6 && el != null) } return false } private fun lastCharIsWhitespace(sb: java.lang.StringBuilder): Boolean { return sb.isNotEmpty() && sb[sb.length - 1] == ' ' } ================================================ FILE: app/src/main/java/io/legado/app/utils/LogUtils.kt ================================================ @file:Suppress("unused") package io.legado.app.utils import android.annotation.SuppressLint import android.content.Context import android.os.Build import android.webkit.WebSettings import io.legado.app.BuildConfig import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog import io.legado.app.help.config.AppConfig import io.legado.app.help.globalExecutor import splitties.init.appCtx import java.text.SimpleDateFormat import java.util.Date import java.util.logging.FileHandler import java.util.logging.Level import java.util.logging.LogRecord import java.util.logging.Logger import kotlin.time.Duration.Companion.days @SuppressLint("SimpleDateFormat") @Suppress("unused") object LogUtils { const val TIME_PATTERN = "yy-MM-dd HH:mm:ss.SSS" val logTimeFormat by lazy { SimpleDateFormat(TIME_PATTERN) } fun init(context: Context) { fileHandler = createFileHandler(context)?.also { logger.addHandler(it) } } @JvmStatic fun d(tag: String, msg: String) { logger.log(Level.INFO, "$tag $msg") } inline fun d(tag: String, lazyMsg: () -> String) { if (logger.isLoggable(Level.INFO)) { logger.log(Level.INFO, "$tag ${lazyMsg()}") } } @JvmStatic fun e(tag: String, msg: String) { logger.log(Level.WARNING, "$tag $msg") } val logger: Logger by lazy { Logger.getLogger("Legado") } private var fileHandler: FileHandler? = null private fun createFileHandler(context: Context): FileHandler? { try { val root = context.externalCacheDir ?: return null val logFolder = FileUtils.createFolderIfNotExist(root, "logs") globalExecutor.execute { val expiredTime = System.currentTimeMillis() - 7.days.inWholeMilliseconds logFolder.listFiles()?.forEach { if (it.lastModified() < expiredTime || it.name.endsWith(".lck")) { it.delete() } } } val date = getCurrentDateStr(TIME_PATTERN).replace(" ", "_").replace(":", "-") val logPath = FileUtils.getPath(root = logFolder, "appLog-$date.txt") return AsyncFileHandler(logPath).apply { formatter = object : java.util.logging.Formatter() { override fun format(record: LogRecord): String { // 设置文件输出格式 return getCurrentDateStr(TIME_PATTERN) + ": " + record.message + "\n" } } level = if (AppConfig.recordLog) { Level.INFO } else { Level.OFF } } } catch (e: Exception) { e.printStackTrace() AppLog.putNotSave("创建fileHandler出错\n$e", e) return null } } fun upLevel() { val level = if (AppConfig.recordLog) { Level.INFO } else { Level.OFF } fileHandler?.level = level } /** * 获取当前时间 */ @SuppressLint("SimpleDateFormat") fun getCurrentDateStr(pattern: String): String { val date = Date() val sdf = SimpleDateFormat(pattern) return sdf.format(date) } fun logDeviceInfo() { d("DeviceInfo") { buildString { kotlin.runCatching { //获取系统信息 append("MANUFACTURER=").append(Build.MANUFACTURER).append("\n") append("BRAND=").append(Build.BRAND).append("\n") append("MODEL=").append(Build.MODEL).append("\n") append("SDK_INT=").append(Build.VERSION.SDK_INT).append("\n") append("RELEASE=").append(Build.VERSION.RELEASE).append("\n") val userAgent = try { WebSettings.getDefaultUserAgent(appCtx) } catch (e: Throwable) { e.toString() } append("WebViewUserAgent=").append(userAgent).append("\n") append("packageName=").append(appCtx.packageName).append("\n") append("heapSize=").append(Runtime.getRuntime().maxMemory()).append("\n") //获取app版本信息 AppConst.appInfo.let { append("versionName=").append(it.versionName).append("\n") append("versionCode=").append(it.versionCode).append("\n") } } } } } } fun Throwable.printOnDebug() { if (BuildConfig.DEBUG) { printStackTrace() } } ================================================ FILE: app/src/main/java/io/legado/app/utils/MD5Utils.kt ================================================ package io.legado.app.utils import cn.hutool.crypto.digest.DigestUtil import cn.hutool.crypto.digest.Digester import java.io.InputStream import kotlin.concurrent.getOrSet /** * 将字符串转化为MD5 */ @Suppress("unused") object MD5Utils { private val threadLocal = ThreadLocal() private val MD5Digester get() = threadLocal.getOrSet { DigestUtil.digester("MD5") } fun md5Encode(str: String?): String { return MD5Digester.digestHex(str) } fun md5Encode(inputStream: InputStream): String { return MD5Digester.digestHex(inputStream) } fun md5Encode16(str: String): String { var reStr = md5Encode(str) reStr = reStr.substring(8, 24) return reStr } } ================================================ FILE: app/src/main/java/io/legado/app/utils/MapExtensions.kt ================================================ package io.legado.app.utils fun HashMap.has(key: String, ignoreCase: Boolean = false): Boolean { for (item in this) { if (key.equals(item.key, ignoreCase)) { return true } } return false } fun HashMap.get(key: String, ignoreCase: Boolean = false): T? { for (item in this) { if (key.equals(item.key, ignoreCase)) { return item.value } } return null } inline fun MutableMap.getOrPutLimit(key: K, maxSize: Int, defaultValue: () -> V): V { var value = get(key) if (containsKey(key)) { @Suppress("UNCHECKED_CAST") return value as V } value = defaultValue() if (size < maxSize) { put(key, value) } return value } ================================================ FILE: app/src/main/java/io/legado/app/utils/MenuExtensions.kt ================================================ @file:Suppress("unused") package io.legado.app.utils import android.annotation.SuppressLint import android.content.Context import android.view.Menu import android.view.MenuItem import android.view.View import android.widget.ImageButton import androidx.appcompat.view.menu.MenuBuilder import androidx.appcompat.view.menu.MenuItemImpl import androidx.appcompat.view.menu.SubMenuBuilder import androidx.core.view.forEach import io.legado.app.R import io.legado.app.constant.Theme import io.legado.app.lib.theme.primaryTextColor import java.lang.reflect.Method @SuppressLint("RestrictedApi") @Suppress("UsePropertyAccessSyntax") fun Menu.applyTint(context: Context, theme: Theme = Theme.Auto): Menu = this.let { menu -> if (menu is MenuBuilder) { menu.setOptionalIconsVisible(true) } val defaultTextColor = context.getCompatColor(R.color.primaryText) val tintColor = MenuExtensions.getMenuColor(context, theme) menu.forEach { item -> (item as MenuItemImpl).let { impl -> //overflow:展开的item impl.icon?.setTintMutate( if (impl.requiresOverflow()) defaultTextColor else tintColor ) } } return menu } @SuppressLint("RestrictedApi") fun Menu.applyOpenTint(context: Context) { //展开菜单显示图标 if (this.javaClass.simpleName.equals("MenuBuilder", ignoreCase = true)) { val defaultTextColor = context.getCompatColor(R.color.primaryText) kotlin.runCatching { var method: Method = this.javaClass.getDeclaredMethod("setOptionalIconsVisible", java.lang.Boolean.TYPE) method.isAccessible = true method.invoke(this, true) method = this.javaClass.getDeclaredMethod("getNonActionItems") val menuItems = method.invoke(this) if (menuItems is ArrayList<*>) { for (menuItem in menuItems) { if (menuItem is MenuItem) { menuItem.icon?.setTintMutate(defaultTextColor) } } } } } else if (this.javaClass.simpleName.equals("SubMenuBuilder", ignoreCase = true)) { val defaultTextColor = context.getCompatColor(R.color.primaryText) (this as? SubMenuBuilder)?.forEach { item: MenuItem -> item.icon?.setTintMutate(defaultTextColor) } } } fun Menu.iconItemOnLongClick(id: Int, function: (view: View) -> Unit) { findItem(id)?.let { item -> item.setActionView(R.layout.view_action_button) item.actionView?.run { contentDescription = item.title findViewById(R.id.item).setImageDrawable(item.icon) setOnLongClickListener { function.invoke(this) true } setOnClickListener { performIdentifierAction(id, 0) } } } } @SuppressLint("RestrictedApi") inline fun Menu.transaction(block: (Menu) -> Unit) { val menuBuilder = this as? MenuBuilder menuBuilder?.stopDispatchingItemsChanged() try { block(this) } finally { menuBuilder?.startDispatchingItemsChanged() } } object MenuExtensions { fun getMenuColor( context: Context, theme: Theme = Theme.Auto, requiresOverflow: Boolean = false ): Int { val defaultTextColor = context.getCompatColor(R.color.primaryText) if (requiresOverflow) return defaultTextColor val primaryTextColor = context.primaryTextColor return when (theme) { Theme.Dark -> context.getCompatColor(R.color.md_white_1000) Theme.Light -> context.getCompatColor(R.color.md_black_1000) else -> primaryTextColor } } } ================================================ FILE: app/src/main/java/io/legado/app/utils/MenuItemExtensions.kt ================================================ package io.legado.app.utils import android.view.MenuItem import android.widget.ImageButton import androidx.annotation.DrawableRes import io.legado.app.R fun MenuItem.setIconCompat(@DrawableRes iconRes: Int) { setIcon(iconRes) actionView?.findViewById(R.id.item)?.setImageDrawable(icon) } ================================================ FILE: app/src/main/java/io/legado/app/utils/MutableLiveDataExtensions.kt ================================================ package io.legado.app.utils import android.os.Handler import android.os.Looper import androidx.lifecycle.MutableLiveData private val mainHandler by lazy { Handler(Looper.getMainLooper()) } fun MutableLiveData.sendValue(value: T) { mainHandler.post { this@sendValue.value = value } } ================================================ FILE: app/src/main/java/io/legado/app/utils/NavigationViewUtils.kt ================================================ @file:Suppress("unused") package io.legado.app.utils import android.content.res.ColorStateList import androidx.annotation.ColorInt import com.google.android.material.internal.NavigationMenuView import com.google.android.material.navigation.NavigationView fun NavigationView.setItemIconColors( @ColorInt normalColor: Int, @ColorInt selectedColor: Int ) { val iconSl = ColorStateList( arrayOf( intArrayOf(-android.R.attr.state_checked), intArrayOf(android.R.attr.state_checked) ), intArrayOf(normalColor, selectedColor) ) itemIconTintList = iconSl } fun NavigationView.setItemTextColors( @ColorInt normalColor: Int, @ColorInt selectedColor: Int ) { val textSl = ColorStateList( arrayOf( intArrayOf(-android.R.attr.state_checked), intArrayOf(android.R.attr.state_checked) ), intArrayOf(normalColor, selectedColor) ) itemTextColor = textSl } fun NavigationView.disableScrollbar() { val navigationMenuView = getChildAt(0) as? NavigationMenuView navigationMenuView?.isVerticalScrollBarEnabled = false } ================================================ FILE: app/src/main/java/io/legado/app/utils/NetworkUtils.kt ================================================ package io.legado.app.utils import android.annotation.SuppressLint import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.os.Build import cn.hutool.core.lang.Validator import io.legado.app.constant.AppLog import okhttp3.internal.publicsuffix.PublicSuffixDatabase import splitties.systemservices.connectivityManager import java.net.InetAddress import java.net.NetworkInterface import java.net.SocketException import java.net.URL import java.util.BitSet import java.util.Enumeration @Suppress("unused", "MemberVisibilityCanBePrivate") object NetworkUtils { /** * 判断是否联网 */ @SuppressLint("ObsoleteSdkInt") @Suppress("DEPRECATION") fun isAvailable(): Boolean { if (Build.VERSION.SDK_INT < 23) { val mWiFiNetworkInfo = connectivityManager.activeNetworkInfo if (mWiFiNetworkInfo != null) { // WIFI return mWiFiNetworkInfo.type == ConnectivityManager.TYPE_WIFI || // 移动数据 mWiFiNetworkInfo.type == ConnectivityManager.TYPE_MOBILE || // 以太网 mWiFiNetworkInfo.type == ConnectivityManager.TYPE_ETHERNET || // VPN mWiFiNetworkInfo.type == ConnectivityManager.TYPE_VPN } } else { val network = connectivityManager.activeNetwork if (network != null) { val nc = connectivityManager.getNetworkCapabilities(network) if (nc != null) { // WIFI return nc.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || // 移动数据 nc.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || // 以太网 nc.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) || // VPN nc.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } } } return false } private val notNeedEncodingQuery: BitSet by lazy { val bitSet = BitSet(256) for (i in 'a'.code..'z'.code) { bitSet.set(i) } for (i in 'A'.code..'Z'.code) { bitSet.set(i) } for (i in '0'.code..'9'.code) { bitSet.set(i) } for (char in "!$&()*+,-./:;=?@[\\]^_`{|}~") { bitSet.set(char.code) } return@lazy bitSet } private val notNeedEncodingForm: BitSet by lazy { val bitSet = BitSet(256) for (i in 'a'.code..'z'.code) { bitSet.set(i) } for (i in 'A'.code..'Z'.code) { bitSet.set(i) } for (i in '0'.code..'9'.code) { bitSet.set(i) } for (char in "*-._") { bitSet.set(char.code) } return@lazy bitSet } /** * 支持JAVA的URLEncoder.encode出来的string做判断。 即: 将' '转成'+' * 0-9a-zA-Z保留

* ! * ' ( ) ; : @ & = + $ , / ? # [ ] 保留 * 其他字符转成%XX的格式,X是16进制的大写字符,范围是[0-9A-F] */ fun encodedQuery(str: String): Boolean { var needEncode = false var i = 0 while (i < str.length) { val c = str[i] if (notNeedEncodingQuery.get(c.code)) { i++ continue } if (c == '%' && i + 2 < str.length) { // 判断是否符合urlEncode规范 val c1 = str[++i] val c2 = str[++i] if (isDigit16Char(c1) && isDigit16Char(c2)) { i++ continue } } // 其他字符,肯定需要urlEncode needEncode = true break } return !needEncode } fun encodedForm(str: String): Boolean { var needEncode = false var i = 0 while (i < str.length) { val c = str[i] if (notNeedEncodingForm.get(c.code)) { i++ continue } if (c == '%' && i + 2 < str.length) { // 判断是否符合urlEncode规范 val c1 = str[++i] val c2 = str[++i] if (isDigit16Char(c1) && isDigit16Char(c2)) { i++ continue } } // 其他字符,肯定需要urlEncode needEncode = true break } return !needEncode } /** * 判断c是否是16进制的字符 */ private fun isDigit16Char(c: Char): Boolean { return c in '0'..'9' || c in 'A'..'F' || c in 'a'..'f' } /** * 获取绝对地址 */ fun getAbsoluteURL(baseURL: String?, relativePath: String): String { if (baseURL.isNullOrEmpty()) return relativePath.trim() var absoluteUrl: URL? = null try { absoluteUrl = URL(baseURL.substringBefore(",")) } catch (e: Exception) { e.printOnDebug() } return getAbsoluteURL(absoluteUrl, relativePath) } /** * 获取绝对地址 */ fun getAbsoluteURL(baseURL: URL?, relativePath: String): String { val relativePathTrim = relativePath.trim() if (baseURL == null) return relativePathTrim if (relativePathTrim.isAbsUrl()) return relativePathTrim if (relativePathTrim.isDataUrl()) return relativePathTrim if (relativePathTrim.startsWith("javascript")) return "" var relativeUrl = relativePathTrim try { val parseUrl = URL(baseURL, relativePath) relativeUrl = parseUrl.toString() return relativeUrl } catch (e: Exception) { AppLog.put("网址拼接出错\n${e.localizedMessage}", e) } return relativeUrl } fun getBaseUrl(url: String?): String? { url ?: return null if (url.startsWith("http://", true) || url.startsWith("https://", true) ) { val index = url.indexOf("/", 9) return if (index == -1) { url } else url.substring(0, index) } return null } /** * 获取域名,供cookie保存和读取,处理失败返回传入的url * http://1.2.3.4 => 1.2.3.4 * https://www.example.com => example.com * http://www.biquge.com.cn => biquge.com.cn * http://www.content.example.com => example.com */ fun getSubDomain(url: String): String { val baseUrl = getBaseUrl(url) ?: return url return kotlin.runCatching { val mURL = URL(baseUrl) val host: String = mURL.host //mURL.scheme https/http //判断是否为ip if (isIPAddress(host)) return host //PublicSuffixDatabase处理域名 PublicSuffixDatabase.get().getEffectiveTldPlusOne(host) ?: host }.getOrDefault(baseUrl) } fun getSubDomainOrNull(url: String): String? { val baseUrl = getBaseUrl(url) ?: return null return kotlin.runCatching { val mURL = URL(baseUrl) val host: String = mURL.host //mURL.scheme https/http //判断是否为ip if (isIPAddress(host)) return host //PublicSuffixDatabase处理域名 PublicSuffixDatabase.get().getEffectiveTldPlusOne(host) ?: host }.getOrDefault(null) } fun getDomain(url: String): String { val baseUrl = getBaseUrl(url) ?: return url return kotlin.runCatching { URL(baseUrl).host }.getOrDefault(baseUrl) } /** * Get local Ip address. */ fun getLocalIPAddress(): List { val enumeration: Enumeration try { enumeration = NetworkInterface.getNetworkInterfaces() } catch (e: SocketException) { e.printOnDebug() return emptyList() } val addressList = mutableListOf() while (enumeration.hasMoreElements()) { val nif = enumeration.nextElement() val addresses = nif.inetAddresses ?: continue while (addresses.hasMoreElements()) { val address = addresses.nextElement() if (!address.isLoopbackAddress && isIPv4Address(address.hostAddress)) { addressList.add(address) } } } return addressList } /** * Check if valid IPV4 address. * * @param input the address string to check for validity. * @return True if the input parameter is a valid IPv4 address. */ fun isIPv4Address(input: String?): Boolean { return input != null && input.isNotEmpty() && input[0] in '1'..'9' && input.count { it == '.' } == 3 && Validator.isIpv4(input) } /** * Check if valid IPV6 address. */ fun isIPv6Address(input: String?): Boolean { return input != null && input.contains(":") && Validator.isIpv6(input) } /** * Check if valid IP address. */ fun isIPAddress(input: String?): Boolean { return isIPv4Address(input) || isIPv6Address(input) } } ================================================ FILE: app/src/main/java/io/legado/app/utils/PaintExtensions.kt ================================================ package io.legado.app.utils import android.os.Build import android.text.TextPaint val TextPaint.textHeight: Float get() = fontMetrics.run { descent - ascent + leading } fun TextPaint.getTextWidthsCompat(text: String, widths: FloatArray) { getTextWidths(text, widths) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { val letterSpacing = letterSpacing * textSize val letterSpacingHalf = letterSpacing * 0.5f for (i in widths.indices) { if (widths[i] > 0) { widths[i] += letterSpacingHalf break } } for (i in text.lastIndex downTo 0) { if (widths[i] > 0) { widths[i] += letterSpacingHalf break } } } } ================================================ FILE: app/src/main/java/io/legado/app/utils/ParcelFileDescriptorChannel.kt ================================================ package io.legado.app.utils import android.annotation.SuppressLint import android.os.ParcelFileDescriptor import android.system.ErrnoException import android.system.Os import android.system.OsConstants import java.io.IOException import java.nio.ByteBuffer import java.nio.channels.SeekableByteChannel @Suppress("unused") @SuppressLint("NewApi") class ParcelFileDescriptorChannel(private val pfd: ParcelFileDescriptor) : SeekableByteChannel { @Throws(IOException::class) override fun read(dst: ByteBuffer): Int { return try { Os.read(pfd.fileDescriptor, dst) } catch (e: ErrnoException) { throw RuntimeException(e) } } @Throws(IOException::class) override fun write(src: ByteBuffer): Int { return try { Os.write(pfd.fileDescriptor, src) } catch (e: ErrnoException) { throw RuntimeException(e) } } @Throws(IOException::class) override fun position(): Long { return try { Os.lseek(pfd.fileDescriptor, 0, OsConstants.SEEK_CUR) } catch (e: ErrnoException) { throw RuntimeException(e) } } @Throws(IOException::class) override fun position(newPosition: Long): SeekableByteChannel { try { Os.lseek(pfd.fileDescriptor, newPosition, OsConstants.SEEK_SET) } catch (e: ErrnoException) { throw RuntimeException(e) } return this } @Throws(IOException::class) override fun size(): Long { return try { Os.fstat(pfd.fileDescriptor).st_size } catch (e: ErrnoException) { throw RuntimeException(e) } } @Throws(IOException::class) override fun truncate(newSize: Long): SeekableByteChannel { require(!(newSize < 0L || newSize > Int.MAX_VALUE)) { "Size has to be in range 0.. " + Int.MAX_VALUE } try { Os.ftruncate(pfd.fileDescriptor, newSize) } catch (e: ErrnoException) { throw RuntimeException(e) } return this } override fun isOpen(): Boolean { return pfd.fileDescriptor.valid() } @Throws(IOException::class) override fun close() { pfd.close() } } ================================================ FILE: app/src/main/java/io/legado/app/utils/PreferencesExtensions.kt ================================================ @file:Suppress("unused") package io.legado.app.utils import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.ContextWrapper import android.content.SharedPreferences import androidx.core.content.edit import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import splitties.init.appCtx import java.io.File /** * 获取自定义路径的SharedPreferences, 用反射生成 SharedPreferences * @param dir 目录路径 * @param fileName 文件名,不需要 '.xml' 后缀 * @return SharedPreferences */ @SuppressLint("DiscouragedPrivateApi") fun Context.getSharedPreferences( dir: String, fileName: String ): SharedPreferences? { try { // 获取 ContextWrapper对象中的mBase变量。该变量保存了 ContextImpl 对象 val fieldMBase = ContextWrapper::class.java.getDeclaredField("mBase") fieldMBase.isAccessible = true // 获取 mBase变量 val objMBase = fieldMBase.get(this) // 获取 ContextImpl。mPreferencesDir变量,该变量保存了数据文件的保存路径 val fieldMPreferencesDir = objMBase.javaClass.getDeclaredField("mPreferencesDir") fieldMPreferencesDir.isAccessible = true // 创建自定义路径 val file = File(dir) // 修改mPreferencesDir变量的值 fieldMPreferencesDir.set(objMBase, file) // 返回修改路径以后的 SharedPreferences :%FILE_PATH%/%fileName%.xml return getSharedPreferences(fileName, Activity.MODE_PRIVATE) } catch (e: NoSuchFieldException) { e.printOnDebug() } catch (e: IllegalArgumentException) { e.printOnDebug() } catch (e: IllegalAccessException) { e.printOnDebug() } return null } fun SharedPreferences.getString(key: String): String? { return getString(key, null) } fun SharedPreferences.putString(key: String, value: String) { edit { putString(key, value) } } fun SharedPreferences.getBoolean(key: String): Boolean { return getBoolean(key, false) } fun SharedPreferences.putBoolean(key: String, value: Boolean) { edit { putBoolean(key, value) } } fun SharedPreferences.getInt(key: String): Int { return getInt(key, 0) } fun SharedPreferences.putInt(key: String, value: Int) { edit { putInt(key, value) } } fun SharedPreferences.getLong(key: String): Long { return getLong(key, 0) } fun SharedPreferences.putLong(key: String, value: Long) { edit { putLong(key, value) } } fun SharedPreferences.getFloat(key: String): Float { return getFloat(key, 0f) } fun SharedPreferences.putFloat(key: String, value: Float) { edit { putFloat(key, value) } } fun SharedPreferences.remove(key: String) { edit { remove(key) } } fun LifecycleOwner.observeSharedPreferences( prefs: SharedPreferences = appCtx.defaultSharedPreferences, l: SharedPreferences.OnSharedPreferenceChangeListener ) { val observer = object : DefaultLifecycleObserver { override fun onCreate(owner: LifecycleOwner) { prefs.registerOnSharedPreferenceChangeListener(l) } override fun onDestroy(owner: LifecycleOwner) { prefs.unregisterOnSharedPreferenceChangeListener(l) lifecycle.removeObserver(this) } override fun onPause(owner: LifecycleOwner) { prefs.unregisterOnSharedPreferenceChangeListener(l) } override fun onResume(owner: LifecycleOwner) { prefs.registerOnSharedPreferenceChangeListener(l) } } lifecycle.addObserver(observer) } ================================================ FILE: app/src/main/java/io/legado/app/utils/QRCodeUtils.kt ================================================ package io.legado.app.utils import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.text.TextPaint import android.text.TextUtils import androidx.annotation.ColorInt import androidx.annotation.FloatRange import com.google.zxing.BarcodeFormat import com.google.zxing.BinaryBitmap import com.google.zxing.DecodeHintType import com.google.zxing.EncodeHintType import com.google.zxing.LuminanceSource import com.google.zxing.MultiFormatReader import com.google.zxing.MultiFormatWriter import com.google.zxing.RGBLuminanceSource import com.google.zxing.Result import com.google.zxing.WriterException import com.google.zxing.common.GlobalHistogramBinarizer import com.google.zxing.common.HybridBinarizer import com.google.zxing.qrcode.QRCodeWriter import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel import com.king.zxing.DecodeFormatManager import java.util.EnumMap import kotlin.math.max @Suppress("MemberVisibilityCanBePrivate", "unused") object QRCodeUtils { const val DEFAULT_REQ_WIDTH = 480 const val DEFAULT_REQ_HEIGHT = 640 /** * 生成二维码 * @param content 二维码的内容 * @param heightPix 二维码的高 * @param logo 二维码中间的logo * @param ratio logo所占比例 因为二维码的最大容错率为30%,所以建议ratio的范围小于0.3 * @param errorCorrectionLevel */ fun createQRCode( content: String, heightPix: Int = DEFAULT_REQ_HEIGHT, logo: Bitmap? = null, @FloatRange(from = 0.0, to = 1.0) ratio: Float = 0.2f, errorCorrectionLevel: ErrorCorrectionLevel = ErrorCorrectionLevel.H ): Bitmap? { //配置参数 val hints: MutableMap = EnumMap(EncodeHintType::class.java) hints[EncodeHintType.CHARACTER_SET] = "utf-8" //容错级别 hints[EncodeHintType.ERROR_CORRECTION] = errorCorrectionLevel //设置空白边距的宽度 hints[EncodeHintType.MARGIN] = 1 //default is 4 return createQRCode(content, heightPix, logo, ratio, hints) } /** * 生成二维码 * @param content 二维码的内容 * @param heightPix 二维码的高 * @param logo 二维码中间的logo * @param ratio logo所占比例 因为二维码的最大容错率为30%,所以建议ratio的范围小于0.3 * @param hints * @param codeColor 二维码的颜色 * @return */ fun createQRCode( content: String?, heightPix: Int, logo: Bitmap?, @FloatRange(from = 0.0, to = 1.0) ratio: Float = 0.2f, hints: Map, codeColor: Int = Color.BLACK ): Bitmap? { try { // 图像数据转换,使用了矩阵转换 val bitMatrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, heightPix, heightPix, hints) val pixels = IntArray(heightPix * heightPix) // 下面这里按照二维码的算法,逐个生成二维码的图片, // 两个for循环是图片横列扫描的结果 for (y in 0 until heightPix) { for (x in 0 until heightPix) { if (bitMatrix[x, y]) { pixels[y * heightPix + x] = codeColor } else { pixels[y * heightPix + x] = Color.WHITE } } } // 生成二维码图片的格式 var bitmap: Bitmap? = Bitmap.createBitmap(heightPix, heightPix, Bitmap.Config.ARGB_8888) bitmap!!.setPixels(pixels, 0, heightPix, 0, 0, heightPix, heightPix) if (logo != null) { bitmap = addLogo(bitmap, logo, ratio) } return bitmap } catch (e: WriterException) { e.printOnDebug() } return null } /** * 在二维码中间添加Logo图案 * @param src * @param logo * @param ratio logo所占比例 因为二维码的最大容错率为30%,所以建议ratio的范围小于0.3 * @return */ private fun addLogo( src: Bitmap?, logo: Bitmap?, @FloatRange(from = 0.0, to = 1.0) ratio: Float ): Bitmap? { if (src == null) { return null } if (logo == null) { return src } //获取图片的宽高 val srcWidth = src.width val srcHeight = src.height val logoWidth = logo.width val logoHeight = logo.height if (srcWidth == 0 || srcHeight == 0) { return null } if (logoWidth == 0 || logoHeight == 0) { return src } //logo大小为二维码整体大小 val scaleFactor = srcWidth * ratio / logoWidth var bitmap: Bitmap? = Bitmap.createBitmap(srcWidth, srcHeight, Bitmap.Config.ARGB_8888) try { val canvas = Canvas(bitmap!!) canvas.drawBitmap(src, 0f, 0f, null) canvas.scale( scaleFactor, scaleFactor, (srcWidth / 2).toFloat(), (srcHeight / 2).toFloat() ) canvas.drawBitmap( logo, ((srcWidth - logoWidth) / 2).toFloat(), ((srcHeight - logoHeight) / 2).toFloat(), null ) canvas.save() canvas.restore() } catch (e: Exception) { bitmap = null e.printOnDebug() } return bitmap } /** * 解析一维码/二维码图片 * @param bitmap 解析的图片 * @param hints 解析编码类型 * @return */ fun parseCode( bitmap: Bitmap, reqWidth: Int = DEFAULT_REQ_WIDTH, reqHeight: Int = DEFAULT_REQ_HEIGHT, hints: Map = DecodeFormatManager.ALL_HINTS ): String? { val result = parseCodeResult(bitmap, reqWidth, reqHeight, hints) return result?.text } /** * 解析一维码/二维码图片 * @param bitmap 解析的图片 * @param hints 解析编码类型 * @return */ fun parseCodeResult( bitmap: Bitmap, reqWidth: Int = DEFAULT_REQ_WIDTH, reqHeight: Int = DEFAULT_REQ_HEIGHT, hints: Map = DecodeFormatManager.ALL_HINTS ): Result? { if (bitmap.width > reqWidth || bitmap.height > reqHeight) { val bm = bitmap.resizeAndRecycle(reqWidth, reqHeight) return parseCodeResult(getRGBLuminanceSource(bm), hints) } return parseCodeResult(getRGBLuminanceSource(bitmap), hints) } /** * 解析一维码/二维码图片 * @param source * @param hints * @return */ fun parseCodeResult(source: LuminanceSource?, hints: Map?): Result? { var result: Result? = null val reader = MultiFormatReader() try { reader.setHints(hints) if (source != null) { result = decodeInternal(reader, source) if (result == null) { result = decodeInternal(reader, source.invert()) } if (result == null && source.isRotateSupported) { result = decodeInternal(reader, source.rotateCounterClockwise()) } } } catch (e: java.lang.Exception) { e.printOnDebug() } finally { reader.reset() } return result } /** * 解析二维码图片 * @param bitmapPath 需要解析的图片路径 * @return */ fun parseQRCode(bitmapPath: String?): String? { val result = parseQRCodeResult(bitmapPath) return result?.text } /** * 解析二维码图片 * @param bitmapPath 需要解析的图片路径 * @param reqWidth 请求目标宽度,如果实际图片宽度大于此值,会自动进行压缩处理,当 reqWidth 和 reqHeight都小于或等于0时,则不进行压缩处理 * @param reqHeight 请求目标高度,如果实际图片高度大于此值,会自动进行压缩处理,当 reqWidth 和 reqHeight都小于或等于0时,则不进行压缩处理 * @return */ fun parseQRCodeResult( bitmapPath: String?, reqWidth: Int = DEFAULT_REQ_WIDTH, reqHeight: Int = DEFAULT_REQ_HEIGHT ): Result? { return parseCodeResult(bitmapPath, reqWidth, reqHeight, DecodeFormatManager.QR_CODE_HINTS) } /** * 解析一维码/二维码图片 * @param bitmapPath 需要解析的图片路径 * @return */ fun parseCode( bitmapPath: String?, reqWidth: Int = DEFAULT_REQ_WIDTH, reqHeight: Int = DEFAULT_REQ_HEIGHT, hints: Map = DecodeFormatManager.ALL_HINTS ): String? { return parseCodeResult(bitmapPath, reqWidth, reqHeight, hints)?.text } /** * 解析一维码/二维码图片 * @param bitmapPath 需要解析的图片路径 * @param reqWidth 请求目标宽度,如果实际图片宽度大于此值,会自动进行压缩处理,当 reqWidth 和 reqHeight都小于或等于0时,则不进行压缩处理 * @param reqHeight 请求目标高度,如果实际图片高度大于此值,会自动进行压缩处理,当 reqWidth 和 reqHeight都小于或等于0时,则不进行压缩处理 * @param hints 解析编码类型 * @return */ fun parseCodeResult( bitmapPath: String?, reqWidth: Int = DEFAULT_REQ_WIDTH, reqHeight: Int = DEFAULT_REQ_HEIGHT, hints: Map = DecodeFormatManager.ALL_HINTS ): Result? { var result: Result? = null val reader = MultiFormatReader() try { reader.setHints(hints) val source = getRGBLuminanceSource(compressBitmap(bitmapPath, reqWidth, reqHeight)) result = decodeInternal(reader, source) if (result == null) { result = decodeInternal(reader, source.invert()) } if (result == null && source.isRotateSupported) { result = decodeInternal(reader, source.rotateCounterClockwise()) } } catch (e: Exception) { e.printOnDebug() } finally { reader.reset() } return result } private fun decodeInternal(reader: MultiFormatReader, source: LuminanceSource): Result? { var result: Result? = null try { try { //采用HybridBinarizer解析 result = reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))) } catch (_: Exception) { } if (result == null) { //如果没有解析成功,再采用GlobalHistogramBinarizer解析一次 result = reader.decodeWithState(BinaryBitmap(GlobalHistogramBinarizer(source))) } } catch (_: Exception) { } return result } /** * 压缩图片 * @param path * @return */ private fun compressBitmap(path: String?, reqWidth: Int, reqHeight: Int): Bitmap { if (reqWidth > 0 && reqHeight > 0) { //都大于进行判断是否压缩 val newOpts = BitmapFactory.Options() // 开始读入图片,此时把options.inJustDecodeBounds 设回true了 newOpts.inJustDecodeBounds = true //获取原始图片大小 BitmapFactory.decodeFile(path, newOpts) // 此时返回bm为空 val width = newOpts.outWidth.toFloat() val height = newOpts.outHeight.toFloat() // 缩放比,由于是固定比例缩放,只用高或者宽其中一个数据进行计算即可 var wSize = 1 // wSize=1表示不缩放 if (width > reqWidth) { // 如果宽度大的话根据宽度固定大小缩放 wSize = (width / reqWidth).toInt() } var hSize = 1 // wSize=1表示不缩放 if (height > reqHeight) { // 如果高度高的话根据宽度固定大小缩放 hSize = (height / reqHeight).toInt() } var size = max(wSize, hSize) if (size <= 0) size = 1 newOpts.inSampleSize = size // 设置缩放比例 // 重新读入图片,注意此时已经把options.inJustDecodeBounds 设回false了 newOpts.inJustDecodeBounds = false return BitmapFactory.decodeFile(path, newOpts) } return BitmapFactory.decodeFile(path) } /** * 获取RGBLuminanceSource * @param bitmap * @return */ private fun getRGBLuminanceSource(bitmap: Bitmap): RGBLuminanceSource { val width = bitmap.width val height = bitmap.height val pixels = IntArray(width * height) bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) return RGBLuminanceSource(width, height, pixels) } /** * 生成条形码 * @param content * @param format * @param desiredWidth * @param desiredHeight * @param hints * @param isShowText * @param textSize * @param codeColor * @return */ fun createBarCode( content: String?, desiredWidth: Int, desiredHeight: Int, format: BarcodeFormat = BarcodeFormat.CODE_128, hints: Map? = null, isShowText: Boolean = true, textSize: Int = 40, @ColorInt codeColor: Int = Color.BLACK ): Bitmap? { if (TextUtils.isEmpty(content)) { return null } val writer = MultiFormatWriter() try { val result = writer.encode( content, format, desiredWidth, desiredHeight, hints ) val width = result.width val height = result.height val pixels = IntArray(width * height) // All are 0, or black, by default for (y in 0 until height) { val offset = y * width for (x in 0 until width) { pixels[offset + x] = if (result[x, y]) codeColor else Color.WHITE } } val bitmap = Bitmap.createBitmap( width, height, Bitmap.Config.ARGB_8888 ) bitmap.setPixels(pixels, 0, width, 0, 0, width, height) return if (isShowText) { addCode(bitmap, content, textSize, codeColor, textSize / 2) } else bitmap } catch (e: WriterException) { e.printOnDebug() } return null } /** * 条形码下面添加文本信息 * @param src * @param code * @param textSize * @param textColor * @return */ private fun addCode( src: Bitmap?, code: String?, textSize: Int, @ColorInt textColor: Int, offset: Int ): Bitmap? { if (src == null) { return null } if (TextUtils.isEmpty(code)) { return src } //获取图片的宽高 val srcWidth = src.width val srcHeight = src.height if (srcWidth <= 0 || srcHeight <= 0) { return null } var bitmap: Bitmap? = Bitmap.createBitmap( srcWidth, srcHeight + textSize + offset * 2, Bitmap.Config.ARGB_8888 ) try { val canvas = Canvas(bitmap!!) canvas.drawBitmap(src, 0f, 0f, null) val paint = TextPaint() paint.textSize = textSize.toFloat() paint.color = textColor paint.textAlign = Paint.Align.CENTER canvas.drawText( code!!, (srcWidth / 2).toFloat(), (srcHeight + textSize / 2 + offset).toFloat(), paint ) canvas.save() canvas.restore() } catch (e: Exception) { bitmap = null e.printOnDebug() } return bitmap } } ================================================ FILE: app/src/main/java/io/legado/app/utils/RandomColor.kt ================================================ package io.legado.app.utils import android.graphics.Color import java.util.* @Suppress("unused") class RandomColor(alpha: Int, lower: Int, upper: Int) { constructor() : this(255, 80, 200) private var alpha: Int = 0 private var lower: Int = 0 private var upper: Int = 0 init { require(upper > lower) { "must be lower < upper" } setAlpha(alpha) setLower(lower) setUpper(upper) } //随机数是前闭 后开 fun build(): Int { val red = getLower() + Random().nextInt(getUpper() - getLower() + 1) val green = getLower() + Random().nextInt(getUpper() - getLower() + 1) val blue = getLower() + Random().nextInt(getUpper() - getLower() + 1) return Color.argb(getAlpha(), red, green, blue) } private fun getAlpha(): Int { return alpha } private fun setAlpha(alpha: Int) { var alpha1 = alpha if (alpha1 > 255) alpha1 = 255 if (alpha1 < 0) alpha1 = 0 this.alpha = alpha1 } private fun getLower(): Int { return lower } private fun setLower(lower: Int) { var lower1 = lower if (lower1 < 0) lower1 = 0 this.lower = lower1 } private fun getUpper(): Int { return upper } private fun setUpper(upper: Int) { var upper1 = upper if (upper1 > 255) upper1 = 255 this.upper = upper1 } } ================================================ FILE: app/src/main/java/io/legado/app/utils/RealPathUtil.kt ================================================ package io.legado.app.utils import android.annotation.SuppressLint import android.content.ContentUris import android.content.Context import android.database.Cursor import android.net.Uri import android.os.Build import android.os.Environment import android.provider.DocumentsContract import android.provider.MediaStore import androidx.core.provider.DocumentsContractCompat import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException @Suppress("unused") object RealPathUtil { /** * Method for return file path of Gallery image * @return path of the selected image file from gallery */ private var filePathUri: Uri? = null fun getPath(context: Context, uri: Uri): String? { //check here to KITKAT or new version @SuppressLint("ObsoleteSdkInt") val isKitKat = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT filePathUri = uri // DocumentProvider if (isKitKat && DocumentsContract.isDocumentUri(context, uri)) { // ExternalStorageProvider if (isExternalStorageDocument(uri)) { val docId = DocumentsContract.getDocumentId(uri) val split = docId.split(":") val type = split[0] if ("primary".equals(type, ignoreCase = true)) { return Environment.getExternalStorageDirectory().toString() + "/" + split[1] } } else if (isDownloadsDocument(uri)) { val id = DocumentsContract.getDocumentId(uri) val contentUri = ContentUris.withAppendedId( Uri.parse("content://downloads/public_downloads"), java.lang.Long.valueOf(id) ) //return getDataColumn(context, uri, null, null); return getDataColumn(context, contentUri, null, null) } else if (isMediaDocument(uri)) { val docId = DocumentsContract.getDocumentId(uri) val split = docId.split(":").toTypedArray() val type = split[0] var contentUri: Uri? = null when (type) { "image" -> { contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI } "video" -> { contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI } "audio" -> { contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI } } val selection = "_id=?" val selectionArgs = arrayOf(split[1]) return getDataColumn(context, contentUri, selection, selectionArgs) } } else if (DocumentsContractCompat.isTreeUri(uri)) { if (isExternalStorageDocument(uri)) { val docId = DocumentsContract.getTreeDocumentId(uri) val split = docId.split(":") val type = split[0] if ("primary".equals(type, ignoreCase = true)) { return Environment.getExternalStorageDirectory().toString() + "/" + split[1] } } } else if ("content".equals(uri.scheme, ignoreCase = true)) { // Return the remote address return if (isGooglePhotosUri(uri)) uri.lastPathSegment else getDataColumn(context, uri, null, null) } else if ("file".equals(uri.scheme, ignoreCase = true)) { return uri.path } return uri.path } fun getTreePath(uri: Uri): String? { if (!DocumentsContractCompat.isTreeUri(uri) || !isExternalStorageDocument(uri)) { return null } val docId = DocumentsContract.getTreeDocumentId(uri) val split = docId.split(":") if (split.size < 2) { return null } val type = split[0] if ("primary".equals(type, ignoreCase = true)) { return Environment.getExternalStorageDirectory().toString() + "/" + split[1] } return null } /** * Get the value of the data column for this Uri. This is useful for * MediaStore Uris, and other file-based ContentProviders. * * @param context The context. * @param uri The Uri to query. * @param selection (Optional) Filter used in the query. * @param selectionArgs (Optional) Selection arguments used in the query. * @return The value of the _data column, which is typically a file path. */ private fun getDataColumn( context: Context, uri: Uri?, selection: String?, selectionArgs: Array? ): String? { var cursor: Cursor? = null val column = "_data" val projection = arrayOf( column ) try { cursor = context.contentResolver.query(uri!!, projection, selection, selectionArgs, null) if (cursor != null && cursor.moveToFirst()) { val index = cursor.getColumnIndexOrThrow(column) return cursor.getString(index) } } catch (e: IllegalArgumentException) { e.printOnDebug() val file = File(context.cacheDir, "tmp") val filePath = file.absolutePath try { return context.contentResolver.openFileDescriptor(filePathUri!!, "r")?.use { val fd = it.fileDescriptor FileInputStream(fd).use { fis -> FileOutputStream(filePath).use { fos -> fis.copyTo(fos) } } File(filePath).absolutePath } } catch (e: IOException) { e.printStackTrace() } } finally { cursor?.close() } return null } /** * @param uri The Uri to check. * @return Whether the Uri authority is ExternalStorageProvider. */ private fun isExternalStorageDocument(uri: Uri): Boolean { return "com.android.externalstorage.documents" == uri.authority } /** * @param uri The Uri to check. * @return Whether the Uri authority is DownloadsProvider. */ private fun isDownloadsDocument(uri: Uri): Boolean { return "com.android.providers.downloads.documents" == uri.authority } /** * @param uri The Uri to check. * @return Whether the Uri authority is MediaProvider. */ private fun isMediaDocument(uri: Uri): Boolean { return "com.android.providers.media.documents" == uri.authority } /** * @param uri The Uri to check. * @return Whether the Uri authority is Google Photos. */ private fun isGooglePhotosUri(uri: Uri): Boolean { return "com.google.android.apps.photos.content" == uri.authority } } ================================================ FILE: app/src/main/java/io/legado/app/utils/RecyclerViewExtensions.kt ================================================ package io.legado.app.utils import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView fun RecyclerView.findCenterViewPosition(): Int { return getChildAdapterPosition( findChildViewUnder(width / 2f, height / 2f) ?: return RecyclerView.NO_POSITION ) } fun RecyclerView.findViewPosition(x: Float, y: Float): Int { return getChildAdapterPosition(findChildViewUnder(x, y) ?: return RecyclerView.NO_POSITION) } fun RecyclerView.findFirstVisibleViewPosition(): Int { var pos = -1 if (layoutManager is LinearLayoutManager) { pos = (layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() } return pos } fun RecyclerView.findLastVisibleViewPosition(): Int { var pos = -1 if (layoutManager is LinearLayoutManager) { pos = (layoutManager as LinearLayoutManager).findLastVisibleItemPosition() } return pos } ================================================ FILE: app/src/main/java/io/legado/app/utils/RegexExtensions.kt ================================================ package io.legado.app.utils import com.script.ScriptBindings import com.script.rhino.RhinoScriptEngine import io.legado.app.exception.RegexTimeoutException import io.legado.app.help.CrashHandler import io.legado.app.help.coroutine.Coroutine import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select import kotlinx.coroutines.suspendCancellableCoroutine import splitties.init.appCtx import java.util.regex.Matcher import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException private val handler by lazy { buildMainHandler() } /** * 带有超时检测的正则替换 */ @OptIn(ExperimentalCoroutinesApi::class) fun CharSequence.replace(regex: Regex, replacement: String, timeout: Long): String { val charSequence = this@replace val isJs = replacement.startsWith("@js:") val replacement1 = if (isJs) replacement.substring(4) else replacement return runBlocking { suspendCancellableCoroutine { block -> Coroutine.async(executeContext = IO) { val job = launch { try { val pattern = regex.toPattern() val matcher = pattern.matcher(charSequence) val stringBuffer = StringBuffer() while (matcher.find()) { if (isJs) { val jsResult = RhinoScriptEngine.run { val bindings = ScriptBindings() bindings["result"] = matcher.group() eval(replacement1, bindings) }.toString() val quotedResult = Matcher.quoteReplacement(jsResult) matcher.appendReplacement(stringBuffer, quotedResult) } else { matcher.appendReplacement(stringBuffer, replacement1) } } matcher.appendTail(stringBuffer) block.resume(stringBuffer.toString()) } catch (e: Exception) { block.resumeWithException(e) } } select { job.onJoin {} onTimeout(timeout) { val timeoutMsg = "替换超时,3秒后还未结束将重启应用\n替换规则$regex\n替换内容:$charSequence" val exception = RegexTimeoutException(timeoutMsg) block.cancel(exception) appCtx.longToastOnUi(timeoutMsg) CrashHandler.saveCrashInfo2File(exception) select { job.onJoin {} onTimeout(3000) { appCtx.restart() } } } } } } } } ================================================ FILE: app/src/main/java/io/legado/app/utils/RequestManagerExtensions.kt ================================================ package io.legado.app.utils import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import com.bumptech.glide.Glide import com.bumptech.glide.RequestManager import splitties.init.appCtx private val applicationRequestManager by lazy { Glide.with(appCtx) } fun RequestManager.lifecycle(lifecycle: Lifecycle): RequestManager { if (this === applicationRequestManager) { return this } val observer = object : DefaultLifecycleObserver { override fun onResume(owner: LifecycleOwner) = onStart() override fun onPause(owner: LifecycleOwner) = onStop() override fun onDestroy(owner: LifecycleOwner) { owner.lifecycle.removeObserver(this) } } lifecycle.addObserver(observer) return this } ================================================ FILE: app/src/main/java/io/legado/app/utils/Snackbars.kt ================================================ @file:Suppress("unused") package io.legado.app.utils import android.view.View import androidx.annotation.StringRes import com.google.android.material.snackbar.Snackbar /** * Display the Snackbar with the [Snackbar.LENGTH_SHORT] duration. * * @param message the message text resource. */ @JvmName("snackbar2") fun View.snackbar( @StringRes message: Int ) = Snackbar .make(this, message, Snackbar.LENGTH_SHORT) .apply { show() } /** * Display Snackbar with the [Snackbar.LENGTH_LONG] duration. * * @param message the message text resource. */ @JvmName("longSnackbar2") fun View.longSnackbar( @StringRes message: Int ) = Snackbar .make(this, message, Snackbar.LENGTH_LONG) .apply { show() } /** * Display Snackbar with the [Snackbar.LENGTH_INDEFINITE] duration. * * @param message the message text resource. */ @JvmName("indefiniteSnackbar2") fun View.indefiniteSnackbar( @StringRes message: Int ) = Snackbar .make(this, message, Snackbar.LENGTH_INDEFINITE) .apply { show() } /** * Display the Snackbar with the [Snackbar.LENGTH_SHORT] duration. * * @param message the message text. */ @JvmName("snackbar2") fun View.snackbar( message: CharSequence ) = Snackbar .make(this, message, Snackbar.LENGTH_SHORT) .apply { show() } /** * Display Snackbar with the [Snackbar.LENGTH_LONG] duration. * * @param message the message text. */ @JvmName("longSnackbar2") fun View.longSnackbar( message: CharSequence ) = Snackbar .make(this, message, Snackbar.LENGTH_LONG) .apply { show() } /** * Display Snackbar with the [Snackbar.LENGTH_INDEFINITE] duration. * * @param message the message text. */ @JvmName("indefiniteSnackbar2") fun View.indefiniteSnackbar( message: CharSequence ) = Snackbar .make(this, message, Snackbar.LENGTH_INDEFINITE) .apply { show() } /** * Display the Snackbar with the [Snackbar.LENGTH_SHORT] duration. * * @param message the message text resource. */ @JvmName("snackbar2") fun View.snackbar( message: Int, @StringRes actionText: Int, action: (View) -> Unit ) = Snackbar .make(this, message, Snackbar.LENGTH_SHORT) .setAction(actionText, action) .apply { show() } /** * Display Snackbar with the [Snackbar.LENGTH_LONG] duration. * * @param message the message text resource. */ @JvmName("longSnackbar2") fun View.longSnackbar( @StringRes message: Int, @StringRes actionText: Int, action: (View) -> Unit ) = Snackbar .make(this, message, Snackbar.LENGTH_LONG) .setAction(actionText, action) .apply { show() } /** * Display Snackbar with the [Snackbar.LENGTH_INDEFINITE] duration. * * @param message the message text resource. */ @JvmName("indefiniteSnackbar2") fun View.indefiniteSnackbar( @StringRes message: Int, @StringRes actionText: Int, action: (View) -> Unit ) = Snackbar .make(this, message, Snackbar.LENGTH_INDEFINITE) .setAction(actionText, action) .apply { show() } /** * Display the Snackbar with the [Snackbar.LENGTH_SHORT] duration. * * @param message the message text. */ @JvmName("snackbar2") fun View.snackbar( message: CharSequence, actionText: CharSequence, action: (View) -> Unit ) = Snackbar .make(this, message, Snackbar.LENGTH_SHORT) .setAction(actionText, action) .apply { show() } /** * Display Snackbar with the [Snackbar.LENGTH_LONG] duration. * * @param message the message text. */ @JvmName("longSnackbar2") fun View.longSnackbar( message: CharSequence, actionText: CharSequence, action: (View) -> Unit ) = Snackbar .make(this, message, Snackbar.LENGTH_LONG) .setAction(actionText, action) .apply { show() } /** * Display Snackbar with the [Snackbar.LENGTH_INDEFINITE] duration. * * @param message the message text. */ @JvmName("indefiniteSnackbar2") fun View.indefiniteSnackbar( message: CharSequence, actionText: CharSequence, action: (View) -> Unit ) = Snackbar .make(this, message, Snackbar.LENGTH_INDEFINITE) .setAction(actionText, action) .apply { show() } ================================================ FILE: app/src/main/java/io/legado/app/utils/StringExtensions.kt ================================================ @file:Suppress("unused") package io.legado.app.utils import android.annotation.SuppressLint import android.icu.text.Collator import android.icu.util.ULocale import android.net.Uri import android.text.Editable 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.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 fun String.toEditable(): Editable = Editable.Factory.getInstance().newEditable(this) fun String.parseToUri(): Uri { return if (isUri()) Uri.parse(this) else { Uri.fromFile(File(this)) } } fun String?.isUri(): Boolean { this ?: return false return this.startsWith("file://", true) || isContentScheme() } fun String?.isAbsUrl() = this?.let { it.startsWith("http://", true) || it.startsWith("https://", true) } ?: false fun String?.isDataUrl() = this?.let { dataUriRegex.matches(it) } ?: false fun String?.isJson(): Boolean = this?.run { val str = this.trim() when { str.startsWith("{") && str.endsWith("}") -> true str.startsWith("[") && str.endsWith("]") -> true else -> false } } ?: false fun String?.isJsonObject(): Boolean = this?.run { val str = this.trim() str.startsWith("{") && str.endsWith("}") } ?: false fun String?.isJsonArray(): Boolean = this?.run { val str = this.trim() str.startsWith("[") && str.endsWith("]") } ?: false fun String?.isXml(): Boolean = this?.run { val str = this.trim() str.startsWith("<") && str.endsWith(">") } ?: false fun String?.isTrue(nullIsTrue: Boolean = false): Boolean { if (this.isNullOrBlank() || this == "null") { return nullIsTrue } return !this.trim().matches("(?i)^(false|no|not|0)$".toRegex()) } fun String.isHex(): Boolean { return all {c -> c in '0'..'9' || c in 'A'..'F' || c in 'a'..'f' } } fun String.splitNotBlank(vararg delimiter: String, limit: Int = 0): Array = run { this.split(*delimiter, limit = limit).map { it.trim() }.filterNot { it.isBlank() } .toTypedArray() } fun String.splitNotBlank(regex: Regex, limit: Int = 0): Array = run { this.split(regex, limit).map { it.trim() }.filterNot { it.isBlank() }.toTypedArray() } @SuppressLint("ObsoleteSdkInt") fun String.cnCompare(other: String): Int { return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { Collator.getInstance(ULocale.SIMPLIFIED_CHINESE).compare(this, other) } else { java.text.Collator.getInstance(Locale.CHINA).compare(this, other) } } /** * 字符串所占内存大小 */ fun String?.memorySize(): Int { this ?: return 0 return 40 + 2 * length } /** * 是否中文 */ fun String.isChinese(): Boolean { val p = Pattern.compile("[\u4e00-\u9fa5]") val m = p.matcher(this) return m.find() } /** * 将字符串拆分为单个字符,包含emoji */ fun CharSequence.toStringArray(): Array { var codePointIndex = 0 return try { Array(codePointCount(this, 0, length)) { val start = codePointIndex codePointIndex = offsetByCodePoints(this, start, 1) substring(start, codePointIndex) } } catch (e: Exception) { split("").toTypedArray() } } fun String.escapeRegex(): String { return replace(AppPattern.regexCharRegex, "\\\\$0") } fun String.encodeURI(): String = URLEncodeUtil.encodeQuery(this) fun String.normalizeFileName(): String { return replace(AppPattern.fileNameRegex2, "_") } ================================================ FILE: app/src/main/java/io/legado/app/utils/StringUtils.kt ================================================ package io.legado.app.utils import android.annotation.SuppressLint import android.text.TextUtils.isEmpty import android.util.Base64 import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.IOException import java.text.DecimalFormat import java.text.SimpleDateFormat import java.util.Calendar import java.util.Locale import java.util.regex.Matcher import java.util.regex.Pattern import java.util.zip.GZIPInputStream import java.util.zip.GZIPOutputStream import kotlin.math.abs @Suppress("unused", "MemberVisibilityCanBePrivate") object StringUtils { private const val HOUR_OF_DAY = 24 private const val DAY_OF_YESTERDAY = 2 private const val TIME_UNIT = 60 private val ChnMap = chnMap private val wordCountFormatter by lazy { DecimalFormat("#.#") } private val chnMap: HashMap get() { val map = HashMap() var cnStr = "零一二三四五六七八九十" var c = cnStr.toCharArray() for (i in 0..10) { map[c[i]] = i } cnStr = "〇壹贰叁肆伍陆柒捌玖拾" c = cnStr.toCharArray() for (i in 0..10) { map[c[i]] = i } map['两'] = 2 map['百'] = 100 map['佰'] = 100 map['千'] = 1000 map['仟'] = 1000 map['万'] = 10000 map['亿'] = 100000000 return map } /** * 将日期转换成昨天、今天、明天 */ fun dateConvert(source: String, pattern: String): String { val format = SimpleDateFormat(pattern, Locale.getDefault()) val calendar = Calendar.getInstance() kotlin.runCatching { val date = format.parse(source) ?: return "" val curTime = calendar.timeInMillis calendar.time = date //将MISC 转换成 sec val difSec = abs((curTime - date.time) / 1000) val difMin = difSec / 60 val difHour = difMin / 60 val difDate = difHour / 60 val oldHour = calendar.get(Calendar.HOUR) //如果没有时间 if (oldHour == 0) { //比日期:昨天今天和明天 return when { difDate == 0L -> "今天" difDate < DAY_OF_YESTERDAY -> "昨天" else -> { @SuppressLint("SimpleDateFormat") val convertFormat = SimpleDateFormat("yyyy-MM-dd") convertFormat.format(date) } } } return when { difSec < TIME_UNIT -> difSec.toString() + "秒前" difMin < TIME_UNIT -> difMin.toString() + "分钟前" difHour < HOUR_OF_DAY -> difHour.toString() + "小时前" difDate < DAY_OF_YESTERDAY -> "昨天" else -> { @SuppressLint("SimpleDateFormat") val convertFormat = SimpleDateFormat("yyyy-MM-dd") convertFormat.format(date) } } }.onFailure { it.printOnDebug() } return "" } /** * 首字母大写 */ @SuppressLint("DefaultLocale") fun toFirstCapital(str: String): String { return str.substring(0, 1).uppercase(Locale.getDefault()) + str.substring(1) } /** * 将文本中的半角字符,转换成全角字符 */ fun halfToFull(input: String): String { val c = input.toCharArray() for (i in c.indices) { if (c[i].code == 32) //半角空格 { c[i] = 12288.toChar() continue } //根据实际情况,过滤不需要转换的符号 //if (c[i] == 46) //半角点号,不转换 // continue; if (c[i].code in 33..126) //其他符号都转换为全角 c[i] = (c[i].code + 65248).toChar() } return String(c) } /** * 字符串全角转换为半角 */ fun fullToHalf(input: String): String { val c = input.toCharArray() for (i in c.indices) { if (c[i].code == 12288) //全角空格 { c[i] = 32.toChar() continue } if (c[i].code in 65281..65374) c[i] = (c[i].code - 65248).toChar() } return String(c) } /** * 中文大写数字转数字 */ fun chineseNumToInt(chNum: String): Int { var result = 0 var tmp = 0 var billion = 0 val cn = chNum.toCharArray() // "一零二五" 形式 if (cn.size > 1 && chNum.matches("^[〇零一二三四五六七八九壹贰叁肆伍陆柒捌玖]$".toRegex())) { for (i in cn.indices) { cn[i] = (48 + ChnMap[cn[i]]!!).toChar() } return Integer.parseInt(String(cn)) } // "一千零二十五", "一千二" 形式 return kotlin.runCatching { for (i in cn.indices) { val tmpNum = ChnMap[cn[i]]!! when { tmpNum == 100000000 -> { result += tmp result *= tmpNum billion = billion * 100000000 + result result = 0 tmp = 0 } tmpNum == 10000 -> { result += tmp result *= tmpNum tmp = 0 } tmpNum >= 10 -> { if (tmp == 0) tmp = 1 result += tmpNum * tmp tmp = 0 } else -> { tmp = if (i >= 2 && i == cn.size - 1 && ChnMap[cn[i - 1]]!! > 10) tmpNum * ChnMap[cn[i - 1]]!! / 10 else tmp * 10 + tmpNum } } } result += tmp + billion result }.getOrDefault(-1) } /** * 字符串转数字 */ fun stringToInt(str: String?): Int { if (str != null) { val num = fullToHalf(str).replace("\\s+".toRegex(), "") return kotlin.runCatching { Integer.parseInt(num) }.getOrElse { chineseNumToInt(num) } } return -1 } /** * 是否包含数字 */ fun isContainNumber(company: String): Boolean { val p = Pattern.compile("[0-9]+") val m = p.matcher(company) return m.find() } /** * 是否数字 */ fun isNumeric(str: String): Boolean { val pattern = Pattern.compile("-?[0-9]+") val isNum = pattern.matcher(str) return isNum.matches() } fun wordCountFormat(words: Int): String { var wordsS = "" if (words > 0) { if (words > 10000) { val df = wordCountFormatter wordsS = df.format(words * 1.0f / 10000f.toDouble()) + "万字" } else { wordsS = words.toString() + "字" } } return wordsS } fun wordCountFormat(wc: String?): String { if (wc == null) return "" var wordsS = "" if (isNumeric(wc)) { val words: Int = wc.toInt() if (words > 0) { if (words > 10000) { val df = wordCountFormatter wordsS = df.format(words * 1.0f / 10000f.toDouble()) + "万字" } else { wordsS = words.toString() + "字" } } } else { wordsS = wc } return wordsS } /** * 移除字符串首尾空字符的高效方法(利用ASCII值判断,包括全角空格) */ fun trim(s: String): String { if (isEmpty(s)) return "" var start = 0 val len = s.length var end = len - 1 while (start < end && (s[start].code <= 0x20 || s[start] == ' ')) { ++start } while (start < end && (s[end].code <= 0x20 || s[end] == ' ')) { --end } ++end return if (start > 0 || end < len) s.substring(start, end) else s } /** * 重复字符串 */ fun repeat(str: String, n: Int): String { val stringBuilder = StringBuilder() for (i in 0 until n) { stringBuilder.append(str) } return stringBuilder.toString() } /** * 移除UTF头 */ fun removeUTFCharacters(data: String?): String? { if (data == null) return null val p = Pattern.compile("\\\\u(\\p{XDigit}{4})") val m = p.matcher(data) val buf = StringBuffer(data.length) while (m.find()) { val ch = Integer.parseInt(m.group(1)!!, 16).toChar().toString() m.appendReplacement(buf, Matcher.quoteReplacement(ch)) } m.appendTail(buf) return buf.toString() } /** * 压缩字符串 */ fun compress(str: String): Result { return kotlin.runCatching { if (str.isEmpty()) { return@runCatching str } val out = ByteArrayOutputStream() var gzip: GZIPOutputStream? = null return@runCatching try { gzip = GZIPOutputStream(out) gzip.write(str.toByteArray()) Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP) } finally { gzip?.runCatching { close() } out.runCatching { close() } } } } /** * 解压字符串 */ @Throws(IOException::class) fun unCompress(str: String): Result { return kotlin.runCatching { val outputStream = ByteArrayOutputStream() var inputStream: ByteArrayInputStream? = null var ginZip: GZIPInputStream? = null return@runCatching try { val compressed = Base64.decode(str, Base64.NO_WRAP) inputStream = ByteArrayInputStream(compressed) ginZip = GZIPInputStream(inputStream) ginZip.copyTo(outputStream) outputStream.toString() } finally { ginZip?.runCatching { close() } inputStream?.runCatching { close() } outputStream.runCatching { close() } } } } } ================================================ FILE: app/src/main/java/io/legado/app/utils/SvgUtils.kt ================================================ package io.legado.app.utils import android.graphics.Canvas import android.graphics.Bitmap import android.graphics.RectF import android.util.Size import java.io.FileInputStream import java.io.InputStream import com.caverock.androidsvg.SVG import kotlin.math.max @Suppress("WeakerAccess", "MemberVisibilityCanBePrivate") object SvgUtils { /** * 从Svg中解码bitmap */ fun createBitmap(filePath: String, width: Int, height: Int? = null): Bitmap? { return kotlin.runCatching { val inputStream = FileInputStream(filePath) createBitmap(inputStream, width, height) }.getOrNull() } fun createBitmap(inputStream: InputStream, width: Int, height: Int? = null): Bitmap? { return kotlin.runCatching { val svg = SVG.getFromInputStream(inputStream) createBitmap(svg, width, height) }.getOrNull() } //获取svg图片大小 fun getSize(filePath: String): Size? { return kotlin.runCatching { val inputStream = FileInputStream(filePath) getSize(inputStream) }.getOrNull() } fun getSize(inputStream: InputStream): Size? { return kotlin.runCatching { val svg = SVG.getFromInputStream(inputStream) getSize(svg) }.getOrNull() } /////// private method private fun createBitmap(svg: SVG, width: Int? = null, height: Int? = null): Bitmap { val size = getSize(svg) val wRatio = width?.let { size.width / it } ?: -1 val hRatio = height?.let { size.height / it } ?: -1 //如果超出指定大小,则缩小相应的比例 val ratio = when { wRatio > 1 && hRatio > 1 -> max(wRatio, hRatio) wRatio > 1 -> wRatio hRatio > 1 -> hRatio else -> 1 } val viewBox: RectF? = svg.documentViewBox if (viewBox == null && size.width > 0 && size.height > 0) { svg.setDocumentViewBox(0f, 0f, svg.documentWidth, svg.documentHeight) } svg.setDocumentWidth("100%") svg.setDocumentHeight("100%") val bitmapWidth = size.width / ratio val bitmapHeight = size.height / ratio val bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888) svg.renderToCanvas(Canvas(bitmap)) return bitmap } private fun getSize(svg: SVG): Size { val width = svg.documentWidth.toInt().takeIf { it > 0 } ?: (svg.documentViewBox.right - svg.documentViewBox.left).toInt() val height = svg.documentHeight.toInt().takeIf { it > 0 } ?: (svg.documentViewBox.bottom - svg.documentViewBox.top).toInt() return Size(width, height) } } ================================================ FILE: app/src/main/java/io/legado/app/utils/SyncedRenderer.kt ================================================ package io.legado.app.utils import android.view.Choreographer class SyncedRenderer(val doFrame: (frameTime: Double) -> Unit) { private var callback: (Long) -> Unit = {} fun start() { var currTime = System.nanoTime() / 1000000.0 callback = { val currTimeMs = it / 1000000.0 val frameTime = currTimeMs - currTime currTime = currTimeMs doFrame(frameTime) Choreographer.getInstance().postFrameCallback(callback) } Choreographer.getInstance().postFrameCallback(callback) } fun stop() { Choreographer.getInstance().removeFrameCallback(callback) callback = {} } } ================================================ FILE: app/src/main/java/io/legado/app/utils/SystemUtils.kt ================================================ package io.legado.app.utils import android.annotation.SuppressLint import android.app.Activity import android.content.Intent import android.net.Uri import android.provider.Settings import android.view.Display import splitties.init.appCtx import splitties.systemservices.displayManager import splitties.systemservices.powerManager @Suppress("unused") object SystemUtils { @SuppressLint("ObsoleteSdkInt") fun ignoreBatteryOptimization(activity: Activity) { if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.M) return val hasIgnored = powerManager.isIgnoringBatteryOptimizations(activity.packageName) // 判断当前APP是否有加入电池优化的白名单,如果没有,弹出加入电池优化的白名单的设置对话框。 if (!hasIgnored) { try { @SuppressLint("BatteryLife") val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) intent.data = Uri.parse("package:" + activity.packageName) activity.startActivity(intent) } catch (ignored: Throwable) { } } } fun isScreenOn(): Boolean { return displayManager.displays.filterNotNull().any { it.state != Display.STATE_OFF } } /** * 屏幕像素宽度 */ val screenWidthPx by lazy { appCtx.resources.displayMetrics.widthPixels } /** * 屏幕像素高度 */ val screenHeightPx by lazy { appCtx.resources.displayMetrics.heightPixels } } ================================================ FILE: app/src/main/java/io/legado/app/utils/Throttle.kt ================================================ package io.legado.app.utils @Suppress("unused") class Throttle( wait: Long = 0L, leading: Boolean = true, trailing: Boolean = true, func: () -> T ) : Debounce(wait, wait, leading, trailing, func) fun throttle( wait: Long = 0L, leading: Boolean = true, trailing: Boolean = true, func: () -> T ) = Throttle(wait, leading, trailing, func) ================================================ FILE: app/src/main/java/io/legado/app/utils/ThrowableExtensions.kt ================================================ package io.legado.app.utils import java.io.IOException val Throwable.stackTraceStr: String get() { val stackTrace = stackTraceToString() val lMsg = this.localizedMessage ?: "noErrorMsg" return when { stackTrace.isNotEmpty() -> stackTrace else -> lMsg } } fun Throwable.asIOException(): IOException { val newException = IOException(this.message) newException.initCause(this) return newException } ================================================ FILE: app/src/main/java/io/legado/app/utils/TimeUtils.kt ================================================ package io.legado.app.utils import kotlin.math.abs fun Long.toTimeAgo(): String { val curTime = System.currentTimeMillis() val time = this val seconds = abs(System.currentTimeMillis() - time) / 1000f val end = if (time < curTime) "前" else "后" val start = when { seconds < 60 -> "${seconds.toInt()}秒" seconds < 3600 -> { val minutes = seconds / 60f "${minutes.toInt()}分钟" } seconds < 86400 -> { val hours = seconds / 3600f "${hours.toInt()}小时" } seconds < 604800 -> { val days = seconds / 86400f "${days.toInt()}天" } seconds < 2_628_000 -> { val weeks = seconds / 604800f "${weeks.toInt()}周" } seconds < 31_536_000 -> { val months = seconds / 2_628_000f "${months.toInt()}月" } else -> { val years = seconds / 31_536_000f "${years.toInt()}年" } } return start + end } fun Int.toDurationTime(): String { val totalSeconds = this / 1000 val hours = totalSeconds / 3600 val minutes = (totalSeconds % 3600) / 60 val seconds = totalSeconds % 60 return if (hours > 0) { "%d:%02d:%02d".format(hours, minutes, seconds) } else { "%02d:%02d".format(minutes, seconds) } } ================================================ FILE: app/src/main/java/io/legado/app/utils/ToastUtils.kt ================================================ @file:Suppress("unused") package io.legado.app.utils import android.annotation.SuppressLint import android.content.Context import android.widget.Toast import androidx.fragment.app.Fragment import io.legado.app.BuildConfig import io.legado.app.databinding.ViewToastBinding import io.legado.app.help.config.AppConfig import io.legado.app.lib.theme.bottomBackground import io.legado.app.lib.theme.getPrimaryTextColor import splitties.systemservices.layoutInflater private var toast: Toast? = null private var toastLegacy: Toast? = null fun Context.toastOnUi(message: Int, duration: Int = Toast.LENGTH_SHORT) { toastOnUi(getString(message), duration) } @SuppressLint("InflateParams") @Suppress("DEPRECATION") fun Context.toastOnUi(message: CharSequence?, duration: Int = Toast.LENGTH_SHORT) { runOnUI { kotlin.runCatching { toast?.cancel() toast = Toast(this) val isLight = ColorUtils.isColorLight(bottomBackground) ViewToastBinding.inflate(layoutInflater).run { toast?.view = root cvToast.setCardBackgroundColor(bottomBackground) tvText.setTextColor(getPrimaryTextColor(isLight)) tvText.text = message } toast?.duration = duration toast?.show() } } } fun Context.toastOnUiLegacy(message: CharSequence) { runOnUI { kotlin.runCatching { if (toastLegacy == null || BuildConfig.DEBUG || AppConfig.recordLog) { toastLegacy = Toast.makeText(this, message, Toast.LENGTH_SHORT) } else { toastLegacy?.setText(message) toastLegacy?.duration = Toast.LENGTH_SHORT } toastLegacy?.show() } } } fun Context.longToastOnUi(message: Int) { toastOnUi(message, Toast.LENGTH_LONG) } fun Context.longToastOnUi(message: CharSequence?) { toastOnUi(message, Toast.LENGTH_LONG) } fun Context.longToastOnUiLegacy(message: CharSequence) { runOnUI { kotlin.runCatching { if (toastLegacy == null || BuildConfig.DEBUG || AppConfig.recordLog) { toastLegacy = Toast.makeText(this, message, Toast.LENGTH_LONG) } else { toastLegacy?.setText(message) toastLegacy?.duration = Toast.LENGTH_LONG } toastLegacy?.show() } } } fun Fragment.toastOnUi(message: Int) = requireActivity().toastOnUi(message) fun Fragment.toastOnUi(message: CharSequence) = requireActivity().toastOnUi(message) fun Fragment.longToast(message: Int) = requireContext().longToastOnUi(message) fun Fragment.longToast(message: CharSequence) = requireContext().longToastOnUi(message) ================================================ FILE: app/src/main/java/io/legado/app/utils/ToolBarExtensions.kt ================================================ @file:Suppress("unused") package io.legado.app.utils import android.annotation.SuppressLint import android.graphics.PorterDuff import android.graphics.PorterDuffColorFilter import android.os.Build import android.widget.Toolbar import androidx.core.content.ContextCompat import io.legado.app.R /** * 设置toolBar更多图标颜色 */ @SuppressLint("ObsoleteSdkInt") fun Toolbar.setMoreIconColor(color: Int) { val moreIcon = ContextCompat.getDrawable(context, R.drawable.ic_more) if (moreIcon != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { moreIcon.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_ATOP) overflowIcon = moreIcon } } ================================================ FILE: app/src/main/java/io/legado/app/utils/UriExtensions.kt ================================================ package io.legado.app.utils import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.ParcelFileDescriptor import androidx.appcompat.app.AppCompatActivity import androidx.documentfile.provider.DocumentFile import androidx.fragment.app.Fragment import io.legado.app.R import io.legado.app.constant.AppLog import io.legado.app.exception.NoStackTraceException import io.legado.app.lib.permission.Permissions import io.legado.app.lib.permission.PermissionsCompat import okhttp3.MediaType import okhttp3.RequestBody import okio.BufferedSink import okio.source import splitties.init.appCtx import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.io.InputStream import java.io.OutputStream import java.nio.charset.Charset fun Uri.isContentScheme() = this.scheme == "content" fun Uri.isFileScheme() = this.scheme == "file" /** * 读取URI */ fun AppCompatActivity.readUri( uri: Uri?, success: (fileDoc: FileDoc, inputStream: InputStream) -> Unit ) { uri ?: return try { if (uri.isContentScheme()) { val doc = DocumentFile.fromSingleUri(this, uri) doc ?: throw NoStackTraceException("未获取到文件") val fileDoc = FileDoc.fromDocumentFile(doc) contentResolver.openInputStream(uri)!!.use { inputStream -> success.invoke(fileDoc, inputStream) } } else { PermissionsCompat.Builder() .addPermissions(*Permissions.Group.STORAGE) .rationale(R.string.get_storage_per) .onGranted { RealPathUtil.getPath(this, uri)?.let { path -> val file = File(path) val fileDoc = FileDoc.fromFile(file) FileInputStream(file).use { inputStream -> success.invoke(fileDoc, inputStream) } } } .request() } } catch (e: Exception) { e.printOnDebug() AppLog.put("读取Uri出错\n$uri\n$e", e, true) if (e is SecurityException) { throw e } } } /** * 读取URI */ fun Fragment.readUri(uri: Uri?, success: (fileDoc: FileDoc, inputStream: InputStream) -> Unit) { uri ?: return try { if (uri.isContentScheme()) { val doc = DocumentFile.fromSingleUri(requireContext(), uri) doc ?: throw NoStackTraceException("未获取到文件") val fileDoc = FileDoc.fromDocumentFile(doc) requireContext().contentResolver.openInputStream(uri)!!.use { inputStream -> success.invoke(fileDoc, inputStream) } } else { PermissionsCompat.Builder() .addPermissions(*Permissions.Group.STORAGE) .rationale(R.string.get_storage_per) .onGranted { RealPathUtil.getPath(requireContext(), uri)?.let { path -> val file = File(path) val fileDoc = FileDoc.fromFile(file) FileInputStream(file).use { inputStream -> success.invoke(fileDoc, inputStream) } } } .request() } } catch (e: Exception) { e.printOnDebug() AppLog.put("读取Uri出错\n$uri\n$e", e, true) } } @Throws(Exception::class) fun Uri.readBytes(context: Context): ByteArray { return if (this.isContentScheme()) { context.contentResolver.openInputStream(this)?.let { val len: Int = it.available() val buffer = ByteArray(len) it.read(buffer) it.close() return buffer } ?: throw NoStackTraceException("打开文件失败\n${this}") } else { val path = RealPathUtil.getPath(context, this) if (path?.isNotEmpty() == true) { File(path).readBytes() } else { throw NoStackTraceException("获取文件真实地址失败\n${this.path}") } } } @Throws(Exception::class) fun Uri.readText(context: Context): String { readBytes(context).let { return String(it) } } @Throws(Exception::class) fun Uri.writeBytes( context: Context, byteArray: ByteArray ): Boolean { if (this.isContentScheme()) { context.contentResolver.openOutputStream(this)?.let { it.write(byteArray) it.close() return true } return false } else { val path = RealPathUtil.getPath(context, this) if (path?.isNotEmpty() == true) { File(path).writeBytes(byteArray) return true } } return false } @Throws(Exception::class) fun Uri.writeText(context: Context, text: String, charset: Charset = Charsets.UTF_8): Boolean { return writeBytes(context, text.toByteArray(charset)) } fun Uri.writeBytes( context: Context, fileName: String, byteArray: ByteArray ): Boolean { if (this.isContentScheme()) { DocumentFile.fromTreeUri(context, this)?.let { pDoc -> DocumentUtils.createFileIfNotExist(pDoc, fileName)?.let { return it.uri.writeBytes(context, byteArray) } } } else { FileUtils.createFileWithReplace(path + File.separatorChar + fileName) .writeBytes(byteArray) return true } return false } fun Uri.inputStream(context: Context): Result { val uri = this return kotlin.runCatching { try { if (isContentScheme()) { DocumentFile.fromSingleUri(context, uri) ?: throw NoStackTraceException("未获取到文件") return@runCatching context.contentResolver.openInputStream(uri)!! } else { val path = RealPathUtil.getPath(context, uri) ?: throw NoStackTraceException("未获取到文件") val file = File(path) if (file.exists()) { return@runCatching FileInputStream(file) } else { throw NoStackTraceException("文件不存在") } } } catch (e: Exception) { e.printOnDebug() AppLog.put("读取inputStream失败:${e.localizedMessage}", e) throw e } } } fun Uri.outputStream(context: Context): Result { val uri = this return kotlin.runCatching { try { if (isContentScheme()) { DocumentFile.fromSingleUri(context, uri) ?: throw NoStackTraceException("未获取到文件") return@runCatching context.contentResolver.openOutputStream(uri)!! } else { val path = RealPathUtil.getPath(context, uri) ?: throw NoStackTraceException("未获取到文件") val file = File(path) if (file.exists()) { return@runCatching FileOutputStream(file) } else { throw NoStackTraceException("文件不存在") } } } catch (e: Exception) { e.printOnDebug() AppLog.put("读取inputStream失败:${e.localizedMessage}", e) throw e } } } fun Uri.toReadPfd(context: Context): Result { val uri = this return kotlin.runCatching { try { if (isContentScheme()) { DocumentFile.fromSingleUri(context, uri) ?: throw NoStackTraceException("未获取到文件") return@runCatching context.contentResolver.openFileDescriptor(uri, "r")!! } else { val path = RealPathUtil.getPath(context, uri) ?: throw NoStackTraceException("未获取到文件") val file = File(path) if (file.exists()) { return@runCatching ParcelFileDescriptor.open( file, ParcelFileDescriptor.MODE_READ_ONLY ) } else { throw NoStackTraceException("文件不存在") } } } catch (e: Exception) { e.printOnDebug() AppLog.put("读取inputStream失败:${e.localizedMessage}", e) throw e } } } fun Uri.toWritePfd(context: Context): Result { val uri = this return kotlin.runCatching { try { if (isContentScheme()) { DocumentFile.fromSingleUri(context, uri) ?: throw NoStackTraceException("未获取到文件") return@runCatching context.contentResolver.openFileDescriptor(uri, "w")!! } else { val path = RealPathUtil.getPath(context, uri) ?: throw NoStackTraceException("未获取到文件") val file = File(path) if (file.exists()) { return@runCatching ParcelFileDescriptor.open( file, ParcelFileDescriptor.MODE_WRITE_ONLY ) } else { throw NoStackTraceException("文件不存在") } } } catch (e: Exception) { e.printOnDebug() AppLog.put("读取inputStream失败:${e.localizedMessage}", e) throw e } } } fun Uri.toRequestBody(contentType: MediaType? = null): RequestBody { val uri = this return object : RequestBody() { override fun contentType() = contentType override fun contentLength(): Long { val length = uri.inputStream(appCtx).getOrThrow().available().toLong() return if (length > 0) length else -1 } override fun writeTo(sink: BufferedSink) { uri.inputStream(appCtx).getOrThrow().source().use { source -> sink.writeAll(source) } } } } fun Uri.canRead(): Boolean { return appCtx.checkSelfUriPermission( this, Intent.FLAG_GRANT_READ_URI_PERMISSION ) == PackageManager.PERMISSION_GRANTED } ================================================ FILE: app/src/main/java/io/legado/app/utils/UrlUtil.kt ================================================ package io.legado.app.utils import io.legado.app.BuildConfig import io.legado.app.constant.AppLog import io.legado.app.constant.AppPattern.semicolonRegex import io.legado.app.help.config.AppConfig import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.model.analyzeRule.CustomUrl import java.net.HttpURLConnection import java.net.URL import java.net.URLDecoder object UrlUtil { // 有时候文件名在query里,截取path会截到其他内容 // https://www.example.com/download.php?filename=文件.txt // https://www.example.com/txt/文件.txt?token=123456 private val unExpectFileSuffixs = arrayOf( "php", "html" ) fun replaceReservedChar(text: String): String { return text.replace("%", "%25") .replace(" ", "%20") .replace("\"", "%22") .replace("#", "%23") .replace("&", "%26") .replace("(", "%28") .replace(")", "%29") .replace("+", "%2B") .replace(",", "%2C") .replace("/", "%2F") .replace(":", "%3A") .replace(";", "%3B") .replace("<", "%3C") .replace("=", "%3D") .replace(">", "%3E") .replace("?", "%3F") .replace("@", "%40") .replace("\\", "%5C") .replace("|", "%7C") } /* 阅读定义的url,{urlOption} */ fun getFileName(analyzeUrl: AnalyzeUrl): String? { return getFileName(analyzeUrl.url, analyzeUrl.headerMap) } /** * 根据网络url获取文件信息 文件名 */ @Suppress("MemberVisibilityCanBePrivate") fun getFileName(fileUrl: String, headerMap: Map? = null): String? { return kotlin.runCatching { val url = URL(fileUrl) var fileName: String? = getFileNameFromPath(url) if (fileName == null) { fileName = getFileNameFromResponseHeader(url, headerMap) } fileName }.getOrNull() } @Suppress("MemberVisibilityCanBePrivate") private fun getFileNameFromResponseHeader( url: URL, headerMap: Map? = null ): String? { // HEAD方式获取链接响应头信息 val conn: HttpURLConnection = url.openConnection() as HttpURLConnection conn.requestMethod = "HEAD" // 下载链接可能还需要header才能成功访问 headerMap?.forEach { (key, value) -> conn.setRequestProperty(key, value) } // 禁止重定向 否则获取不到响应头返回的Location conn.instanceFollowRedirects = false conn.connect() if (AppConfig.recordLog || BuildConfig.DEBUG) { val headers = conn.headerFields val headersString = buildString { headers.forEach { (key, value) -> value.forEach { append(key) append(": ") append(it) append("\n") } } } AppLog.put("$url response header:\n$headersString") } // val fileSize = conn.getContentLengthLong() / 1024 /** Content-Disposition 存在三种情况 文件名应该用引号 有些用空格 * filename="filename" * filename*="charset''filename" */ val raw: String? = conn.getHeaderField("Content-Disposition") // Location跳转到实际链接 val redirectUrl: String? = conn.getHeaderField("Location") return if (raw != null) { val fileNames = raw.split(semicolonRegex).filter { it.contains("filename") } val names = hashSetOf() fileNames.forEach { val fileName = it.substringAfter("=") .trim() .replace("^\"".toRegex(), "") .replace("\"$".toRegex(), "") if (it.contains("filename*")) { val data = fileName.split("''") names.add(URLDecoder.decode(data[1], data[0])) } else { names.add(fileName) /* 好像不用这样 names.add( String( fileName.toByteArray(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8 ) ) */ } } names.firstOrNull() } else if (redirectUrl != null) { val newUrl= URL(URLDecoder.decode(redirectUrl, "UTF-8")) getFileNameFromPath(newUrl) } else { AppLog.put("Cannot obtain URL file name, enable recordLog for response header") null } } private fun getFileNameFromPath(fileUrl: URL): String? { val path = fileUrl.path ?: return null val suffix = getSuffix(path, "") return if ( suffix != "" && !unExpectFileSuffixs.contains(suffix) ) { path.substringAfterLast("/") } else { AppLog.put("getFileNameFromPath: Unexpected file suffix: $suffix") null } } private val fileSuffixRegex = Regex("^[a-z\\d]+$", RegexOption.IGNORE_CASE) /* 获取合法的文件后缀 */ fun getSuffix(str: String, default: String? = null): String { val suffix = CustomUrl(str).getUrl() .substringAfterLast("/") .substringBefore("?") .substringBefore("#") .substringAfterLast(".", "") //检查截取的后缀字符是否合法 [a-zA-Z0-9] return if (suffix.length > 5 || !suffix.matches(fileSuffixRegex)) { if (default == null) { AppLog.put("Cannot find legal suffix:\n target: $str\n suffix: $suffix") } default ?: "ext" } else { suffix } } } ================================================ FILE: app/src/main/java/io/legado/app/utils/Utf8BomUtils.kt ================================================ package io.legado.app.utils @Suppress("unused") object Utf8BomUtils { private val UTF8_BOM_BYTES = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte()) fun removeUTF8BOM(xmlText: String): String { val bytes = xmlText.toByteArray() val containsBOM = (bytes.size > 3 && bytes[0] == UTF8_BOM_BYTES[0] && bytes[1] == UTF8_BOM_BYTES[1] && bytes[2] == UTF8_BOM_BYTES[2]) if (containsBOM) { return String(bytes, 3, bytes.size - 3) } return xmlText } fun removeUTF8BOM(bytes: ByteArray): ByteArray { val containsBOM = (bytes.size > 3 && bytes[0] == UTF8_BOM_BYTES[0] && bytes[1] == UTF8_BOM_BYTES[1] && bytes[2] == UTF8_BOM_BYTES[2]) if (containsBOM) { val copy = ByteArray(bytes.size - 3) System.arraycopy(bytes, 3, copy, 0, bytes.size - 3) return copy } return bytes } fun hasBom(bytes: ByteArray): Boolean { return (bytes.size > 3 && bytes[0] == UTF8_BOM_BYTES[0] && bytes[1] == UTF8_BOM_BYTES[1] && bytes[2] == UTF8_BOM_BYTES[2]) } } ================================================ FILE: app/src/main/java/io/legado/app/utils/ViewExtensions.kt ================================================ @file:Suppress("unused") package io.legado.app.utils import android.annotation.SuppressLint import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color import android.graphics.Picture import android.os.Build import android.text.Html import android.view.MotionEvent import android.view.View import android.view.View.GONE import android.view.View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS import android.view.View.INVISIBLE import android.view.View.VISIBLE import android.view.ViewGroup import android.view.inputmethod.InputMethodManager import android.widget.EdgeEffect import android.widget.EditText import android.widget.RadioGroup import android.widget.SeekBar import android.widget.TextView import androidx.annotation.ColorInt import androidx.annotation.DrawableRes import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.menu.MenuPopupHelper import androidx.appcompat.widget.PopupMenu import androidx.core.graphics.record import androidx.core.graphics.withTranslation import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.get import androidx.core.view.marginBottom import androidx.core.view.updateLayoutParams import androidx.recyclerview.widget.RecyclerView import androidx.viewpager.widget.ViewPager import io.legado.app.help.config.AppConfig import io.legado.app.lib.theme.TintHelper import io.legado.app.utils.canvasrecorder.CanvasRecorder import io.legado.app.utils.canvasrecorder.record import splitties.systemservices.inputMethodManager import splitties.views.bottomPadding import splitties.views.topPadding import java.lang.reflect.Field private tailrec fun getCompatActivity(context: Context?): AppCompatActivity? { return when (context) { is AppCompatActivity -> context is androidx.appcompat.view.ContextThemeWrapper -> getCompatActivity(context.baseContext) is android.view.ContextThemeWrapper -> getCompatActivity(context.baseContext) else -> null } } val View.activity: AppCompatActivity? get() = getCompatActivity(context) fun View.hideSoftInput() = run { inputMethodManager.hideSoftInputFromWindow(this.windowToken, 0) } fun EditText.showSoftInput() = run { requestFocus() inputMethodManager.showSoftInput(this, InputMethodManager.RESULT_SHOWN) } fun View.disableAutoFill() = run { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { this.importantForAutofill = IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS } } fun View.applyTint( @ColorInt color: Int, isDark: Boolean = AppConfig.isNightTheme ) { TintHelper.setTintAuto(this, color, false, isDark) } fun View.applyBackgroundTint( @ColorInt color: Int, isDark: Boolean = AppConfig.isNightTheme ) { if (background == null) { setBackgroundColor(color) } else { TintHelper.setTintAuto(this, color, true, isDark) } } fun RecyclerView.setEdgeEffectColor(@ColorInt color: Int) { edgeEffectFactory = object : RecyclerView.EdgeEffectFactory() { override fun createEdgeEffect(view: RecyclerView, direction: Int): EdgeEffect { val edgeEffect = super.createEdgeEffect(view, direction) edgeEffect.color = color return edgeEffect } } } fun ViewPager.setEdgeEffectColor(@ColorInt color: Int) { try { val clazz = ViewPager::class.java for (name in arrayOf("mLeftEdge", "mRightEdge")) { val field = clazz.getDeclaredField(name) field.isAccessible = true val edge = field.get(this) (edge as EdgeEffect).color = color } } catch (ignored: Exception) { } } fun EditText.disableEdit() { keyListener = null } fun View.gone() { if (visibility != GONE) { visibility = GONE } } fun View.gone(gone: Boolean) { if (gone) { gone() } else { visibility = VISIBLE } } fun View.invisible() { if (visibility != INVISIBLE) { visibility = INVISIBLE } } fun View.visible() { if (visibility != VISIBLE) { visibility = VISIBLE } } fun View.visible(visible: Boolean) { if (visible && visibility != VISIBLE) { visibility = VISIBLE } else if (!visible && visibility == VISIBLE) { visibility = INVISIBLE } } fun View.screenshot(bitmap: Bitmap? = null, canvas: Canvas? = null): Bitmap? { return if (width > 0 && height > 0) { val screenshot = if (bitmap != null && bitmap.width == width && bitmap.height == height) { bitmap.eraseColor(Color.TRANSPARENT) bitmap } else { bitmap?.recycle() Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) } val c = canvas ?: Canvas() c.setBitmap(screenshot) c.save() c.translate(-scrollX.toFloat(), -scrollY.toFloat()) this.draw(c) c.restore() c.setBitmap(null) screenshot.prepareToDraw() screenshot } else { null } } fun View.screenshot(picture: Picture) { if (width > 0 && height > 0) { picture.record(width, height) { withTranslation(-scrollX.toFloat(), -scrollY.toFloat()) { draw(this) } } } } fun View.screenshot(canvasRecorder: CanvasRecorder) { if (width > 0 && height > 0) { canvasRecorder.record(width, height) { draw(this) } } } fun View.setPaddingBottom(bottom: Int) { setPadding(paddingLeft, paddingTop, paddingRight, bottom) } fun SeekBar.progressAdd(int: Int) { progress += int } fun RadioGroup.getIndexById(id: Int): Int { for (i in 0 until this.childCount) { if (id == get(i).id) { return i } } return 0 } fun RadioGroup.getCheckedIndex(): Int { for (i in 0 until this.childCount) { if (checkedRadioButtonId == get(i).id) { return i } } return 0 } fun RadioGroup.checkByIndex(index: Int) { check(get(index).id) } @SuppressLint("ObsoleteSdkInt") fun TextView.setHtml(html: String) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { text = Html.fromHtml(html, Html.FROM_HTML_MODE_COMPACT) } else { @Suppress("DEPRECATION") text = Html.fromHtml(html) } } fun TextView.setTextIfNotEqual(charSequence: CharSequence?) { if (text != charSequence) { text = charSequence } } @SuppressLint("RestrictedApi") fun PopupMenu.show(x: Int, y: Int) { kotlin.runCatching { val field: Field = this.javaClass.getDeclaredField("mPopup") field.isAccessible = true (field.get(this) as MenuPopupHelper).show(x, y) }.onFailure { it.printOnDebug() } } fun View.shouldHideSoftInput(event: MotionEvent): Boolean { if (this is EditText) { val l = intArrayOf(0, 0) getLocationInWindow(l) val left = l[0] val top = l[1] val bottom = top + getHeight() val right = left + getWidth() return !(event.x > left && event.x < right && event.y > top && event.y < bottom) } return false } fun View.applyStatusBarPadding(withInitialPadding: Boolean = false) { val initialPadding = if (withInitialPadding) topPadding else 0 setOnApplyWindowInsetsListenerCompat { _, windowInsets -> val insets = windowInsets.getInsets(WindowInsetsCompat.Type.statusBars()) topPadding = initialPadding + insets.top windowInsets } } fun View.applyNavigationBarPadding(withInitialPadding: Boolean = false) { val initialPadding = if (withInitialPadding) bottomPadding else 0 setOnApplyWindowInsetsListenerCompat { _, windowInsets -> bottomPadding = initialPadding + windowInsets.navigationBarHeight windowInsets } } fun View.applyNavigationBarMargin(withInitialMargin: Boolean = false) { val initialMargin = if (withInitialMargin) marginBottom else 0 setOnApplyWindowInsetsListenerCompat { _, windowInsets -> updateLayoutParams { bottomMargin = initialMargin + windowInsets.navigationBarHeight } windowInsets } } fun View.setBackgroundKeepPadding(@DrawableRes backgroundResId: Int) { val paddingLeft = paddingLeft val paddingTop = paddingTop val paddingRight = paddingRight val paddingBottom = paddingBottom setBackgroundResource(backgroundResId) setPadding(paddingLeft, paddingTop, paddingRight, paddingBottom) } fun View.canScroll(direction: Int): Boolean { return canScrollVertically(direction) || canScrollHorizontally(direction) } private val requestLayoutBroken = Build.VERSION.SDK_INT <= Build.VERSION_CODES.M || Build.VERSION.SDK_INT in Build.VERSION_CODES.O..Build.VERSION_CODES.Q fun View.setOnApplyWindowInsetsListenerCompat(listener: (View, WindowInsetsCompat) -> WindowInsetsCompat) { ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets -> val windowInsets = listener(view, insets) if (requestLayoutBroken && isLayoutRequested) { post { requestLayout() } } windowInsets } } ================================================ FILE: app/src/main/java/io/legado/app/utils/WebSettingsExtensions.kt ================================================ package io.legado.app.utils import android.annotation.SuppressLint import android.os.Build import android.webkit.WebSettings import androidx.webkit.WebSettingsCompat import androidx.webkit.WebViewFeature import io.legado.app.help.config.AppConfig /** * 设置是否夜间模式 */ @SuppressLint("RequiresFeature") fun WebSettings.setDarkeningAllowed(allow: Boolean) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { kotlin.runCatching { WebSettingsCompat.setAlgorithmicDarkeningAllowed(this, allow) return }.onFailure { it.printOnDebug() } } if (AppConfig.isNightTheme) { if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK_STRATEGY)) { @Suppress("DEPRECATION") WebSettingsCompat.setForceDarkStrategy( this, WebSettingsCompat.DARK_STRATEGY_PREFER_WEB_THEME_OVER_USER_AGENT_DARKENING ) } if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) { @Suppress("DEPRECATION") WebSettingsCompat.setForceDark( this, WebSettingsCompat.FORCE_DARK_ON ) } } } ================================================ FILE: app/src/main/java/io/legado/app/utils/WindowInsetsExtensions.kt ================================================ package io.legado.app.utils import androidx.core.view.WindowInsetsCompat val WindowInsetsCompat.navigationBarHeight get() = (getInsets(WindowInsetsCompat.Type.systemBars()).bottom - imeHeight).coerceAtLeast(0) val WindowInsetsCompat.imeHeight get() = getInsets(WindowInsetsCompat.Type.ime()).bottom ================================================ FILE: app/src/main/java/io/legado/app/utils/canvasrecorder/BaseCanvasRecorder.kt ================================================ package io.legado.app.utils.canvasrecorder import androidx.annotation.CallSuper abstract class BaseCanvasRecorder : CanvasRecorder { @JvmField protected var isDirty = true override fun invalidate() { isDirty = true } @CallSuper override fun recycle() { isDirty = true } @CallSuper override fun endRecording() { isDirty = false } override fun isDirty(): Boolean { return isDirty } override fun isLocked(): Boolean { return false } override fun needRecord(): Boolean { return isDirty() && !isLocked() } } ================================================ FILE: app/src/main/java/io/legado/app/utils/canvasrecorder/CanvasRecorder.kt ================================================ package io.legado.app.utils.canvasrecorder import android.graphics.Canvas interface CanvasRecorder { val width: Int val height: Int fun beginRecording(width: Int, height: Int): Canvas fun endRecording() fun draw(canvas: Canvas) fun invalidate() fun recycle() fun isDirty(): Boolean fun isLocked(): Boolean fun needRecord(): Boolean } ================================================ FILE: app/src/main/java/io/legado/app/utils/canvasrecorder/CanvasRecorderApi23Impl.kt ================================================ package io.legado.app.utils.canvasrecorder import android.graphics.Canvas import android.graphics.Picture import io.legado.app.utils.canvasrecorder.pools.PicturePool import io.legado.app.utils.objectpool.synchronized class CanvasRecorderApi23Impl : BaseCanvasRecorder() { private var picture: Picture? = null override val width get() = picture?.width ?: -1 override val height get() = picture?.height ?: -1 private fun initPicture() { if (picture == null) { picture = picturePool.obtain() } } override fun beginRecording(width: Int, height: Int): Canvas { initPicture() return picture!!.beginRecording(width, height) } override fun endRecording() { picture!!.endRecording() super.endRecording() } override fun draw(canvas: Canvas) { if (picture == null) return canvas.drawPicture(picture!!) } override fun recycle() { super.recycle() if (picture == null) return picturePool.recycle(picture!!) picture = null } companion object { private val picturePool = PicturePool().synchronized() } } ================================================ FILE: app/src/main/java/io/legado/app/utils/canvasrecorder/CanvasRecorderApi29Impl.kt ================================================ package io.legado.app.utils.canvasrecorder import android.graphics.Canvas import android.graphics.Picture import android.graphics.RenderNode import android.os.Build import androidx.annotation.RequiresApi import io.legado.app.utils.objectpool.synchronized import io.legado.app.utils.canvasrecorder.pools.PicturePool import io.legado.app.utils.canvasrecorder.pools.RenderNodePool @RequiresApi(Build.VERSION_CODES.Q) class CanvasRecorderApi29Impl : BaseCanvasRecorder() { private var renderNode: RenderNode? = null private var picture: Picture? = null override val width get() = renderNode?.width ?: -1 override val height get() = renderNode?.height ?: -1 private fun init() { if (renderNode == null) { renderNode = renderNodePool.obtain() } if (picture == null) { picture = picturePool.obtain() } } private fun flushRenderNode() { val rc = renderNode!!.beginRecording() rc.drawPicture(picture!!) renderNode!!.endRecording() } override fun beginRecording(width: Int, height: Int): Canvas { init() renderNode!!.setPosition(0, 0, width, height) return picture!!.beginRecording(width, height) } override fun endRecording() { picture!!.endRecording() flushRenderNode() super.endRecording() } override fun draw(canvas: Canvas) { if (renderNode == null || picture == null) { return } if (canvas.isHardwareAccelerated) { if (!renderNode!!.hasDisplayList()) { flushRenderNode() } canvas.drawRenderNode(renderNode!!) } else { canvas.drawPicture(picture!!) } } override fun recycle() { super.recycle() if (renderNode == null || picture == null) return renderNodePool.recycle(renderNode!!) renderNode = null picturePool.recycle(picture!!) picture = null } companion object { private val picturePool = PicturePool().synchronized() private val renderNodePool = RenderNodePool().synchronized() } } ================================================ FILE: app/src/main/java/io/legado/app/utils/canvasrecorder/CanvasRecorderExtensions.kt ================================================ package io.legado.app.utils.canvasrecorder import android.graphics.Canvas import android.view.View import androidx.core.graphics.withSave inline fun CanvasRecorder.recordIfNeeded( width: Int, height: Int, block: Canvas.() -> Unit ): Boolean { if (!needRecord()) return false record(width, height, block) return true } fun CanvasRecorder.recordIfNeeded(view: View): Boolean { if (!needRecord()) return false record(view.width, view.height) { view.draw(this) } return true } inline fun CanvasRecorder.record(width: Int, height: Int, block: Canvas.() -> Unit) { val canvas = beginRecording(width, height) try { canvas.withSave { block() } } finally { endRecording() } } inline fun CanvasRecorder.recordIfNeededThenDraw( canvas: Canvas, width: Int, height: Int, block: Canvas.() -> Unit ) { recordIfNeeded(width, height, block) draw(canvas) } ================================================ FILE: app/src/main/java/io/legado/app/utils/canvasrecorder/CanvasRecorderFactory.kt ================================================ package io.legado.app.utils.canvasrecorder import android.os.Build import io.legado.app.help.config.AppConfig object CanvasRecorderFactory { private val atLeastApi24 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N private val atLeastApi29 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q val isSupport = atLeastApi24 // issue 3868 fun create(locked: Boolean = false): CanvasRecorder { val impl = when { !AppConfig.optimizeRender -> CanvasRecorderImpl() atLeastApi29 -> CanvasRecorderApi29Impl() atLeastApi24 -> CanvasRecorderApi23Impl() else -> CanvasRecorderImpl() } return if (locked) { CanvasRecorderLocked(impl) } else { impl } } } ================================================ FILE: app/src/main/java/io/legado/app/utils/canvasrecorder/CanvasRecorderImpl.kt ================================================ package io.legado.app.utils.canvasrecorder import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color import com.bumptech.glide.Glide import io.legado.app.utils.canvasrecorder.pools.CanvasPool import splitties.init.appCtx class CanvasRecorderImpl : BaseCanvasRecorder() { var bitmap: Bitmap? = null var canvas: Canvas? = null override val width get() = bitmap?.width ?: -1 override val height get() = bitmap?.height ?: -1 private fun init(width: Int, height: Int) { if (width <= 0 || height <= 0) { return } if (bitmap == null) { bitmap = bitmapPool.get(width, height, Bitmap.Config.ARGB_8888) } if (bitmap!!.width != width || bitmap!!.height != height) { if (bitmap!!.isMutable && canReconfigure(width, height)) { bitmap!!.reconfigure(width, height, Bitmap.Config.ARGB_8888) } else { bitmapPool.put(bitmap!!) bitmap = bitmapPool.get(width, height, Bitmap.Config.ARGB_8888) } } } private fun canReconfigure(width: Int, height: Int): Boolean { return bitmap!!.allocationByteCount >= width * height * 4 } override fun beginRecording(width: Int, height: Int): Canvas { init(width, height) bitmap?.eraseColor(Color.TRANSPARENT) canvas = canvasPool.obtain().apply { setBitmap(bitmap) } return canvas!! } override fun endRecording() { bitmap?.prepareToDraw() super.endRecording() canvasPool.recycle(canvas!!) canvas = null } override fun draw(canvas: Canvas) { if (bitmap == null) return canvas.drawBitmap(bitmap!!, 0f, 0f, null) } override fun recycle() { super.recycle() val bitmap = bitmap ?: return bitmapPool.put(bitmap) this.bitmap = null } companion object { private val canvasPool = CanvasPool(2) private val bitmapPool = Glide.get(appCtx).bitmapPool } } ================================================ FILE: app/src/main/java/io/legado/app/utils/canvasrecorder/CanvasRecorderLocked.kt ================================================ package io.legado.app.utils.canvasrecorder import android.graphics.Canvas import java.util.concurrent.locks.ReentrantLock class CanvasRecorderLocked(private val delegate: CanvasRecorder) : CanvasRecorder by delegate { var lock: ReentrantLock? = ReentrantLock() private fun initLock() { if (lock == null) { synchronized(this) { if (lock == null) { lock = ReentrantLock() } } } } override fun beginRecording(width: Int, height: Int): Canvas { initLock() lock!!.lock() return delegate.beginRecording(width, height) } override fun endRecording() { delegate.endRecording() lock!!.unlock() } override fun draw(canvas: Canvas) { if (lock == null) { return } lock!!.lock() try { delegate.draw(canvas) } finally { lock!!.unlock() } } override fun isLocked(): Boolean { if (lock == null) { return false } return lock!!.isLocked } override fun recycle() { if (lock == null) { return } lock!!.lock() try { delegate.recycle() } finally { lock!!.unlock() } lock = null } } ================================================ FILE: app/src/main/java/io/legado/app/utils/canvasrecorder/pools/CanvasPool.kt ================================================ package io.legado.app.utils.canvasrecorder.pools import android.graphics.Canvas import androidx.core.util.Pools class CanvasPool(size: Int) { private val pool = Pools.SynchronizedPool(size) fun obtain(): Canvas { val canvas = pool.acquire() ?: Canvas() return canvas } fun recycle(canvas: Canvas) { canvas.setBitmap(null) canvas.restoreToCount(1) pool.release(canvas) } } ================================================ FILE: app/src/main/java/io/legado/app/utils/canvasrecorder/pools/PicturePool.kt ================================================ package io.legado.app.utils.canvasrecorder.pools import android.graphics.Picture import io.legado.app.utils.objectpool.BaseObjectPool class PicturePool : BaseObjectPool(64) { override fun create(): Picture = Picture() } ================================================ FILE: app/src/main/java/io/legado/app/utils/canvasrecorder/pools/RenderNodePool.kt ================================================ package io.legado.app.utils.canvasrecorder.pools import android.graphics.RenderNode import android.os.Build import androidx.annotation.RequiresApi import io.legado.app.utils.objectpool.BaseObjectPool @RequiresApi(Build.VERSION_CODES.Q) class RenderNodePool : BaseObjectPool(64) { override fun recycle(target: RenderNode) { target.discardDisplayList() super.recycle(target) } override fun create(): RenderNode = RenderNode("CanvasRecorder") } ================================================ FILE: app/src/main/java/io/legado/app/utils/compress/LibArchiveUtils.kt ================================================ package io.legado.app.utils.compress import android.os.ParcelFileDescriptor import android.system.ErrnoException import android.system.Os import android.system.OsConstants import android.system.OsConstants.S_ISDIR import io.legado.app.lib.icu4j.CharsetDetector import me.zhanghai.android.libarchive.Archive import me.zhanghai.android.libarchive.ArchiveEntry import me.zhanghai.android.libarchive.ArchiveException import okio.Buffer import java.io.File import java.io.FileDescriptor import java.io.IOException import java.io.InputStream import java.io.InterruptedIOException import java.nio.ByteBuffer import java.nio.channels.SeekableByteChannel import java.nio.charset.Charset import java.nio.charset.StandardCharsets object LibArchiveUtils { @Throws(ArchiveException::class) fun openArchive( inputStream: InputStream, ): Long { val archive: Long = Archive.readNew() var successful = false try { Archive.setCharset(archive, StandardCharsets.UTF_8.name().toByteArray()) Archive.readSupportFilterAll(archive) Archive.readSupportFormatAll(archive) Archive.readSetCallbackData(archive, null) val buffer = ByteBuffer.allocate(DEFAULT_BUFFER_SIZE) Archive.readSetReadCallback(archive) { _, _ -> buffer.clear() val bytesRead = try { inputStream.read(buffer.array()) } catch (e: IOException) { throw ArchiveException(Archive.ERRNO_FATAL, "InputStream.read", e) } if (bytesRead != -1) { buffer.limit(bytesRead) buffer } else { null } } Archive.readSetSkipCallback(archive) { _, _, request -> try { inputStream.skip(request) } catch (e: IOException) { throw ArchiveException(Archive.ERRNO_FATAL, "InputStream.skip", e) } } Archive.readOpen1(archive) successful = true return archive } finally { if (!successful) { Archive.free(archive) } } } @Throws(ArchiveException::class) private fun openArchive( channel: SeekableByteChannel, ): Long { val archive: Long = Archive.readNew() var successful = false try { Archive.setCharset(archive, StandardCharsets.UTF_8.name().toByteArray()) Archive.readSupportFilterAll(archive) Archive.readSupportFormatAll(archive) Archive.readSetCallbackData(archive, null) val buffer = ByteBuffer.allocateDirect(DEFAULT_BUFFER_SIZE) Archive.readSetReadCallback(archive) { _, _ -> buffer.clear() val bytesRead = try { channel.read(buffer) } catch (e: IOException) { throw ArchiveException(Archive.ERRNO_FATAL, "SeekableByteChannel.read", e) } if (bytesRead != -1) { buffer.flip() buffer } else { null } } Archive.readSetSkipCallback(archive) { _, _, request -> try { channel.position(channel.position() + request) } catch (e: IOException) { throw ArchiveException(Archive.ERRNO_FATAL, "SeekableByteChannel.position", e) } request } Archive.readSetSeekCallback(archive) { _, _, offset, whence -> val newPosition: Long try { newPosition = when (whence) { OsConstants.SEEK_SET -> offset OsConstants.SEEK_CUR -> channel.position() + offset OsConstants.SEEK_END -> channel.size() + offset else -> throw ArchiveException( Archive.ERRNO_FATAL, "Unknown whence $whence" ) } channel.position(newPosition) } catch (e: IOException) { throw ArchiveException(Archive.ERRNO_FATAL, "SeekableByteChannel.position", e) } newPosition } Archive.readOpen1(archive) successful = true return archive } finally { if (!successful) { Archive.free(archive) } } } @Throws(ArchiveException::class) private fun openArchive( pfd: ParcelFileDescriptor, useCb: Boolean = true ): Long { val archive: Long = Archive.readNew() var successful = false try { Archive.setCharset(archive, StandardCharsets.UTF_8.name().toByteArray()) Archive.readSupportFilterAll(archive) Archive.readSupportFormatAll(archive) if (useCb) { Archive.readSetCallbackData(archive, pfd.fileDescriptor) val buffer = ByteBuffer.allocateDirect(DEFAULT_BUFFER_SIZE) Archive.readSetReadCallback( archive ) { _: Long, fd: Any? -> buffer.clear() try { Os.read(fd as FileDescriptor?, buffer) } catch (e: ErrnoException) { throw ArchiveException(Archive.ERRNO_FATAL, "Os.read", e) } catch (e: InterruptedIOException) { throw ArchiveException(Archive.ERRNO_FATAL, "Os.read", e) } buffer.flip() buffer } Archive.readSetSkipCallback( archive ) { _: Long, fd: Any?, request: Long -> try { Os.lseek( fd as FileDescriptor?, request, OsConstants.SEEK_CUR ) } catch (e: ErrnoException) { throw ArchiveException(Archive.ERRNO_FATAL, "Os.lseek", e) } request } Archive.readSetSeekCallback( archive ) { _: Long, fd: Any?, offset: Long, whence: Int -> try { return@readSetSeekCallback Os.lseek( fd as FileDescriptor?, offset, whence ) } catch (e: ErrnoException) { throw ArchiveException(Archive.ERRNO_FATAL, "Os.lseek", e) } } Archive.readOpen1(archive) } else { Archive.readOpenFd(archive, pfd.fd, DEFAULT_BUFFER_SIZE.toLong()) } successful = true return archive } finally { if (!successful) { Archive.free(archive) } } } /** * 解压文件 */ @Throws(NullPointerException::class, SecurityException::class) fun unArchive( pfd: ParcelFileDescriptor, destDir: File, filter: ((String) -> Boolean)? = null ): List { return unArchive(openArchive(pfd), destDir, filter) } /** * 解压 */ @Throws(NullPointerException::class, SecurityException::class) private fun unArchive( archive: Long, destDir: File?, filter: ((String) -> Boolean)? = null ): List { destDir ?: throw NullPointerException("解压路径不能为空") val files = arrayListOf() try { var entry: Long while (Archive.readNextHeader(archive).also { entry = it } != 0L) { val entryName = getEntryString(ArchiveEntry.pathnameUtf8(entry), ArchiveEntry.pathname(entry)) ?: continue val entryFile = File(destDir, entryName) if (!entryFile.canonicalPath.startsWith(destDir.canonicalPath)) { throw SecurityException("压缩文件只能解压到指定路径") } val entryStat = ArchiveEntry.stat(entry) //判断是否是文件夹 if (entryStat.isDir()) { if (!entryFile.exists()) { entryFile.mkdirs() } continue } if (entryFile.parentFile?.exists() != true) { entryFile.parentFile?.mkdirs() } if (filter != null && !filter.invoke(entryName)) continue if (!entryFile.exists()) { entryFile.createNewFile() entryFile.setReadable(true) entryFile.setExecutable(true) } ParcelFileDescriptor.open(entryFile, ParcelFileDescriptor.MODE_WRITE_ONLY).use { Archive.readDataIntoFd(archive, it.fd) files.add(entryFile) } } } finally { Archive.free(archive) } return files } fun getFilesName(pfd: ParcelFileDescriptor, filter: ((String) -> Boolean)?): List { return getFilesName(openArchive(pfd), filter) } fun getByteArrayContent(inputStream: InputStream, path: String): ByteArray? { val archive = openArchive(inputStream) try { var entry: Long while (Archive.readNextHeader(archive).also { entry = it } != 0L) { val entryName = getEntryString(ArchiveEntry.pathnameUtf8(entry), ArchiveEntry.pathname(entry)) ?: continue val entryStat = ArchiveEntry.stat(entry) //判断是否是文件夹 if (entryStat.isDir()) { continue } if (entryName == path) { val byteBuffer = ByteBuffer.allocateDirect(DEFAULT_BUFFER_SIZE) val buffer = Buffer() while (true) { Archive.readData(archive, byteBuffer) byteBuffer.flip() if (!byteBuffer.hasRemaining()) { return buffer.readByteArray() } buffer.write(byteBuffer) byteBuffer.clear() } } } } finally { Archive.free(archive) } return null } @Throws(SecurityException::class) private fun getFilesName( archive: Long, filter: ((String) -> Boolean)? = null ): List { val fileNames = mutableListOf() try { var entry: Long while (Archive.readNextHeader(archive).also { entry = it } != 0L) { val fileName = getEntryString(ArchiveEntry.pathnameUtf8(entry), ArchiveEntry.pathname(entry)) ?: continue val entryStat = ArchiveEntry.stat(entry) if (entryStat.isDir()) { continue } if (filter != null && filter.invoke(fileName)) fileNames.add(fileName) } } finally { Archive.free(archive) } return fileNames } private fun ArchiveEntry.StructStat.isDir() = S_ISDIR(this.stMode) private fun getEntryString(utf8: String?, bytes: ByteArray?): String? { return utf8 ?: newStringFromBytes(bytes) } private fun newStringFromBytes(bytes: ByteArray?): String? { bytes ?: return null val cd = CharsetDetector() cd.setText(bytes) val c = cd.detectAll().first().name return String(bytes, Charset.forName(c)) } } ================================================ FILE: app/src/main/java/io/legado/app/utils/compress/ZipUtils.kt ================================================ package io.legado.app.utils.compress import android.annotation.SuppressLint import io.legado.app.utils.DebugLog import io.legado.app.utils.compress.ZipUtils.zipFile import io.legado.app.utils.printOnDebug import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.withContext import java.io.BufferedInputStream import java.io.ByteArrayOutputStream import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException import java.io.InputStream import java.util.zip.GZIPOutputStream import java.util.zip.ZipEntry import java.util.zip.ZipFile import java.util.zip.ZipInputStream import java.util.zip.ZipOutputStream @SuppressLint("ObsoleteSdkInt") @Suppress("unused", "MemberVisibilityCanBePrivate") object ZipUtils { fun gzipByteArray(byteArray: ByteArray): ByteArray { val byteOut = ByteArrayOutputStream() val zip = GZIPOutputStream(byteOut) return zip.use { it.write(byteArray) byteOut.use { byteOut.toByteArray() } } } fun zipByteArray(byteArray: ByteArray, fileName: String): ByteArray { val byteOut = ByteArrayOutputStream() val zipOutputStream = ZipOutputStream(byteOut) zipOutputStream.putNextEntry(ZipEntry(fileName)) zipOutputStream.write(byteArray) zipOutputStream.closeEntry() zipOutputStream.finish() return zipOutputStream.use { byteOut.use { byteOut.toByteArray() } } } /** * Zip the files. * * @param srcFiles The source of files. * @param zipFilePath The path of ZIP file. * @return `true`: success

`false`: fail * @throws IOException if an I/O error has occurred */ suspend fun zipFiles( srcFiles: Collection, zipFilePath: String ): Boolean { return zipFiles(srcFiles, zipFilePath, null) } /** * Zip the files. * * @param srcFilePaths The paths of source files. * @param zipFilePath The path of ZIP file. * @param comment The comment. * @return `true`: success

`false`: fail * @throws IOException if an I/O error has occurred */ suspend fun zipFiles( srcFilePaths: Collection?, zipFilePath: String?, comment: String? ): Boolean = withContext(IO) { if (srcFilePaths == null || zipFilePath == null) return@withContext false ZipOutputStream(FileOutputStream(zipFilePath)).use { for (srcFile in srcFilePaths) { if (!zipFile(getFileByPath(srcFile)!!, "", it, comment)) return@withContext false } return@withContext true } } /** * Zip the files. * * @param srcFiles The source of files. * @param zipFile The ZIP file. * @param comment The comment. * @return `true`: success

`false`: fail * @throws IOException if an I/O error has occurred */ @Throws(IOException::class) @JvmOverloads fun zipFiles( srcFiles: Collection?, zipFile: File?, comment: String? = null ): Boolean { if (srcFiles == null || zipFile == null) return false ZipOutputStream(FileOutputStream(zipFile)).use { for (srcFile in srcFiles) { if (!zipFile(srcFile, "", it, comment)) return false } return true } } /** * Zip the file. * * @param srcFilePath The path of source file. * @param zipFilePath The path of ZIP file. * @return `true`: success

`false`: fail * @throws IOException if an I/O error has occurred */ @Throws(IOException::class) fun zipFile( srcFilePath: String, zipFilePath: String ): Boolean { return zipFile(getFileByPath(srcFilePath), getFileByPath(zipFilePath), null) } /** * Zip the file. * * @param srcFilePath The path of source file. * @param zipFilePath The path of ZIP file. * @param comment The comment. * @return `true`: success

`false`: fail * @throws IOException if an I/O error has occurred */ @Throws(IOException::class) fun zipFile( srcFilePath: String, zipFilePath: String, comment: String ): Boolean { return zipFile(getFileByPath(srcFilePath), getFileByPath(zipFilePath), comment) } /** * Zip the file. * * @param srcFile The source of file. * @param zipFile The ZIP file. * @param comment The comment. * @return `true`: success

`false`: fail * @throws IOException if an I/O error has occurred */ @Throws(IOException::class) @JvmOverloads fun zipFile( srcFile: File?, zipFile: File?, comment: String? = null ): Boolean { if (srcFile == null || zipFile == null) return false ZipOutputStream(FileOutputStream(zipFile)).use { zos -> return zipFile(srcFile, "", zos, comment) } } @Throws(IOException::class) private fun zipFile( srcFile: File, rootPath: String, zos: ZipOutputStream, comment: String? ): Boolean { var rootPath1 = rootPath if (!srcFile.exists()) return true rootPath1 = rootPath1 + (if (isSpace(rootPath1)) "" else File.separator) + srcFile.name if (srcFile.isDirectory) { val fileList = srcFile.listFiles() if (fileList == null || fileList.isEmpty()) { val entry = ZipEntry("$rootPath1/") entry.comment = comment zos.putNextEntry(entry) zos.closeEntry() } else { for (file in fileList) { if (!zipFile(file, rootPath1, zos, comment)) return false } } } else { BufferedInputStream(FileInputStream(srcFile)).use { val entry = ZipEntry(rootPath1) entry.comment = comment zos.putNextEntry(entry) it.copyTo(zos) zos.closeEntry() } } return true } @Throws(SecurityException::class) fun unZipToPath(file: File, path: String, filter: ((String) -> Boolean)? = null): List { return FileInputStream(file).use { unZipToPath(it, path, filter) } } @Throws(SecurityException::class) fun unZipToPath(file: File, dir: File, filter: ((String) -> Boolean)? = null): List { return FileInputStream(file).use { unZipToPath(it, dir, filter) } } @Throws(SecurityException::class) fun unZipToPath( inputStream: InputStream, path: String, filter: ((String) -> Boolean)? = null ): List { return ZipInputStream(inputStream).use { unZipToPath(it, File(path), filter) } } @Throws(SecurityException::class) fun unZipToPath( inputStream: InputStream, dir: File, filter: ((String) -> Boolean)? = null ): List { return ZipInputStream(inputStream).use { unZipToPath(it, dir, filter) } } @Throws(SecurityException::class) private fun unZipToPath( zipInputStream: ZipInputStream, dir: File, filter: ((String) -> Boolean)? = null ): List { val files = arrayListOf() var entry: ZipEntry? while (zipInputStream.nextEntry.also { entry = it } != null) { val entryName = entry!!.name val entryFile = File(dir, entryName) if (!entryFile.canonicalPath.startsWith(dir.canonicalPath)) { throw SecurityException("压缩文件只能解压到指定路径") } if (entry.isDirectory) { if (!entryFile.exists()) { entryFile.mkdirs() } continue } if (entryFile.parentFile?.exists() != true) { entryFile.parentFile?.mkdirs() } if (filter != null && !filter.invoke(entryName)) continue if (!entryFile.exists()) { entryFile.createNewFile() entryFile.setReadable(true) entryFile.setExecutable(true) } FileOutputStream(entryFile).use { zipInputStream.copyTo(it) files.add(entryFile) } } return files } /* 遍历目录获取所有文件名 */ @Throws(SecurityException::class) fun getFilesName( inputStream: InputStream, filter: ((String) -> Boolean)? = null ): List { return ZipInputStream(inputStream).use { getFilesName(it, filter) } } @Throws(SecurityException::class) private fun getFilesName( zipInputStream: ZipInputStream, filter: ((String) -> Boolean)? = null ): List { val fileNames = mutableListOf() var entry: ZipEntry? while (zipInputStream.nextEntry.also { entry = it } != null) { if (entry!!.isDirectory) { continue } val fileName = entry.name if (filter != null && filter.invoke(fileName)) fileNames.add(fileName) } return fileNames } /** * Return the files' path in ZIP file. * * @param zipFilePath The path of ZIP file. * @return the files' path in ZIP file * @throws IOException if an I/O error has occurred */ @Throws(IOException::class) fun getFilesPath(zipFilePath: String): List? { return getFilesPath(getFileByPath(zipFilePath)) } /** * Return the files' path in ZIP file. * * @param zipFile The ZIP file. * @return the files' path in ZIP file * @throws IOException if an I/O error has occurred */ @Throws(IOException::class) fun getFilesPath(zipFile: File?): List? { if (zipFile == null) return null val paths = ArrayList() val zip = ZipFile(zipFile) val entries = zip.entries() while (entries.hasMoreElements()) { val entryName = (entries.nextElement() as ZipEntry).name if (entryName.contains("../")) { DebugLog.e(javaClass.name, "entryName: $entryName is dangerous!") paths.add(entryName) } else { paths.add(entryName) } } zip.close() return paths } /** * Return the files' comment in ZIP file. * * @param zipFilePath The path of ZIP file. * @return the files' comment in ZIP file * @throws IOException if an I/O error has occurred */ @Throws(IOException::class) fun getComments(zipFilePath: String): List? { return getComments(getFileByPath(zipFilePath)) } /** * Return the files' comment in ZIP file. * * @param zipFile The ZIP file. * @return the files' comment in ZIP file * @throws IOException if an I/O error has occurred */ @Throws(IOException::class) fun getComments(zipFile: File?): List? { if (zipFile == null) return null val comments = ArrayList() val zip = ZipFile(zipFile) val entries = zip.entries() while (entries.hasMoreElements()) { val entry = entries.nextElement() as ZipEntry comments.add(entry.comment) } zip.close() return comments } private fun createOrExistsDir(file: File?): Boolean { return file != null && if (file.exists()) file.isDirectory else file.mkdirs() } private fun createOrExistsFile(file: File?): Boolean { if (file == null) return false if (file.exists()) return file.isFile if (!createOrExistsDir(file.parentFile)) return false return try { file.createNewFile() } catch (e: IOException) { e.printOnDebug() false } } private fun getFileByPath(filePath: String): File? { return if (isSpace(filePath)) null else File(filePath) } private fun isSpace(s: String?): Boolean { if (s == null) return true var i = 0 val len = s.length while (i < len) { if (!Character.isWhitespace(s[i])) { return false } ++i } return true } } ================================================ FILE: app/src/main/java/io/legado/app/utils/objectpool/BaseObjectPool.kt ================================================ package io.legado.app.utils.objectpool import androidx.annotation.CallSuper import androidx.core.util.Pools abstract class BaseObjectPool(size: Int) : ObjectPool { open val pool = Pools.SimplePool(size) override fun obtain(): T { return pool.acquire() ?: create() } @CallSuper override fun recycle(target: T) { pool.release(target) } } ================================================ FILE: app/src/main/java/io/legado/app/utils/objectpool/BaseSafeObjectPool.kt ================================================ package io.legado.app.utils.objectpool import androidx.core.util.Pools abstract class BaseSafeObjectPool(size: Int): BaseObjectPool(size) { override val pool = Pools.SynchronizedPool(size) } ================================================ FILE: app/src/main/java/io/legado/app/utils/objectpool/ObjectPool.kt ================================================ package io.legado.app.utils.objectpool interface ObjectPool { fun obtain(): T fun recycle(target: T) fun create(): T } ================================================ FILE: app/src/main/java/io/legado/app/utils/objectpool/ObjectPoolExtensions.kt ================================================ package io.legado.app.utils.objectpool fun ObjectPool.synchronized(): ObjectPool = ObjectPoolLocked(this) ================================================ FILE: app/src/main/java/io/legado/app/utils/objectpool/ObjectPoolLocked.kt ================================================ package io.legado.app.utils.objectpool class ObjectPoolLocked(private val delegate: ObjectPool) : ObjectPool by delegate { @Synchronized override fun obtain(): T { return delegate.obtain() } @Synchronized override fun recycle(target: T) { return delegate.recycle(target) } } ================================================ FILE: app/src/main/java/io/legado/app/utils/viewbindingdelegate/ActivityViewBindings.kt ================================================ @file:Suppress("RedundantVisibilityModifier", "unused") package io.legado.app.utils.viewbindingdelegate import android.view.LayoutInflater import androidx.core.app.ComponentActivity import androidx.viewbinding.ViewBinding /** * Create new [ViewBinding] associated with the [ComponentActivity] */ @JvmName("viewBindingActivity") inline fun ComponentActivity.viewBinding( crossinline bindingInflater: (LayoutInflater) -> T, setContentView: Boolean = false ) = lazy(LazyThreadSafetyMode.SYNCHRONIZED) { val binding = bindingInflater.invoke(layoutInflater) if (setContentView) { setContentView(binding.root) } binding } ================================================ FILE: app/src/main/java/io/legado/app/utils/viewbindingdelegate/FragmentViewBindings.kt ================================================ @file:Suppress("RedundantVisibilityModifier", "unused", "UnusedReceiverParameter") @file:JvmName("ReflectionFragmentViewBindings") package io.legado.app.utils.viewbindingdelegate import android.view.View import androidx.annotation.IdRes import androidx.fragment.app.Fragment import androidx.viewbinding.ViewBinding private class FragmentViewBindingProperty( viewBinder: (F) -> T ) : ViewBindingProperty(viewBinder) { override fun getLifecycleOwner(thisRef: F) = thisRef.viewLifecycleOwner } /** * Create new [ViewBinding] associated with the [Fragment] */ @JvmName("viewBindingFragment") public fun Fragment.viewBinding(viewBinder: (F) -> T): ViewBindingProperty { return FragmentViewBindingProperty(viewBinder) } /** * Create new [ViewBinding] associated with the [Fragment] * * @param vbFactory Function that create new instance of [ViewBinding]. `MyViewBinding::bind` can be used * @param viewProvider Provide a [View] from the Fragment. By default call [Fragment.requireView] */ @JvmName("viewBindingFragment") public inline fun Fragment.viewBinding( crossinline vbFactory: (View) -> T, crossinline viewProvider: (F) -> View = Fragment::requireView ): ViewBindingProperty { return viewBinding { fragment: F -> vbFactory(viewProvider(fragment)) } } /** * Create new [ViewBinding] associated with the [Fragment] * * @param vbFactory Function that create new instance of [ViewBinding]. `MyViewBinding::bind` can be used * @param viewBindingRootId Root view's id that will be used as root for the view binding */ @JvmName("viewBindingFragment") public inline fun Fragment.viewBinding( crossinline vbFactory: (View) -> T, @IdRes viewBindingRootId: Int ): ViewBindingProperty { return viewBinding(vbFactory) { fragment: Fragment -> fragment.requireView().findViewById(viewBindingRootId) } } ================================================ FILE: app/src/main/java/io/legado/app/utils/viewbindingdelegate/ViewBindingProperty.kt ================================================ @file:Suppress("RedundantVisibilityModifier") package io.legado.app.utils.viewbindingdelegate import android.os.Handler import android.os.Looper import androidx.annotation.MainThread import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.viewbinding.ViewBinding import kotlin.properties.ReadOnlyProperty import kotlin.reflect.KProperty public abstract class ViewBindingProperty( private val viewBinder: (R) -> T ) : ReadOnlyProperty { private var viewBinding: T? = null private val lifecycleObserver = ClearOnDestroyLifecycleObserver() private var thisRef: R? = null protected abstract fun getLifecycleOwner(thisRef: R): LifecycleOwner @MainThread public override fun getValue(thisRef: R, property: KProperty<*>): T { viewBinding?.let { return it } this.thisRef = thisRef val lifecycle = getLifecycleOwner(thisRef).lifecycle if (lifecycle.currentState == Lifecycle.State.DESTROYED) { mainHandler.post { viewBinding = null } } else { lifecycle.addObserver(lifecycleObserver) } return viewBinder(thisRef).also { viewBinding = it } } @MainThread public fun clear() { val thisRef = thisRef ?: return this.thisRef = null getLifecycleOwner(thisRef).lifecycle.removeObserver(lifecycleObserver) mainHandler.post { viewBinding = null } } private inner class ClearOnDestroyLifecycleObserver : DefaultLifecycleObserver { @MainThread override fun onDestroy(owner: LifecycleOwner): Unit = clear() } private companion object { private val mainHandler = Handler(Looper.getMainLooper()) } } ================================================ FILE: app/src/main/java/io/legado/app/web/HttpServer.kt ================================================ package io.legado.app.web import android.graphics.Bitmap import fi.iki.elonen.NanoHTTPD import io.legado.app.api.ReturnData import io.legado.app.api.controller.BookController import io.legado.app.api.controller.BookSourceController import io.legado.app.api.controller.ReplaceRuleController import io.legado.app.api.controller.RssSourceController import io.legado.app.help.coroutine.Coroutine import io.legado.app.service.WebService import io.legado.app.utils.GSON import io.legado.app.utils.LogUtils import io.legado.app.utils.stackTraceStr import io.legado.app.web.utils.AssetsWeb import kotlinx.coroutines.runBlocking import okio.Pipe import okio.buffer import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream class HttpServer(port: Int) : NanoHTTPD(port) { private val assetsWeb = AssetsWeb("web") override fun serve(session: IHTTPSession): Response { WebService.serve() var returnData: ReturnData? = null val ct = ContentType(session.headers["content-type"]).tryUTF8() session.headers["content-type"] = ct.contentTypeHeader var uri = session.uri val startAt = System.currentTimeMillis() LogUtils.d(TAG) { "${session.method.name} - $uri - ${session.queryParameterString} - Start($startAt)" } try { when (session.method) { Method.OPTIONS -> { val response = newFixedLengthResponse("") response.addHeader("Access-Control-Allow-Methods", "POST") response.addHeader("Access-Control-Allow-Headers", "content-type") response.addHeader("Access-Control-Allow-Origin", session.headers["origin"]) //response.addHeader("Access-Control-Max-Age", "3600"); return response } Method.POST -> { val files = HashMap() session.parseBody(files) val postData = files["postData"] returnData = runBlocking { when (uri) { "/saveBookSource" -> BookSourceController.saveSource(postData) "/saveBookSources" -> BookSourceController.saveSources(postData) "/deleteBookSources" -> BookSourceController.deleteSources(postData) "/saveBook" -> BookController.saveBook(postData) "/deleteBook" -> BookController.deleteBook(postData) "/saveBookProgress" -> BookController.saveBookProgress(postData) "/addLocalBook" -> BookController.addLocalBook(session.parameters, files) "/saveReadConfig" -> BookController.saveWebReadConfig(postData) "/saveRssSource" -> RssSourceController.saveSource(postData) "/saveRssSources" -> RssSourceController.saveSources(postData) "/deleteRssSources" -> RssSourceController.deleteSources(postData) "/saveReplaceRule" -> ReplaceRuleController.saveRule(postData) "/deleteReplaceRule" -> ReplaceRuleController.delete(postData) "/testReplaceRule" -> ReplaceRuleController.testRule(postData) else -> null } } } Method.GET -> { val parameters = session.parameters returnData = when (uri) { "/getBookSource" -> BookSourceController.getSource(parameters) "/getBookSources" -> BookSourceController.sources "/getBookshelf" -> BookController.bookshelf "/getChapterList" -> BookController.getChapterList(parameters) "/refreshToc" -> BookController.refreshToc(parameters) "/getBookContent" -> BookController.getBookContent(parameters) "/cover" -> BookController.getCover(parameters) "/image" -> BookController.getImg(parameters) "/getReadConfig" -> BookController.getWebReadConfig() "/getRssSource" -> RssSourceController.getSource(parameters) "/getRssSources" -> RssSourceController.sources "/getReplaceRules" -> ReplaceRuleController.allRules else -> null } } else -> Unit } if (returnData == null) { if (uri.endsWith("/")) uri += "index.html" return assetsWeb.getResponse(uri) } val response = if (returnData.data is Bitmap) { val outputStream = ByteArrayOutputStream() (returnData.data as Bitmap).compress(Bitmap.CompressFormat.PNG, 100, outputStream) val byteArray = outputStream.toByteArray() outputStream.close() val inputStream = ByteArrayInputStream(byteArray) newFixedLengthResponse( Response.Status.OK, "image/png", inputStream, byteArray.size.toLong() ) } else { val data = returnData.data if (data is List<*> && data.size > 3000) { val pipe = Pipe(16 * 1024) Coroutine.async { pipe.sink.buffer().outputStream().bufferedWriter(Charsets.UTF_8).use { GSON.toJson(returnData, it) } } newChunkedResponse( Response.Status.OK, "application/json", pipe.source.buffer().inputStream() ) } else { newFixedLengthResponse(GSON.toJson(returnData)) } } response.addHeader("Access-Control-Allow-Methods", "GET, POST") response.addHeader("Access-Control-Allow-Origin", session.headers["origin"]) LogUtils.d(TAG) { "${session.method.name} - $uri - ${session.queryParameterString} - End($startAt)" } return response } catch (e: Exception) { LogUtils.d(TAG) { "${session.method.name} - $uri - ${session.queryParameterString} - Error End($startAt)\n$e\n${e.stackTraceStr}" } return newFixedLengthResponse(e.message) } } companion object { private const val TAG = "HttpServer" } } ================================================ FILE: app/src/main/java/io/legado/app/web/ReadMe.md ================================================ # web服务 * controller 数据操作 * HttpServer http服务 * WebSocketServer 持续通讯服务 ================================================ FILE: app/src/main/java/io/legado/app/web/WebSocketServer.kt ================================================ package io.legado.app.web import fi.iki.elonen.NanoWSD import io.legado.app.service.WebService import io.legado.app.web.socket.* class WebSocketServer(port: Int) : NanoWSD(port) { override fun openWebSocket(handshake: IHTTPSession): WebSocket? { WebService.serve() return when (handshake.uri) { "/bookSourceDebug" -> { BookSourceDebugWebSocket(handshake) } "/rssSourceDebug" -> { RssSourceDebugWebSocket(handshake) } "/searchBook" -> { BookSearchWebSocket(handshake) } else -> null } } } ================================================ FILE: app/src/main/java/io/legado/app/web/socket/BookSearchWebSocket.kt ================================================ package io.legado.app.web.socket import fi.iki.elonen.NanoHTTPD import fi.iki.elonen.NanoWSD import io.legado.app.R import io.legado.app.data.entities.SearchBook import io.legado.app.help.config.AppConfig import io.legado.app.model.webBook.SearchModel import io.legado.app.ui.book.search.SearchScope import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonObject import io.legado.app.utils.isJson import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import splitties.init.appCtx import java.io.IOException class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) : NanoWSD.WebSocket(handshakeRequest), CoroutineScope by MainScope(), SearchModel.CallBack { private val normalClosure = NanoWSD.WebSocketFrame.CloseCode.NormalClosure private val searchModel = SearchModel(this, this) private val SEARCH_FINISH = "Search finish" override fun onOpen() { launch(IO) { kotlin.runCatching { while (isOpen) { ping("ping".toByteArray()) delay(30000) } } } } override fun onClose( code: NanoWSD.WebSocketFrame.CloseCode, reason: String, initiatedByRemote: Boolean ) { cancel() searchModel.close() } override fun onMessage(message: NanoWSD.WebSocketFrame) { launch(IO) { kotlin.runCatching { if (!message.textPayload.isJson()) { send("数据必须为Json格式") close(normalClosure, SEARCH_FINISH, false) return@launch } val searchMap = GSON.fromJsonObject>(message.textPayload).getOrNull() if (searchMap != null) { val key = searchMap["key"] if (key.isNullOrBlank()) { send(appCtx.getString(R.string.cannot_empty)) close(normalClosure, SEARCH_FINISH, false) return@launch } searchModel.search(System.currentTimeMillis(), key) } } } } override fun onPong(pong: NanoWSD.WebSocketFrame) { } override fun onException(exception: IOException) { } override fun getSearchScope(): SearchScope = SearchScope(AppConfig.searchScope) override fun onSearchStart() { } override fun onSearchSuccess(searchBooks: List) { send(GSON.toJson(searchBooks)) } override fun onSearchFinish(isEmpty: Boolean, hasMore: Boolean) = close(normalClosure, SEARCH_FINISH, false) override fun onSearchCancel(exception: Throwable?) = close(normalClosure, exception?.toString() ?: SEARCH_FINISH, false) } ================================================ FILE: app/src/main/java/io/legado/app/web/socket/BookSourceDebugWebSocket.kt ================================================ package io.legado.app.web.socket import fi.iki.elonen.NanoHTTPD import fi.iki.elonen.NanoWSD import io.legado.app.R import io.legado.app.data.appDb import io.legado.app.model.Debug import io.legado.app.utils.* import kotlinx.coroutines.* import kotlinx.coroutines.Dispatchers.IO import splitties.init.appCtx import java.io.IOException /** * web端书源调试 */ class BookSourceDebugWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) : NanoWSD.WebSocket(handshakeRequest), CoroutineScope by MainScope(), Debug.Callback { private val notPrintState = arrayOf(10, 20, 30, 40) override fun onOpen() { launch(IO) { kotlin.runCatching { while (isOpen) { ping("ping".toByteArray()) delay(30000) } } } } override fun onClose( code: NanoWSD.WebSocketFrame.CloseCode, reason: String, initiatedByRemote: Boolean ) { cancel() Debug.cancelDebug(true) } override fun onMessage(message: NanoWSD.WebSocketFrame) { launch(IO) { kotlin.runCatching { if (!message.textPayload.isJson()) { send("数据必须为Json格式") close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false) return@launch } val debugBean = GSON.fromJsonObject>(message.textPayload).getOrNull() if (debugBean != null) { val tag = debugBean["tag"] val key = debugBean["key"] if (tag.isNullOrBlank() || key.isNullOrBlank()) { send(appCtx.getString(R.string.cannot_empty)) close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false) return@launch } appDb.bookSourceDao.getBookSource(tag)?.let { Debug.callback = this@BookSourceDebugWebSocket Debug.startDebug(this, it, key) } } else { send("数据必须为Json格式") close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false) return@launch } } } } override fun onPong(pong: NanoWSD.WebSocketFrame) { } override fun onException(exception: IOException) { Debug.cancelDebug(true) } override fun printLog(state: Int, msg: String) { if (state in notPrintState) { return } runOnIO { runCatching { send(msg) if (state == -1 || state == 1000) { Debug.cancelDebug(true) close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false) } }.onFailure { it.printOnDebug() } } } } ================================================ FILE: app/src/main/java/io/legado/app/web/socket/RssSourceDebugWebSocket.kt ================================================ package io.legado.app.web.socket import fi.iki.elonen.NanoHTTPD import fi.iki.elonen.NanoWSD import io.legado.app.R import io.legado.app.data.appDb import io.legado.app.model.Debug import io.legado.app.utils.* import kotlinx.coroutines.* import kotlinx.coroutines.Dispatchers.IO import splitties.init.appCtx import java.io.IOException /** * web端订阅源调试 */ class RssSourceDebugWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) : NanoWSD.WebSocket(handshakeRequest), CoroutineScope by MainScope(), Debug.Callback { private val notPrintState = arrayOf(10, 20, 30, 40) override fun onOpen() { launch(IO) { kotlin.runCatching { while (isOpen) { ping("ping".toByteArray()) delay(30000) } } } } override fun onClose( code: NanoWSD.WebSocketFrame.CloseCode, reason: String, initiatedByRemote: Boolean ) { cancel() Debug.cancelDebug(true) } override fun onMessage(message: NanoWSD.WebSocketFrame) { launch(IO) { kotlin.runCatching { if (!message.textPayload.isJson()) { send("数据必须为Json格式") close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false) return@launch } val debugBean = GSON.fromJsonObject>(message.textPayload).getOrNull() if (debugBean != null) { val tag = debugBean["tag"] if (tag.isNullOrBlank()) { send(appCtx.getString(R.string.cannot_empty)) close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false) return@launch } appDb.rssSourceDao.getByKey(tag)?.let { Debug.callback = this@RssSourceDebugWebSocket Debug.startDebug(this, it) } } } } } override fun onPong(pong: NanoWSD.WebSocketFrame) { } override fun onException(exception: IOException) { Debug.cancelDebug(true) } override fun printLog(state: Int, msg: String) { if (state in notPrintState) { return } runOnIO { runCatching { send(msg) if (state == -1 || state == 1000) { Debug.cancelDebug(true) close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false) } }.onFailure { it.printOnDebug() } } } } ================================================ FILE: app/src/main/java/io/legado/app/web/utils/AssetsWeb.kt ================================================ package io.legado.app.web.utils import android.content.res.AssetManager import android.text.TextUtils import fi.iki.elonen.NanoHTTPD import splitties.init.appCtx import java.io.File import java.io.IOException class AssetsWeb(rootPath: String) { private val assetManager: AssetManager = appCtx.assets private var rootPath = "web" init { if (!TextUtils.isEmpty(rootPath)) { this.rootPath = rootPath } } @Throws(IOException::class) fun getResponse(path: String): NanoHTTPD.Response { var path1 = path path1 = (rootPath + path1).replace("/+".toRegex(), File.separator) val inputStream = assetManager.open(path1) return NanoHTTPD.newChunkedResponse( NanoHTTPD.Response.Status.OK, getMimeType(path1), inputStream ) } private fun getMimeType(path: String): String { val suffix = path.substring(path.lastIndexOf(".")) return when { suffix.equals(".html", ignoreCase = true) || suffix.equals(".htm", ignoreCase = true) -> "text/html" suffix.equals(".js", ignoreCase = true) -> "text/javascript" suffix.equals(".css", ignoreCase = true) -> "text/css" suffix.equals(".ico", ignoreCase = true) -> "image/x-icon" suffix.equals(".jpg", ignoreCase = true) -> "image/jpg" else -> "text/html" } } } ================================================ FILE: app/src/main/res/anim/anim_none.xml ================================================ ================================================ FILE: app/src/main/res/anim/anim_readbook_bottom_in.xml ================================================ ================================================ FILE: app/src/main/res/anim/anim_readbook_bottom_out.xml ================================================ ================================================ FILE: app/src/main/res/anim/anim_readbook_top_in.xml ================================================ ================================================ FILE: app/src/main/res/anim/anim_readbook_top_out.xml ================================================ ================================================ FILE: app/src/main/res/color/selector_image.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_chapter_item_divider.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_edit.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_eink_border_bottom.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_eink_border_dialog.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_eink_border_top.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_find_book_group.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_gradient.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_img_border.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_item_focused_on_tv.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_popup_menu.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_prefs_color.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_searchview.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_shadow_bottom.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_shadow_bottom_night.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_shadow_top.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_shadow_top_night.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_textfield_search.xml ================================================ ================================================ FILE: app/src/main/res/drawable/fastscroll_bubble.xml ================================================ ================================================ FILE: app/src/main/res/drawable/fastscroll_handle.xml ================================================ ================================================ FILE: app/src/main/res/drawable/fastscroll_track.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_add.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_add_online.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_arrange.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_arrow_back.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_arrow_down.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_arrow_drop_down.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_arrow_drop_up.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_arrow_right.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_author.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_auto_page.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_auto_page_stop.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_backup.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_close.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_sort_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_book_has.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_book_last.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bookmark.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bottom_books.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bottom_books_e.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bottom_books_s.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bottom_explore.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bottom_explore_e.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bottom_explore_s.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bottom_person.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bottom_person_e.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bottom_person_s.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bottom_rss_feed.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bottom_rss_feed_e.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bottom_rss_feed_s.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_brightness.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_brightness_auto.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bubble_chart.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bug_report.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_cfg_about.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_cfg_backup.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_cfg_donate.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_cfg_other.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_cfg_replace.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_cfg_source.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_cfg_theme.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_cfg_web.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_chapter_list.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_check.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_check_source.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_clear_all.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_copy.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_create_folder_outline.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_cursor_left.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_cursor_right.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_daytime.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_divider.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_download.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_download_line.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_edit.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_exchange.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_exchange_order.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_exit.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_expand_less.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_expand_more.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_export.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_fast_forward.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_fast_rewind.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_find_replace.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_folder.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_folder_open.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_folder_outline.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_groups.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_help.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_history.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_image.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_import.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_interface_setting.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher1.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher1_b.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher2.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher3.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher4.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher4_b.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher5.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher5_b.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher6.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher7.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher7_b.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_lock_outline.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_menu.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_more.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_more_vert.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_network_check.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_cloud_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_delete.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_pause_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_pause_outline_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_play_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_play_mode_list_end_stop.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_play_mode_list_loop.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_play_mode_random.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_play_mode_single_loop.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_play_outline_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_praise.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_read_aloud.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_reduce.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_refresh_black_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_refresh_white_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_restore.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_save.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_scan.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_scoring.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_screen.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_search.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_search_hint.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_settings.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_share.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_skip_next.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_skip_previous.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_sort.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_star.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_star_border.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_stop_black_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_storage_black_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_swap_horiz.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_time_add_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_timer_black_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_toc.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_translate.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_update.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_view_quilt.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_visibility_off.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_volume_up.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_web_outline.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_web_service_noti.xml ================================================ ================================================ FILE: app/src/main/res/drawable/recyclerview_divider_horizontal.xml ================================================ ================================================ FILE: app/src/main/res/drawable/recyclerview_divider_vertical.xml ================================================ ================================================ FILE: app/src/main/res/drawable/selector_btn_accent_bg.xml ================================================ ================================================ FILE: app/src/main/res/drawable/selector_circle_btn_bg.xml ================================================ ================================================ FILE: app/src/main/res/drawable/selector_common_bg.xml ================================================ ================================================ FILE: app/src/main/res/drawable/selector_fillet_btn_bg.xml ================================================ ================================================ FILE: app/src/main/res/drawable/selector_tv_black.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shape_card_view.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shape_circle.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shape_fillet_btn.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shape_fillet_btn_press.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shape_pop_checkaddshelf_bg.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shape_radius_10dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shape_radius_1dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shape_space_divider.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shape_text_cursor.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shape_translucent_card.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_about.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_all_bookmark.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_arrange_book.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_audio_play.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_book_info.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_book_info_edit.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_book_read.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_book_search.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_book_source.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_book_source_edit.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_cache_book.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_chapter_list.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_config.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_dict_rule.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_donate.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_explore_show.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_file_manage.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_import_book.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_main.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_manga.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_qrcode_capture.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_read_record.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_replace_edit.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_replace_rule.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_rss_artivles.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_rss_favorites.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_rss_read.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_rss_source.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_rss_source_edit.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_rule_sub.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_search_content.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_source_debug.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_source_login.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_translucence.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_txt_toc_rule.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_web_view.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_welcome.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_add_to_bookshelf.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_auto_read.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_book_change_source.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_book_group_edit.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_book_group_picker.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_bookmark.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_bookshelf_config.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_change_cover.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_chapter_change_source.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_check_source_config.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_click_action_config.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_code_view.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_content_edit.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_cover_rule_config.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_custom_group.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_dict.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_dict_rule_edit.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_direct_link_upload_config.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_download_choice.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_edit_text.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_file_chooser.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_font_select.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_http_tts_edit.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_image_blurring.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_login.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_manga_color_filter.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_manga_epaper.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_manga_footer_setting.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_multiple_edit_text.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_number_picker.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_open_url_confirm.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_page_key.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_photo_view.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_progressbar_view.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_read_aloud.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_read_bg_text.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_read_book_style.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_read_padding.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_recycler_view.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_rss_favorite_config.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_rule_sub_edit.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_search_scope.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_select_section_export.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_simulated_reading.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_source_picker.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_text_view.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_tip_config.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_toc_regex.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_toc_regex_edit.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_update.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_url_option_edit.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_variable.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_verification_code_view.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_wait.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_webdav_server.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_bookmark.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_books.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_bookshelf1.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_bookshelf2.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_chapter_list.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_explore.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_my_config.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_rss.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_rss_articles.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_web_view_login.xml ================================================ ================================================ FILE: app/src/main/res/layout/item_1line_text.xml ================================================ ================================================ FILE: app/src/main/res/layout/item_1line_text_and_del.xml ================================================ ================================================ FILE: app/src/main/res/layout/item_app_log.xml ================================================ ================================================ FILE: app/src/main/res/layout/item_arrange_book.xml ================================================ ================================================ FILE: app/src/main/res/layout/item_bg_image.xml ================================================ ================================================ FILE: app/src/main/res/layout/item_book_file_import.xml ================================================ ================================================ FILE: app/src/main/res/layout/item_book_group_manage.xml ================================================ ================================================ FILE: app/src/main/res/layout/item_book_manga_edge.xml ================================================ ================================================ FILE: app/src/main/res/layout/item_book_manga_page.xml ================================================