Repository: KotatsuApp/Kotatsu
Branch: devel
Commit: 34f6e5232bf7
Files: 1703
Total size: 5.4 MB
Directory structure:
gitextract_884x2re1/
├── .editorconfig
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── config.yml
│ │ ├── report_bug.yml
│ │ └── request_feature.yml
│ ├── ISSUE_TEMPLATE.md
│ └── workflows/
│ ├── issue_moderator.yml
│ └── trigger-site-deploy.yml
├── .gitignore
├── .idea/
│ ├── .gitignore
│ ├── appInsightsSettings.xml
│ ├── codeStyles/
│ │ ├── Project.xml
│ │ └── codeStyleConfig.xml
│ ├── gradle.xml
│ ├── jarRepositories.xml
│ ├── kotlinCodeInsightSettings.xml
│ ├── ktlint.xml
│ └── vcs.xml
├── .weblate
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── app/
│ ├── .gitignore
│ ├── build.gradle
│ ├── libs/
│ │ └── .gitkeep
│ ├── proguard-rules.pro
│ └── src/
│ ├── androidTest/
│ │ ├── assets/
│ │ │ ├── categories/
│ │ │ │ └── simple.json
│ │ │ ├── kotatsu_test.bak
│ │ │ └── manga/
│ │ │ ├── bad_ids.json
│ │ │ ├── empty.json
│ │ │ ├── first_chapters.json
│ │ │ ├── full.json
│ │ │ ├── header.json
│ │ │ └── without_middle_chapter.json
│ │ └── kotlin/
│ │ └── org/
│ │ └── koitharu/
│ │ └── kotatsu/
│ │ ├── HiltTestRunner.kt
│ │ ├── Instrumentation.kt
│ │ ├── SampleData.kt
│ │ ├── core/
│ │ │ ├── db/
│ │ │ │ └── MangaDatabaseTest.kt
│ │ │ └── os/
│ │ │ └── AppShortcutManagerTest.kt
│ │ └── settings/
│ │ └── backup/
│ │ └── AppBackupAgentTest.kt
│ ├── debug/
│ │ ├── kotlin/
│ │ │ └── org/
│ │ │ └── koitharu/
│ │ │ └── kotatsu/
│ │ │ ├── KotatsuApp.kt
│ │ │ ├── StrictModeNotifier.kt
│ │ │ ├── core/
│ │ │ │ ├── network/
│ │ │ │ │ └── CurlLoggingInterceptor.kt
│ │ │ │ ├── parser/
│ │ │ │ │ └── TestMangaRepository.kt
│ │ │ │ ├── ui/
│ │ │ │ │ └── BaseService.kt
│ │ │ │ └── util/
│ │ │ │ └── ext/
│ │ │ │ └── Debug.kt
│ │ │ └── settings/
│ │ │ └── DebugSettingsFragment.kt
│ │ └── res/
│ │ ├── drawable/
│ │ │ └── ic_debug.xml
│ │ ├── drawable-anydpi-v24/
│ │ │ └── ic_bug.xml
│ │ ├── values/
│ │ │ ├── bools.xml
│ │ │ ├── constants.xml
│ │ │ └── strings.xml
│ │ └── xml/
│ │ ├── pref_debug.xml
│ │ └── pref_root_debug.xml
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ ├── assets/
│ │ │ └── isrgrootx1.pem
│ │ ├── kotlin/
│ │ │ └── org/
│ │ │ └── koitharu/
│ │ │ └── kotatsu/
│ │ │ ├── alternatives/
│ │ │ │ ├── domain/
│ │ │ │ │ ├── AlternativesUseCase.kt
│ │ │ │ │ ├── AutoFixUseCase.kt
│ │ │ │ │ └── MigrateUseCase.kt
│ │ │ │ └── ui/
│ │ │ │ ├── AlternativeAD.kt
│ │ │ │ ├── AlternativesActivity.kt
│ │ │ │ ├── AlternativesViewModel.kt
│ │ │ │ ├── AutoFixService.kt
│ │ │ │ └── MangaAlternativeModel.kt
│ │ │ ├── backups/
│ │ │ │ ├── data/
│ │ │ │ │ ├── BackupRepository.kt
│ │ │ │ │ └── model/
│ │ │ │ │ ├── BackupIndex.kt
│ │ │ │ │ ├── BookmarkBackup.kt
│ │ │ │ │ ├── CategoryBackup.kt
│ │ │ │ │ ├── FavouriteBackup.kt
│ │ │ │ │ ├── HistoryBackup.kt
│ │ │ │ │ ├── MangaBackup.kt
│ │ │ │ │ ├── ScrobblingBackup.kt
│ │ │ │ │ ├── SourceBackup.kt
│ │ │ │ │ ├── StatisticBackup.kt
│ │ │ │ │ └── TagBackup.kt
│ │ │ │ ├── domain/
│ │ │ │ │ ├── AppBackupAgent.kt
│ │ │ │ │ ├── BackupFile.kt
│ │ │ │ │ ├── BackupObserver.kt
│ │ │ │ │ ├── BackupSection.kt
│ │ │ │ │ ├── BackupUtils.kt
│ │ │ │ │ └── ExternalBackupStorage.kt
│ │ │ │ └── ui/
│ │ │ │ ├── BaseBackupRestoreService.kt
│ │ │ │ ├── backup/
│ │ │ │ │ ├── BackupDialogFragment.kt
│ │ │ │ │ ├── BackupService.kt
│ │ │ │ │ └── BackupViewModel.kt
│ │ │ │ ├── periodical/
│ │ │ │ │ ├── PeriodicalBackupService.kt
│ │ │ │ │ ├── PeriodicalBackupSettingsFragment.kt
│ │ │ │ │ ├── PeriodicalBackupSettingsViewModel.kt
│ │ │ │ │ └── TelegramBackupUploader.kt
│ │ │ │ └── restore/
│ │ │ │ ├── BackupEntriesAdapter.kt
│ │ │ │ ├── BackupSectionModel.kt
│ │ │ │ ├── RestoreDialogFragment.kt
│ │ │ │ ├── RestoreService.kt
│ │ │ │ └── RestoreViewModel.kt
│ │ │ ├── bookmarks/
│ │ │ │ ├── data/
│ │ │ │ │ ├── BookmarkEntity.kt
│ │ │ │ │ ├── BookmarksDao.kt
│ │ │ │ │ └── EntityMapping.kt
│ │ │ │ ├── domain/
│ │ │ │ │ ├── Bookmark.kt
│ │ │ │ │ └── BookmarksRepository.kt
│ │ │ │ └── ui/
│ │ │ │ ├── AllBookmarksActivity.kt
│ │ │ │ ├── AllBookmarksFragment.kt
│ │ │ │ ├── AllBookmarksViewModel.kt
│ │ │ │ ├── BookmarksSelectionDecoration.kt
│ │ │ │ └── adapter/
│ │ │ │ ├── BookmarkLargeAD.kt
│ │ │ │ └── BookmarksAdapter.kt
│ │ │ ├── browser/
│ │ │ │ ├── AdListUpdateService.kt
│ │ │ │ ├── BaseBrowserActivity.kt
│ │ │ │ ├── BrowserActivity.kt
│ │ │ │ ├── BrowserCallback.kt
│ │ │ │ ├── BrowserClient.kt
│ │ │ │ ├── OnHistoryChangedListener.kt
│ │ │ │ ├── ProgressChromeClient.kt
│ │ │ │ ├── WebViewBackPressedCallback.kt
│ │ │ │ └── cloudflare/
│ │ │ │ ├── CloudFlareActivity.kt
│ │ │ │ ├── CloudFlareCallback.kt
│ │ │ │ └── CloudFlareClient.kt
│ │ │ ├── core/
│ │ │ │ ├── AppModule.kt
│ │ │ │ ├── BaseApp.kt
│ │ │ │ ├── ErrorReporterReceiver.kt
│ │ │ │ ├── LocalizedAppContext.kt
│ │ │ │ ├── cache/
│ │ │ │ │ ├── ExpiringLruCache.kt
│ │ │ │ │ ├── ExpiringValue.kt
│ │ │ │ │ ├── MemoryContentCache.kt
│ │ │ │ │ └── SafeDeferred.kt
│ │ │ │ ├── db/
│ │ │ │ │ ├── DatabasePrePopulateCallback.kt
│ │ │ │ │ ├── MangaDatabase.kt
│ │ │ │ │ ├── MangaQueryBuilder.kt
│ │ │ │ │ ├── Tables.kt
│ │ │ │ │ ├── dao/
│ │ │ │ │ │ ├── ChaptersDao.kt
│ │ │ │ │ │ ├── MangaDao.kt
│ │ │ │ │ │ ├── MangaSourcesDao.kt
│ │ │ │ │ │ ├── PreferencesDao.kt
│ │ │ │ │ │ ├── TagsDao.kt
│ │ │ │ │ │ └── TrackLogsDao.kt
│ │ │ │ │ ├── entity/
│ │ │ │ │ │ ├── ChapterEntity.kt
│ │ │ │ │ │ ├── EntityMapping.kt
│ │ │ │ │ │ ├── MangaEntity.kt
│ │ │ │ │ │ ├── MangaPrefsEntity.kt
│ │ │ │ │ │ ├── MangaSourceEntity.kt
│ │ │ │ │ │ ├── MangaTagsEntity.kt
│ │ │ │ │ │ ├── MangaWithTags.kt
│ │ │ │ │ │ └── TagEntity.kt
│ │ │ │ │ └── migrations/
│ │ │ │ │ ├── Migration10To11.kt
│ │ │ │ │ ├── Migration11To12.kt
│ │ │ │ │ ├── Migration12To13.kt
│ │ │ │ │ ├── Migration13To14.kt
│ │ │ │ │ ├── Migration14To15.kt
│ │ │ │ │ ├── Migration15To16.kt
│ │ │ │ │ ├── Migration16To17.kt
│ │ │ │ │ ├── Migration17To18.kt
│ │ │ │ │ ├── Migration18To19.kt
│ │ │ │ │ ├── Migration19To20.kt
│ │ │ │ │ ├── Migration1To2.kt
│ │ │ │ │ ├── Migration20To21.kt
│ │ │ │ │ ├── Migration21To22.kt
│ │ │ │ │ ├── Migration22To23.kt
│ │ │ │ │ ├── Migration23To24.kt
│ │ │ │ │ ├── Migration24To23.kt
│ │ │ │ │ ├── Migration24To25.kt
│ │ │ │ │ ├── Migration25To26.kt
│ │ │ │ │ ├── Migration26To27.kt
│ │ │ │ │ ├── Migration2To3.kt
│ │ │ │ │ ├── Migration3To4.kt
│ │ │ │ │ ├── Migration4To5.kt
│ │ │ │ │ ├── Migration5To6.kt
│ │ │ │ │ ├── Migration6To7.kt
│ │ │ │ │ ├── Migration7To8.kt
│ │ │ │ │ ├── Migration8To9.kt
│ │ │ │ │ └── Migration9To10.kt
│ │ │ │ ├── exceptions/
│ │ │ │ │ ├── BadBackupFormatException.kt
│ │ │ │ │ ├── CaughtException.kt
│ │ │ │ │ ├── CloudFlareBlockedException.kt
│ │ │ │ │ ├── CloudFlareException.kt
│ │ │ │ │ ├── CloudFlareProtectedException.kt
│ │ │ │ │ ├── EmptyHistoryException.kt
│ │ │ │ │ ├── EmptyMangaException.kt
│ │ │ │ │ ├── IncompatiblePluginException.kt
│ │ │ │ │ ├── InteractiveActionRequiredException.kt
│ │ │ │ │ ├── NoDataReceivedException.kt
│ │ │ │ │ ├── NonFileUriException.kt
│ │ │ │ │ ├── ProxyConfigException.kt
│ │ │ │ │ ├── SyncApiException.kt
│ │ │ │ │ ├── UnsupportedFileException.kt
│ │ │ │ │ ├── UnsupportedSourceException.kt
│ │ │ │ │ ├── WrapperIOException.kt
│ │ │ │ │ ├── WrongPasswordException.kt
│ │ │ │ │ └── resolve/
│ │ │ │ │ ├── CaptchaHandler.kt
│ │ │ │ │ ├── DialogErrorObserver.kt
│ │ │ │ │ ├── ErrorObserver.kt
│ │ │ │ │ ├── ExceptionResolver.kt
│ │ │ │ │ ├── SnackbarErrorObserver.kt
│ │ │ │ │ └── ToastErrorObserver.kt
│ │ │ │ ├── fs/
│ │ │ │ │ └── FileSequence.kt
│ │ │ │ ├── github/
│ │ │ │ │ ├── AppUpdateRepository.kt
│ │ │ │ │ ├── AppVersion.kt
│ │ │ │ │ └── VersionId.kt
│ │ │ │ ├── image/
│ │ │ │ │ ├── AvifImageDecoder.kt
│ │ │ │ │ ├── BitmapDecoderCompat.kt
│ │ │ │ │ ├── CbzFetcher.kt
│ │ │ │ │ ├── CoilImageView.kt
│ │ │ │ │ ├── CoilMemoryCacheKey.kt
│ │ │ │ │ ├── MangaSourceHeaderInterceptor.kt
│ │ │ │ │ └── RegionBitmapDecoder.kt
│ │ │ │ ├── io/
│ │ │ │ │ └── NullOutputStream.kt
│ │ │ │ ├── model/
│ │ │ │ │ ├── FavouriteCategory.kt
│ │ │ │ │ ├── GenericSortOrder.kt
│ │ │ │ │ ├── Manga.kt
│ │ │ │ │ ├── MangaHistory.kt
│ │ │ │ │ ├── MangaSource.kt
│ │ │ │ │ ├── MangaSourceInfo.kt
│ │ │ │ │ ├── MangaSourceSerializer.kt
│ │ │ │ │ ├── QuickFilter.kt
│ │ │ │ │ ├── SortDirection.kt
│ │ │ │ │ ├── ZoomMode.kt
│ │ │ │ │ └── parcelable/
│ │ │ │ │ ├── MangaSourceParceler.kt
│ │ │ │ │ ├── ParcelableChapter.kt
│ │ │ │ │ ├── ParcelableManga.kt
│ │ │ │ │ ├── ParcelableMangaListFilter.kt
│ │ │ │ │ ├── ParcelableMangaPage.kt
│ │ │ │ │ └── ParcelableMangaTags.kt
│ │ │ │ ├── nav/
│ │ │ │ │ ├── AppRouter.kt
│ │ │ │ │ ├── AppRouterEntryPoint.kt
│ │ │ │ │ ├── MangaIntent.kt
│ │ │ │ │ ├── NavUtil.kt
│ │ │ │ │ └── ReaderIntent.kt
│ │ │ │ ├── network/
│ │ │ │ │ ├── CacheLimitInterceptor.kt
│ │ │ │ │ ├── CloudFlareInterceptor.kt
│ │ │ │ │ ├── CommonHeaders.kt
│ │ │ │ │ ├── CommonHeadersInterceptor.kt
│ │ │ │ │ ├── DoHManager.kt
│ │ │ │ │ ├── DoHProvider.kt
│ │ │ │ │ ├── GZipInterceptor.kt
│ │ │ │ │ ├── HttpClients.kt
│ │ │ │ │ ├── NetworkModule.kt
│ │ │ │ │ ├── RateLimitInterceptor.kt
│ │ │ │ │ ├── SSLUtils.kt
│ │ │ │ │ ├── cookies/
│ │ │ │ │ │ ├── AndroidCookieJar.kt
│ │ │ │ │ │ ├── CookieWrapper.kt
│ │ │ │ │ │ ├── MutableCookieJar.kt
│ │ │ │ │ │ └── PreferencesCookieJar.kt
│ │ │ │ │ ├── imageproxy/
│ │ │ │ │ │ ├── BaseImageProxyInterceptor.kt
│ │ │ │ │ │ ├── ImageProxyInterceptor.kt
│ │ │ │ │ │ ├── RealImageProxyInterceptor.kt
│ │ │ │ │ │ ├── WsrvNlProxyInterceptor.kt
│ │ │ │ │ │ └── ZeroMsProxyInterceptor.kt
│ │ │ │ │ ├── proxy/
│ │ │ │ │ │ └── ProxyProvider.kt
│ │ │ │ │ └── webview/
│ │ │ │ │ ├── CaptchaContinuationClient.kt
│ │ │ │ │ ├── ContinuationResumeWebViewClient.kt
│ │ │ │ │ ├── WebViewExecutor.kt
│ │ │ │ │ └── adblock/
│ │ │ │ │ ├── AdBlock.kt
│ │ │ │ │ ├── CSSRuleBuilder.kt
│ │ │ │ │ ├── Rule.kt
│ │ │ │ │ └── RulesList.kt
│ │ │ │ ├── os/
│ │ │ │ │ ├── AppShortcutManager.kt
│ │ │ │ │ ├── AppValidator.kt
│ │ │ │ │ ├── NetworkManageIntent.kt
│ │ │ │ │ ├── NetworkState.kt
│ │ │ │ │ ├── OpenDocumentTreeHelper.kt
│ │ │ │ │ ├── RomCompat.kt
│ │ │ │ │ └── VoiceInputContract.kt
│ │ │ │ ├── parser/
│ │ │ │ │ ├── BitmapWrapper.kt
│ │ │ │ │ ├── CachingMangaRepository.kt
│ │ │ │ │ ├── EmptyMangaRepository.kt
│ │ │ │ │ ├── MangaDataRepository.kt
│ │ │ │ │ ├── MangaLinkResolver.kt
│ │ │ │ │ ├── MangaLoaderContextImpl.kt
│ │ │ │ │ ├── MangaRepository.kt
│ │ │ │ │ ├── MirrorSwitcher.kt
│ │ │ │ │ ├── ParserMangaRepository.kt
│ │ │ │ │ ├── external/
│ │ │ │ │ │ ├── ExternalMangaRepository.kt
│ │ │ │ │ │ ├── ExternalMangaSource.kt
│ │ │ │ │ │ ├── ExternalPluginContentSource.kt
│ │ │ │ │ │ └── ExternalPluginCursor.kt
│ │ │ │ │ └── favicon/
│ │ │ │ │ ├── FaviconFetcher.kt
│ │ │ │ │ └── FaviconUri.kt
│ │ │ │ ├── prefs/
│ │ │ │ │ ├── AppSettings.kt
│ │ │ │ │ ├── AppSettingsObserver.kt
│ │ │ │ │ ├── AppWidgetConfig.kt
│ │ │ │ │ ├── ColorScheme.kt
│ │ │ │ │ ├── DownloadFormat.kt
│ │ │ │ │ ├── ListMode.kt
│ │ │ │ │ ├── NavItem.kt
│ │ │ │ │ ├── NetworkPolicy.kt
│ │ │ │ │ ├── ProgressIndicatorMode.kt
│ │ │ │ │ ├── ReaderAnimation.kt
│ │ │ │ │ ├── ReaderBackground.kt
│ │ │ │ │ ├── ReaderControl.kt
│ │ │ │ │ ├── ReaderMode.kt
│ │ │ │ │ ├── ScreenshotsPolicy.kt
│ │ │ │ │ ├── SearchSuggestionType.kt
│ │ │ │ │ ├── SourceSettings.kt
│ │ │ │ │ ├── TrackerDownloadStrategy.kt
│ │ │ │ │ └── TriStateOption.kt
│ │ │ │ ├── ui/
│ │ │ │ │ ├── AlertDialogFragment.kt
│ │ │ │ │ ├── BaseActivity.kt
│ │ │ │ │ ├── BaseActivityEntryPoint.kt
│ │ │ │ │ ├── BaseAppWidgetProvider.kt
│ │ │ │ │ ├── BaseFragment.kt
│ │ │ │ │ ├── BaseFullscreenActivity.kt
│ │ │ │ │ ├── BaseListAdapter.kt
│ │ │ │ │ ├── BasePreferenceFragment.kt
│ │ │ │ │ ├── BaseViewModel.kt
│ │ │ │ │ ├── CoroutineIntentService.kt
│ │ │ │ │ ├── DefaultActivityLifecycleCallbacks.kt
│ │ │ │ │ ├── FragmentContainerActivity.kt
│ │ │ │ │ ├── ReorderableListAdapter.kt
│ │ │ │ │ ├── dialog/
│ │ │ │ │ │ ├── AlertDialogs.kt
│ │ │ │ │ │ ├── BigButtonsAlertDialog.kt
│ │ │ │ │ │ ├── ErrorDetailsDialog.kt
│ │ │ │ │ │ ├── RememberCheckListener.kt
│ │ │ │ │ │ └── RememberSelectionDialogListener.kt
│ │ │ │ │ ├── image/
│ │ │ │ │ │ ├── AnimatedFaviconDrawable.kt
│ │ │ │ │ │ ├── AnimatedPlaceholderDrawable.kt
│ │ │ │ │ │ ├── ChipIconTarget.kt
│ │ │ │ │ │ ├── CoilImageGetter.kt
│ │ │ │ │ │ ├── FaviconDrawable.kt
│ │ │ │ │ │ ├── FaviconView.kt
│ │ │ │ │ │ ├── PaintDrawable.kt
│ │ │ │ │ │ ├── TextDrawable.kt
│ │ │ │ │ │ ├── TextViewTarget.kt
│ │ │ │ │ │ ├── ThumbnailTransformation.kt
│ │ │ │ │ │ └── TrimTransformation.kt
│ │ │ │ │ ├── list/
│ │ │ │ │ │ ├── AdapterDelegateClickListenerAdapter.kt
│ │ │ │ │ │ ├── BaseListSelectionCallback.kt
│ │ │ │ │ │ ├── BoundsScrollListener.kt
│ │ │ │ │ │ ├── FitHeightGridLayoutManager.kt
│ │ │ │ │ │ ├── FitHeightLinearLayoutManager.kt
│ │ │ │ │ │ ├── ListSelectionController.kt
│ │ │ │ │ │ ├── OnListItemClickListener.kt
│ │ │ │ │ │ ├── OnTipCloseListener.kt
│ │ │ │ │ │ ├── PaginationScrollListener.kt
│ │ │ │ │ │ ├── RecyclerScrollKeeper.kt
│ │ │ │ │ │ ├── SectionedSelectionController.kt
│ │ │ │ │ │ ├── decor/
│ │ │ │ │ │ │ ├── AbstractSelectionItemDecoration.kt
│ │ │ │ │ │ │ └── SpacingItemDecoration.kt
│ │ │ │ │ │ ├── fastscroll/
│ │ │ │ │ │ │ ├── BubbleAnimator.kt
│ │ │ │ │ │ │ ├── FastScrollRecyclerView.kt
│ │ │ │ │ │ │ ├── FastScroller.kt
│ │ │ │ │ │ │ └── ScrollbarAnimator.kt
│ │ │ │ │ │ └── lifecycle/
│ │ │ │ │ │ ├── LifecycleAwareViewHolder.kt
│ │ │ │ │ │ ├── PagerLifecycleDispatcher.kt
│ │ │ │ │ │ └── RecyclerViewLifecycleDispatcher.kt
│ │ │ │ │ ├── model/
│ │ │ │ │ │ ├── DateTimeAgo.kt
│ │ │ │ │ │ ├── MangaOverride.kt
│ │ │ │ │ │ └── SortOrder.kt
│ │ │ │ │ ├── sheet/
│ │ │ │ │ │ ├── AdaptiveSheetBehavior.kt
│ │ │ │ │ │ ├── AdaptiveSheetCallback.kt
│ │ │ │ │ │ ├── AdaptiveSheetHeaderBar.kt
│ │ │ │ │ │ ├── BaseAdaptiveSheet.kt
│ │ │ │ │ │ └── BottomSheetCollapseCallback.kt
│ │ │ │ │ ├── util/
│ │ │ │ │ │ ├── ActionModeDelegate.kt
│ │ │ │ │ │ ├── ActionModeListener.kt
│ │ │ │ │ │ ├── ActivityRecreationHandle.kt
│ │ │ │ │ │ ├── CollapseActionViewCallback.kt
│ │ │ │ │ │ ├── DefaultTextWatcher.kt
│ │ │ │ │ │ ├── FadingAppbarMediator.kt
│ │ │ │ │ │ ├── MenuInvalidator.kt
│ │ │ │ │ │ ├── OptionsMenuBadgeHelper.kt
│ │ │ │ │ │ ├── PagerNestedScrollHelper.kt
│ │ │ │ │ │ ├── PopupMenuMediator.kt
│ │ │ │ │ │ ├── RecyclerViewOwner.kt
│ │ │ │ │ │ ├── ReversibleAction.kt
│ │ │ │ │ │ ├── ReversibleActionObserver.kt
│ │ │ │ │ │ ├── ReversibleHandle.kt
│ │ │ │ │ │ ├── ShrinkOnScrollBehavior.kt
│ │ │ │ │ │ ├── SpanSizeResolver.kt
│ │ │ │ │ │ └── SystemUiController.kt
│ │ │ │ │ └── widgets/
│ │ │ │ │ ├── BadgeView.kt
│ │ │ │ │ ├── CheckableImageButton.kt
│ │ │ │ │ ├── CheckableImageView.kt
│ │ │ │ │ ├── ChipsView.kt
│ │ │ │ │ ├── CubicSlider.kt
│ │ │ │ │ ├── DotsIndicator.kt
│ │ │ │ │ ├── HideBottomNavigationOnScrollBehavior.kt
│ │ │ │ │ ├── IconsView.kt
│ │ │ │ │ ├── ListItemTextView.kt
│ │ │ │ │ ├── MultilineEllipsizeTextView.kt
│ │ │ │ │ ├── NestedRecyclerView.kt
│ │ │ │ │ ├── SegmentedBarView.kt
│ │ │ │ │ ├── SelectableTextView.kt
│ │ │ │ │ ├── ShapeView.kt
│ │ │ │ │ ├── SlidingBottomNavigationView.kt
│ │ │ │ │ ├── StackLayout.kt
│ │ │ │ │ ├── TipView.kt
│ │ │ │ │ ├── TouchBlockLayout.kt
│ │ │ │ │ ├── TwoLinesItemView.kt
│ │ │ │ │ ├── WindowInsetHolder.kt
│ │ │ │ │ └── ZoomControl.kt
│ │ │ │ ├── util/
│ │ │ │ │ ├── AcraCoroutineErrorHandler.kt
│ │ │ │ │ ├── AcraScreenLogger.kt
│ │ │ │ │ ├── AlphanumComparator.kt
│ │ │ │ │ ├── CancellableSource.kt
│ │ │ │ │ ├── CloseableSequence.kt
│ │ │ │ │ ├── CompositeResult.kt
│ │ │ │ │ ├── ContinuationResumeRunnable.kt
│ │ │ │ │ ├── EditTextValidator.kt
│ │ │ │ │ ├── Event.kt
│ │ │ │ │ ├── FileSize.kt
│ │ │ │ │ ├── IdlingDetector.kt
│ │ │ │ │ ├── KotatsuColors.kt
│ │ │ │ │ ├── LocaleComparator.kt
│ │ │ │ │ ├── LocaleStringComparator.kt
│ │ │ │ │ ├── LocaleUtils.kt
│ │ │ │ │ ├── MediatorStateFlow.kt
│ │ │ │ │ ├── MimeTypes.kt
│ │ │ │ │ ├── MultiMutex.kt
│ │ │ │ │ ├── RecyclerViewScrollCallback.kt
│ │ │ │ │ ├── RetainedLifecycleCoroutineScope.kt
│ │ │ │ │ ├── ShareHelper.kt
│ │ │ │ │ ├── SynchronizedSieveCache.kt
│ │ │ │ │ ├── Throttler.kt
│ │ │ │ │ ├── ViewBadge.kt
│ │ │ │ │ ├── ext/
│ │ │ │ │ │ ├── Android.kt
│ │ │ │ │ │ ├── Bundle.kt
│ │ │ │ │ │ ├── Coil.kt
│ │ │ │ │ │ ├── Collections.kt
│ │ │ │ │ │ ├── ContentResolver.kt
│ │ │ │ │ │ ├── Coroutines.kt
│ │ │ │ │ │ ├── Cursor.kt
│ │ │ │ │ │ ├── Date.kt
│ │ │ │ │ │ ├── EventFlow.kt
│ │ │ │ │ │ ├── File.kt
│ │ │ │ │ │ ├── Flow.kt
│ │ │ │ │ │ ├── FlowObserver.kt
│ │ │ │ │ │ ├── Fragment.kt
│ │ │ │ │ │ ├── Graphics.kt
│ │ │ │ │ │ ├── Http.kt
│ │ │ │ │ │ ├── IO.kt
│ │ │ │ │ │ ├── Insets.kt
│ │ │ │ │ │ ├── LocaleList.kt
│ │ │ │ │ │ ├── MimeType.kt
│ │ │ │ │ │ ├── Other.kt
│ │ │ │ │ │ ├── Preferences.kt
│ │ │ │ │ │ ├── Primitive.kt
│ │ │ │ │ │ ├── RecyclerView.kt
│ │ │ │ │ │ ├── Resources.kt
│ │ │ │ │ │ ├── String.kt
│ │ │ │ │ │ ├── TextView.kt
│ │ │ │ │ │ ├── Theme.kt
│ │ │ │ │ │ ├── Throwable.kt
│ │ │ │ │ │ ├── Toolbar.kt
│ │ │ │ │ │ ├── Uri.kt
│ │ │ │ │ │ ├── View.kt
│ │ │ │ │ │ ├── ViewModel.kt
│ │ │ │ │ │ └── WorkManager.kt
│ │ │ │ │ ├── iterator/
│ │ │ │ │ │ └── MappingIterator.kt
│ │ │ │ │ └── progress/
│ │ │ │ │ ├── ImageRequestIndicatorListener.kt
│ │ │ │ │ ├── IntPercentLabelFormatter.kt
│ │ │ │ │ ├── Progress.kt
│ │ │ │ │ ├── ProgressDeferred.kt
│ │ │ │ │ ├── ProgressResponseBody.kt
│ │ │ │ │ └── RealtimeEtaEstimator.kt
│ │ │ │ └── zip/
│ │ │ │ └── ZipOutput.kt
│ │ │ ├── details/
│ │ │ │ ├── data/
│ │ │ │ │ ├── MangaDetails.kt
│ │ │ │ │ └── ReadingTime.kt
│ │ │ │ ├── domain/
│ │ │ │ │ ├── BranchComparator.kt
│ │ │ │ │ ├── DetailsInteractor.kt
│ │ │ │ │ ├── DetailsLoadUseCase.kt
│ │ │ │ │ ├── ProgressUpdateUseCase.kt
│ │ │ │ │ ├── ReadingTimeUseCase.kt
│ │ │ │ │ └── RelatedMangaUseCase.kt
│ │ │ │ ├── service/
│ │ │ │ │ ├── MangaPrefetchService.kt
│ │ │ │ │ └── PrefetchCompanionEntryPoint.kt
│ │ │ │ └── ui/
│ │ │ │ ├── AuthorSpan.kt
│ │ │ │ ├── ChaptersMapper.kt
│ │ │ │ ├── DetailsActivity.kt
│ │ │ │ ├── DetailsBottomSheetCallback.kt
│ │ │ │ ├── DetailsErrorObserver.kt
│ │ │ │ ├── DetailsMenuProvider.kt
│ │ │ │ ├── DetailsViewModel.kt
│ │ │ │ ├── ReadButtonDelegate.kt
│ │ │ │ ├── TitleScrollCoordinator.kt
│ │ │ │ ├── adapter/
│ │ │ │ │ ├── ChapterGridItemAD.kt
│ │ │ │ │ ├── ChapterListItemAD.kt
│ │ │ │ │ ├── ChaptersAdapter.kt
│ │ │ │ │ └── ChaptersSelectionDecoration.kt
│ │ │ │ ├── model/
│ │ │ │ │ ├── ChapterListItem.kt
│ │ │ │ │ ├── HistoryInfo.kt
│ │ │ │ │ ├── ListModelConversionExt.kt
│ │ │ │ │ └── MangaBranch.kt
│ │ │ │ ├── pager/
│ │ │ │ │ ├── ChapterPagesMenuProvider.kt
│ │ │ │ │ ├── ChaptersPagesAdapter.kt
│ │ │ │ │ ├── ChaptersPagesSheet.kt
│ │ │ │ │ ├── ChaptersPagesViewModel.kt
│ │ │ │ │ ├── EmptyMangaReason.kt
│ │ │ │ │ ├── PeekHeightController.kt
│ │ │ │ │ ├── bookmarks/
│ │ │ │ │ │ ├── BookmarksFragment.kt
│ │ │ │ │ │ └── BookmarksViewModel.kt
│ │ │ │ │ ├── chapters/
│ │ │ │ │ │ ├── ChapterGridSpanHelper.kt
│ │ │ │ │ │ ├── ChaptersFragment.kt
│ │ │ │ │ │ └── ChaptersSelectionCallback.kt
│ │ │ │ │ └── pages/
│ │ │ │ │ ├── MangaPageFetcher.kt
│ │ │ │ │ ├── MangaPageKeyer.kt
│ │ │ │ │ ├── PageThumbnail.kt
│ │ │ │ │ ├── PageThumbnailAD.kt
│ │ │ │ │ ├── PageThumbnailAdapter.kt
│ │ │ │ │ ├── PagesFragment.kt
│ │ │ │ │ ├── PagesSavedObserver.kt
│ │ │ │ │ ├── PagesSelectionDecoration.kt
│ │ │ │ │ └── PagesViewModel.kt
│ │ │ │ ├── related/
│ │ │ │ │ ├── RelatedListFragment.kt
│ │ │ │ │ ├── RelatedListViewModel.kt
│ │ │ │ │ └── RelatedMangaActivity.kt
│ │ │ │ └── scrobbling/
│ │ │ │ ├── ScrobblingInfoAD.kt
│ │ │ │ ├── ScrobblingInfoSheet.kt
│ │ │ │ ├── ScrobblingItemDecoration.kt
│ │ │ │ └── ScrollingInfoAdapter.kt
│ │ │ ├── download/
│ │ │ │ ├── domain/
│ │ │ │ │ ├── DownloadProgress.kt
│ │ │ │ │ └── DownloadState.kt
│ │ │ │ └── ui/
│ │ │ │ ├── dialog/
│ │ │ │ │ ├── ChapterSelectOptions.kt
│ │ │ │ │ ├── ChaptersSelectMacro.kt
│ │ │ │ │ ├── DestinationsAdapter.kt
│ │ │ │ │ ├── DownloadDialogFragment.kt
│ │ │ │ │ └── DownloadDialogViewModel.kt
│ │ │ │ ├── list/
│ │ │ │ │ ├── DownloadItemAD.kt
│ │ │ │ │ ├── DownloadItemListener.kt
│ │ │ │ │ ├── DownloadItemModel.kt
│ │ │ │ │ ├── DownloadsActivity.kt
│ │ │ │ │ ├── DownloadsAdapter.kt
│ │ │ │ │ ├── DownloadsMenuProvider.kt
│ │ │ │ │ ├── DownloadsSelectionDecoration.kt
│ │ │ │ │ ├── DownloadsViewModel.kt
│ │ │ │ │ └── chapters/
│ │ │ │ │ ├── DownloadChapter.kt
│ │ │ │ │ └── DownloadChapterAD.kt
│ │ │ │ └── worker/
│ │ │ │ ├── DownloadNotificationFactory.kt
│ │ │ │ ├── DownloadSlowdownDispatcher.kt
│ │ │ │ ├── DownloadStartedObserver.kt
│ │ │ │ ├── DownloadTask.kt
│ │ │ │ ├── DownloadWorker.kt
│ │ │ │ ├── PausingHandle.kt
│ │ │ │ └── PausingReceiver.kt
│ │ │ ├── explore/
│ │ │ │ ├── data/
│ │ │ │ │ ├── MangaSourcesRepository.kt
│ │ │ │ │ └── SourcesSortOrder.kt
│ │ │ │ ├── domain/
│ │ │ │ │ ├── ExploreRepository.kt
│ │ │ │ │ └── RecoverMangaUseCase.kt
│ │ │ │ └── ui/
│ │ │ │ ├── ExploreFragment.kt
│ │ │ │ ├── ExploreGridSpanSizeLookup.kt
│ │ │ │ ├── ExploreMenuProvider.kt
│ │ │ │ ├── ExploreViewModel.kt
│ │ │ │ ├── SourceSelectionDecoration.kt
│ │ │ │ ├── adapter/
│ │ │ │ │ ├── ExploreAdapter.kt
│ │ │ │ │ ├── ExploreAdapterDelegates.kt
│ │ │ │ │ └── ExploreListEventListener.kt
│ │ │ │ └── model/
│ │ │ │ ├── ExploreButtons.kt
│ │ │ │ ├── MangaSourceItem.kt
│ │ │ │ └── RecommendationsItem.kt
│ │ │ ├── favourites/
│ │ │ │ ├── data/
│ │ │ │ │ ├── EntityMapping.kt
│ │ │ │ │ ├── FavouriteCategoriesDao.kt
│ │ │ │ │ ├── FavouriteCategoryEntity.kt
│ │ │ │ │ ├── FavouriteEntity.kt
│ │ │ │ │ ├── FavouriteManga.kt
│ │ │ │ │ └── FavouritesDao.kt
│ │ │ │ ├── domain/
│ │ │ │ │ ├── FavoritesListQuickFilter.kt
│ │ │ │ │ ├── FavouritesRepository.kt
│ │ │ │ │ ├── LocalFavoritesObserver.kt
│ │ │ │ │ └── model/
│ │ │ │ │ └── Cover.kt
│ │ │ │ └── ui/
│ │ │ │ ├── FavouritesActivity.kt
│ │ │ │ ├── categories/
│ │ │ │ │ ├── CategoriesSelectionCallback.kt
│ │ │ │ │ ├── CategoriesSelectionDecoration.kt
│ │ │ │ │ ├── FavouriteCategoriesActivity.kt
│ │ │ │ │ ├── FavouriteCategoriesListListener.kt
│ │ │ │ │ ├── FavouritesCategoriesViewModel.kt
│ │ │ │ │ ├── adapter/
│ │ │ │ │ │ ├── AllCategoriesListModel.kt
│ │ │ │ │ │ ├── CategoriesAdapter.kt
│ │ │ │ │ │ ├── CategoryAD.kt
│ │ │ │ │ │ └── CategoryListModel.kt
│ │ │ │ │ ├── edit/
│ │ │ │ │ │ ├── FavouritesCategoryEditActivity.kt
│ │ │ │ │ │ └── FavouritesCategoryEditViewModel.kt
│ │ │ │ │ └── select/
│ │ │ │ │ ├── FavoriteDialog.kt
│ │ │ │ │ ├── FavoriteDialogViewModel.kt
│ │ │ │ │ ├── adapter/
│ │ │ │ │ │ ├── MangaCategoriesAdapter.kt
│ │ │ │ │ │ └── MangaCategoryAD.kt
│ │ │ │ │ └── model/
│ │ │ │ │ └── MangaCategoryItem.kt
│ │ │ │ ├── container/
│ │ │ │ │ ├── FavouriteTabModel.kt
│ │ │ │ │ ├── FavouriteTabPopupMenuProvider.kt
│ │ │ │ │ ├── FavouritesContainerAdapter.kt
│ │ │ │ │ ├── FavouritesContainerFragment.kt
│ │ │ │ │ ├── FavouritesContainerMenuProvider.kt
│ │ │ │ │ ├── FavouritesContainerViewModel.kt
│ │ │ │ │ └── FavouritesTabConfigurationStrategy.kt
│ │ │ │ └── list/
│ │ │ │ ├── FavouritesListFragment.kt
│ │ │ │ └── FavouritesListViewModel.kt
│ │ │ ├── filter/
│ │ │ │ ├── data/
│ │ │ │ │ ├── MangaListFilterSerializer.kt
│ │ │ │ │ ├── PersistableFilter.kt
│ │ │ │ │ └── SavedFiltersRepository.kt
│ │ │ │ └── ui/
│ │ │ │ ├── FilterCoordinator.kt
│ │ │ │ ├── FilterFieldLayout.kt
│ │ │ │ ├── FilterHeaderFragment.kt
│ │ │ │ ├── FilterHeaderProducer.kt
│ │ │ │ ├── model/
│ │ │ │ │ ├── FilterHeaderModel.kt
│ │ │ │ │ ├── FilterProperty.kt
│ │ │ │ │ └── TagCatalogItem.kt
│ │ │ │ ├── sheet/
│ │ │ │ │ └── FilterSheetFragment.kt
│ │ │ │ └── tags/
│ │ │ │ ├── TagTitleComparator.kt
│ │ │ │ ├── TagsCatalogAdapter.kt
│ │ │ │ ├── TagsCatalogSheet.kt
│ │ │ │ └── TagsCatalogViewModel.kt
│ │ │ ├── history/
│ │ │ │ ├── data/
│ │ │ │ │ ├── EntityMapping.kt
│ │ │ │ │ ├── HistoryDao.kt
│ │ │ │ │ ├── HistoryEntity.kt
│ │ │ │ │ ├── HistoryLocalObserver.kt
│ │ │ │ │ ├── HistoryRepository.kt
│ │ │ │ │ └── HistoryWithManga.kt
│ │ │ │ ├── domain/
│ │ │ │ │ ├── HistoryListQuickFilter.kt
│ │ │ │ │ ├── HistoryUpdateUseCase.kt
│ │ │ │ │ ├── MarkAsReadUseCase.kt
│ │ │ │ │ └── model/
│ │ │ │ │ └── MangaWithHistory.kt
│ │ │ │ └── ui/
│ │ │ │ ├── HistoryActivity.kt
│ │ │ │ ├── HistoryListAdapter.kt
│ │ │ │ ├── HistoryListFragment.kt
│ │ │ │ ├── HistoryListMenuProvider.kt
│ │ │ │ ├── HistoryListViewModel.kt
│ │ │ │ └── util/
│ │ │ │ ├── ReadingProgressDrawable.kt
│ │ │ │ └── ReadingProgressView.kt
│ │ │ ├── image/
│ │ │ │ └── ui/
│ │ │ │ ├── CoverImageView.kt
│ │ │ │ ├── CoverStackView.kt
│ │ │ │ ├── ImageActivity.kt
│ │ │ │ ├── ImageMenuProvider.kt
│ │ │ │ └── ImageViewModel.kt
│ │ │ ├── list/
│ │ │ │ ├── domain/
│ │ │ │ │ ├── ListFilterOption.kt
│ │ │ │ │ ├── ListSortOrder.kt
│ │ │ │ │ ├── MangaListMapper.kt
│ │ │ │ │ ├── MangaListQuickFilter.kt
│ │ │ │ │ ├── QuickFilterListener.kt
│ │ │ │ │ └── ReadingProgress.kt
│ │ │ │ └── ui/
│ │ │ │ ├── GridSpanResolver.kt
│ │ │ │ ├── ListModelDiffCallback.kt
│ │ │ │ ├── MangaListFragment.kt
│ │ │ │ ├── MangaListMenuProvider.kt
│ │ │ │ ├── MangaListViewModel.kt
│ │ │ │ ├── MangaSelectionDecoration.kt
│ │ │ │ ├── adapter/
│ │ │ │ │ ├── BadgeADUtil.kt
│ │ │ │ │ ├── ButtonFooterAD.kt
│ │ │ │ │ ├── EmptyHintAD.kt
│ │ │ │ │ ├── EmptyStateListAD.kt
│ │ │ │ │ ├── ErrorFooterAD.kt
│ │ │ │ │ ├── ErrorStateListAD.kt
│ │ │ │ │ ├── InfoAD.kt
│ │ │ │ │ ├── ListHeaderAD.kt
│ │ │ │ │ ├── ListHeaderClickListener.kt
│ │ │ │ │ ├── ListItemType.kt
│ │ │ │ │ ├── ListStateHolderListener.kt
│ │ │ │ │ ├── LoadingFooterAD.kt
│ │ │ │ │ ├── LoadingStateAD.kt
│ │ │ │ │ ├── MangaDetailsClickListener.kt
│ │ │ │ │ ├── MangaGridItemAD.kt
│ │ │ │ │ ├── MangaListAdapter.kt
│ │ │ │ │ ├── MangaListDetailedItemAD.kt
│ │ │ │ │ ├── MangaListItemAD.kt
│ │ │ │ │ ├── MangaListListener.kt
│ │ │ │ │ ├── QuickFilterAD.kt
│ │ │ │ │ ├── QuickFilterClickListener.kt
│ │ │ │ │ ├── TipAD.kt
│ │ │ │ │ └── TypedListSpacingDecoration.kt
│ │ │ │ ├── config/
│ │ │ │ │ ├── ListConfigBottomSheet.kt
│ │ │ │ │ ├── ListConfigSection.kt
│ │ │ │ │ └── ListConfigViewModel.kt
│ │ │ │ ├── model/
│ │ │ │ │ ├── ButtonFooter.kt
│ │ │ │ │ ├── EmptyHint.kt
│ │ │ │ │ ├── EmptyState.kt
│ │ │ │ │ ├── ErrorFooter.kt
│ │ │ │ │ ├── ErrorState.kt
│ │ │ │ │ ├── InfoModel.kt
│ │ │ │ │ ├── ListHeader.kt
│ │ │ │ │ ├── ListModel.kt
│ │ │ │ │ ├── ListModelExt.kt
│ │ │ │ │ ├── LoadingFooter.kt
│ │ │ │ │ ├── LoadingState.kt
│ │ │ │ │ ├── MangaCompactListModel.kt
│ │ │ │ │ ├── MangaDetailedListModel.kt
│ │ │ │ │ ├── MangaGridModel.kt
│ │ │ │ │ ├── MangaListModel.kt
│ │ │ │ │ ├── QuickFilter.kt
│ │ │ │ │ └── TipModel.kt
│ │ │ │ ├── preview/
│ │ │ │ │ ├── PreviewFragment.kt
│ │ │ │ │ └── PreviewViewModel.kt
│ │ │ │ └── size/
│ │ │ │ ├── DynamicItemSizeResolver.kt
│ │ │ │ ├── ItemSizeResolver.kt
│ │ │ │ └── StaticItemSizeResolver.kt
│ │ │ ├── local/
│ │ │ │ ├── data/
│ │ │ │ │ ├── CacheDir.kt
│ │ │ │ │ ├── Caches.kt
│ │ │ │ │ ├── CbzFilter.kt
│ │ │ │ │ ├── LocalMangaRepository.kt
│ │ │ │ │ ├── LocalStorageCache.kt
│ │ │ │ │ ├── LocalStorageManager.kt
│ │ │ │ │ ├── MangaIndex.kt
│ │ │ │ │ ├── Qualifiers.kt
│ │ │ │ │ ├── TempFileFilter.kt
│ │ │ │ │ ├── importer/
│ │ │ │ │ │ └── SingleMangaImporter.kt
│ │ │ │ │ ├── index/
│ │ │ │ │ │ ├── LocalMangaIndex.kt
│ │ │ │ │ │ ├── LocalMangaIndexDao.kt
│ │ │ │ │ │ └── LocalMangaIndexEntity.kt
│ │ │ │ │ ├── input/
│ │ │ │ │ │ └── LocalMangaParser.kt
│ │ │ │ │ └── output/
│ │ │ │ │ ├── LocalMangaDirOutput.kt
│ │ │ │ │ ├── LocalMangaOutput.kt
│ │ │ │ │ ├── LocalMangaUtil.kt
│ │ │ │ │ └── LocalMangaZipOutput.kt
│ │ │ │ ├── domain/
│ │ │ │ │ ├── DeleteLocalMangaUseCase.kt
│ │ │ │ │ ├── DeleteReadChaptersUseCase.kt
│ │ │ │ │ ├── LocalObserveMapper.kt
│ │ │ │ │ ├── MangaLock.kt
│ │ │ │ │ └── model/
│ │ │ │ │ └── LocalManga.kt
│ │ │ │ └── ui/
│ │ │ │ ├── ImportDialogFragment.kt
│ │ │ │ ├── ImportService.kt
│ │ │ │ ├── LocalChaptersRemoveService.kt
│ │ │ │ ├── LocalIndexUpdateService.kt
│ │ │ │ ├── LocalListFragment.kt
│ │ │ │ ├── LocalListMenuProvider.kt
│ │ │ │ ├── LocalListViewModel.kt
│ │ │ │ ├── LocalStorageCleanupWorker.kt
│ │ │ │ └── info/
│ │ │ │ ├── LocalInfoDialog.kt
│ │ │ │ └── LocalInfoViewModel.kt
│ │ │ ├── main/
│ │ │ │ ├── domain/
│ │ │ │ │ ├── CoverRestoreInterceptor.kt
│ │ │ │ │ └── ReadingResumeEnabledUseCase.kt
│ │ │ │ └── ui/
│ │ │ │ ├── ExitCallback.kt
│ │ │ │ ├── MainActionButtonBehavior.kt
│ │ │ │ ├── MainActivity.kt
│ │ │ │ ├── MainMenuProvider.kt
│ │ │ │ ├── MainNavigationDelegate.kt
│ │ │ │ ├── MainViewModel.kt
│ │ │ │ ├── SearchViewLegacyBackCallback.kt
│ │ │ │ ├── owners/
│ │ │ │ │ ├── AppBarOwner.kt
│ │ │ │ │ ├── BottomNavOwner.kt
│ │ │ │ │ ├── BottomSheetOwner.kt
│ │ │ │ │ └── SnackbarOwner.kt
│ │ │ │ ├── protect/
│ │ │ │ │ ├── AppProtectHelper.kt
│ │ │ │ │ ├── ProtectActivity.kt
│ │ │ │ │ ├── ProtectViewModel.kt
│ │ │ │ │ └── ScreenshotPolicyHelper.kt
│ │ │ │ └── welcome/
│ │ │ │ ├── WelcomeSheet.kt
│ │ │ │ └── WelcomeViewModel.kt
│ │ │ ├── picker/
│ │ │ │ └── ui/
│ │ │ │ ├── PageImagePickActivity.kt
│ │ │ │ ├── PageImagePickContract.kt
│ │ │ │ ├── PageImagePickViewModel.kt
│ │ │ │ ├── manga/
│ │ │ │ │ ├── MangaPickerFragment.kt
│ │ │ │ │ └── MangaPickerViewModel.kt
│ │ │ │ └── page/
│ │ │ │ ├── PagePickerFragment.kt
│ │ │ │ └── PagePickerViewModel.kt
│ │ │ ├── reader/
│ │ │ │ ├── data/
│ │ │ │ │ ├── ModelMapping.kt
│ │ │ │ │ └── TapGridSettings.kt
│ │ │ │ ├── domain/
│ │ │ │ │ ├── ChapterPages.kt
│ │ │ │ │ ├── ChaptersLoader.kt
│ │ │ │ │ ├── DetectReaderModeUseCase.kt
│ │ │ │ │ ├── EdgeDetector.kt
│ │ │ │ │ ├── PageLoader.kt
│ │ │ │ │ ├── ReaderColorFilter.kt
│ │ │ │ │ └── TapGridArea.kt
│ │ │ │ └── ui/
│ │ │ │ ├── PageLabelFormatter.kt
│ │ │ │ ├── PageSaveContract.kt
│ │ │ │ ├── PageSaveHelper.kt
│ │ │ │ ├── ReaderActionsView.kt
│ │ │ │ ├── ReaderActivity.kt
│ │ │ │ ├── ReaderContent.kt
│ │ │ │ ├── ReaderControlDelegate.kt
│ │ │ │ ├── ReaderInfoBarView.kt
│ │ │ │ ├── ReaderManager.kt
│ │ │ │ ├── ReaderMenuProvider.kt
│ │ │ │ ├── ReaderNavigationCallback.kt
│ │ │ │ ├── ReaderState.kt
│ │ │ │ ├── ReaderToastView.kt
│ │ │ │ ├── ReaderViewModel.kt
│ │ │ │ ├── ScreenOrientationHelper.kt
│ │ │ │ ├── ScrollTimer.kt
│ │ │ │ ├── ScrollTimerControlView.kt
│ │ │ │ ├── colorfilter/
│ │ │ │ │ ├── ColorFilterConfigActivity.kt
│ │ │ │ │ ├── ColorFilterConfigBackPressedDispatcher.kt
│ │ │ │ │ └── ColorFilterConfigViewModel.kt
│ │ │ │ ├── config/
│ │ │ │ │ ├── ImageServerDelegate.kt
│ │ │ │ │ ├── ReaderConfigSheet.kt
│ │ │ │ │ └── ReaderSettings.kt
│ │ │ │ ├── pager/
│ │ │ │ │ ├── BasePageHolder.kt
│ │ │ │ │ ├── BasePagerReaderFragment.kt
│ │ │ │ │ ├── BaseReaderAdapter.kt
│ │ │ │ │ ├── BaseReaderFragment.kt
│ │ │ │ │ ├── ReaderPage.kt
│ │ │ │ │ ├── ReaderUiState.kt
│ │ │ │ │ ├── doublepage/
│ │ │ │ │ │ ├── DoublePageHolder.kt
│ │ │ │ │ │ ├── DoublePageLayoutManager.kt
│ │ │ │ │ │ ├── DoublePageSnapHelper.kt
│ │ │ │ │ │ ├── DoublePagesAdapter.kt
│ │ │ │ │ │ ├── DoubleReaderFragment.kt
│ │ │ │ │ │ └── Utils.kt
│ │ │ │ │ ├── doublereversed/
│ │ │ │ │ │ └── ReversedDoubleReaderFragment.kt
│ │ │ │ │ ├── reversed/
│ │ │ │ │ │ ├── ReversedPageAnimTransformer.kt
│ │ │ │ │ │ ├── ReversedPageHolder.kt
│ │ │ │ │ │ ├── ReversedPagesAdapter.kt
│ │ │ │ │ │ └── ReversedReaderFragment.kt
│ │ │ │ │ ├── standard/
│ │ │ │ │ │ ├── NoAnimPageTransformer.kt
│ │ │ │ │ │ ├── PageAnimTransformer.kt
│ │ │ │ │ │ ├── PageHolder.kt
│ │ │ │ │ │ ├── PagerEventSupplier.kt
│ │ │ │ │ │ ├── PagerReaderFragment.kt
│ │ │ │ │ │ └── PagesAdapter.kt
│ │ │ │ │ ├── vertical/
│ │ │ │ │ │ ├── VerticalPageAnimTransformer.kt
│ │ │ │ │ │ └── VerticalReaderFragment.kt
│ │ │ │ │ ├── vm/
│ │ │ │ │ │ ├── PageState.kt
│ │ │ │ │ │ └── PageViewModel.kt
│ │ │ │ │ └── webtoon/
│ │ │ │ │ ├── WebtoonAdapter.kt
│ │ │ │ │ ├── WebtoonFrameLayout.kt
│ │ │ │ │ ├── WebtoonGapsDecoration.kt
│ │ │ │ │ ├── WebtoonHolder.kt
│ │ │ │ │ ├── WebtoonImageView.kt
│ │ │ │ │ ├── WebtoonLayoutManager.kt
│ │ │ │ │ ├── WebtoonReaderFragment.kt
│ │ │ │ │ ├── WebtoonRecyclerView.kt
│ │ │ │ │ └── WebtoonScalingFrame.kt
│ │ │ │ └── tapgrid/
│ │ │ │ ├── TapAction.kt
│ │ │ │ └── TapGridDispatcher.kt
│ │ │ ├── remotelist/
│ │ │ │ └── ui/
│ │ │ │ ├── MangaSearchMenuProvider.kt
│ │ │ │ ├── RemoteListFragment.kt
│ │ │ │ └── RemoteListViewModel.kt
│ │ │ ├── scrobbling/
│ │ │ │ ├── ScrobblingModule.kt
│ │ │ │ ├── anilist/
│ │ │ │ │ ├── data/
│ │ │ │ │ │ ├── AniListAuthenticator.kt
│ │ │ │ │ │ ├── AniListInterceptor.kt
│ │ │ │ │ │ ├── AniListRepository.kt
│ │ │ │ │ │ └── ScoreFormat.kt
│ │ │ │ │ └── domain/
│ │ │ │ │ └── AniListScrobbler.kt
│ │ │ │ ├── common/
│ │ │ │ │ ├── data/
│ │ │ │ │ │ ├── ScrobblerRepository.kt
│ │ │ │ │ │ ├── ScrobblerStorage.kt
│ │ │ │ │ │ ├── ScrobblingDao.kt
│ │ │ │ │ │ └── ScrobblingEntity.kt
│ │ │ │ │ ├── domain/
│ │ │ │ │ │ ├── Scrobbler.kt
│ │ │ │ │ │ ├── ScrobblerAuthRequiredException.kt
│ │ │ │ │ │ ├── ScrobblerRepositoryMap.kt
│ │ │ │ │ │ └── model/
│ │ │ │ │ │ ├── ScrobblerManga.kt
│ │ │ │ │ │ ├── ScrobblerMangaInfo.kt
│ │ │ │ │ │ ├── ScrobblerService.kt
│ │ │ │ │ │ ├── ScrobblerType.kt
│ │ │ │ │ │ ├── ScrobblerUser.kt
│ │ │ │ │ │ ├── ScrobblingInfo.kt
│ │ │ │ │ │ └── ScrobblingStatus.kt
│ │ │ │ │ └── ui/
│ │ │ │ │ ├── ScrobblerAuthHelper.kt
│ │ │ │ │ ├── config/
│ │ │ │ │ │ ├── ScrobblerConfigActivity.kt
│ │ │ │ │ │ ├── ScrobblerConfigViewModel.kt
│ │ │ │ │ │ └── adapter/
│ │ │ │ │ │ ├── ScrobblingHeaderAD.kt
│ │ │ │ │ │ ├── ScrobblingMangaAD.kt
│ │ │ │ │ │ └── ScrobblingMangaAdapter.kt
│ │ │ │ │ └── selector/
│ │ │ │ │ ├── ScrobblingSelectorSheet.kt
│ │ │ │ │ ├── ScrobblingSelectorViewModel.kt
│ │ │ │ │ ├── adapter/
│ │ │ │ │ │ ├── ScrobblerHintAD.kt
│ │ │ │ │ │ ├── ScrobblerMangaSelectionDecoration.kt
│ │ │ │ │ │ ├── ScrobblerSelectorAdapter.kt
│ │ │ │ │ │ └── ScrobblingMangaAD.kt
│ │ │ │ │ └── model/
│ │ │ │ │ └── ScrobblerHint.kt
│ │ │ │ ├── discord/
│ │ │ │ │ ├── data/
│ │ │ │ │ │ └── DiscordRepository.kt
│ │ │ │ │ └── ui/
│ │ │ │ │ ├── DiscordAuthActivity.kt
│ │ │ │ │ ├── DiscordRpc.kt
│ │ │ │ │ └── DiscordTokenWebClient.kt
│ │ │ │ ├── kitsu/
│ │ │ │ │ ├── data/
│ │ │ │ │ │ ├── KitsuAuthenticator.kt
│ │ │ │ │ │ ├── KitsuInterceptor.kt
│ │ │ │ │ │ └── KitsuRepository.kt
│ │ │ │ │ ├── domain/
│ │ │ │ │ │ └── KitsuScrobbler.kt
│ │ │ │ │ └── ui/
│ │ │ │ │ └── KitsuAuthActivity.kt
│ │ │ │ ├── mal/
│ │ │ │ │ ├── data/
│ │ │ │ │ │ ├── MALAuthenticator.kt
│ │ │ │ │ │ ├── MALInterceptor.kt
│ │ │ │ │ │ └── MALRepository.kt
│ │ │ │ │ └── domain/
│ │ │ │ │ └── MALScrobbler.kt
│ │ │ │ └── shikimori/
│ │ │ │ ├── data/
│ │ │ │ │ ├── ShikimoriAuthenticator.kt
│ │ │ │ │ ├── ShikimoriInterceptor.kt
│ │ │ │ │ └── ShikimoriRepository.kt
│ │ │ │ └── domain/
│ │ │ │ └── ShikimoriScrobbler.kt
│ │ │ ├── search/
│ │ │ │ ├── domain/
│ │ │ │ │ ├── MangaSearchRepository.kt
│ │ │ │ │ ├── SearchKind.kt
│ │ │ │ │ ├── SearchResults.kt
│ │ │ │ │ └── SearchV2Helper.kt
│ │ │ │ └── ui/
│ │ │ │ ├── MangaListActivity.kt
│ │ │ │ ├── MangaSuggestionsProvider.kt
│ │ │ │ ├── multi/
│ │ │ │ │ ├── SearchActivity.kt
│ │ │ │ │ ├── SearchMenuProvider.kt
│ │ │ │ │ ├── SearchResultsListModel.kt
│ │ │ │ │ ├── SearchViewModel.kt
│ │ │ │ │ └── adapter/
│ │ │ │ │ ├── SearchAdapter.kt
│ │ │ │ │ └── SearchResultsAD.kt
│ │ │ │ └── suggestion/
│ │ │ │ ├── SearchSuggestionItemCallback.kt
│ │ │ │ ├── SearchSuggestionListener.kt
│ │ │ │ ├── SearchSuggestionListenerImpl.kt
│ │ │ │ ├── SearchSuggestionMenuProvider.kt
│ │ │ │ ├── SearchSuggestionViewModel.kt
│ │ │ │ ├── adapter/
│ │ │ │ │ ├── SearchSuggestionAdapter.kt
│ │ │ │ │ ├── SearchSuggestionAuthorAD.kt
│ │ │ │ │ ├── SearchSuggestionQueryAD.kt
│ │ │ │ │ ├── SearchSuggestionQueryHintAD.kt
│ │ │ │ │ ├── SearchSuggestionSourceAD.kt
│ │ │ │ │ ├── SearchSuggestionSourceTipAD.kt
│ │ │ │ │ ├── SearchSuggestionTagsAD.kt
│ │ │ │ │ ├── SearchSuggestionTextAD.kt
│ │ │ │ │ └── SearchSuggestionsMangaListAD.kt
│ │ │ │ └── model/
│ │ │ │ └── SearchSuggestionItem.kt
│ │ │ ├── settings/
│ │ │ │ ├── AppearanceSettingsFragment.kt
│ │ │ │ ├── DownloadsSettingsFragment.kt
│ │ │ │ ├── NotificationSettingsLegacyFragment.kt
│ │ │ │ ├── ProxySettingsFragment.kt
│ │ │ │ ├── ReaderSettingsFragment.kt
│ │ │ │ ├── RootSettingsFragment.kt
│ │ │ │ ├── RootSettingsViewModel.kt
│ │ │ │ ├── ServicesSettingsFragment.kt
│ │ │ │ ├── SettingsActivity.kt
│ │ │ │ ├── StorageAndNetworkSettingsFragment.kt
│ │ │ │ ├── StorageAndNetworkSettingsViewModel.kt
│ │ │ │ ├── SuggestionsSettingsFragment.kt
│ │ │ │ ├── SyncSettingsFragment.kt
│ │ │ │ ├── about/
│ │ │ │ │ ├── AboutSettingsFragment.kt
│ │ │ │ │ ├── AboutSettingsViewModel.kt
│ │ │ │ │ ├── AppUpdateActivity.kt
│ │ │ │ │ ├── AppUpdateViewModel.kt
│ │ │ │ │ └── changelog/
│ │ │ │ │ ├── ChangelogFragment.kt
│ │ │ │ │ └── ChangelogViewModel.kt
│ │ │ │ ├── discord/
│ │ │ │ │ ├── DiscordSettingsFragment.kt
│ │ │ │ │ ├── DiscordSettingsViewModel.kt
│ │ │ │ │ └── TokenState.kt
│ │ │ │ ├── nav/
│ │ │ │ │ ├── NavConfigFragment.kt
│ │ │ │ │ ├── NavConfigViewModel.kt
│ │ │ │ │ ├── adapter/
│ │ │ │ │ │ └── NavConfigAD.kt
│ │ │ │ │ └── model/
│ │ │ │ │ ├── NavItemAddModel.kt
│ │ │ │ │ └── NavItemConfigModel.kt
│ │ │ │ ├── override/
│ │ │ │ │ ├── OverrideConfigActivity.kt
│ │ │ │ │ └── OverrideConfigViewModel.kt
│ │ │ │ ├── protect/
│ │ │ │ │ ├── ProtectSetupActivity.kt
│ │ │ │ │ └── ProtectSetupViewModel.kt
│ │ │ │ ├── reader/
│ │ │ │ │ ├── ReaderTapGridConfigActivity.kt
│ │ │ │ │ └── ReaderTapGridConfigViewModel.kt
│ │ │ │ ├── search/
│ │ │ │ │ ├── SettingsItem.kt
│ │ │ │ │ ├── SettingsItemAD.kt
│ │ │ │ │ ├── SettingsSearchFragment.kt
│ │ │ │ │ ├── SettingsSearchHelper.kt
│ │ │ │ │ ├── SettingsSearchMenuProvider.kt
│ │ │ │ │ └── SettingsSearchViewModel.kt
│ │ │ │ ├── sources/
│ │ │ │ │ ├── SourceSettingsExt.kt
│ │ │ │ │ ├── SourceSettingsFragment.kt
│ │ │ │ │ ├── SourceSettingsViewModel.kt
│ │ │ │ │ ├── SourcesSettingsFragment.kt
│ │ │ │ │ ├── SourcesSettingsViewModel.kt
│ │ │ │ │ ├── adapter/
│ │ │ │ │ │ ├── SourceConfigAdapter.kt
│ │ │ │ │ │ ├── SourceConfigAdapterDelegates.kt
│ │ │ │ │ │ └── SourceConfigListener.kt
│ │ │ │ │ ├── auth/
│ │ │ │ │ │ └── SourceAuthActivity.kt
│ │ │ │ │ ├── catalog/
│ │ │ │ │ │ ├── SourceCatalogItem.kt
│ │ │ │ │ │ ├── SourceCatalogItemAD.kt
│ │ │ │ │ │ ├── SourceCatalogPage.kt
│ │ │ │ │ │ ├── SourcesCatalogActivity.kt
│ │ │ │ │ │ ├── SourcesCatalogAdapter.kt
│ │ │ │ │ │ ├── SourcesCatalogFilter.kt
│ │ │ │ │ │ ├── SourcesCatalogMenuProvider.kt
│ │ │ │ │ │ └── SourcesCatalogViewModel.kt
│ │ │ │ │ ├── manage/
│ │ │ │ │ │ ├── SourcesListProducer.kt
│ │ │ │ │ │ ├── SourcesManageFragment.kt
│ │ │ │ │ │ └── SourcesManageViewModel.kt
│ │ │ │ │ └── model/
│ │ │ │ │ └── SourceConfigItem.kt
│ │ │ │ ├── storage/
│ │ │ │ │ ├── DirectoryAD.kt
│ │ │ │ │ ├── DirectoryDiffCallback.kt
│ │ │ │ │ ├── DirectoryModel.kt
│ │ │ │ │ ├── MangaDirectorySelectDialog.kt
│ │ │ │ │ ├── MangaDirectorySelectViewModel.kt
│ │ │ │ │ ├── RequestStorageManagerPermissionContract.kt
│ │ │ │ │ └── directories/
│ │ │ │ │ ├── DirectoryConfigAD.kt
│ │ │ │ │ ├── DirectoryConfigDiffCallback.kt
│ │ │ │ │ ├── DirectoryConfigModel.kt
│ │ │ │ │ ├── MangaDirectoriesActivity.kt
│ │ │ │ │ └── MangaDirectoriesViewModel.kt
│ │ │ │ ├── tracker/
│ │ │ │ │ ├── TrackerSettingsFragment.kt
│ │ │ │ │ ├── TrackerSettingsViewModel.kt
│ │ │ │ │ └── categories/
│ │ │ │ │ ├── TrackerCategoriesConfigAdapter.kt
│ │ │ │ │ ├── TrackerCategoriesConfigSheet.kt
│ │ │ │ │ ├── TrackerCategoriesConfigViewModel.kt
│ │ │ │ │ └── TrackerCategoryAD.kt
│ │ │ │ ├── userdata/
│ │ │ │ │ ├── BackupsSettingsFragment.kt
│ │ │ │ │ ├── BackupsSettingsViewModel.kt
│ │ │ │ │ └── storage/
│ │ │ │ │ ├── DataCleanupSettingsFragment.kt
│ │ │ │ │ ├── DataCleanupSettingsViewModel.kt
│ │ │ │ │ ├── StorageUsage.kt
│ │ │ │ │ └── StorageUsagePreference.kt
│ │ │ │ ├── utils/
│ │ │ │ │ ├── ActivityListPreference.kt
│ │ │ │ │ ├── AutoCompleteTextViewPreference.kt
│ │ │ │ │ ├── DozeHelper.kt
│ │ │ │ │ ├── EditTextBindListener.kt
│ │ │ │ │ ├── EditTextDefaultSummaryProvider.kt
│ │ │ │ │ ├── EditTextFallbackSummaryProvider.kt
│ │ │ │ │ ├── LinksPreference.kt
│ │ │ │ │ ├── MultiAutoCompleteTextViewPreference.kt
│ │ │ │ │ ├── MultiSummaryProvider.kt
│ │ │ │ │ ├── PasswordSummaryProvider.kt
│ │ │ │ │ ├── PercentSummaryProvider.kt
│ │ │ │ │ ├── RingtonePickContract.kt
│ │ │ │ │ ├── SliderPreference.kt
│ │ │ │ │ ├── SplitSwitchPreference.kt
│ │ │ │ │ ├── TagsAutoCompleteProvider.kt
│ │ │ │ │ ├── ThemeChooserPreference.kt
│ │ │ │ │ └── validation/
│ │ │ │ │ ├── DomainValidator.kt
│ │ │ │ │ ├── HeaderValidator.kt
│ │ │ │ │ ├── PortNumberValidator.kt
│ │ │ │ │ └── UrlValidator.kt
│ │ │ │ └── work/
│ │ │ │ ├── PeriodicWorkScheduler.kt
│ │ │ │ └── WorkScheduleManager.kt
│ │ │ ├── stats/
│ │ │ │ ├── data/
│ │ │ │ │ ├── StatsDao.kt
│ │ │ │ │ ├── StatsEntity.kt
│ │ │ │ │ └── StatsRepository.kt
│ │ │ │ ├── domain/
│ │ │ │ │ ├── StatsCollector.kt
│ │ │ │ │ ├── StatsPeriod.kt
│ │ │ │ │ └── StatsRecord.kt
│ │ │ │ └── ui/
│ │ │ │ ├── StatsAD.kt
│ │ │ │ ├── StatsActivity.kt
│ │ │ │ ├── StatsViewModel.kt
│ │ │ │ ├── sheet/
│ │ │ │ │ ├── MangaStatsSheet.kt
│ │ │ │ │ └── MangaStatsViewModel.kt
│ │ │ │ └── views/
│ │ │ │ ├── BarChartView.kt
│ │ │ │ └── PieChartView.kt
│ │ │ ├── suggestions/
│ │ │ │ ├── data/
│ │ │ │ │ ├── SuggestionDao.kt
│ │ │ │ │ ├── SuggestionEntity.kt
│ │ │ │ │ └── SuggestionWithManga.kt
│ │ │ │ ├── domain/
│ │ │ │ │ ├── MangaSuggestion.kt
│ │ │ │ │ ├── SuggestionRepository.kt
│ │ │ │ │ ├── SuggestionsListQuickFilter.kt
│ │ │ │ │ └── TagsBlacklist.kt
│ │ │ │ └── ui/
│ │ │ │ ├── SuggestionsActivity.kt
│ │ │ │ ├── SuggestionsFragment.kt
│ │ │ │ ├── SuggestionsViewModel.kt
│ │ │ │ └── SuggestionsWorker.kt
│ │ │ ├── sync/
│ │ │ │ ├── data/
│ │ │ │ │ ├── SyncAuthApi.kt
│ │ │ │ │ ├── SyncAuthenticator.kt
│ │ │ │ │ ├── SyncInterceptor.kt
│ │ │ │ │ ├── SyncSettings.kt
│ │ │ │ │ └── model/
│ │ │ │ │ ├── FavouriteCategorySyncDto.kt
│ │ │ │ │ ├── FavouriteSyncDto.kt
│ │ │ │ │ ├── HistorySyncDto.kt
│ │ │ │ │ ├── MangaSyncDto.kt
│ │ │ │ │ ├── MangaTagSyncDto.kt
│ │ │ │ │ └── SyncDto.kt
│ │ │ │ ├── domain/
│ │ │ │ │ ├── SyncAuthResult.kt
│ │ │ │ │ ├── SyncController.kt
│ │ │ │ │ └── SyncHelper.kt
│ │ │ │ └── ui/
│ │ │ │ ├── SyncAccountAuthenticator.kt
│ │ │ │ ├── SyncAdapterEntryPoint.kt
│ │ │ │ ├── SyncAuthActivity.kt
│ │ │ │ ├── SyncAuthViewModel.kt
│ │ │ │ ├── SyncAuthenticatorService.kt
│ │ │ │ ├── SyncHostDialogFragment.kt
│ │ │ │ ├── SyncProvider.kt
│ │ │ │ ├── favourites/
│ │ │ │ │ ├── FavouritesSyncAdapter.kt
│ │ │ │ │ ├── FavouritesSyncProvider.kt
│ │ │ │ │ └── FavouritesSyncService.kt
│ │ │ │ └── history/
│ │ │ │ ├── HistorySyncAdapter.kt
│ │ │ │ ├── HistorySyncProvider.kt
│ │ │ │ └── HistorySyncService.kt
│ │ │ ├── tracker/
│ │ │ │ ├── data/
│ │ │ │ │ ├── EntityMapping.kt
│ │ │ │ │ ├── MangaWithTrack.kt
│ │ │ │ │ ├── TrackEntity.kt
│ │ │ │ │ ├── TrackLogEntity.kt
│ │ │ │ │ ├── TrackLogWithManga.kt
│ │ │ │ │ ├── TrackWithManga.kt
│ │ │ │ │ └── TracksDao.kt
│ │ │ │ ├── domain/
│ │ │ │ │ ├── CheckNewChaptersUseCase.kt
│ │ │ │ │ ├── GetTracksUseCase.kt
│ │ │ │ │ ├── TrackingRepository.kt
│ │ │ │ │ ├── UpdatesListQuickFilter.kt
│ │ │ │ │ └── model/
│ │ │ │ │ ├── MangaTracking.kt
│ │ │ │ │ ├── MangaUpdates.kt
│ │ │ │ │ └── TrackingLogItem.kt
│ │ │ │ ├── ui/
│ │ │ │ │ ├── debug/
│ │ │ │ │ │ ├── TrackDebugAD.kt
│ │ │ │ │ │ ├── TrackDebugItem.kt
│ │ │ │ │ │ ├── TrackerDebugActivity.kt
│ │ │ │ │ │ └── TrackerDebugViewModel.kt
│ │ │ │ │ ├── feed/
│ │ │ │ │ │ ├── FeedFragment.kt
│ │ │ │ │ │ ├── FeedMenuProvider.kt
│ │ │ │ │ │ ├── FeedViewModel.kt
│ │ │ │ │ │ ├── adapter/
│ │ │ │ │ │ │ ├── FeedAdapter.kt
│ │ │ │ │ │ │ ├── FeedItemAD.kt
│ │ │ │ │ │ │ └── UpdatedMangaAD.kt
│ │ │ │ │ │ └── model/
│ │ │ │ │ │ ├── FeedItem.kt
│ │ │ │ │ │ └── UpdatedMangaHeader.kt
│ │ │ │ │ └── updates/
│ │ │ │ │ ├── UpdatesActivity.kt
│ │ │ │ │ ├── UpdatesFragment.kt
│ │ │ │ │ └── UpdatesViewModel.kt
│ │ │ │ └── work/
│ │ │ │ ├── TrackWorker.kt
│ │ │ │ └── TrackerNotificationHelper.kt
│ │ │ └── widget/
│ │ │ ├── WidgetUpdater.kt
│ │ │ ├── recent/
│ │ │ │ ├── RecentListFactory.kt
│ │ │ │ ├── RecentWidgetConfigActivity.kt
│ │ │ │ ├── RecentWidgetProvider.kt
│ │ │ │ └── RecentWidgetService.kt
│ │ │ └── shelf/
│ │ │ ├── ShelfConfigViewModel.kt
│ │ │ ├── ShelfListFactory.kt
│ │ │ ├── ShelfWidgetConfigActivity.kt
│ │ │ ├── ShelfWidgetProvider.kt
│ │ │ ├── ShelfWidgetService.kt
│ │ │ ├── adapter/
│ │ │ │ ├── CategorySelectAdapter.kt
│ │ │ │ └── CategorySelectItemAD.kt
│ │ │ └── model/
│ │ │ └── CategoryItem.kt
│ │ └── res/
│ │ ├── anim/
│ │ │ ├── bottom_sheet_slide_in.xml
│ │ │ └── bottom_sheet_slide_out.xml
│ │ ├── color/
│ │ │ ├── bg_background_transparency.xml
│ │ │ ├── bg_floating_button.xml
│ │ │ ├── bottom_menu_active_indicator.xml
│ │ │ ├── bottom_menu_active_item.xml
│ │ │ ├── colored_button.xml
│ │ │ ├── list_item_background_color.xml
│ │ │ ├── list_item_text_color.xml
│ │ │ └── selector_overlay.xml
│ │ ├── drawable/
│ │ │ ├── avd_explore_enter.xml
│ │ │ ├── avd_explore_leave.xml
│ │ │ ├── avd_favourites_enter.xml
│ │ │ ├── avd_favourites_leave.xml
│ │ │ ├── avd_feed_enter.xml
│ │ │ ├── avd_history_enter.xml
│ │ │ ├── avd_splash.xml
│ │ │ ├── bg_appwidget_card.xml
│ │ │ ├── bg_appwidget_root.xml
│ │ │ ├── bg_badge_accent.xml
│ │ │ ├── bg_badge_default.xml
│ │ │ ├── bg_badge_empty.xml
│ │ │ ├── bg_card.xml
│ │ │ ├── bg_chip.xml
│ │ │ ├── bg_circle_button.xml
│ │ │ ├── bg_list_icons.xml
│ │ │ ├── bg_reader_indicator.xml
│ │ │ ├── bg_rounded_square.xml
│ │ │ ├── bg_rounded_transparency.xml
│ │ │ ├── bg_search_suggestion.xml
│ │ │ ├── bg_tab_pill.xml
│ │ │ ├── custom_selectable_item_background.xml
│ │ │ ├── divider_horizontal.xml
│ │ │ ├── divider_transparent.xml
│ │ │ ├── fastscroll_bubble.xml
│ │ │ ├── fastscroll_bubble_small.xml
│ │ │ ├── fastscroll_handle.xml
│ │ │ ├── fastscroll_track.xml
│ │ │ ├── ic_action_pause.xml
│ │ │ ├── ic_action_resume.xml
│ │ │ ├── ic_action_skip.xml
│ │ │ ├── ic_add.xml
│ │ │ ├── ic_alert_outline.xml
│ │ │ ├── ic_anilist.xml
│ │ │ ├── ic_app_update.xml
│ │ │ ├── ic_appearance.xml
│ │ │ ├── ic_arrow_forward.xml
│ │ │ ├── ic_auth_key_large.xml
│ │ │ ├── ic_auto_fix.xml
│ │ │ ├── ic_backup_restore.xml
│ │ │ ├── ic_battery_outline.xml
│ │ │ ├── ic_book_page.xml
│ │ │ ├── ic_bookmark.xml
│ │ │ ├── ic_bookmark_added.xml
│ │ │ ├── ic_bookmark_checked.xml
│ │ │ ├── ic_bookmark_selector.xml
│ │ │ ├── ic_bot_large.xml
│ │ │ ├── ic_cancel_multiple.xml
│ │ │ ├── ic_check.xml
│ │ │ ├── ic_clear_all.xml
│ │ │ ├── ic_current_chapter.xml
│ │ │ ├── ic_data_privacy.xml
│ │ │ ├── ic_delete.xml
│ │ │ ├── ic_delete_all.xml
│ │ │ ├── ic_denied_large.xml
│ │ │ ├── ic_dice.xml
│ │ │ ├── ic_disable.xml
│ │ │ ├── ic_discord.xml
│ │ │ ├── ic_download.xml
│ │ │ ├── ic_drawer_menu.xml
│ │ │ ├── ic_drawer_menu_open.xml
│ │ │ ├── ic_edit.xml
│ │ │ ├── ic_empty_common.xml
│ │ │ ├── ic_empty_favourites.xml
│ │ │ ├── ic_empty_feed.xml
│ │ │ ├── ic_empty_history.xml
│ │ │ ├── ic_empty_local.xml
│ │ │ ├── ic_error_large.xml
│ │ │ ├── ic_error_small.xml
│ │ │ ├── ic_expand.xml
│ │ │ ├── ic_expand_more.xml
│ │ │ ├── ic_explore_checked.xml
│ │ │ ├── ic_explore_normal.xml
│ │ │ ├── ic_explore_selector.xml
│ │ │ ├── ic_eye.xml
│ │ │ ├── ic_eye_check.xml
│ │ │ ├── ic_eye_off.xml
│ │ │ ├── ic_favourites_selector.xml
│ │ │ ├── ic_feed.xml
│ │ │ ├── ic_feed_selector.xml
│ │ │ ├── ic_file_zip.xml
│ │ │ ├── ic_filter_menu.xml
│ │ │ ├── ic_folder_file.xml
│ │ │ ├── ic_gesture_vertical.xml
│ │ │ ├── ic_grid.xml
│ │ │ ├── ic_heart.xml
│ │ │ ├── ic_heart_off.xml
│ │ │ ├── ic_heart_outline.xml
│ │ │ ├── ic_history.xml
│ │ │ ├── ic_history_selector.xml
│ │ │ ├── ic_images.xml
│ │ │ ├── ic_incognito.xml
│ │ │ ├── ic_info_outline.xml
│ │ │ ├── ic_interaction_large.xml
│ │ │ ├── ic_kitsu.xml
│ │ │ ├── ic_language.xml
│ │ │ ├── ic_list.xml
│ │ │ ├── ic_list_detailed.xml
│ │ │ ├── ic_list_group.xml
│ │ │ ├── ic_lock.xml
│ │ │ ├── ic_mal.xml
│ │ │ ├── ic_manga_source.xml
│ │ │ ├── ic_move_horizontal.xml
│ │ │ ├── ic_network_cellular.xml
│ │ │ ├── ic_new.xml
│ │ │ ├── ic_next.xml
│ │ │ ├── ic_notification.xml
│ │ │ ├── ic_nsfw.xml
│ │ │ ├── ic_off_small.xml
│ │ │ ├── ic_offline.xml
│ │ │ ├── ic_open_external.xml
│ │ │ ├── ic_pin.xml
│ │ │ ├── ic_pin_small.xml
│ │ │ ├── ic_placeholder.xml
│ │ │ ├── ic_play.xml
│ │ │ ├── ic_plug_large.xml
│ │ │ ├── ic_prev.xml
│ │ │ ├── ic_read.xml
│ │ │ ├── ic_reader_ltr.xml
│ │ │ ├── ic_reader_rtl.xml
│ │ │ ├── ic_reader_vertical.xml
│ │ │ ├── ic_reorder_handle.xml
│ │ │ ├── ic_replace.xml
│ │ │ ├── ic_retry.xml
│ │ │ ├── ic_revert.xml
│ │ │ ├── ic_save.xml
│ │ │ ├── ic_save_ok.xml
│ │ │ ├── ic_screen_rotation.xml
│ │ │ ├── ic_screen_rotation_lock.xml
│ │ │ ├── ic_script.xml
│ │ │ ├── ic_select_group.xml
│ │ │ ├── ic_select_range.xml
│ │ │ ├── ic_services.xml
│ │ │ ├── ic_settings.xml
│ │ │ ├── ic_sfw.xml
│ │ │ ├── ic_shikimori.xml
│ │ │ ├── ic_shortcut.xml
│ │ │ ├── ic_size_large.xml
│ │ │ ├── ic_sort_asc.xml
│ │ │ ├── ic_sort_desc.xml
│ │ │ ├── ic_split_horizontal.xml
│ │ │ ├── ic_star_small.xml
│ │ │ ├── ic_state_abandoned.xml
│ │ │ ├── ic_state_finished.xml
│ │ │ ├── ic_state_ongoing.xml
│ │ │ ├── ic_storage.xml
│ │ │ ├── ic_storage_checked.xml
│ │ │ ├── ic_storage_selector.xml
│ │ │ ├── ic_suggestion.xml
│ │ │ ├── ic_suggestion_checked.xml
│ │ │ ├── ic_suggestion_selector.xml
│ │ │ ├── ic_sync.xml
│ │ │ ├── ic_tag.xml
│ │ │ ├── ic_tap.xml
│ │ │ ├── ic_tap_reorder.xml
│ │ │ ├── ic_timelapse.xml
│ │ │ ├── ic_timer.xml
│ │ │ ├── ic_timer_run.xml
│ │ │ ├── ic_unpin.xml
│ │ │ ├── ic_updated.xml
│ │ │ ├── ic_updated_checked.xml
│ │ │ ├── ic_updated_selector.xml
│ │ │ ├── ic_usage.xml
│ │ │ ├── ic_user.xml
│ │ │ ├── ic_voice_input.xml
│ │ │ ├── ic_web.xml
│ │ │ ├── ic_welcome.xml
│ │ │ ├── ic_zoom_in.xml
│ │ │ ├── ic_zoom_out.xml
│ │ │ ├── list_selector.xml
│ │ │ ├── m3_popup_background.xml
│ │ │ ├── m3_spinner_popup_background.xml
│ │ │ ├── search_bar_background.xml
│ │ │ └── tabs_background.xml
│ │ ├── drawable-anydpi-v24/
│ │ │ ├── ic_bot.xml
│ │ │ ├── ic_stat_auto_fix.xml
│ │ │ ├── ic_stat_book_plus.xml
│ │ │ ├── ic_stat_done.xml
│ │ │ ├── ic_stat_paused.xml
│ │ │ └── ic_stat_suggestion.xml
│ │ ├── drawable-night/
│ │ │ └── avd_splash.xml
│ │ ├── layout/
│ │ │ ├── activity_alternatives.xml
│ │ │ ├── activity_app_update.xml
│ │ │ ├── activity_appwidget_recent.xml
│ │ │ ├── activity_appwidget_shelf.xml
│ │ │ ├── activity_browser.xml
│ │ │ ├── activity_categories.xml
│ │ │ ├── activity_category_edit.xml
│ │ │ ├── activity_color_filter.xml
│ │ │ ├── activity_container.xml
│ │ │ ├── activity_details.xml
│ │ │ ├── activity_downloads.xml
│ │ │ ├── activity_image.xml
│ │ │ ├── activity_kitsu_auth.xml
│ │ │ ├── activity_main.xml
│ │ │ ├── activity_manga_directories.xml
│ │ │ ├── activity_manga_list.xml
│ │ │ ├── activity_override_edit.xml
│ │ │ ├── activity_picker.xml
│ │ │ ├── activity_protect.xml
│ │ │ ├── activity_reader.xml
│ │ │ ├── activity_reader_tap_actions.xml
│ │ │ ├── activity_scrobbler_config.xml
│ │ │ ├── activity_search.xml
│ │ │ ├── activity_settings.xml
│ │ │ ├── activity_setup_protect.xml
│ │ │ ├── activity_sources_catalog.xml
│ │ │ ├── activity_stats.xml
│ │ │ ├── activity_sync_auth.xml
│ │ │ ├── activity_tracker_debug.xml
│ │ │ ├── dialog_checkbox.xml
│ │ │ ├── dialog_directory_select.xml
│ │ │ ├── dialog_download.xml
│ │ │ ├── dialog_error_details.xml
│ │ │ ├── dialog_favorite.xml
│ │ │ ├── dialog_import.xml
│ │ │ ├── dialog_local_info.xml
│ │ │ ├── dialog_progress.xml
│ │ │ ├── dialog_restore.xml
│ │ │ ├── dialog_two_buttons.xml
│ │ │ ├── fast_scroller.xml
│ │ │ ├── fragment_changelog.xml
│ │ │ ├── fragment_chapters.xml
│ │ │ ├── fragment_explore.xml
│ │ │ ├── fragment_favourites_container.xml
│ │ │ ├── fragment_filter_header.xml
│ │ │ ├── fragment_list.xml
│ │ │ ├── fragment_list_simple.xml
│ │ │ ├── fragment_manga_bookmarks.xml
│ │ │ ├── fragment_pages.xml
│ │ │ ├── fragment_preview.xml
│ │ │ ├── fragment_reader_double.xml
│ │ │ ├── fragment_reader_pager.xml
│ │ │ ├── fragment_reader_webtoon.xml
│ │ │ ├── fragment_search_suggestion.xml
│ │ │ ├── fragment_settings_sources.xml
│ │ │ ├── item_bookmark_large.xml
│ │ │ ├── item_button_footer.xml
│ │ │ ├── item_categories_all.xml
│ │ │ ├── item_category.xml
│ │ │ ├── item_category_checkable.xml
│ │ │ ├── item_category_checkable_multiple.xml
│ │ │ ├── item_category_checkable_single.xml
│ │ │ ├── item_chapter.xml
│ │ │ ├── item_chapter_download.xml
│ │ │ ├── item_chapter_grid.xml
│ │ │ ├── item_checkable_multiple.xml
│ │ │ ├── item_checkable_new.xml
│ │ │ ├── item_checkable_single.xml
│ │ │ ├── item_color_scheme.xml
│ │ │ ├── item_download.xml
│ │ │ ├── item_empty_card.xml
│ │ │ ├── item_empty_hint.xml
│ │ │ ├── item_empty_state.xml
│ │ │ ├── item_error_footer.xml
│ │ │ ├── item_error_state.xml
│ │ │ ├── item_explore_buttons.xml
│ │ │ ├── item_explore_source_grid.xml
│ │ │ ├── item_explore_source_list.xml
│ │ │ ├── item_feed.xml
│ │ │ ├── item_header.xml
│ │ │ ├── item_info.xml
│ │ │ ├── item_list_group.xml
│ │ │ ├── item_loading_footer.xml
│ │ │ ├── item_loading_state.xml
│ │ │ ├── item_manga_alternative.xml
│ │ │ ├── item_manga_grid.xml
│ │ │ ├── item_manga_list.xml
│ │ │ ├── item_manga_list_details.xml
│ │ │ ├── item_nav_available.xml
│ │ │ ├── item_nav_config.xml
│ │ │ ├── item_page.xml
│ │ │ ├── item_page_thumb.xml
│ │ │ ├── item_page_webtoon.xml
│ │ │ ├── item_preference.xml
│ │ │ ├── item_quick_filter.xml
│ │ │ ├── item_recent.xml
│ │ │ ├── item_recommendation.xml
│ │ │ ├── item_recommendation_manga.xml
│ │ │ ├── item_scrobbling_info.xml
│ │ │ ├── item_scrobbling_manga.xml
│ │ │ ├── item_search_suggestion_manga_grid.xml
│ │ │ ├── item_search_suggestion_manga_list.xml
│ │ │ ├── item_search_suggestion_query.xml
│ │ │ ├── item_search_suggestion_query_hint.xml
│ │ │ ├── item_search_suggestion_source.xml
│ │ │ ├── item_search_suggestion_source_tip.xml
│ │ │ ├── item_search_suggestion_tags.xml
│ │ │ ├── item_search_suggestion_text.xml
│ │ │ ├── item_shelf.xml
│ │ │ ├── item_source_catalog.xml
│ │ │ ├── item_source_config.xml
│ │ │ ├── item_sources_empty.xml
│ │ │ ├── item_stats.xml
│ │ │ ├── item_storage.xml
│ │ │ ├── item_storage_config.xml
│ │ │ ├── item_storage_config2.xml
│ │ │ ├── item_tip.xml
│ │ │ ├── item_tip2.xml
│ │ │ ├── item_track_debug.xml
│ │ │ ├── layout_details_table.xml
│ │ │ ├── layout_page_info.xml
│ │ │ ├── layout_reader_actions.xml
│ │ │ ├── layout_sheet_header_adaptive.xml
│ │ │ ├── navigation_rail_fab.xml
│ │ │ ├── preference_dialog_autocompletetextview.xml
│ │ │ ├── preference_dialog_multiautocompletetextview.xml
│ │ │ ├── preference_memory_usage.xml
│ │ │ ├── preference_slider.xml
│ │ │ ├── preference_split_switch.xml
│ │ │ ├── preference_theme.xml
│ │ │ ├── preference_toggle_header.xml
│ │ │ ├── preference_widget_material_switch.xml
│ │ │ ├── sheet_base.xml
│ │ │ ├── sheet_chapters_pages.xml
│ │ │ ├── sheet_filter.xml
│ │ │ ├── sheet_list_mode.xml
│ │ │ ├── sheet_reader_config.xml
│ │ │ ├── sheet_scrobbling.xml
│ │ │ ├── sheet_scrobbling_selector.xml
│ │ │ ├── sheet_stats_manga.xml
│ │ │ ├── sheet_tags.xml
│ │ │ ├── sheet_welcome.xml
│ │ │ ├── view_cover_stack.xml
│ │ │ ├── view_dialog_autocomplete.xml
│ │ │ ├── view_filter_field.xml
│ │ │ ├── view_scroll_timer.xml
│ │ │ ├── view_tip.xml
│ │ │ ├── view_two_lines_item.xml
│ │ │ ├── view_zoom.xml
│ │ │ ├── widget_recent.xml
│ │ │ └── widget_shelf.xml
│ │ ├── layout-land/
│ │ │ └── item_empty_state.xml
│ │ ├── layout-w600dp-land/
│ │ │ ├── activity_color_filter.xml
│ │ │ ├── activity_container.xml
│ │ │ ├── activity_details.xml
│ │ │ ├── activity_main.xml
│ │ │ ├── activity_manga_list.xml
│ │ │ ├── activity_reader.xml
│ │ │ ├── activity_settings.xml
│ │ │ ├── activity_stats.xml
│ │ │ └── fragment_settings_sources.xml
│ │ ├── menu/
│ │ │ ├── mode_bookmarks.xml
│ │ │ ├── mode_category.xml
│ │ │ ├── mode_chapters.xml
│ │ │ ├── mode_downloads.xml
│ │ │ ├── mode_favourites.xml
│ │ │ ├── mode_history.xml
│ │ │ ├── mode_local.xml
│ │ │ ├── mode_pages.xml
│ │ │ ├── mode_remote.xml
│ │ │ ├── mode_source.xml
│ │ │ ├── mode_updates.xml
│ │ │ ├── opt_browser.xml
│ │ │ ├── opt_captcha.xml
│ │ │ ├── opt_chapters.xml
│ │ │ ├── opt_details.xml
│ │ │ ├── opt_downloads.xml
│ │ │ ├── opt_explore.xml
│ │ │ ├── opt_favourites_container.xml
│ │ │ ├── opt_feed.xml
│ │ │ ├── opt_history.xml
│ │ │ ├── opt_image.xml
│ │ │ ├── opt_list.xml
│ │ │ ├── opt_list_remote.xml
│ │ │ ├── opt_local.xml
│ │ │ ├── opt_main.xml
│ │ │ ├── opt_pages.xml
│ │ │ ├── opt_reader.xml
│ │ │ ├── opt_scrobbling.xml
│ │ │ ├── opt_search.xml
│ │ │ ├── opt_search_kind.xml
│ │ │ ├── opt_search_suggestion.xml
│ │ │ ├── opt_shiki_selector.xml
│ │ │ ├── opt_sources.xml
│ │ │ ├── opt_sources_catalog.xml
│ │ │ ├── opt_stats.xml
│ │ │ ├── opt_suggestions.xml
│ │ │ ├── opt_tap_grid_config.xml
│ │ │ ├── popup_fav_tab.xml
│ │ │ ├── popup_fav_tab_all.xml
│ │ │ ├── popup_read.xml
│ │ │ ├── popup_saved_filter.xml
│ │ │ └── popup_source_config.xml
│ │ ├── mipmap-anydpi-v26/
│ │ │ ├── ic_launcher.xml
│ │ │ └── ic_launcher_round.xml
│ │ ├── raw/
│ │ │ ├── keep.xml
│ │ │ └── tags_warnlist
│ │ ├── values/
│ │ │ ├── arrays.xml
│ │ │ ├── attrs.xml
│ │ │ ├── bools.xml
│ │ │ ├── colors.xml
│ │ │ ├── colors_kotatsu.xml
│ │ │ ├── colors_themed.xml
│ │ │ ├── constants.xml
│ │ │ ├── dimens.xml
│ │ │ ├── ids.xml
│ │ │ ├── integers.xml
│ │ │ ├── plurals.xml
│ │ │ ├── strings.xml
│ │ │ ├── styles.xml
│ │ │ ├── themes.xml
│ │ │ └── themes_colored.xml
│ │ ├── values-ab/
│ │ │ └── plurals.xml
│ │ ├── values-ar/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-arq/
│ │ │ └── plurals.xml
│ │ ├── values-arz/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-as/
│ │ │ └── plurals.xml
│ │ ├── values-b+yue+Hant/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-bci/
│ │ │ └── plurals.xml
│ │ ├── values-be/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-bn/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-ca/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-ckb/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-cs/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-de/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-el/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-en-rGB/
│ │ │ └── strings.xml
│ │ ├── values-enm/
│ │ │ └── plurals.xml
│ │ ├── values-es/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-et/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-eu/
│ │ │ └── plurals.xml
│ │ ├── values-fa/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-fi/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-fil/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-fr/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-frp/
│ │ │ └── plurals.xml
│ │ ├── values-got/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-gu/
│ │ │ └── strings.xml
│ │ ├── values-hi/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-hr/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-hu/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-in/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-it/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-iw/
│ │ │ └── plurals.xml
│ │ ├── values-ja/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-jv/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-kk/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-km/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-ko/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-ldrtl/
│ │ │ └── strings.xml
│ │ ├── values-lt/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-lv/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-lzh/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-ml/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-ms/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-my/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-nb-rNO/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-ne/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-night/
│ │ │ ├── bools.xml
│ │ │ ├── colors.xml
│ │ │ ├── colors_kotatsu.xml
│ │ │ ├── colors_themed.xml
│ │ │ └── themes.xml
│ │ ├── values-night-v31/
│ │ │ └── themes.xml
│ │ ├── values-nl/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-nn/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-or/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-pa/
│ │ │ └── strings.xml
│ │ ├── values-pa-rPK/
│ │ │ └── strings.xml
│ │ ├── values-pl/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-pt/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-pt-rBR/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-ro/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-ru/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-si/
│ │ │ └── strings.xml
│ │ ├── values-sr/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-sv/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-sw360dp/
│ │ │ └── bools.xml
│ │ ├── values-ta/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-te/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-th/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-tr/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-uk/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-v27/
│ │ │ ├── bools.xml
│ │ │ └── themes.xml
│ │ ├── values-v31/
│ │ │ ├── dimens.xml
│ │ │ └── themes.xml
│ │ ├── values-v33/
│ │ │ └── bools.xml
│ │ ├── values-vi/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-w600dp-land/
│ │ │ ├── bools.xml
│ │ │ ├── dimens.xml
│ │ │ └── integers.xml
│ │ ├── values-zh-rCN/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── values-zh-rTW/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ └── xml/
│ │ ├── authenticator_sync.xml
│ │ ├── backup_content.xml
│ │ ├── backup_rules.xml
│ │ ├── filepaths.xml
│ │ ├── locales_config.xml
│ │ ├── network_security_config.xml
│ │ ├── pref_about.xml
│ │ ├── pref_appearance.xml
│ │ ├── pref_backup_periodic.xml
│ │ ├── pref_backups.xml
│ │ ├── pref_data_cleanup.xml
│ │ ├── pref_discord.xml
│ │ ├── pref_downloads.xml
│ │ ├── pref_network_storage.xml
│ │ ├── pref_notifications.xml
│ │ ├── pref_proxy.xml
│ │ ├── pref_reader.xml
│ │ ├── pref_root.xml
│ │ ├── pref_services.xml
│ │ ├── pref_source.xml
│ │ ├── pref_source_parser.xml
│ │ ├── pref_sources.xml
│ │ ├── pref_suggestions.xml
│ │ ├── pref_sync.xml
│ │ ├── pref_sync_header.xml
│ │ ├── pref_tracker.xml
│ │ ├── remote_action.xml
│ │ ├── sync_favourites.xml
│ │ ├── sync_history.xml
│ │ ├── widget_recent.xml
│ │ └── widget_shelf.xml
│ ├── nightly/
│ │ ├── kotlin/
│ │ │ └── org/
│ │ │ └── koitharu/
│ │ │ └── kotatsu/
│ │ │ ├── KotatsuApp.kt
│ │ │ ├── core/
│ │ │ │ ├── network/
│ │ │ │ │ └── CurlLoggingInterceptor.kt
│ │ │ │ ├── parser/
│ │ │ │ │ └── TestMangaRepository.kt
│ │ │ │ ├── ui/
│ │ │ │ │ └── BaseService.kt
│ │ │ │ └── util/
│ │ │ │ └── ext/
│ │ │ │ └── Debug.kt
│ │ │ └── settings/
│ │ │ └── DebugSettingsFragment.kt
│ │ └── res/
│ │ ├── drawable/
│ │ │ └── ic_debug.xml
│ │ ├── mipmap-anydpi-v26/
│ │ │ ├── ic_launcher.xml
│ │ │ └── ic_launcher_round.xml
│ │ ├── values/
│ │ │ ├── bools.xml
│ │ │ ├── constants.xml
│ │ │ └── strings.xml
│ │ └── xml/
│ │ ├── pref_debug.xml
│ │ └── pref_root_debug.xml
│ ├── release/
│ │ ├── kotlin/
│ │ │ └── org/
│ │ │ └── koitharu/
│ │ │ └── kotatsu/
│ │ │ ├── KotatsuApp.kt
│ │ │ └── core/
│ │ │ ├── network/
│ │ │ │ └── CurlLoggingInterceptor.kt
│ │ │ ├── parser/
│ │ │ │ └── TestMangaRepository.kt
│ │ │ ├── ui/
│ │ │ │ └── BaseService.kt
│ │ │ └── util/
│ │ │ └── ext/
│ │ │ └── Debug.kt
│ │ └── res/
│ │ ├── values/
│ │ │ └── bools.xml
│ │ └── xml/
│ │ └── pref_root_debug.xml
│ └── test/
│ └── kotlin/
│ └── org/
│ └── koitharu/
│ └── kotatsu/
│ ├── core/
│ │ └── github/
│ │ └── VersionIdTest.kt
│ ├── reader/
│ │ └── domain/
│ │ └── ChapterPagesTest.kt
│ └── util/
│ └── MultiMutexTest.kt
├── build.gradle
├── gradle/
│ ├── libs.versions.toml
│ └── wrapper/
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradle.properties
├── gradlew
├── gradlew.bat
├── metadata/
│ ├── en-US/
│ │ ├── full_description.txt
│ │ └── short_description.txt
│ ├── ru/
│ │ ├── full_description.txt
│ │ └── short_description.txt
│ └── vi/
│ ├── full_description.txt
│ └── short_description.txt
└── settings.gradle
================================================
FILE CONTENTS
================================================
================================================
FILE: .editorconfig
================================================
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
max_line_length = 120
tab_width = 4
# noinspection EditorConfigKeyCorrectness
disabled_rules = no-wildcard-imports, no-unused-imports
[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.rng,*.tld,*.wsdl,*.xml,*.xsd,*.xsl,*.xslt,*.xul}]
ij_continuation_indent_size = 4
ij_xml_attribute_wrap = on_every_item
[{*.kt,*.kts}]
ij_kotlin_allow_trailing_comma_on_call_site = true
ij_kotlin_allow_trailing_comma = true
ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL
================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
blank_issues_enabled: false
contact_links:
- name: ⚠️ Source issue
url: https://github.com/KotatsuApp/kotatsu-parsers/issues/new
about: If you have a problem with a specific **manga source** or want to propose a new one, please open an issue in the kotatsu-parsers repository instead
================================================
FILE: .github/ISSUE_TEMPLATE/report_bug.yml
================================================
name: 🐞 Bug report
description: Report a bug in Kotatsu
labels: [bug]
body:
- type: textarea
id: summary
attributes:
label: Brief summary
description: Please describe, what went wrong
validations:
required: true
- type: textarea
id: reproduce-steps
attributes:
label: Steps to reproduce
description: Please provide a way to reproduce this issue. Screenshots or videos can be very helpful
placeholder: |
Example:
1. First step
2. Second step
3. Issue here
validations:
required: false
- type: input
id: kotatsu-version
attributes:
label: Kotatsu version
description: You can find your Kotatsu version in **Settings → About**.
placeholder: |
Example: "3.3"
validations:
required: true
- type: input
id: android-version
attributes:
label: Android version
description: You can find this somewhere in your Android settings.
placeholder: |
Example: "12.0"
validations:
required: true
- type: input
id: device
attributes:
label: Device
description: List your device and model.
placeholder: |
Example: "LG Nexus 5X"
validations:
required: false
- type: checkboxes
id: acknowledgements
attributes:
label: Acknowledgements
options:
- label: This is not a duplicate of an existing issue. Please look through the list of [open issues](https://github.com/KotatsuApp/Kotatsu/issues) before creating a new one.
required: true
- label: This is not an issue with a specific manga source. Otherwise, you have to open an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new/choose).
required: true
================================================
FILE: .github/ISSUE_TEMPLATE/request_feature.yml
================================================
name: ⭐ Feature request
description: Suggest a new idea how to improve Kotatsu
labels: [feature request]
body:
- type: textarea
id: feature-description
attributes:
label: Describe your suggested feature
description: How can Kotatsu be improved?
placeholder: |
Example:
"It should work like this..."
validations:
required: true
- type: checkboxes
id: acknowledgements
attributes:
label: Acknowledgements
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
options:
- label: This is not a duplicate of an existing issue. Please look through the list of [open issues](https://github.com/KotatsuApp/Kotatsu/issues) before creating a new one.
required: true
================================================
FILE: .github/ISSUE_TEMPLATE.md
================================================
**PLEASE READ THIS**
I acknowledge that:
- I have updated to the latest version of the app (https://github.com/KotatsuApp/Kotatsu/releases/latest)
- If this is an issue with a parser, that I should be opening an issue in https://github.com/KotatsuApp/kotatsu-parsers
- I have searched the existing issues and this is new ticket **NOT** a duplicate or related to another open or closed issue
- I will fill out the title and the information in this template
Note that the issue will be automatically closed if you do not fill out the title or requested information.
**DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT**
---
## Device information
* Kotatsu version: ?
* Android version: ?
* Device: ?
## Steps to reproduce
1. First step
2. Second step
## Issue/Request
?
## Other details
Additional details and attachments.
================================================
FILE: .github/workflows/issue_moderator.yml
================================================
name: Issue moderator
on:
issues:
types: [opened, edited, reopened]
issue_comment:
types: [created]
jobs:
moderate:
runs-on: ubuntu-latest
steps:
- name: Moderate issues
uses: tachiyomiorg/issue-moderator-action@v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
auto-close-rules: |
[
{
"type": "body",
"regex": ".*DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT.*",
"message": "The acknowledgment section was not removed."
},
{
"type": "body",
"regex": ".*\\* (Kotatsu version|Android version|Device): \\?.*",
"message": "Requested information in the template was not filled out."
}
]
================================================
FILE: .github/workflows/trigger-site-deploy.yml
================================================
name: Trigger Site Update
on:
release:
types: [published]
jobs:
trigger-site:
runs-on: ubuntu-latest
steps:
- name: Send repository_dispatch to site-repo
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.SITE_REPO_TOKEN }}
repository: KotatsuApp/website
event-type: app-release
================================================
FILE: .gitignore
================================================
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/dictionaries
/.idea/modules.xml
/.idea/misc.xml
/.idea/markdown.xml
/.idea/discord.xml
/.idea/compiler.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/ktlint-plugin.xml
/.idea/assetWizardSettings.xml
/.idea/kotlinScripting.xml
/.idea/kotlinc.xml
/.idea/deploymentTargetDropDown.xml
/.idea/androidTestResultsUserPreferences.xml
/.idea/deploymentTargetSelector.xml
/.idea/render.experimental.xml
/.idea/inspectionProfiles/
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
/.idea/deviceManager.xml
/.kotlin/
/.idea/AndroidProjectSystem.xml
================================================
FILE: .idea/.gitignore
================================================
# Default ignored files
/shelf/
/workspace.xml
/migrations.xml
/runConfigurations.xml
/appInsightsSettings.xml
/kotlinCodeInsightSettings.xml
================================================
FILE: .idea/appInsightsSettings.xml
================================================
================================================
FILE: .idea/codeStyles/Project.xml
================================================
.*:id
http://schemas.android.com/apk/res/android
.*:name
http://schemas.android.com/apk/res/android
.*
http://schemas.android.com/apk/res/android
ANDROID_ATTRIBUTE_ORDER
================================================
FILE: .idea/codeStyles/codeStyleConfig.xml
================================================
================================================
FILE: .idea/gradle.xml
================================================
================================================
FILE: .idea/jarRepositories.xml
================================================
================================================
FILE: .idea/kotlinCodeInsightSettings.xml
================================================
================================================
FILE: .idea/ktlint.xml
================================================
false
true
false
================================================
FILE: .idea/vcs.xml
================================================
================================================
FILE: .weblate
================================================
[weblate]
url = https://hosted.weblate.org/api/
translation = kotatsu/strings
================================================
FILE: CONTRIBUTING.md
================================================
## Kotatsu contribution guidelines
+ If you want to **fix bugs** or **implement new features** that **already have an [issue card](https://github.com/KotatsuApp/Kotatsu/issues):** please assign this issue to you and/or comment about it.
+ If you want to **implement a new feature:** open an issue or discussion regarding it to ensure it will be accepted.
+ **Translations** have to be managed using the [Weblate](https://hosted.weblate.org/engage/kotatsu/) platform.
+ In case you want to **add a new manga source,** refer to the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers).
**Refactoring** or some **dev-faces improvements** might also be accepted. However, please stick to the following principles:
+ **Performance matters.** In the case of choosing between source code beauty and performance, performance should be a priority.
+ Please, **do not modify readme and other information files** (except for typos).
+ **Avoid adding new dependencies** unless required. APK size is important.
================================================
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
================================================
FILE: README.md
================================================
> [!IMPORTANT]
> In light of recent challenges — including threating actions from Kakao Entertainment Corp and upcoming Google’s
> [new sideloading policy](https://f-droid.org/ru/2025/10/28/sideloading.html) — we’ve made the difficult decision to shut down Kotatsu and end its support. We’re deeply grateful
> to everyone who contributed and to the amazing community that grew around this project.
---
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in
online content sources.**
 [](https://github.com/KotatsuApp/kotatsu-parsers) [](https://hosted.weblate.org/engage/kotatsu/) [](https://discord.gg/NNJ5RgVBC5) [](https://t.me/kotatsuapp) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
### Main Features
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers) (with 1200+ manga sources)
* Search manga by name, genres and more filters
* Favorites organized by user-defined categories
* Reading history, bookmarks and incognito mode support
* Download manga and read it offline. Third-party CBZ archives are also supported
* Clean and convenient Material You UI, optimized for phones, tablets and desktop
* Standard and Webtoon-optimized customizable reader, gesture support on reading interface
* Notifications about new chapters with updates feed, manga recommendations (with filters)
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
* Password / fingerprint-protected access to the app
* Automatically sync app data with other devices on the same account
* Support for older devices running Android 6.0+
### In-App Screenshots
### Localization
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is localized in a number of different languages.**
**📌 If you would like to help improve these or add new languages,
please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/)**
### Contributing
**📌 Pull requests are welcome, if you want:
See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines**
### Certificate fingerprints
```plaintext
2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE
```
```plaintext
67:E1:51:00:BB:80:93:01:78:3E:DC:B6:34:8F:A3:BB:F8:30:34:D9:1E:62:86:8A:91:05:3D:BD:70:DB:3F:18
```
### License
[](http://www.gnu.org/licenses/gpl-3.0.en.html)
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications
to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build &
install instructions.
### DMCA disclaimer
The developers of this application do not have any affiliation with the content available in the app and does not store
or distribute any content. This application should be considered a web browser, all content that can be found using this
application is freely available on the Internet. All DMCA takedown requests should be sent to the owners of the website
where the content is hosted.
================================================
FILE: app/.gitignore
================================================
/build
/schemas/
================================================
FILE: app/build.gradle
================================================
import java.time.LocalDateTime
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'com.google.devtools.ksp'
id 'kotlin-parcelize'
id 'dagger.hilt.android.plugin'
id 'androidx.room'
id 'org.jetbrains.kotlin.plugin.serialization'
// enable if needed
// id 'dev.reformator.stacktracedecoroutinator'
}
android {
compileSdk = 36
buildToolsVersion = '35.0.0'
namespace = 'org.koitharu.kotatsu'
defaultConfig {
applicationId 'org.koitharu.kotatsu'
minSdk = 23
targetSdk = 36
versionCode = 1033
versionName = '9.4.1'
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {
arg('room.generateKotlin', 'true')
}
androidResources {
// https://issuetracker.google.com/issues/408030127
generateLocaleConfig false
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localProperties.load(new FileInputStream(localPropertiesFile))
}
resValue 'string', 'tg_backup_bot_token', localProperties.getProperty('tg_backup_bot_token', '')
}
buildTypes {
debug {
applicationIdSuffix = '.debug'
}
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
nightly {
initWith release
applicationIdSuffix = '.nightly'
}
}
buildFeatures {
viewBinding true
buildConfig true
}
packagingOptions {
resources {
excludes += [
'META-INF/README.md',
'META-INF/NOTICE.md'
]
}
}
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
main.java.srcDirs += 'src/main/kotlin/'
}
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
freeCompilerArgs += [
'-opt-in=kotlin.ExperimentalStdlibApi',
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
'-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi',
'-opt-in=kotlinx.coroutines.InternalForInheritanceCoroutinesApi',
'-opt-in=kotlinx.coroutines.FlowPreview',
'-opt-in=kotlin.contracts.ExperimentalContracts',
'-opt-in=coil3.annotation.ExperimentalCoilApi',
'-opt-in=coil3.annotation.InternalCoilApi',
'-opt-in=kotlinx.serialization.ExperimentalSerializationApi',
'-Xjspecify-annotations=strict',
'-Xannotation-default-target=first-only',
'-Xtype-enhancement-improvements-strict-mode'
]
}
room {
schemaDirectory "$projectDir/schemas"
}
lint {
abortOnError true
disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled', 'SimpleDateFormat'
}
testOptions {
unitTests.includeAndroidResources true
unitTests.returnDefaultValues false
kotlinOptions {
freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
}
}
applicationVariants.configureEach { variant ->
if (variant.name == 'nightly') {
variant.outputs.each { output ->
def now = LocalDateTime.now()
output.versionCodeOverride = now.format("yyMMdd").toInteger()
output.versionNameOverride = 'N' + now.format("yyyyMMdd")
}
}
}
}
dependencies {
def parsersVersion = libs.versions.parsers.get()
if (System.properties.containsKey('parsersVersionOverride')) {
// usage:
// -DparsersVersionOverride=$(curl -s https://api.github.com/repos/kotatsuapp/kotatsu-parsers/commits/master -H "Accept: application/vnd.github.sha" | cut -c -10)
parsersVersion = System.getProperty('parsersVersionOverride')
}
//noinspection UseTomlInstead
implementation("com.github.KotatsuApp:kotatsu-parsers:$parsersVersion") {
exclude group: 'org.json', module: 'json'
}
coreLibraryDesugaring libs.desugar.jdk.libs
implementation libs.kotlin.stdlib
implementation libs.kotlinx.coroutines.android
implementation libs.kotlinx.coroutines.guava
implementation libs.androidx.appcompat
implementation libs.androidx.core
implementation libs.androidx.activity
implementation libs.androidx.fragment
implementation libs.androidx.transition
implementation libs.androidx.collection
implementation libs.lifecycle.viewmodel
implementation libs.lifecycle.service
implementation libs.lifecycle.process
implementation libs.androidx.constraintlayout
implementation libs.androidx.documentfile
implementation libs.androidx.swiperefreshlayout
implementation libs.androidx.recyclerview
implementation libs.androidx.viewpager2
implementation libs.androidx.preference
implementation libs.androidx.biometric
implementation libs.material
implementation libs.androidx.lifecycle.common.java8
implementation libs.androidx.webkit
implementation libs.androidx.work.runtime
implementation libs.guava
// Foldable/Window layout
implementation libs.androidx.window
implementation libs.androidx.room.runtime
implementation libs.androidx.room.ktx
ksp libs.androidx.room.compiler
implementation libs.okhttp
implementation libs.okhttp.tls
implementation libs.okhttp.dnsoverhttps
implementation libs.okio
implementation libs.kotlinx.serialization.json
implementation libs.adapterdelegates
implementation libs.adapterdelegates.viewbinding
implementation libs.hilt.android
ksp libs.hilt.compiler
implementation libs.androidx.hilt.work
ksp libs.androidx.hilt.compiler
implementation libs.coil.core
implementation libs.coil.network
implementation libs.coil.gif
implementation libs.coil.svg
implementation libs.avif.decoder
implementation libs.ssiv
implementation libs.disk.lru.cache
implementation libs.markwon
implementation libs.kizzyrpc
implementation libs.acra.http
implementation libs.acra.dialog
implementation libs.conscrypt.android
debugImplementation libs.leakcanary.android
nightlyImplementation libs.leakcanary.android
debugImplementation libs.workinspector
testImplementation libs.junit
testImplementation libs.json
testImplementation libs.kotlinx.coroutines.test
androidTestImplementation libs.androidx.runner
androidTestImplementation libs.androidx.rules
androidTestImplementation libs.androidx.test.core
androidTestImplementation libs.androidx.junit
androidTestImplementation libs.kotlinx.coroutines.test
androidTestImplementation libs.androidx.room.testing
androidTestImplementation libs.moshi.kotlin
androidTestImplementation libs.hilt.android.testing
kspAndroidTest libs.hilt.android.compiler
}
================================================
FILE: app/libs/.gitkeep
================================================
================================================
FILE: app/proguard-rules.pro
================================================
-optimizationpasses 8
-dontobfuscate
-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
public static void checkExpressionValueIsNotNull(...);
public static void checkNotNullExpressionValue(...);
public static void checkReturnedValueIsNotNull(...);
public static void checkFieldIsNotNull(...);
public static void checkParameterIsNotNull(...);
public static void checkNotNullParameter(...);
}
-dontwarn okhttp3.internal.platform.**
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**
-dontwarn com.google.j2objc.annotations.**
-dontwarn coil3.PlatformContext
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
-keep class org.koitharu.kotatsu.settings.about.changelog.ChangelogFragment
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
-keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; }
-keep class org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupSettingsFragment { *; }
-keep class org.jsoup.parser.Tag
-keep class org.jsoup.internal.StringUtil
-keep class org.acra.security.NoKeyStoreFactory { *; }
-keep class org.acra.config.DefaultRetryPolicy { *; }
-keep class org.acra.attachment.DefaultAttachmentProvider { *; }
-keep class org.acra.sender.JobSenderService
================================================
FILE: app/src/androidTest/assets/categories/simple.json
================================================
{
"id": 4,
"title": "Read later",
"sortKey": 1,
"order": "NEWEST",
"createdAt": 1335906000000,
"isTrackingEnabled": true,
"isVisibleInLibrary": true
}
================================================
FILE: app/src/androidTest/assets/manga/bad_ids.json
================================================
{
"id": -2096681732556647985,
"title": "Странствия Эманон",
"altTitles": [],
"url": "/stranstviia_emanon",
"publicUrl": "https://readmanga.io/stranstviia_emanon",
"rating": 0.9400894,
"isNsfw": true,
"coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
"tags": [
{
"title": "Сверхъестественное",
"key": "supernatural",
"source": "READMANGA_RU"
},
{
"title": "Сэйнэн",
"key": "seinen",
"source": "READMANGA_RU"
},
{
"title": "Повседневность",
"key": "slice_of_life",
"source": "READMANGA_RU"
},
{
"title": "Приключения",
"key": "adventure",
"source": "READMANGA_RU"
}
],
"state": "FINISHED",
"authors": [],
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n
Начало истории читайте в \"Воспоминаниях Эманон\". \n
",
"chapters": [
{
"id": 1552943969433540704,
"title": "1 - 1",
"number": 1,
"volume": 0,
"url": "/stranstviia_emanon/vol1/1",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433540705,
"title": "1 - 2",
"number": 2,
"volume": 0,
"url": "/stranstviia_emanon/vol1/2",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433540706,
"title": "1 - 3",
"number": 3,
"volume": 0,
"url": "/stranstviia_emanon/vol1/3",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433540707,
"title": "1 - 4",
"number": 4,
"volume": 0,
"url": "/stranstviia_emanon/vol1/4",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433540708,
"title": "1 - 5",
"number": 5,
"volume": 0,
"url": "/stranstviia_emanon/vol1/5",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433541665,
"title": "2 - 1",
"number": 6,
"volume": 0,
"url": "/stranstviia_emanon/vol2/1",
"scanlator": "Sup!",
"uploadDate": 1415570400000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433541666,
"title": "2 - 2",
"number": 7,
"volume": 0,
"url": "/stranstviia_emanon/vol2/2",
"scanlator": "Sup!",
"uploadDate": 1419976800000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433541667,
"title": "2 - 3",
"number": 8,
"volume": 0,
"url": "/stranstviia_emanon/vol2/3",
"scanlator": "Sup!",
"uploadDate": 1427922000000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433541668,
"title": "2 - 4",
"number": 9,
"volume": 0,
"url": "/stranstviia_emanon/vol2/4",
"scanlator": "Sup!",
"uploadDate": 1436907600000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433541669,
"title": "2 - 5",
"number": 10,
"volume": 0,
"url": "/stranstviia_emanon/vol2/5",
"scanlator": "Sup!",
"uploadDate": 1446674400000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433541670,
"title": "2 - 6",
"number": 11,
"volume": 0,
"url": "/stranstviia_emanon/vol2/6",
"scanlator": "Sup!",
"uploadDate": 1451512800000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433542626,
"title": "3 - 1",
"number": 12,
"volume": 0,
"url": "/stranstviia_emanon/vol3/1",
"scanlator": "Sup!",
"uploadDate": 1461618000000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433542627,
"title": "3 - 2",
"number": 13,
"volume": 0,
"url": "/stranstviia_emanon/vol3/2",
"scanlator": "Sup!",
"uploadDate": 1461618000000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433542628,
"title": "3 - 3",
"number": 14,
"volume": 0,
"url": "/stranstviia_emanon/vol3/3",
"scanlator": "",
"uploadDate": 1465851600000,
"source": "READMANGA_RU"
}
],
"source": "READMANGA_RU"
}
================================================
FILE: app/src/androidTest/assets/manga/empty.json
================================================
{
"id": -2096681732556647985,
"title": "Странствия Эманон",
"altTitles": [],
"url": "/stranstviia_emanon",
"publicUrl": "https://readmanga.io/stranstviia_emanon",
"rating": 0.9400894,
"isNsfw": true,
"coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
"tags": [
{
"title": "Сверхъестественное",
"key": "supernatural",
"source": "READMANGA_RU"
},
{
"title": "Сэйнэн",
"key": "seinen",
"source": "READMANGA_RU"
},
{
"title": "Повседневность",
"key": "slice_of_life",
"source": "READMANGA_RU"
},
{
"title": "Приключения",
"key": "adventure",
"source": "READMANGA_RU"
}
],
"state": "FINISHED",
"authors": [],
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n
Начало истории читайте в \"Воспоминаниях Эманон\". \n
",
"chapters": [],
"source": "READMANGA_RU"
}
================================================
FILE: app/src/androidTest/assets/manga/first_chapters.json
================================================
{
"id": -2096681732556647985,
"title": "Странствия Эманон",
"altTitles": [],
"url": "/stranstviia_emanon",
"publicUrl": "https://readmanga.io/stranstviia_emanon",
"rating": 0.9400894,
"isNsfw": true,
"coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
"tags": [
{
"title": "Сверхъестественное",
"key": "supernatural",
"source": "READMANGA_RU"
},
{
"title": "Сэйнэн",
"key": "seinen",
"source": "READMANGA_RU"
},
{
"title": "Повседневность",
"key": "slice_of_life",
"source": "READMANGA_RU"
},
{
"title": "Приключения",
"key": "adventure",
"source": "READMANGA_RU"
}
],
"state": "FINISHED",
"authors": [],
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n
Начало истории читайте в \"Воспоминаниях Эманон\". \n
",
"chapters": [
{
"id": 3552943969433540704,
"title": "1 - 1",
"number": 1,
"volume": 0,
"url": "/stranstviia_emanon/vol1/1",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540705,
"title": "1 - 2",
"number": 2,
"volume": 0,
"url": "/stranstviia_emanon/vol1/2",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540706,
"title": "1 - 3",
"number": 3,
"volume": 0,
"url": "/stranstviia_emanon/vol1/3",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540707,
"title": "1 - 4",
"number": 4,
"volume": 0,
"url": "/stranstviia_emanon/vol1/4",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540708,
"title": "1 - 5",
"number": 5,
"volume": 0,
"url": "/stranstviia_emanon/vol1/5",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541665,
"title": "2 - 1",
"number": 6,
"volume": 0,
"url": "/stranstviia_emanon/vol2/1",
"scanlator": "Sup!",
"uploadDate": 1415570400000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541666,
"title": "2 - 2",
"number": 7,
"volume": 0,
"url": "/stranstviia_emanon/vol2/2",
"scanlator": "Sup!",
"uploadDate": 1419976800000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541667,
"title": "2 - 3",
"number": 8,
"volume": 0,
"url": "/stranstviia_emanon/vol2/3",
"scanlator": "Sup!",
"uploadDate": 1427922000000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541668,
"title": "2 - 4",
"number": 9,
"volume": 0,
"url": "/stranstviia_emanon/vol2/4",
"scanlator": "Sup!",
"uploadDate": 1436907600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541669,
"title": "2 - 5",
"number": 10,
"volume": 0,
"url": "/stranstviia_emanon/vol2/5",
"scanlator": "Sup!",
"uploadDate": 1446674400000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541670,
"title": "2 - 6",
"number": 11,
"volume": 0,
"url": "/stranstviia_emanon/vol2/6",
"scanlator": "Sup!",
"uploadDate": 1451512800000,
"source": "READMANGA_RU"
}
],
"source": "READMANGA_RU"
}
================================================
FILE: app/src/androidTest/assets/manga/full.json
================================================
{
"id": -2096681732556647985,
"title": "Странствия Эманон",
"altTitles": [],
"url": "/stranstviia_emanon",
"publicUrl": "https://readmanga.io/stranstviia_emanon",
"rating": 0.9400894,
"isNsfw": true,
"coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
"tags": [
{
"title": "Сверхъестественное",
"key": "supernatural",
"source": "READMANGA_RU"
},
{
"title": "Сэйнэн",
"key": "seinen",
"source": "READMANGA_RU"
},
{
"title": "Повседневность",
"key": "slice_of_life",
"source": "READMANGA_RU"
},
{
"title": "Приключения",
"key": "adventure",
"source": "READMANGA_RU"
}
],
"state": "FINISHED",
"authors": [],
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n
Начало истории читайте в \"Воспоминаниях Эманон\". \n
",
"chapters": [
{
"id": 3552943969433540704,
"title": "1 - 1",
"number": 1,
"volume": 0,
"url": "/stranstviia_emanon/vol1/1",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540705,
"title": "1 - 2",
"number": 2,
"volume": 0,
"url": "/stranstviia_emanon/vol1/2",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540706,
"title": "1 - 3",
"number": 3,
"volume": 0,
"url": "/stranstviia_emanon/vol1/3",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540707,
"title": "1 - 4",
"number": 4,
"volume": 0,
"url": "/stranstviia_emanon/vol1/4",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540708,
"title": "1 - 5",
"number": 5,
"volume": 0,
"url": "/stranstviia_emanon/vol1/5",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541665,
"title": "2 - 1",
"number": 6,
"volume": 0,
"url": "/stranstviia_emanon/vol2/1",
"scanlator": "Sup!",
"uploadDate": 1415570400000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541666,
"title": "2 - 2",
"number": 7,
"volume": 0,
"url": "/stranstviia_emanon/vol2/2",
"scanlator": "Sup!",
"uploadDate": 1419976800000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541667,
"title": "2 - 3",
"number": 8,
"volume": 0,
"url": "/stranstviia_emanon/vol2/3",
"scanlator": "Sup!",
"uploadDate": 1427922000000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541668,
"title": "2 - 4",
"number": 9,
"volume": 0,
"url": "/stranstviia_emanon/vol2/4",
"scanlator": "Sup!",
"uploadDate": 1436907600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541669,
"title": "2 - 5",
"number": 10,
"volume": 0,
"url": "/stranstviia_emanon/vol2/5",
"scanlator": "Sup!",
"uploadDate": 1446674400000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541670,
"title": "2 - 6",
"number": 11,
"volume": 0,
"url": "/stranstviia_emanon/vol2/6",
"scanlator": "Sup!",
"uploadDate": 1451512800000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433542626,
"title": "3 - 1",
"number": 12,
"volume": 0,
"url": "/stranstviia_emanon/vol3/1",
"scanlator": "Sup!",
"uploadDate": 1461618000000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433542627,
"title": "3 - 2",
"number": 13,
"volume": 0,
"url": "/stranstviia_emanon/vol3/2",
"scanlator": "Sup!",
"uploadDate": 1461618000000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433542628,
"title": "3 - 3",
"number": 14,
"volume": 0,
"url": "/stranstviia_emanon/vol3/3",
"scanlator": "",
"uploadDate": 1465851600000,
"source": "READMANGA_RU"
}
],
"source": "READMANGA_RU"
}
================================================
FILE: app/src/androidTest/assets/manga/header.json
================================================
{
"id": -2096681732556647985,
"title": "Странствия Эманон",
"altTitles": [],
"url": "/stranstviia_emanon",
"publicUrl": "https://readmanga.io/stranstviia_emanon",
"rating": 0.9400894,
"isNsfw": true,
"coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
"tags": [
{
"title": "Сверхъестественное",
"key": "supernatural",
"source": "READMANGA_RU"
},
{
"title": "Сэйнэн",
"key": "seinen",
"source": "READMANGA_RU"
},
{
"title": "Повседневность",
"key": "slice_of_life",
"source": "READMANGA_RU"
},
{
"title": "Приключения",
"key": "adventure",
"source": "READMANGA_RU"
}
],
"state": "FINISHED",
"authors": [],
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
"description": null,
"source": "READMANGA_RU"
}
================================================
FILE: app/src/androidTest/assets/manga/without_middle_chapter.json
================================================
{
"id": -2096681732556647985,
"title": "Странствия Эманон",
"altTitles": [],
"url": "/stranstviia_emanon",
"publicUrl": "https://readmanga.io/stranstviia_emanon",
"rating": 0.9400894,
"isNsfw": true,
"coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
"tags": [
{
"title": "Сверхъестественное",
"key": "supernatural",
"source": "READMANGA_RU"
},
{
"title": "Сэйнэн",
"key": "seinen",
"source": "READMANGA_RU"
},
{
"title": "Повседневность",
"key": "slice_of_life",
"source": "READMANGA_RU"
},
{
"title": "Приключения",
"key": "adventure",
"source": "READMANGA_RU"
}
],
"state": "FINISHED",
"authors": [],
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n
Начало истории читайте в \"Воспоминаниях Эманон\". \n
",
"chapters": [
{
"id": 3552943969433540704,
"title": "1 - 1",
"number": 1,
"volume": 0,
"url": "/stranstviia_emanon/vol1/1",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540705,
"title": "1 - 2",
"number": 2,
"volume": 0,
"url": "/stranstviia_emanon/vol1/2",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540706,
"title": "1 - 3",
"number": 3,
"volume": 0,
"url": "/stranstviia_emanon/vol1/3",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540707,
"title": "1 - 4",
"number": 4,
"volume": 0,
"url": "/stranstviia_emanon/vol1/4",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540708,
"title": "1 - 5",
"number": 5,
"volume": 0,
"url": "/stranstviia_emanon/vol1/5",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541666,
"title": "2 - 2",
"number": 7,
"volume": 0,
"url": "/stranstviia_emanon/vol2/2",
"scanlator": "Sup!",
"uploadDate": 1419976800000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541667,
"title": "2 - 3",
"number": 8,
"volume": 0,
"url": "/stranstviia_emanon/vol2/3",
"scanlator": "Sup!",
"uploadDate": 1427922000000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541668,
"title": "2 - 4",
"number": 9,
"volume": 0,
"url": "/stranstviia_emanon/vol2/4",
"scanlator": "Sup!",
"uploadDate": 1436907600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541669,
"title": "2 - 5",
"number": 10,
"volume": 0,
"url": "/stranstviia_emanon/vol2/5",
"scanlator": "Sup!",
"uploadDate": 1446674400000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541670,
"title": "2 - 6",
"number": 11,
"volume": 0,
"url": "/stranstviia_emanon/vol2/6",
"scanlator": "Sup!",
"uploadDate": 1451512800000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433542626,
"title": "3 - 1",
"number": 12,
"volume": 0,
"url": "/stranstviia_emanon/vol3/1",
"scanlator": "Sup!",
"uploadDate": 1461618000000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433542627,
"title": "3 - 2",
"number": 13,
"volume": 0,
"url": "/stranstviia_emanon/vol3/2",
"scanlator": "Sup!",
"uploadDate": 1461618000000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433542628,
"title": "3 - 3",
"number": 14,
"volume": 0,
"url": "/stranstviia_emanon/vol3/3",
"scanlator": "",
"uploadDate": 1465851600000,
"source": "READMANGA_RU"
}
],
"source": "READMANGA_RU"
}
================================================
FILE: app/src/androidTest/kotlin/org/koitharu/kotatsu/HiltTestRunner.kt
================================================
package org.koitharu.kotatsu
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication
class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}
================================================
FILE: app/src/androidTest/kotlin/org/koitharu/kotatsu/Instrumentation.kt
================================================
package org.koitharu.kotatsu
import android.app.Instrumentation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
suspend fun Instrumentation.awaitForIdle() = suspendCoroutine
{ cont ->
waitForIdle { cont.resume(Unit) }
}
================================================
FILE: app/src/androidTest/kotlin/org/koitharu/kotatsu/SampleData.kt
================================================
package org.koitharu.kotatsu
import androidx.test.platform.app.InstrumentationRegistry
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
import com.squareup.moshi.Moshi
import com.squareup.moshi.ToJson
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okio.buffer
import okio.source
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import java.time.Instant
import java.util.Date
import kotlin.reflect.KClass
object SampleData {
private val moshi = Moshi.Builder()
.add(DateAdapter())
.add(InstantAdapter())
.add(MangaSourceAdapter())
.add(KotlinJsonAdapterFactory())
.build()
val manga: Manga = loadAsset("manga/header.json", Manga::class)
val mangaDetails: Manga = loadAsset("manga/full.json", Manga::class)
val tag = mangaDetails.tags.elementAt(2)
val chapter = checkNotNull(mangaDetails.chapters)[2]
val favouriteCategory: FavouriteCategory = loadAsset("categories/simple.json", FavouriteCategory::class)
fun loadAsset(name: String, cls: KClass): T {
val assets = InstrumentationRegistry.getInstrumentation().context.assets
return assets.open(name).use {
moshi.adapter(cls.java).fromJson(it.source().buffer())
} ?: throw RuntimeException("Cannot read asset from json \"$name\"")
}
private class DateAdapter : JsonAdapter() {
@FromJson
override fun fromJson(reader: JsonReader): Date? {
val ms = reader.nextLong()
return if (ms == 0L) {
null
} else {
Date(ms)
}
}
@ToJson
override fun toJson(writer: JsonWriter, value: Date?) {
writer.value(value?.time ?: 0L)
}
}
private class MangaSourceAdapter : JsonAdapter() {
@FromJson
override fun fromJson(reader: JsonReader): MangaSource? {
val name = reader.nextString() ?: return null
return MangaSource(name)
}
@ToJson
override fun toJson(writer: JsonWriter, value: MangaSource?) {
writer.value(value?.name)
}
}
private class InstantAdapter : JsonAdapter() {
@FromJson
override fun fromJson(reader: JsonReader): Instant? {
val ms = reader.nextLong()
return if (ms == 0L) {
null
} else {
Instant.ofEpochMilli(ms)
}
}
@ToJson
override fun toJson(writer: JsonWriter, value: Instant?) {
writer.value(value?.toEpochMilli() ?: 0L)
}
}
}
================================================
FILE: app/src/androidTest/kotlin/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt
================================================
package org.koitharu.kotatsu.core.db
import androidx.room.testing.MigrationTestHelper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MangaDatabaseTest {
@get:Rule
val helper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
MangaDatabase::class.java,
)
private val migrations = getDatabaseMigrations(InstrumentationRegistry.getInstrumentation().targetContext)
@Test
fun versions() {
assertEquals(1, migrations.first().startVersion)
repeat(migrations.size) { i ->
assertEquals(i + 1, migrations[i].startVersion)
assertEquals(i + 2, migrations[i].endVersion)
}
assertEquals(DATABASE_VERSION, migrations.last().endVersion)
}
@Test
fun migrateAll() {
helper.createDatabase(TEST_DB, 1).close()
for (migration in migrations) {
helper.runMigrationsAndValidate(
TEST_DB,
migration.endVersion,
true,
migration,
).close()
}
}
@Test
fun prePopulate() {
val resources = InstrumentationRegistry.getInstrumentation().targetContext.resources
helper.createDatabase(TEST_DB, DATABASE_VERSION).use {
DatabasePrePopulateCallback(resources).onCreate(it)
}
}
private companion object {
const val TEST_DB = "test-db"
}
}
================================================
FILE: app/src/androidTest/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManagerTest.kt
================================================
package org.koitharu.kotatsu.core.os
import android.content.pm.ShortcutInfo
import android.content.pm.ShortcutManager
import android.os.Build
import androidx.core.content.getSystemService
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.koitharu.kotatsu.SampleData
import org.koitharu.kotatsu.awaitForIdle
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.history.data.HistoryRepository
import javax.inject.Inject
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class AppShortcutManagerTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Inject
lateinit var historyRepository: HistoryRepository
@Inject
lateinit var appShortcutManager: AppShortcutManager
@Inject
lateinit var database: MangaDatabase
@Before
fun setUp() {
hiltRule.inject()
database.clearAllTables()
}
@Test
fun testUpdateShortcuts() = runTest {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) {
return@runTest
}
database.invalidationTracker.addObserver(appShortcutManager)
awaitUpdate()
assertTrue(getShortcuts().isEmpty())
historyRepository.addOrUpdate(
manga = SampleData.manga,
chapterId = SampleData.chapter.id,
page = 4,
scroll = 2,
percent = 0.3f,
force = false,
)
awaitUpdate()
val shortcuts = getShortcuts()
assertEquals(1, shortcuts.size)
}
private fun getShortcuts(): List {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val manager = checkNotNull(context.getSystemService())
return manager.dynamicShortcuts.filterNot { it.id == "com.squareup.leakcanary.dynamic_shortcut" }
}
private suspend fun awaitUpdate() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
instrumentation.awaitForIdle()
appShortcutManager.await()
}
}
================================================
FILE: app/src/androidTest/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgentTest.kt
================================================
package org.koitharu.kotatsu.settings.backup
import android.content.res.AssetManager
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.koitharu.kotatsu.SampleData
import org.koitharu.kotatsu.backups.data.BackupRepository
import org.koitharu.kotatsu.backups.domain.AppBackupAgent
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.data.HistoryRepository
import java.io.File
import javax.inject.Inject
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class AppBackupAgentTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Inject
lateinit var historyRepository: HistoryRepository
@Inject
lateinit var favouritesRepository: FavouritesRepository
@Inject
lateinit var backupRepository: BackupRepository
@Inject
lateinit var database: MangaDatabase
@Before
fun setUp() {
hiltRule.inject()
database.clearAllTables()
}
@Test
fun backupAndRestore() = runTest {
val category = favouritesRepository.createCategory(
title = SampleData.favouriteCategory.title,
sortOrder = SampleData.favouriteCategory.order,
isTrackerEnabled = SampleData.favouriteCategory.isTrackingEnabled,
isVisibleOnShelf = SampleData.favouriteCategory.isVisibleInLibrary,
)
favouritesRepository.addToCategory(categoryId = category.id, mangas = listOf(SampleData.manga))
historyRepository.addOrUpdate(
manga = SampleData.mangaDetails,
chapterId = SampleData.mangaDetails.chapters!![2].id,
page = 3,
scroll = 40,
percent = 0.2f,
force = false,
)
val history = checkNotNull(historyRepository.getOne(SampleData.manga))
val agent = AppBackupAgent()
val backup = agent.createBackupFile(
context = InstrumentationRegistry.getInstrumentation().targetContext,
repository = backupRepository,
)
database.clearAllTables()
assertTrue(favouritesRepository.getAllManga().isEmpty())
assertNull(historyRepository.getLastOrNull())
backup.inputStream().use {
agent.restoreBackupFile(it.fd, backup.length(), backupRepository)
}
assertEquals(category, favouritesRepository.getCategory(category.id))
assertEquals(history, historyRepository.getOne(SampleData.manga))
assertEquals(listOf(SampleData.manga), favouritesRepository.getManga(category.id))
val allTags = database.getTagsDao().findTags(SampleData.tag.source.name).toMangaTags()
assertTrue(SampleData.tag in allTags)
}
@Test
fun restoreOldBackup() {
val agent = AppBackupAgent()
val backup = File.createTempFile("backup_", ".tmp")
InstrumentationRegistry.getInstrumentation().context.assets
.open("kotatsu_test.bak", AssetManager.ACCESS_STREAMING)
.use { input ->
backup.outputStream().use { output ->
input.copyTo(output)
}
}
backup.inputStream().use {
agent.restoreBackupFile(it.fd, backup.length(), backupRepository)
}
runTest {
assertEquals(6, historyRepository.observeAll().first().size)
assertEquals(2, favouritesRepository.observeCategories().first().size)
assertEquals(15, favouritesRepository.getAllManga().size)
}
}
}
================================================
FILE: app/src/debug/kotlin/org/koitharu/kotatsu/KotatsuApp.kt
================================================
package org.koitharu.kotatsu
import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import android.os.StrictMode
import androidx.core.content.edit
import androidx.fragment.app.strictmode.FragmentStrictMode
import leakcanary.LeakCanary
import org.koitharu.kotatsu.core.BaseApp
class KotatsuApp : BaseApp() {
var isLeakCanaryEnabled: Boolean
get() = getDebugPreferences(this).getBoolean(KEY_LEAK_CANARY, true)
set(value) {
getDebugPreferences(this).edit { putBoolean(KEY_LEAK_CANARY, value) }
configureLeakCanary()
}
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
enableStrictMode()
configureLeakCanary()
}
private fun configureLeakCanary() {
LeakCanary.config = LeakCanary.config.copy(
dumpHeap = isLeakCanaryEnabled,
)
}
private fun enableStrictMode() {
val notifier = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
StrictModeNotifier(this)
} else {
null
}
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder().apply {
detectNetwork()
detectDiskWrites()
detectCustomSlowCalls()
detectResourceMismatches()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectUnbufferedIo()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) detectExplicitGc()
penaltyLog()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
penaltyListener(notifier.executor, notifier)
}
}.build(),
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder().apply {
detectActivityLeaks()
detectLeakedSqlLiteObjects()
detectLeakedClosableObjects()
detectLeakedRegistrationObjects()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
detectContentUriWithoutPermission()
}
detectFileUriExposure()
penaltyLog()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
penaltyListener(notifier.executor, notifier)
}
}.build(),
)
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder().apply {
detectWrongFragmentContainer()
detectFragmentTagUsage()
detectRetainInstanceUsage()
detectSetUserVisibleHint()
detectWrongNestedHierarchy()
detectFragmentReuse()
penaltyLog()
if (notifier != null) {
penaltyListener(notifier)
}
}.build()
}
private companion object {
const val PREFS_DEBUG = "_debug"
const val KEY_LEAK_CANARY = "leak_canary"
fun getDebugPreferences(context: Context): SharedPreferences =
context.getSharedPreferences(PREFS_DEBUG, MODE_PRIVATE)
}
}
================================================
FILE: app/src/debug/kotlin/org/koitharu/kotatsu/StrictModeNotifier.kt
================================================
package org.koitharu.kotatsu
import android.app.Notification
import android.app.Notification.BigTextStyle
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import android.os.StrictMode
import android.os.strictmode.Violation
import androidx.annotation.RequiresApi
import androidx.core.app.PendingIntentCompat
import androidx.core.content.getSystemService
import androidx.fragment.app.strictmode.FragmentStrictMode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import org.koitharu.kotatsu.core.util.ShareHelper
import kotlin.math.absoluteValue
import androidx.fragment.app.strictmode.Violation as FragmentViolation
@RequiresApi(Build.VERSION_CODES.P)
class StrictModeNotifier(
private val context: Context,
) : StrictMode.OnVmViolationListener, StrictMode.OnThreadViolationListener, FragmentStrictMode.OnViolationListener {
val executor = Dispatchers.Default.asExecutor()
private val notificationManager by lazy {
val nm = checkNotNull(context.getSystemService())
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.strict_mode),
NotificationManager.IMPORTANCE_LOW,
)
nm.createNotificationChannel(channel)
nm
}
override fun onVmViolation(v: Violation) = showNotification(v)
override fun onThreadViolation(v: Violation) = showNotification(v)
override fun onViolation(violation: FragmentViolation) = showNotification(violation)
private fun showNotification(violation: Throwable) = Notification.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_bug)
.setContentTitle(context.getString(R.string.strict_mode))
.setContentText(violation.message)
.setStyle(
BigTextStyle()
.setBigContentTitle(context.getString(R.string.strict_mode))
.setSummaryText(violation.message)
.bigText(violation.stackTraceToString()),
).setShowWhen(true)
.setContentIntent(
PendingIntentCompat.getActivity(
context,
violation.hashCode(),
ShareHelper(context).getShareTextIntent(violation.stackTraceToString()),
0,
false,
),
)
.setAutoCancel(true)
.setGroup(CHANNEL_ID)
.build()
.let { notificationManager.notify(CHANNEL_ID, violation.hashCode().absoluteValue, it) }
private companion object {
const val CHANNEL_ID = "strict_mode"
}
}
================================================
FILE: app/src/debug/kotlin/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt
================================================
package org.koitharu.kotatsu.core.network
import android.util.Log
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import okio.Buffer
import org.koitharu.kotatsu.core.network.CommonHeaders.ACCEPT_ENCODING
class CurlLoggingInterceptor(
private val curlOptions: String? = null
) : Interceptor {
private val escapeRegex = Regex("([\\[\\]\"])")
override fun intercept(chain: Interceptor.Chain): Response = chain.proceed(chain.request()).also {
logRequest(it.networkResponse?.request ?: it.request)
}
private fun logRequest(request: Request) {
var isCompressed = false
val curlCmd = StringBuilder()
curlCmd.append("curl")
if (curlOptions != null) {
curlCmd.append(' ').append(curlOptions)
}
curlCmd.append(" -X ").append(request.method)
for ((name, value) in request.headers) {
if (name.equals(ACCEPT_ENCODING, ignoreCase = true) && value.equals("gzip", ignoreCase = true)) {
isCompressed = true
}
curlCmd.append(" -H \"").append(name).append(": ").append(value.escape()).append('\"')
}
val body = request.body
if (body != null) {
val buffer = Buffer()
body.writeTo(buffer)
val charset = body.contentType()?.charset() ?: Charsets.UTF_8
curlCmd.append(" --data-raw '")
.append(buffer.readString(charset).replace("\n", "\\n"))
.append("'")
}
if (isCompressed) {
curlCmd.append(" --compressed")
}
curlCmd.append(" \"").append(request.url.toString().escape()).append('"')
log("---cURL (" + request.url + ")")
log(curlCmd.toString())
}
private fun String.escape() = replace(escapeRegex) { match ->
"\\" + match.value
}
private fun log(msg: String) {
Log.d("CURL", msg)
}
}
================================================
FILE: app/src/debug/kotlin/org/koitharu/kotatsu/core/parser/TestMangaRepository.kt
================================================
package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.model.TestMangaSource
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.EnumSet
/*
This class is for parser development and testing purposes
You can open it in the app via Settings -> Debug
*/
class TestMangaRepository(
@Suppress("unused") private val loaderContext: MangaLoaderContext,
cache: MemoryContentCache
) : CachingMangaRepository(cache) {
override val source = TestMangaSource
override val sortOrders: Set = EnumSet.allOf(SortOrder::class.java)
override var defaultSortOrder: SortOrder
get() = sortOrders.first()
set(value) = Unit
override val filterCapabilities = MangaListFilterCapabilities()
override suspend fun getFilterOptions() = MangaListFilterOptions()
override suspend fun getList(
offset: Int,
order: SortOrder?,
filter: MangaListFilter?
): List = TODO("Get manga list by filter")
override suspend fun getDetailsImpl(
manga: Manga
): Manga = TODO("Fetch manga details")
override suspend fun getPagesImpl(
chapter: MangaChapter
): List = TODO("Get pages for specific chapter")
override suspend fun getPageUrl(
page: MangaPage
): String = TODO("Return direct url of page image or page.url if it is already a direct url")
override suspend fun getRelatedMangaImpl(
seed: Manga
): List = TODO("Get list of related manga. This method is optional and parser library has a default implementation")
}
================================================
FILE: app/src/debug/kotlin/org/koitharu/kotatsu/core/ui/BaseService.kt
================================================
package org.koitharu.kotatsu.core.ui
import android.content.Context
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleService
import leakcanary.AppWatcher
abstract class BaseService : LifecycleService() {
override fun attachBaseContext(newBase: Context) {
super.attachBaseContext(ContextCompat.getContextForLanguage(newBase))
}
override fun onDestroy() {
super.onDestroy()
AppWatcher.objectWatcher.watch(
watchedObject = this,
description = "${javaClass.simpleName} service received Service#onDestroy() callback",
)
}
}
================================================
FILE: app/src/debug/kotlin/org/koitharu/kotatsu/core/util/ext/Debug.kt
================================================
package org.koitharu.kotatsu.core.util.ext
import android.os.Looper
fun Throwable.printStackTraceDebug() = printStackTrace()
fun assertNotInMainThread() = check(Looper.myLooper() != Looper.getMainLooper()) {
"Calling this from the main thread is prohibited"
}
================================================
FILE: app/src/debug/kotlin/org/koitharu/kotatsu/settings/DebugSettingsFragment.kt
================================================
package org.koitharu.kotatsu.settings
import android.os.Bundle
import androidx.preference.Preference
import leakcanary.LeakCanary
import org.koitharu.kotatsu.KotatsuApp
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.TestMangaSource
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.settings.utils.SplitSwitchPreference
import org.koitharu.workinspector.WorkInspector
class DebugSettingsFragment : BasePreferenceFragment(R.string.debug), Preference.OnPreferenceChangeListener,
Preference.OnPreferenceClickListener {
private val application
get() = requireContext().applicationContext as KotatsuApp
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_debug)
findPreference(KEY_LEAK_CANARY)?.let { pref ->
pref.isChecked = application.isLeakCanaryEnabled
pref.onPreferenceChangeListener = this
pref.onContainerClickListener = this
}
}
override fun onResume() {
super.onResume()
findPreference(KEY_LEAK_CANARY)?.isChecked = application.isLeakCanaryEnabled
}
override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) {
KEY_WORK_INSPECTOR -> {
startActivity(WorkInspector.getIntent(preference.context))
true
}
KEY_TEST_PARSER -> {
router.openList(TestMangaSource, null, null)
true
}
else -> super.onPreferenceTreeClick(preference)
}
override fun onPreferenceClick(preference: Preference): Boolean = when (preference.key) {
KEY_LEAK_CANARY -> {
startActivity(LeakCanary.newLeakDisplayActivityIntent())
true
}
else -> super.onPreferenceTreeClick(preference)
}
override fun onPreferenceChange(preference: Preference, newValue: Any?): Boolean = when (preference.key) {
KEY_LEAK_CANARY -> {
application.isLeakCanaryEnabled = newValue as Boolean
true
}
else -> false
}
private companion object {
const val KEY_LEAK_CANARY = "leak_canary"
const val KEY_WORK_INSPECTOR = "work_inspector"
const val KEY_TEST_PARSER = "test_parser"
}
}
================================================
FILE: app/src/debug/res/drawable/ic_debug.xml
================================================
================================================
FILE: app/src/debug/res/drawable-anydpi-v24/ic_bug.xml
================================================
================================================
FILE: app/src/debug/res/values/bools.xml
================================================
false
false
================================================
FILE: app/src/debug/res/values/constants.xml
================================================
org.kotatsu.debug.sync
org.koitharu.kotatsu.debug.history
org.koitharu.kotatsu.debug.favourites
================================================
FILE: app/src/debug/res/values/strings.xml
================================================
Kotatsu Dev
Strict mode
================================================
FILE: app/src/debug/res/xml/pref_debug.xml
================================================
================================================
FILE: app/src/debug/res/xml/pref_root_debug.xml
================================================
================================================
FILE: app/src/main/AndroidManifest.xml
================================================
================================================
FILE: app/src/main/assets/isrgrootx1.pem
================================================
-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AlternativesUseCase.kt
================================================
package org.koitharu.kotatsu.alternatives.domain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.ext.toLocale
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.search.domain.SearchKind
import org.koitharu.kotatsu.search.domain.SearchV2Helper
import java.util.Locale
import javax.inject.Inject
private const val MAX_PARALLELISM = 4
class AlternativesUseCase @Inject constructor(
private val sourcesRepository: MangaSourcesRepository,
private val searchHelperFactory: SearchV2Helper.Factory,
private val mangaRepositoryFactory: MangaRepository.Factory,
) {
suspend operator fun invoke(manga: Manga, throughDisabledSources: Boolean): Flow {
val sources = getSources(manga.source, throughDisabledSources)
if (sources.isEmpty()) {
return emptyFlow()
}
val semaphore = Semaphore(MAX_PARALLELISM)
return channelFlow {
for (source in sources) {
launch {
val searchHelper = searchHelperFactory.create(source)
val list = runCatchingCancellable {
semaphore.withPermit {
searchHelper(manga.title, SearchKind.TITLE)?.manga
}
}.getOrNull()
list?.forEach { m ->
if (m.id != manga.id) {
launch {
val details = runCatchingCancellable {
mangaRepositoryFactory.create(m.source).getDetails(m)
}.getOrDefault(m)
send(details)
}
}
}
}
}
}
}
private suspend fun getSources(ref: MangaSource, disabled: Boolean): List = if (disabled) {
sourcesRepository.getDisabledSources()
} else {
sourcesRepository.getEnabledSources()
}.sortedByDescending { it.priority(ref) }
private fun MangaSource.priority(ref: MangaSource): Int {
var res = 0
if (this is MangaParserSource && ref is MangaParserSource) {
if (locale == ref.locale) {
res += 4
} else if (locale.toLocale() == Locale.getDefault()) {
res += 2
}
if (contentType == ref.contentType) {
res++
}
}
return res
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AutoFixUseCase.kt
================================================
package org.koitharu.kotatsu.alternatives.domain
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.lastOrNull
import kotlinx.coroutines.flow.runningFold
import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.flow.withIndex
import kotlinx.coroutines.launch
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.koitharu.kotatsu.core.model.chaptersCount
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.ext.concat
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException
class AutoFixUseCase @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val alternativesUseCase: AlternativesUseCase,
private val migrateUseCase: MigrateUseCase,
private val mangaDataRepository: MangaDataRepository,
) {
suspend operator fun invoke(mangaId: Long): Pair {
val seed = checkNotNull(
mangaDataRepository.findMangaById(mangaId, withChapters = true),
) { "Manga $mangaId not found" }.getDetailsSafe()
if (seed.isHealthy()) {
return seed to null // no fix required
}
val replacement = alternativesUseCase(seed, throughDisabledSources = false)
.concat(alternativesUseCase(seed, throughDisabledSources = true))
.filter { it.isHealthy() }
.runningFold(null) { best, candidate ->
if (best == null || best < candidate) {
candidate
} else {
best
}
}.selectLastWithTimeout(4, 40, TimeUnit.SECONDS)
migrateUseCase(seed, replacement ?: throw NoAlternativesException(ParcelableManga(seed)))
return seed to replacement
}
private suspend fun Manga.isHealthy(): Boolean = runCatchingCancellable {
val repo = mangaRepositoryFactory.create(source)
val details = if (this.chapters != null) this else repo.getDetails(this)
val firstChapter = details.chapters?.firstOrNull() ?: return@runCatchingCancellable false
val pageUrl = repo.getPageUrl(repo.getPages(firstChapter).first())
pageUrl.toHttpUrlOrNull() != null
}.getOrDefault(false)
private suspend fun Manga.getDetailsSafe() = runCatchingCancellable {
mangaRepositoryFactory.create(source).getDetails(this)
}.getOrDefault(this)
private operator fun Manga.compareTo(other: Manga) = chaptersCount().compareTo(other.chaptersCount())
@Suppress("UNCHECKED_CAST", "OPT_IN_USAGE")
private suspend fun Flow.selectLastWithTimeout(
minCount: Int,
timeout: Long,
timeUnit: TimeUnit
): T? = channelFlow {
var lastValue: T? = null
launch {
delay(timeUnit.toMillis(timeout))
close(InternalTimeoutException(lastValue))
}
withIndex().transformWhile { (index, value) ->
lastValue = value
emit(value)
index < minCount && !isClosedForSend
}.collect {
send(it)
}
}.catch { e ->
if (e is InternalTimeoutException) {
emit(e.value as T?)
} else {
throw e
}
}.lastOrNull()
class NoAlternativesException(val seed: ParcelableManga) : NoSuchElementException()
private class InternalTimeoutException(val value: Any?) : CancellationException()
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/MigrateUseCase.kt
================================================
package org.koitharu.kotatsu.alternatives.domain
import androidx.room.withTransaction
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.history.data.toMangaHistory
import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.tracker.data.TrackEntity
import javax.inject.Inject
class MigrateUseCase
@Inject
constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val mangaDataRepository: MangaDataRepository,
private val database: MangaDatabase,
private val progressUpdateUseCase: ProgressUpdateUseCase,
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
) {
suspend operator fun invoke(
oldManga: Manga,
newManga: Manga,
) {
val oldDetails = if (oldManga.chapters.isNullOrEmpty()) {
runCatchingCancellable {
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
}.getOrDefault(oldManga)
} else {
oldManga
}
val newDetails = if (newManga.chapters.isNullOrEmpty()) {
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
} else {
newManga
}
mangaDataRepository.storeManga(newDetails, replaceExisting = true)
database.withTransaction {
// replace favorites
val favoritesDao = database.getFavouritesDao()
val oldFavourites = favoritesDao.findAllRaw(oldDetails.id)
if (oldFavourites.isNotEmpty()) {
favoritesDao.delete(oldManga.id)
for (f in oldFavourites) {
val e =
f.copy(
mangaId = newManga.id,
)
favoritesDao.upsert(e)
}
}
// replace history
val historyDao = database.getHistoryDao()
val oldHistory = historyDao.find(oldDetails.id)
val newHistory =
if (oldHistory != null) {
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
historyDao.delete(oldDetails.id)
historyDao.upsert(newHistory)
newHistory
} else {
null
}
// track
val tracksDao = database.getTracksDao()
val oldTrack = tracksDao.find(oldDetails.id)
if (oldTrack != null) {
val lastChapter = newDetails.chapters?.lastOrNull()
val newTrack =
TrackEntity(
mangaId = newDetails.id,
lastChapterId = lastChapter?.id ?: 0L,
newChapters = 0,
lastCheckTime = System.currentTimeMillis(),
lastChapterDate = lastChapter?.uploadDate ?: 0L,
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
lastError = null,
)
tracksDao.delete(oldDetails.id)
tracksDao.upsert(newTrack)
}
// scrobbling
for (scrobbler in scrobblers) {
if (!scrobbler.isEnabled) {
continue
}
val prevInfo = scrobbler.getScrobblingInfoOrNull(oldDetails.id) ?: continue
scrobbler.unregisterScrobbling(oldDetails.id)
scrobbler.linkManga(newDetails.id, prevInfo.targetId)
scrobbler.updateScrobblingInfo(
mangaId = newDetails.id,
rating = prevInfo.rating,
status =
prevInfo.status ?: when {
newHistory == null -> ScrobblingStatus.PLANNED
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
else -> ScrobblingStatus.READING
},
comment = prevInfo.comment,
)
if (newHistory != null) {
scrobbler.scrobble(
manga = newDetails,
chapterId = newHistory.chapterId,
)
}
}
}
progressUpdateUseCase(newManga)
}
private fun makeNewHistory(
oldManga: Manga,
newManga: Manga,
history: HistoryEntity,
): HistoryEntity {
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
val branch = newManga.getPreferredBranch(null)
val chapters = checkNotNull(newManga.getChapters(branch))
val currentChapter =
if (history.percent in 0f..1f) {
chapters[(chapters.lastIndex * history.percent).toInt()]
} else {
chapters.first()
}
return HistoryEntity(
mangaId = newManga.id,
createdAt = history.createdAt,
updatedAt = history.updatedAt,
chapterId = currentChapter.id,
page = history.page,
scroll = history.scroll,
percent = history.percent,
deletedAt = 0,
chaptersCount = chapters.count { it.branch == currentChapter.branch },
)
}
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
val oldChapters = checkNotNull(oldManga.getChapters(branch))
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
if (index < 0) {
index =
if (history.percent in 0f..1f) {
(oldChapters.lastIndex * history.percent).toInt()
} else {
0
}
}
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
val newBranch =
if (newChapters.containsKey(branch)) {
branch
} else {
newManga.getPreferredBranch(null)
}
val newChapterId =
checkNotNull(newChapters[newBranch])
.let {
val oldChapter = oldChapters[index]
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
}.id
return HistoryEntity(
mangaId = newManga.id,
createdAt = history.createdAt,
updatedAt = history.updatedAt,
chapterId = newChapterId,
page = history.page,
scroll = history.scroll,
percent = PROGRESS_NONE,
deletedAt = 0,
chaptersCount = checkNotNull(newChapters[newBranch]).size,
)
}
private fun List.findByNumber(
volume: Int,
number: Float,
): MangaChapter? =
if (number <= 0f) {
null
} else {
firstOrNull { it.volume == volume && it.number == number }
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativeAD.kt
================================================
package org.koitharu.kotatsu.alternatives.ui
import android.text.style.ForegroundColorSpan
import androidx.core.content.ContextCompat
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import coil3.ImageLoader
import coil3.request.ImageRequest
import coil3.request.allowRgb565
import coil3.request.crossfade
import coil3.request.error
import coil3.request.fallback
import coil3.request.lifecycle
import coil3.request.placeholder
import coil3.request.transformations
import coil3.transform.RoundedCornersTransformation
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.databinding.ItemMangaAlternativeBinding
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
import kotlin.math.sign
import com.google.android.material.R as materialR
fun alternativeAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
listener: OnListItemClickListener,
) = adapterDelegateViewBinding(
{ inflater, parent -> ItemMangaAlternativeBinding.inflate(inflater, parent, false) },
) {
val colorGreen = ContextCompat.getColor(context, R.color.common_green)
val colorRed = ContextCompat.getColor(context, R.color.common_red)
val clickListener = AdapterDelegateClickListenerAdapter(this, listener)
itemView.setOnClickListener(clickListener)
binding.buttonMigrate.setOnClickListener(clickListener)
binding.chipSource.setOnClickListener(clickListener)
bind { payloads ->
binding.textViewTitle.text = item.mangaModel.title
with(binding.iconsView) {
clearIcons()
if (item.mangaModel.isSaved) addIcon(R.drawable.ic_storage)
if (item.mangaModel.isFavorite) addIcon(R.drawable.ic_heart_outline)
isVisible = iconsCount > 0
}
binding.textViewSubtitle.text = buildSpannedString {
if (item.chaptersCount > 0) {
append(
context.resources.getQuantityStringSafe(
R.plurals.chapters,
item.chaptersCount,
item.chaptersCount,
),
)
} else {
append(context.getString(R.string.no_chapters))
}
when (item.chaptersDiff.sign) {
-1 -> inSpans(ForegroundColorSpan(colorRed)) {
append(" ▼ ")
append(item.chaptersDiff.toString())
}
1 -> inSpans(ForegroundColorSpan(colorGreen)) {
append(" ▲ +")
append(item.chaptersDiff.toString())
}
}
}
binding.progressView.setProgress(
item.mangaModel.progress,
ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads,
)
binding.chipSource.also { chip ->
chip.text = item.manga.source.getTitle(chip.context)
ImageRequest.Builder(context)
.data(item.manga.source.faviconUri())
.lifecycle(lifecycleOwner)
.crossfade(false)
.size(context.resources.getDimensionPixelSize(materialR.dimen.m3_chip_icon_size))
.target(ChipIconTarget(chip))
.placeholder(R.drawable.ic_web)
.fallback(R.drawable.ic_web)
.error(R.drawable.ic_web)
.mangaSourceExtra(item.manga.source)
.transformations(RoundedCornersTransformation(context.resources.getDimension(R.dimen.chip_icon_corner)))
.allowRgb565(true)
.enqueueWith(coil)
}
binding.imageViewCover.setImageAsync(item.manga.coverUrl, item.manga)
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativesActivity.kt
================================================
package org.koitharu.kotatsu.alternatives.ui
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.viewModels
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import coil3.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.systemBarsInsets
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.adapter.buttonFooterAD
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import javax.inject.Inject
@AndroidEntryPoint
class AlternativesActivity : BaseActivity(),
ListStateHolderListener,
OnListItemClickListener {
@Inject
lateinit var coil: ImageLoader
private val viewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityAlternativesBinding.inflate(layoutInflater))
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
subtitle = viewModel.manga.title
}
val listAdapter = BaseListAdapter()
.addDelegate(ListItemType.MANGA_LIST_DETAILED, alternativeAD(coil, this, this))
.addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(null))
.addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
.addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
.addDelegate(ListItemType.FOOTER_BUTTON, buttonFooterAD(this))
with(viewBinding.recyclerView) {
setHasFixedSize(true)
addItemDecoration(TypedListSpacingDecoration(context, addHorizontalPadding = false))
adapter = listAdapter
}
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
viewModel.list.observe(this, listAdapter)
viewModel.onMigrated.observeEvent(this) {
Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show()
router.openDetails(it)
finishAfterTransition()
}
}
override fun onApplyWindowInsets(
v: View,
insets: WindowInsetsCompat
): WindowInsetsCompat {
val barsInsets = insets.systemBarsInsets
viewBinding.recyclerView.updatePadding(
left = barsInsets.left,
right = barsInsets.right,
bottom = barsInsets.bottom,
)
viewBinding.appbar.updatePadding(
left = barsInsets.left,
right = barsInsets.right,
top = barsInsets.top,
)
return insets.consumeAllSystemBarsInsets()
}
override fun onItemClick(item: MangaAlternativeModel, view: View) {
when (view.id) {
R.id.chip_source -> router.openSearch(item.manga.source, viewModel.manga.title)
R.id.button_migrate -> confirmMigration(item.manga)
else -> router.openDetails(item.manga)
}
}
override fun onRetryClick(error: Throwable) = viewModel.retry()
override fun onEmptyActionClick() = Unit
override fun onFooterButtonClick() = viewModel.continueSearch()
private fun confirmMigration(target: Manga) {
buildAlertDialog(this, isCentered = true) {
setIcon(R.drawable.ic_replace)
setTitle(R.string.manga_migration)
setMessage(
getString(
R.string.migrate_confirmation,
viewModel.manga.title,
viewModel.manga.source.getTitle(context),
target.title,
target.source.getTitle(context),
),
)
setNegativeButton(android.R.string.cancel, null)
setPositiveButton(R.string.migrate) { _, _ ->
viewModel.migrate(target)
}
}.show()
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativesViewModel.kt
================================================
package org.koitharu.kotatsu.alternatives.ui
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.domain.AlternativesUseCase
import org.koitharu.kotatsu.alternatives.domain.MigrateUseCase
import org.koitharu.kotatsu.core.model.chaptersCount
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.append
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.list.domain.MangaListMapper
import org.koitharu.kotatsu.list.ui.model.ButtonFooter
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrDefault
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import javax.inject.Inject
@HiltViewModel
class AlternativesViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val mangaRepositoryFactory: MangaRepository.Factory,
private val alternativesUseCase: AlternativesUseCase,
private val migrateUseCase: MigrateUseCase,
private val mangaListMapper: MangaListMapper,
) : BaseViewModel() {
val manga = savedStateHandle.require(AppRouter.KEY_MANGA).manga
private var includeDisabledSources = MutableStateFlow(false)
private val results = MutableStateFlow>(emptyList())
private var migrationJob: Job? = null
private var searchJob: Job? = null
private val mangaDetails = suspendLazy {
mangaRepositoryFactory.create(manga.source).getDetails(manga)
}
val onMigrated = MutableEventFlow()
val list: StateFlow> = combine(
results,
isLoading,
includeDisabledSources,
) { list, loading, includeDisabled ->
when {
list.isEmpty() -> listOf(
when {
loading -> LoadingState
else -> EmptyState(
icon = R.drawable.ic_empty_common,
textPrimary = R.string.nothing_found,
textSecondary = R.string.text_search_holder_secondary,
actionStringRes = 0,
)
},
)
loading -> list + LoadingFooter()
includeDisabled -> list
else -> list + ButtonFooter(R.string.search_disabled_sources)
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
init {
doSearch(throughDisabledSources = false)
}
fun retry() {
searchJob?.cancel()
results.value = emptyList()
includeDisabledSources.value = false
doSearch(throughDisabledSources = false)
}
fun continueSearch() {
if (includeDisabledSources.value) {
return
}
val prevJob = searchJob
searchJob = launchLoadingJob(Dispatchers.Default) {
includeDisabledSources.value = true
prevJob?.join()
doSearch(throughDisabledSources = true)
}
}
fun migrate(target: Manga) {
if (migrationJob?.isActive == true) {
return
}
migrationJob = launchLoadingJob(Dispatchers.Default) {
migrateUseCase(manga, target)
onMigrated.call(target)
}
}
private fun doSearch(throughDisabledSources: Boolean) {
val prevJob = searchJob
searchJob = launchLoadingJob(Dispatchers.Default) {
prevJob?.cancelAndJoin()
val ref = mangaDetails.getOrDefault(manga)
val refCount = ref.chaptersCount()
alternativesUseCase.invoke(ref, throughDisabledSources)
.collect {
val model = MangaAlternativeModel(
mangaModel = mangaListMapper.toListModel(it, ListMode.GRID) as MangaGridModel,
referenceChapters = refCount,
)
results.append(model)
}
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AutoFixService.kt
================================================
package org.koitharu.kotatsu.alternatives.ui
import android.annotation.SuppressLint
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.content.ContextCompat
import coil3.ImageLoader
import coil3.request.ImageRequest
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase.NoAlternativesException
import org.koitharu.kotatsu.core.ErrorReporterReceiver
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.core.util.ext.powerManager
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
import androidx.appcompat.R as appcompatR
@AndroidEntryPoint
class AutoFixService : CoroutineIntentService() {
@Inject
lateinit var autoFixUseCase: AutoFixUseCase
@Inject
lateinit var coil: ImageLoader
private lateinit var notificationManager: NotificationManagerCompat
override fun onCreate() {
super.onCreate()
notificationManager = NotificationManagerCompat.from(this)
}
override suspend fun IntentJobContext.processIntent(intent: Intent) {
val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS))
startForeground(this)
for (mangaId in ids) {
powerManager.withPartialWakeLock(TAG) {
val result = runCatchingCancellable {
autoFixUseCase.invoke(mangaId)
}
if (checkNotificationPermission(CHANNEL_ID)) {
val notification = buildNotification(startId, result)
notificationManager.notify(TAG, startId, notification)
}
}
}
}
override fun IntentJobContext.onError(error: Throwable) {
if (checkNotificationPermission(CHANNEL_ID)) {
val notification = runBlocking { buildNotification(startId, Result.failure(error)) }
notificationManager.notify(TAG, startId, notification)
}
}
@SuppressLint("InlinedApi")
private fun startForeground(jobContext: IntentJobContext) {
val title = getString(R.string.fixing_manga)
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN)
.setName(title)
.setShowBadge(false)
.setVibrationEnabled(false)
.setSound(null, null)
.setLightsEnabled(false)
.build()
notificationManager.createNotificationChannel(channel)
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setDefaults(0)
.setSilent(true)
.setOngoing(true)
.setProgress(0, 0, true)
.setSmallIcon(R.drawable.ic_stat_auto_fix)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.addAction(
appcompatR.drawable.abc_ic_clear_material,
getString(android.R.string.cancel),
jobContext.getCancelIntent(),
)
.build()
jobContext.setForeground(
FOREGROUND_NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
}
private suspend fun buildNotification(startId: Int, result: Result>): Notification {
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setDefaults(0)
.setSilent(true)
.setAutoCancel(true)
result.onSuccess { (seed, replacement) ->
if (replacement != null) {
notification.setLargeIcon(
coil.execute(
ImageRequest.Builder(this)
.data(replacement.coverUrl)
.mangaSourceExtra(replacement.source)
.build(),
).toBitmapOrNull(),
)
notification.setSubText(replacement.title)
val intent = AppRouter.detailsIntent(this, replacement)
notification.setContentIntent(
PendingIntentCompat.getActivity(
this,
replacement.id.toInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT,
false,
),
).setVisibility(
if (replacement.isNsfw()) {
NotificationCompat.VISIBILITY_SECRET
} else {
NotificationCompat.VISIBILITY_PUBLIC
},
)
notification
.setContentTitle(getString(R.string.fixed))
.setContentText(
getString(
R.string.manga_replaced,
seed.title,
seed.source.getTitle(this),
replacement.title,
replacement.source.getTitle(this),
),
)
.setSmallIcon(R.drawable.ic_stat_done)
} else {
notification
.setContentTitle(getString(R.string.fixing_manga))
.setContentText(getString(R.string.no_fix_required, seed.title))
.setSmallIcon(android.R.drawable.stat_sys_warning)
}
}.onFailure { error ->
notification
.setContentTitle(getString(R.string.error_occurred))
.setContentText(
if (error is NoAlternativesException) {
getString(R.string.no_alternatives_found, error.seed.manga.title)
} else {
error.getDisplayMessage(resources)
},
).setSmallIcon(android.R.drawable.stat_notify_error)
ErrorReporterReceiver.getNotificationAction(
context = this,
e = error,
notificationId = startId,
notificationTag = TAG,
)?.let { action ->
notification.addAction(action)
}
}
return notification.build()
}
companion object {
private const val DATA_IDS = "ids"
private const val TAG = "auto_fix"
private const val CHANNEL_ID = "auto_fix"
private const val FOREGROUND_NOTIFICATION_ID = 38
fun start(context: Context, mangaIds: Collection): Boolean = try {
val intent = Intent(context, AutoFixService::class.java)
intent.putExtra(DATA_IDS, mangaIds.toLongArray())
ContextCompat.startForegroundService(context, intent)
true
} catch (e: Exception) {
e.printStackTraceDebug()
false
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/MangaAlternativeModel.kt
================================================
package org.koitharu.kotatsu.alternatives.ui
import org.koitharu.kotatsu.core.model.chaptersCount
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
import org.koitharu.kotatsu.parsers.model.Manga
data class MangaAlternativeModel(
val mangaModel: MangaGridModel,
private val referenceChapters: Int,
) : ListModel {
val manga: Manga
get() = mangaModel.manga
val chaptersCount = manga.chaptersCount()
val chaptersDiff: Int
get() = if (referenceChapters == 0 || chaptersCount == 0) 0 else chaptersCount - referenceChapters
override fun areItemsTheSame(other: ListModel): Boolean {
return other is MangaAlternativeModel && other.manga.id == manga.id
}
override fun getChangePayload(previousState: ListModel): Any? = if (previousState is MangaAlternativeModel) {
mangaModel.getChangePayload(previousState.mangaModel)
} else {
null
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/data/BackupRepository.kt
================================================
package org.koitharu.kotatsu.backups.data
import androidx.collection.ArrayMap
import androidx.room.withTransaction
import dagger.Reusable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.collectIndexed
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.SerializationStrategy
import kotlinx.serialization.json.DecodeSequenceMode
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeToSequence
import kotlinx.serialization.json.encodeToStream
import kotlinx.serialization.serializer
import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.backups.data.model.BackupIndex
import org.koitharu.kotatsu.backups.data.model.BookmarkBackup
import org.koitharu.kotatsu.backups.data.model.CategoryBackup
import org.koitharu.kotatsu.backups.data.model.FavouriteBackup
import org.koitharu.kotatsu.backups.data.model.HistoryBackup
import org.koitharu.kotatsu.backups.data.model.MangaBackup
import org.koitharu.kotatsu.backups.data.model.ScrobblingBackup
import org.koitharu.kotatsu.backups.data.model.SourceBackup
import org.koitharu.kotatsu.backups.data.model.StatisticBackup
import org.koitharu.kotatsu.backups.domain.BackupSection
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.CompositeResult
import org.koitharu.kotatsu.core.util.progress.Progress
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.filter.data.PersistableFilter
import org.koitharu.kotatsu.filter.data.SavedFiltersRepository
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.data.TapGridSettings
import java.io.InputStream
import java.io.OutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
import javax.inject.Inject
@Reusable
class BackupRepository @Inject constructor(
private val database: MangaDatabase,
private val settings: AppSettings,
private val tapGridSettings: TapGridSettings,
private val mangaSourcesRepository: MangaSourcesRepository,
private val savedFiltersRepository: SavedFiltersRepository,
) {
private val json = Json {
allowSpecialFloatingPointValues = true
coerceInputValues = true
encodeDefaults = true
ignoreUnknownKeys = true
useAlternativeNames = false
}
suspend fun createBackup(
output: ZipOutputStream,
progress: FlowCollector?,
) {
progress?.emit(Progress.INDETERMINATE)
var commonProgress = Progress(0, BackupSection.entries.size)
for (section in BackupSection.entries) {
when (section) {
BackupSection.INDEX -> output.writeJsonArray(
section = BackupSection.INDEX,
data = flowOf(BackupIndex()),
serializer = serializer(),
)
BackupSection.HISTORY -> output.writeJsonArray(
section = BackupSection.HISTORY,
data = database.getHistoryDao().dump().map { HistoryBackup(it) },
serializer = serializer(),
)
BackupSection.CATEGORIES -> output.writeJsonArray(
section = BackupSection.CATEGORIES,
data = database.getFavouriteCategoriesDao().findAll().asFlow().map { CategoryBackup(it) },
serializer = serializer(),
)
BackupSection.FAVOURITES -> output.writeJsonArray(
section = BackupSection.FAVOURITES,
data = database.getFavouritesDao().dump().map { FavouriteBackup(it) },
serializer = serializer(),
)
BackupSection.SETTINGS -> output.writeString(
section = BackupSection.SETTINGS,
data = dumpSettings(),
)
BackupSection.SETTINGS_READER_GRID -> output.writeString(
section = BackupSection.SETTINGS_READER_GRID,
data = dumpReaderGridSettings(),
)
BackupSection.BOOKMARKS -> output.writeJsonArray(
section = BackupSection.BOOKMARKS,
data = database.getBookmarksDao().dump().map { BookmarkBackup(it.first, it.second) },
serializer = serializer(),
)
BackupSection.SOURCES -> output.writeJsonArray(
section = BackupSection.SOURCES,
data = database.getSourcesDao().dumpEnabled().map { SourceBackup(it) },
serializer = serializer(),
)
BackupSection.SCROBBLING -> output.writeJsonArray(
section = BackupSection.SCROBBLING,
data = database.getScrobblingDao().dumpEnabled().map { ScrobblingBackup(it) },
serializer = serializer(),
)
BackupSection.STATS -> output.writeJsonArray(
section = BackupSection.STATS,
data = database.getStatsDao().dumpEnabled().map { StatisticBackup(it) },
serializer = serializer(),
)
BackupSection.SAVED_FILTERS -> {
val sources = mangaSourcesRepository.getEnabledSources()
val filters = sources.flatMap { source ->
savedFiltersRepository.getAll(source)
}
output.writeJsonArray(
section = BackupSection.SAVED_FILTERS,
data = filters.asFlow(),
serializer = serializer(),
)
}
}
progress?.emit(commonProgress)
commonProgress++
}
progress?.emit(commonProgress)
}
suspend fun restoreBackup(
input: ZipInputStream,
sections: Set,
progress: FlowCollector?,
): CompositeResult {
progress?.emit(Progress.INDETERMINATE)
var commonProgress = Progress(0, sections.size)
var entry = input.nextEntry
var result = CompositeResult.EMPTY
while (entry != null) {
val section = BackupSection.of(entry)
if (section in sections) {
result += when (section) {
BackupSection.INDEX -> CompositeResult.EMPTY // useless in our case
BackupSection.HISTORY -> input.readJsonArray(serializer()).restoreToDb {
upsertManga(it.manga)
getHistoryDao().upsert(it.toEntity())
}
BackupSection.CATEGORIES -> input.readJsonArray(serializer()).restoreToDb {
getFavouriteCategoriesDao().upsert(it.toEntity())
}
BackupSection.FAVOURITES -> input.readJsonArray(serializer()).restoreToDb {
upsertManga(it.manga)
getFavouritesDao().upsert(it.toEntity())
}
BackupSection.SETTINGS -> input.readMap().let {
settings.upsertAll(it)
CompositeResult.success()
}
BackupSection.SETTINGS_READER_GRID -> input.readMap().let {
tapGridSettings.upsertAll(it)
CompositeResult.success()
}
BackupSection.BOOKMARKS -> input.readJsonArray(serializer()).restoreToDb {
upsertManga(it.manga)
getBookmarksDao().upsert(it.bookmarks.map { b -> b.toEntity() })
}
BackupSection.SOURCES -> input.readJsonArray(serializer()).restoreToDb {
getSourcesDao().upsert(it.toEntity())
}
BackupSection.SCROBBLING -> input.readJsonArray(serializer()).restoreToDb {
getScrobblingDao().upsert(it.toEntity())
}
BackupSection.STATS -> input.readJsonArray(serializer()).restoreToDb {
getStatsDao().upsert(it.toEntity())
}
BackupSection.SAVED_FILTERS -> input.readJsonArray(serializer())
.restoreWithoutTransaction {
savedFiltersRepository.save(it)
}
null -> CompositeResult.EMPTY // skip unknown entries
}
progress?.emit(commonProgress)
commonProgress++
}
input.closeEntry()
entry = input.nextEntry
}
progress?.emit(commonProgress)
return result
}
private suspend fun ZipOutputStream.writeJsonArray(
section: BackupSection,
data: Flow,
serializer: SerializationStrategy,
) {
data.onStart {
putNextEntry(ZipEntry(section.entryName))
write("[")
}.onCompletion { error ->
if (error == null) {
write("]")
}
closeEntry()
flush()
}.collectIndexed { index, value ->
if (index > 0) {
write(",")
}
json.encodeToStream(serializer, value, this)
}
}
private fun InputStream.readJsonArray(
serializer: DeserializationStrategy,
): Sequence = json.decodeToSequence(this, serializer, DecodeSequenceMode.ARRAY_WRAPPED)
private fun InputStream.readMap(): Map {
val jo = JSONArray(readString()).getJSONObject(0)
val map = ArrayMap(jo.length())
val keys = jo.keys()
while (keys.hasNext()) {
val key = keys.next()
map[key] = jo.get(key)
}
return map
}
private fun ZipOutputStream.writeString(
section: BackupSection,
data: String,
) {
putNextEntry(ZipEntry(section.entryName))
try {
write("[")
write(data)
write("]")
} finally {
closeEntry()
flush()
}
}
private fun OutputStream.write(str: String) = write(str.toByteArray())
private fun InputStream.readString(): String = readBytes().decodeToString()
private fun dumpSettings(): String {
val map = settings.getAllValues().toMutableMap()
map.remove(AppSettings.KEY_APP_PASSWORD)
map.remove(AppSettings.KEY_PROXY_PASSWORD)
map.remove(AppSettings.KEY_PROXY_LOGIN)
map.remove(AppSettings.KEY_INCOGNITO_MODE)
return JSONObject(map).toString()
}
private fun dumpReaderGridSettings(): String {
return JSONObject(tapGridSettings.getAllValues()).toString()
}
private suspend fun MangaDatabase.upsertManga(manga: MangaBackup) {
val tags = manga.tags.map { it.toEntity() }
getTagsDao().upsert(tags)
getMangaDao().upsert(manga.toEntity(), tags)
}
private suspend inline fun Sequence.restoreToDb(crossinline block: suspend MangaDatabase.(T) -> Unit): CompositeResult {
return fold(CompositeResult.EMPTY) { result, item ->
result + runCatchingCancellable {
database.withTransaction {
database.block(item)
}
}
}
}
private suspend inline fun Sequence.restoreWithoutTransaction(crossinline block: suspend (T) -> Unit): CompositeResult {
return fold(CompositeResult.EMPTY) { result, item ->
result + runCatchingCancellable {
block(item)
}
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/BackupIndex.kt
================================================
package org.koitharu.kotatsu.backups.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.koitharu.kotatsu.BuildConfig
@Serializable
class BackupIndex(
@SerialName("app_id") val appId: String,
@SerialName("app_version") val appVersion: Int,
@SerialName("created_at") val createdAt: Long,
) {
constructor() : this(
appId = BuildConfig.APPLICATION_ID,
appVersion = BuildConfig.VERSION_CODE,
createdAt = System.currentTimeMillis(),
)
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/BookmarkBackup.kt
================================================
package org.koitharu.kotatsu.backups.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
import org.koitharu.kotatsu.parsers.util.mapToSet
@Serializable
class BookmarkBackup(
@SerialName("manga") val manga: MangaBackup,
@SerialName("tags") val tags: Set,
@SerialName("bookmarks") val bookmarks: List,
) {
@Serializable
class Bookmark(
@SerialName("manga_id") val mangaId: Long,
@SerialName("page_id") val pageId: Long,
@SerialName("chapter_id") val chapterId: Long,
@SerialName("page") val page: Int,
@SerialName("scroll") val scroll: Int,
@SerialName("image_url") val imageUrl: String,
@SerialName("created_at") val createdAt: Long,
@SerialName("percent") val percent: Float,
) {
fun toEntity() = BookmarkEntity(
mangaId = mangaId,
pageId = pageId,
chapterId = chapterId,
page = page,
scroll = scroll,
imageUrl = imageUrl,
createdAt = createdAt,
percent = percent,
)
}
constructor(manga: MangaWithTags, entities: List) : this(
manga = MangaBackup(manga.copy(tags = emptyList())),
tags = manga.tags.mapToSet { TagBackup(it) },
bookmarks = entities.map {
Bookmark(
mangaId = it.mangaId,
pageId = it.pageId,
chapterId = it.chapterId,
page = it.page,
scroll = it.scroll,
imageUrl = it.imageUrl,
createdAt = it.createdAt,
percent = it.percent,
)
},
)
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/CategoryBackup.kt
================================================
package org.koitharu.kotatsu.backups.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.list.domain.ListSortOrder
@Serializable
class CategoryBackup(
@SerialName("category_id") val categoryId: Int,
@SerialName("created_at") val createdAt: Long,
@SerialName("sort_key") val sortKey: Int,
@SerialName("title") val title: String,
@SerialName("order") val order: String = ListSortOrder.NEWEST.name,
@SerialName("track") val track: Boolean = true,
@SerialName("show_in_lib") val isVisibleInLibrary: Boolean = true,
) {
constructor(entity: FavouriteCategoryEntity) : this(
categoryId = entity.categoryId,
createdAt = entity.createdAt,
sortKey = entity.sortKey,
title = entity.title,
order = entity.order,
track = entity.track,
isVisibleInLibrary = entity.isVisibleInLibrary,
)
fun toEntity() = FavouriteCategoryEntity(
categoryId = categoryId,
createdAt = createdAt,
sortKey = sortKey,
title = title,
order = order,
track = track,
isVisibleInLibrary = isVisibleInLibrary,
deletedAt = 0L,
)
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/FavouriteBackup.kt
================================================
package org.koitharu.kotatsu.backups.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.favourites.data.FavouriteManga
@Serializable
class FavouriteBackup(
@SerialName("manga_id") val mangaId: Long,
@SerialName("category_id") val categoryId: Long,
@SerialName("sort_key") val sortKey: Int = 0,
@SerialName("pinned") val isPinned: Boolean = false,
@SerialName("created_at") val createdAt: Long,
@SerialName("manga") val manga: MangaBackup,
) {
constructor(entity: FavouriteManga) : this(
mangaId = entity.manga.id,
categoryId = entity.favourite.categoryId,
sortKey = entity.favourite.sortKey,
isPinned = entity.favourite.isPinned,
createdAt = entity.favourite.createdAt,
manga = MangaBackup(MangaWithTags(entity.manga, entity.tags)),
)
fun toEntity() = FavouriteEntity(
mangaId = mangaId,
categoryId = categoryId,
sortKey = sortKey,
isPinned = isPinned,
createdAt = createdAt,
deletedAt = 0L,
)
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/HistoryBackup.kt
================================================
package org.koitharu.kotatsu.backups.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.history.data.HistoryWithManga
import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE
@Serializable
class HistoryBackup(
@SerialName("manga_id") val mangaId: Long,
@SerialName("created_at") val createdAt: Long,
@SerialName("updated_at") val updatedAt: Long,
@SerialName("chapter_id") val chapterId: Long,
@SerialName("page") val page: Int,
@SerialName("scroll") val scroll: Float,
@SerialName("percent") val percent: Float = PROGRESS_NONE,
@SerialName("chapters") val chaptersCount: Int = 0,
@SerialName("manga") val manga: MangaBackup,
) {
constructor(entity: HistoryWithManga) : this(
mangaId = entity.manga.id,
createdAt = entity.history.createdAt,
updatedAt = entity.history.updatedAt,
chapterId = entity.history.chapterId,
page = entity.history.page,
scroll = entity.history.scroll,
percent = entity.history.percent,
chaptersCount = entity.history.chaptersCount,
manga = MangaBackup(MangaWithTags(entity.manga, entity.tags)),
)
fun toEntity() = HistoryEntity(
mangaId = mangaId,
createdAt = createdAt,
updatedAt = updatedAt,
chapterId = chapterId,
page = page,
scroll = scroll,
percent = percent,
deletedAt = 0L,
chaptersCount = chaptersCount,
)
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/MangaBackup.kt
================================================
package org.koitharu.kotatsu.backups.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN
import org.koitharu.kotatsu.parsers.util.mapToSet
@Serializable
class MangaBackup(
@SerialName("id") val id: Long,
@SerialName("title") val title: String,
@SerialName("alt_title") val altTitles: String? = null,
@SerialName("url") val url: String,
@SerialName("public_url") val publicUrl: String,
@SerialName("rating") val rating: Float = RATING_UNKNOWN,
@SerialName("nsfw") val isNsfw: Boolean = false,
@SerialName("content_rating") val contentRating: String? = null,
@SerialName("cover_url") val coverUrl: String,
@SerialName("large_cover_url") val largeCoverUrl: String? = null,
@SerialName("state") val state: String? = null,
@SerialName("author") val authors: String? = null,
@SerialName("source") val source: String,
@SerialName("tags") val tags: Set = emptySet(),
) {
constructor(entity: MangaWithTags) : this(
id = entity.manga.id,
title = entity.manga.title,
altTitles = entity.manga.altTitles,
url = entity.manga.url,
publicUrl = entity.manga.publicUrl,
rating = entity.manga.rating,
isNsfw = entity.manga.isNsfw,
contentRating = entity.manga.contentRating,
coverUrl = entity.manga.coverUrl,
largeCoverUrl = entity.manga.largeCoverUrl,
state = entity.manga.state,
authors = entity.manga.authors,
source = entity.manga.source,
tags = entity.tags.mapToSet { TagBackup(it) },
)
fun toEntity() = MangaEntity(
id = id,
title = title,
altTitles = altTitles,
url = url,
publicUrl = publicUrl,
rating = rating,
isNsfw = isNsfw,
contentRating = contentRating,
coverUrl = coverUrl,
largeCoverUrl = largeCoverUrl,
state = state,
authors = authors,
source = source,
)
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/ScrobblingBackup.kt
================================================
package org.koitharu.kotatsu.backups.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
@Serializable
class ScrobblingBackup(
@SerialName("scrobbler") val scrobbler: Int,
@SerialName("id") val id: Int,
@SerialName("manga_id") val mangaId: Long,
@SerialName("target_id") val targetId: Long,
@SerialName("status") val status: String?,
@SerialName("chapter") val chapter: Int,
@SerialName("comment") val comment: String?,
@SerialName("rating") val rating: Float,
) {
constructor(entity: ScrobblingEntity) : this(
scrobbler = entity.scrobbler,
id = entity.id,
mangaId = entity.mangaId,
targetId = entity.targetId,
status = entity.status,
chapter = entity.chapter,
comment = entity.comment,
rating = entity.rating,
)
fun toEntity() = ScrobblingEntity(
scrobbler = scrobbler,
id = id,
mangaId = mangaId,
targetId = targetId,
status = status,
chapter = chapter,
comment = comment,
rating = rating,
)
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/SourceBackup.kt
================================================
package org.koitharu.kotatsu.backups.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
@Serializable
class SourceBackup(
@SerialName("source") val source: String,
@SerialName("sort_key") val sortKey: Int,
@SerialName("used_at") val lastUsedAt: Long,
@SerialName("added_in") val addedIn: Int,
@SerialName("pinned") val isPinned: Boolean = false,
@SerialName("enabled") val isEnabled: Boolean = true, // for compatibility purposes, should be only true
) {
constructor(entity: MangaSourceEntity) : this(
source = entity.source,
sortKey = entity.sortKey,
lastUsedAt = entity.lastUsedAt,
addedIn = entity.addedIn,
isPinned = entity.isPinned,
isEnabled = entity.isEnabled,
)
fun toEntity() = MangaSourceEntity(
source = source,
isEnabled = isEnabled,
sortKey = sortKey,
addedIn = addedIn,
lastUsedAt = lastUsedAt,
isPinned = isPinned,
cfState = 0,
)
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/StatisticBackup.kt
================================================
package org.koitharu.kotatsu.backups.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.koitharu.kotatsu.stats.data.StatsEntity
@Serializable
class StatisticBackup(
@SerialName("manga_id") val mangaId: Long,
@SerialName("started_at") val startedAt: Long,
@SerialName("duration") val duration: Long,
@SerialName("pages") val pages: Int,
) {
constructor(entity: StatsEntity) : this(
mangaId = entity.mangaId,
startedAt = entity.startedAt,
duration = entity.duration,
pages = entity.pages,
)
fun toEntity() = StatsEntity(
mangaId = mangaId,
startedAt = startedAt,
duration = duration,
pages = pages,
)
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/TagBackup.kt
================================================
package org.koitharu.kotatsu.backups.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.koitharu.kotatsu.core.db.entity.TagEntity
@Serializable
class TagBackup(
@SerialName("id") val id: Long,
@SerialName("title") val title: String,
@SerialName("key") val key: String,
@SerialName("source") val source: String,
@SerialName("pinned") val isPinned: Boolean = false,
) {
constructor(entity: TagEntity) : this(
id = entity.id,
title = entity.title,
key = entity.key,
source = entity.source,
isPinned = entity.isPinned,
)
fun toEntity() = TagEntity(
id = id,
title = title,
key = key,
source = source,
isPinned = isPinned,
)
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/domain/AppBackupAgent.kt
================================================
package org.koitharu.kotatsu.backups.domain
import android.app.backup.BackupAgent
import android.app.backup.BackupDataInput
import android.app.backup.BackupDataOutput
import android.app.backup.FullBackupDataOutput
import android.content.Context
import android.os.ParcelFileDescriptor
import androidx.annotation.VisibleForTesting
import com.google.common.io.ByteStreams
import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.backups.data.BackupRepository
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.filter.data.SavedFiltersRepository
import org.koitharu.kotatsu.reader.data.TapGridSettings
import java.io.File
import java.io.FileDescriptor
import java.io.FileInputStream
import java.util.EnumSet
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
class AppBackupAgent : BackupAgent() {
override fun onBackup(
oldState: ParcelFileDescriptor?,
data: BackupDataOutput?,
newState: ParcelFileDescriptor?
) = Unit
override fun onRestore(
data: BackupDataInput?,
appVersionCode: Int,
newState: ParcelFileDescriptor?
) = Unit
override fun onFullBackup(data: FullBackupDataOutput) {
super.onFullBackup(data)
val file = createBackupFile(
this,
BackupRepository(
database = MangaDatabase(context = applicationContext),
settings = AppSettings(applicationContext),
tapGridSettings = TapGridSettings(applicationContext),
mangaSourcesRepository = MangaSourcesRepository(
context = applicationContext,
db = MangaDatabase(context = applicationContext),
settings = AppSettings(applicationContext),
),
savedFiltersRepository = SavedFiltersRepository(
context = applicationContext,
),
),
)
try {
fullBackupFile(file, data)
} finally {
file.delete()
}
}
override fun onRestoreFile(
data: ParcelFileDescriptor,
size: Long,
destination: File?,
type: Int,
mode: Long,
mtime: Long
) {
if (destination?.name?.endsWith(".bk.zip") == true) {
restoreBackupFile(
data.fileDescriptor,
size,
BackupRepository(
database = MangaDatabase(applicationContext),
settings = AppSettings(applicationContext),
tapGridSettings = TapGridSettings(applicationContext),
mangaSourcesRepository = MangaSourcesRepository(
context = applicationContext,
db = MangaDatabase(context = applicationContext),
settings = AppSettings(applicationContext),
),
savedFiltersRepository = SavedFiltersRepository(
context = applicationContext,
),
),
)
destination.delete()
} else {
super.onRestoreFile(data, size, destination, type, mode, mtime)
}
}
@VisibleForTesting
fun createBackupFile(context: Context, repository: BackupRepository): File {
val file = BackupUtils.createTempFile(context)
ZipOutputStream(file.outputStream()).use { output ->
runBlocking {
repository.createBackup(output, null)
}
}
return file
}
@VisibleForTesting
fun restoreBackupFile(fd: FileDescriptor, size: Long, repository: BackupRepository) {
ZipInputStream(ByteStreams.limit(FileInputStream(fd), size)).use { input ->
val sections = EnumSet.allOf(BackupSection::class.java)
// managed externally
sections.remove(BackupSection.SETTINGS)
sections.remove(BackupSection.SETTINGS_READER_GRID)
runBlocking {
repository.restoreBackup(input, sections, null)
}
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/domain/BackupFile.kt
================================================
package org.koitharu.kotatsu.backups.domain
import android.net.Uri
import java.util.Date
data class BackupFile(
val uri: Uri,
val dateTime: Date,
) : Comparable {
override fun compareTo(other: BackupFile): Int = compareValues(dateTime, other.dateTime)
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/domain/BackupObserver.kt
================================================
package org.koitharu.kotatsu.backups.domain
import android.app.backup.BackupManager
import android.content.Context
import androidx.room.InvalidationTracker
import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BackupObserver @Inject constructor(
@ApplicationContext context: Context,
) : InvalidationTracker.Observer(
arrayOf(
TABLE_HISTORY,
TABLE_FAVOURITES,
TABLE_FAVOURITE_CATEGORIES,
),
) {
private val backupManager = BackupManager(context)
override fun onInvalidated(tables: Set) {
backupManager.dataChanged()
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/domain/BackupSection.kt
================================================
package org.koitharu.kotatsu.backups.domain
import java.util.Locale
import java.util.zip.ZipEntry
enum class BackupSection(
val entryName: String,
) {
INDEX("index"),
HISTORY("history"),
CATEGORIES("categories"),
FAVOURITES("favourites"),
SETTINGS("settings"),
SETTINGS_READER_GRID("reader_grid"),
BOOKMARKS("bookmarks"),
SOURCES("sources"),
SCROBBLING("scrobbling"),
STATS("statistics"),
SAVED_FILTERS("saved_filters"),
;
companion object {
fun of(entry: ZipEntry): BackupSection? {
val name = entry.name.lowercase(Locale.ROOT)
return entries.find { x -> x.entryName == name }
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/domain/BackupUtils.kt
================================================
package org.koitharu.kotatsu.backups.domain
import android.content.Context
import androidx.annotation.CheckResult
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.io.File
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
object BackupUtils {
private const val DIR_BACKUPS = "backups"
private val dateTimeFormat = SimpleDateFormat("yyyyMMdd-HHmm")
@CheckResult
fun createTempFile(context: Context): File {
val dir = getAppBackupDir(context)
dir.mkdirs()
return File(dir, generateFileName(context))
}
fun getAppBackupDir(context: Context) = context.run {
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
}
fun parseBackupDateTime(fileName: String): Date? = try {
dateTimeFormat.parse(fileName.substringAfterLast('_').substringBefore('.'))
} catch (e: ParseException) {
e.printStackTraceDebug()
null
}
fun generateFileName(context: Context) = buildString {
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
append('_')
append(dateTimeFormat.format(Date()))
append(".bk.zip")
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/domain/ExternalBackupStorage.kt
================================================
package org.koitharu.kotatsu.backups.domain
import android.content.Context
import android.net.Uri
import androidx.annotation.CheckResult
import androidx.documentfile.provider.DocumentFile
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.buffer
import okio.sink
import okio.source
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.io.File
import javax.inject.Inject
class ExternalBackupStorage @Inject constructor(
@ApplicationContext private val context: Context,
private val settings: AppSettings,
) {
suspend fun list(): List = runInterruptible(Dispatchers.IO) {
getRootOrThrow().listFiles().mapNotNull {
if (it.isFile && it.canRead()) {
BackupFile(
uri = it.uri,
dateTime = it.name?.let { fileName ->
BackupUtils.parseBackupDateTime(fileName)
} ?: return@mapNotNull null,
)
} else {
null
}
}.sortedDescending()
}
suspend fun listOrNull() = runCatchingCancellable {
list()
}.onFailure { e ->
e.printStackTraceDebug()
}.getOrNull()
suspend fun put(file: File): Uri = runInterruptible(Dispatchers.IO) {
val out = checkNotNull(
getRootOrThrow().createFile(
"application/zip",
file.nameWithoutExtension,
),
) {
"Cannot create target backup file"
}
checkNotNull(context.contentResolver.openOutputStream(out.uri, "wt")).sink().use { sink ->
file.source().buffer().use { src ->
src.readAll(sink)
}
}
out.uri
}
@CheckResult
suspend fun delete(victim: BackupFile) = runInterruptible(Dispatchers.IO) {
val df = DocumentFile.fromSingleUri(context, victim.uri)
df != null && df.delete()
}
suspend fun getLastBackupDate() = listOrNull()?.maxOfOrNull { it.dateTime }
suspend fun trim(maxCount: Int): Boolean {
if (maxCount == Int.MAX_VALUE) {
return false
}
val list = listOrNull()
if (list == null || list.size <= maxCount) {
return false
}
var result = false
for (i in maxCount until list.size) {
if (delete(list[i])) {
result = true
}
}
return result
}
@Blocking
private fun getRootOrThrow(): DocumentFile {
val uri = checkNotNull(settings.periodicalBackupDirectory) {
"Backup directory is not specified"
}
val root = DocumentFile.fromTreeUri(context, uri)
return checkNotNull(root) { "Cannot obtain DocumentFile from $uri" }
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/BaseBackupRestoreService.kt
================================================
package org.koitharu.kotatsu.backups.ui
import android.content.Context
import android.net.Uri
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.app.ShareCompat
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ErrorReporterReceiver
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.util.CompositeResult
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getFileDisplayName
import androidx.appcompat.R as appcompatR
abstract class BaseBackupRestoreService : CoroutineIntentService() {
protected abstract val notificationTag: String
protected abstract val isRestoreService: Boolean
protected lateinit var notificationManager: NotificationManagerCompat
private set
override fun onCreate() {
super.onCreate()
notificationManager = NotificationManagerCompat.from(applicationContext)
createNotificationChannel(this)
}
override fun IntentJobContext.onError(error: Throwable) {
showResultNotification(null, CompositeResult.failure(error))
}
protected fun IntentJobContext.showResultNotification(
fileUri: Uri?,
result: CompositeResult,
) {
if (!applicationContext.checkNotificationPermission(CHANNEL_ID)) {
return
}
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setDefaults(0)
.setSilent(true)
.setAutoCancel(true)
.setSubText(fileUri?.let { contentResolver.getFileDisplayName(it) })
when {
result.isAllSuccess -> {
if (isRestoreService) {
notification
.setContentTitle(getString(R.string.restoring_backup))
.setContentText(getString(R.string.data_restored_success))
} else {
notification
.setContentTitle(getString(R.string.backup_saved))
.setContentText(fileUri?.let { contentResolver.getFileDisplayName(it) })
.setSubText(null)
}
notification.setSmallIcon(R.drawable.ic_stat_done)
}
result.isAllFailed || !isRestoreService -> {
val title = getString(if (isRestoreService) R.string.data_not_restored else R.string.error_occurred)
val message = result.failures.joinToString("\n") {
it.getDisplayMessage(applicationContext.resources)
}
notification
.setContentText(if (isRestoreService) getString(R.string.data_not_restored_text) else message)
.setBigText(title, message)
.setSmallIcon(android.R.drawable.stat_notify_error)
result.failures.firstNotNullOfOrNull { error ->
ErrorReporterReceiver.getNotificationAction(applicationContext, error, startId, notificationTag)
}?.let { action ->
notification.addAction(action)
}
}
else -> {
notification
.setContentTitle(getString(R.string.restoring_backup))
.setContentText(getString(R.string.data_restored_with_errors))
.setSmallIcon(R.drawable.ic_stat_done)
}
}
notification.setContentIntent(
PendingIntentCompat.getActivity(
applicationContext,
0,
AppRouter.homeIntent(this@BaseBackupRestoreService),
0,
false,
),
)
if (!isRestoreService && fileUri != null) {
val shareIntent = ShareCompat.IntentBuilder(this@BaseBackupRestoreService)
.setStream(fileUri)
.setType("application/zip")
.setChooserTitle(R.string.share_backup)
.createChooserIntent()
notification.addAction(
appcompatR.drawable.abc_ic_menu_share_mtrl_alpha,
getString(R.string.share),
PendingIntentCompat.getActivity(this@BaseBackupRestoreService, 0, shareIntent, 0, false),
)
}
notificationManager.notify(notificationTag, startId, notification.build())
}
protected fun NotificationCompat.Builder.setBigText(title: String, text: CharSequence) = setStyle(
NotificationCompat.BigTextStyle()
.bigText(text)
.setSummaryText(text)
.setBigContentTitle(title),
)
companion object {
const val CHANNEL_ID = "backup_restore"
fun createNotificationChannel(context: Context) {
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_HIGH)
.setName(context.getString(R.string.backup_restore))
.setShowBadge(true)
.setVibrationEnabled(false)
.setSound(null, null)
.setLightsEnabled(false)
.build()
NotificationManagerCompat.from(context).createNotificationChannel(channel)
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/backup/BackupDialogFragment.kt
================================================
package org.koitharu.kotatsu.backups.ui.backup
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.progress.Progress
import org.koitharu.kotatsu.databinding.DialogProgressBinding
@AndroidEntryPoint
class BackupDialogFragment : AlertDialogFragment() {
private val viewModel by viewModels()
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
) = DialogProgressBinding.inflate(inflater, container, false)
override fun onViewBindingCreated(binding: DialogProgressBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
binding.textViewTitle.setText(R.string.create_backup)
binding.textViewSubtitle.setText(R.string.processing_)
viewModel.progress.observe(viewLifecycleOwner, this::onProgressChanged)
viewModel.onBackupDone.observeEvent(viewLifecycleOwner, this::onBackupDone)
viewModel.onError.observeEvent(viewLifecycleOwner, this::onError)
}
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder)
.setCancelable(false)
.setNegativeButton(android.R.string.cancel, null)
}
private fun onError(e: Throwable) {
MaterialAlertDialogBuilder(context ?: return)
.setNegativeButton(R.string.close, null)
.setTitle(R.string.error)
.setMessage(e.getDisplayMessage(resources))
.show()
dismiss()
}
private fun onProgressChanged(value: Progress) {
with(requireViewBinding().progressBar) {
isVisible = true
val wasIndeterminate = isIndeterminate
isIndeterminate = value.isIndeterminate
if (!value.isIndeterminate) {
max = value.total
setProgressCompat(value.progress, !wasIndeterminate)
}
}
}
private fun onBackupDone(uri: Uri) {
Toast.makeText(requireContext(), R.string.backup_saved, Toast.LENGTH_SHORT).show()
dismiss()
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/backup/BackupService.kt
================================================
package org.koitharu.kotatsu.backups.ui.backup
import android.annotation.SuppressLint
import android.app.Notification
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.net.Uri
import android.widget.Toast
import androidx.annotation.CheckResult
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.documentfile.provider.DocumentFile
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.backups.data.BackupRepository
import org.koitharu.kotatsu.backups.ui.BaseBackupRestoreService
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.util.CompositeResult
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.core.util.ext.powerManager
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock
import org.koitharu.kotatsu.core.util.progress.Progress
import java.io.FileNotFoundException
import java.util.zip.ZipOutputStream
import javax.inject.Inject
import androidx.appcompat.R as appcompatR
@AndroidEntryPoint
@SuppressLint("InlinedApi")
class BackupService : BaseBackupRestoreService() {
override val notificationTag = TAG
override val isRestoreService = false
@Inject
lateinit var repository: BackupRepository
override suspend fun IntentJobContext.processIntent(intent: Intent) {
val notification = buildNotification(Progress.INDETERMINATE)
setForeground(
FOREGROUND_NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
val destination = intent.getStringExtra(AppRouter.KEY_DATA)?.toUriOrNull() ?: throw FileNotFoundException()
powerManager.withPartialWakeLock(TAG) {
val progress = MutableStateFlow(Progress.INDETERMINATE)
val progressUpdateJob = if (checkNotificationPermission(CHANNEL_ID)) {
launch {
progress.collect {
notificationManager.notify(FOREGROUND_NOTIFICATION_ID, buildNotification(it))
}
}
} else {
null
}
try {
ZipOutputStream(contentResolver.openOutputStream(destination)).use { output ->
repository.createBackup(output, progress)
}
} catch (e: Throwable) {
try {
DocumentFile.fromSingleUri(applicationContext, destination)?.delete()
} catch (e2: Throwable) {
e.addSuppressed(e2)
}
throw e
}
progressUpdateJob?.cancelAndJoin()
contentResolver.notifyChange(destination, null)
showResultNotification(destination, CompositeResult.success())
withContext(Dispatchers.Main) {
Toast.makeText(this@BackupService, R.string.backup_saved, Toast.LENGTH_SHORT).show()
}
}
}
private fun IntentJobContext.buildNotification(progress: Progress): Notification {
return NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setContentTitle(getString(R.string.creating_backup))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setDefaults(0)
.setSilent(true)
.setOngoing(true)
.setProgress(
progress.total.coerceAtLeast(0),
progress.progress.coerceAtLeast(0),
progress.isIndeterminate,
)
.setContentText(
if (progress.isIndeterminate) {
getString(R.string.processing_)
} else {
getString(R.string.fraction_pattern, progress.progress, progress.total)
},
)
.setSmallIcon(android.R.drawable.stat_sys_upload)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.addAction(
appcompatR.drawable.abc_ic_clear_material,
applicationContext.getString(android.R.string.cancel),
getCancelIntent(),
).build()
}
companion object {
private const val TAG = "BACKUP"
private const val FOREGROUND_NOTIFICATION_ID = 33
@CheckResult
fun start(context: Context, uri: Uri): Boolean = try {
val intent = Intent(context, BackupService::class.java)
intent.putExtra(AppRouter.KEY_DATA, uri.toString())
ContextCompat.startForegroundService(context, intent)
true
} catch (e: Exception) {
e.printStackTraceDebug()
false
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/backup/BackupViewModel.kt
================================================
package org.koitharu.kotatsu.backups.ui.backup
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.backups.data.BackupRepository
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.core.util.progress.Progress
import java.util.zip.Deflater
import java.util.zip.ZipOutputStream
import javax.inject.Inject
@HiltViewModel
class BackupViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val repository: BackupRepository,
@ApplicationContext context: Context,
) : BaseViewModel() {
val progress = MutableStateFlow(Progress.INDETERMINATE)
val onBackupDone = MutableEventFlow()
private val destination = savedStateHandle.require(AppRouter.KEY_DATA)
private val contentResolver: ContentResolver = context.contentResolver
init {
launchLoadingJob(Dispatchers.Default) {
ZipOutputStream(checkNotNull(contentResolver.openOutputStream(destination))).use {
it.setLevel(Deflater.BEST_COMPRESSION)
repository.createBackup(it, progress)
}
onBackupDone.call(destination)
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/periodical/PeriodicalBackupService.kt
================================================
package org.koitharu.kotatsu.backups.ui.periodical
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.backups.data.BackupRepository
import org.koitharu.kotatsu.backups.domain.BackupUtils
import org.koitharu.kotatsu.backups.domain.ExternalBackupStorage
import org.koitharu.kotatsu.backups.ui.BaseBackupRestoreService
import org.koitharu.kotatsu.core.ErrorReporterReceiver
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import java.util.zip.ZipOutputStream
import javax.inject.Inject
@AndroidEntryPoint
class PeriodicalBackupService : CoroutineIntentService() {
@Inject
lateinit var externalBackupStorage: ExternalBackupStorage
@Inject
lateinit var telegramBackupUploader: TelegramBackupUploader
@Inject
lateinit var repository: BackupRepository
@Inject
lateinit var settings: AppSettings
override suspend fun IntentJobContext.processIntent(intent: Intent) {
if (!settings.isPeriodicalBackupEnabled || settings.periodicalBackupDirectory == null) {
return
}
val lastBackupDate = externalBackupStorage.getLastBackupDate()
if (lastBackupDate != null && lastBackupDate.time + settings.periodicalBackupFrequencyMillis > System.currentTimeMillis()) {
return
}
val output = BackupUtils.createTempFile(applicationContext)
try {
ZipOutputStream(output.outputStream()).use {
repository.createBackup(it, null)
}
externalBackupStorage.put(output)
externalBackupStorage.trim(settings.periodicalBackupMaxCount)
if (settings.isBackupTelegramUploadEnabled && telegramBackupUploader.isAvailable) {
telegramBackupUploader.uploadBackup(output)
}
} finally {
output.delete()
}
}
override fun IntentJobContext.onError(error: Throwable) {
if (!applicationContext.checkNotificationPermission(CHANNEL_ID)) {
return
}
BaseBackupRestoreService.createNotificationChannel(applicationContext)
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setDefaults(0)
.setSilent(true)
.setAutoCancel(true)
val title = getString(R.string.periodic_backups)
val message = getString(
R.string.inline_preference_pattern,
getString(R.string.packup_creation_failed),
error.getDisplayMessage(resources),
)
notification
.setContentText(message)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setStyle(
NotificationCompat.BigTextStyle()
.bigText(message)
.setSummaryText(getString(R.string.packup_creation_failed))
.setBigContentTitle(title),
)
ErrorReporterReceiver.getNotificationAction(applicationContext, error, startId, TAG)?.let { action ->
notification.addAction(action)
}
notification.setContentIntent(
PendingIntentCompat.getActivity(
applicationContext,
0,
AppRouter.periodicBackupSettingsIntent(applicationContext),
0,
false,
),
)
NotificationManagerCompat.from(applicationContext).notify(TAG, startId, notification.build())
}
private companion object {
const val CHANNEL_ID = BaseBackupRestoreService.CHANNEL_ID
const val TAG = "periodical_backup"
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/periodical/PeriodicalBackupSettingsFragment.kt
================================================
package org.koitharu.kotatsu.backups.ui.periodical
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.text.format.DateUtils
import android.view.View
import androidx.activity.result.ActivityResultCallback
import androidx.fragment.app.viewModels
import androidx.preference.EditTextPreference
import androidx.preference.Preference
import androidx.preference.PreferenceCategory
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.os.OpenDocumentTreeHelper
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.tryLaunch
import org.koitharu.kotatsu.settings.utils.EditTextFallbackSummaryProvider
import java.util.Date
import javax.inject.Inject
@AndroidEntryPoint
class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodic_backups),
ActivityResultCallback {
@Inject
lateinit var telegramBackupUploader: TelegramBackupUploader
private val viewModel by viewModels()
private val outputSelectCall = OpenDocumentTreeHelper(this, this)
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_backup_periodic)
findPreference(AppSettings.KEY_BACKUP_TG)?.isVisible = viewModel.isTelegramAvailable
findPreference(AppSettings.KEY_BACKUP_TG_CHAT)?.summaryProvider =
EditTextFallbackSummaryProvider(R.string.telegram_chat_id_summary)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.lastBackupDate.observe(viewLifecycleOwner, ::bindLastBackupInfo)
viewModel.backupsDirectory.observe(viewLifecycleOwner, ::bindOutputSummary)
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this))
viewModel.isTelegramCheckLoading.observe(viewLifecycleOwner) {
findPreference(AppSettings.KEY_BACKUP_TG_TEST)?.isEnabled = !it
}
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
val result = when (preference.key) {
AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT -> outputSelectCall.tryLaunch(null)
AppSettings.KEY_BACKUP_TG_OPEN -> telegramBackupUploader.openBotInApp(router)
AppSettings.KEY_BACKUP_TG_TEST -> {
viewModel.checkTelegram()
true
}
else -> return super.onPreferenceTreeClick(preference)
}
if (!result) {
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
}
return true
}
override fun onActivityResult(result: Uri?) {
if (result != null) {
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context?.contentResolver?.takePersistableUriPermission(result, takeFlags)
settings.periodicalBackupDirectory = result
viewModel.updateSummaryData()
}
}
private fun bindOutputSummary(path: String?) {
val preference = findPreference(AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT) ?: return
preference.summary = when (path) {
null -> getString(R.string.invalid_value_message)
"" -> null
else -> path
}
preference.icon = if (path == null) {
getWarningIcon()
} else {
null
}
}
private fun bindLastBackupInfo(lastBackupDate: Date?) {
val preference = findPreference(AppSettings.KEY_BACKUP_PERIODICAL_LAST) ?: return
preference.summary = lastBackupDate?.let {
preference.context.getString(
R.string.last_successful_backup,
DateUtils.getRelativeTimeSpanString(it.time),
)
}
preference.isVisible = lastBackupDate != null
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/periodical/PeriodicalBackupSettingsViewModel.kt
================================================
package org.koitharu.kotatsu.backups.ui.periodical
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.backups.domain.BackupUtils
import org.koitharu.kotatsu.backups.domain.ExternalBackupStorage
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.resolveFile
import java.util.Date
import javax.inject.Inject
@HiltViewModel
class PeriodicalBackupSettingsViewModel @Inject constructor(
private val settings: AppSettings,
private val telegramUploader: TelegramBackupUploader,
private val backupStorage: ExternalBackupStorage,
@ApplicationContext private val appContext: Context,
) : BaseViewModel() {
val isTelegramAvailable
get() = telegramUploader.isAvailable
val lastBackupDate = MutableStateFlow(null)
val backupsDirectory = MutableStateFlow("")
val isTelegramCheckLoading = MutableStateFlow(false)
val onActionDone = MutableEventFlow()
init {
updateSummaryData()
}
fun checkTelegram() {
launchJob(Dispatchers.Default) {
try {
isTelegramCheckLoading.value = true
telegramUploader.sendTestMessage()
onActionDone.call(ReversibleAction(R.string.connection_ok, null))
} finally {
isTelegramCheckLoading.value = false
}
}
}
fun updateSummaryData() {
updateBackupsDirectory()
updateLastBackupDate()
}
private fun updateBackupsDirectory() = launchJob(Dispatchers.Default) {
val dir = settings.periodicalBackupDirectory
backupsDirectory.value = if (dir != null) {
dir.toUserFriendlyString()
} else {
BackupUtils.getAppBackupDir(appContext).path
}
}
private fun updateLastBackupDate() = launchJob(Dispatchers.Default) {
lastBackupDate.value = backupStorage.getLastBackupDate()
}
private fun Uri.toUserFriendlyString(): String? {
val df = DocumentFile.fromTreeUri(appContext, this)
if (df?.canWrite() != true) {
return null
}
return resolveFile(appContext)?.path ?: toString()
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/periodical/TelegramBackupUploader.kt
================================================
package org.koitharu.kotatsu.backups.ui.periodical
import android.content.Context
import androidx.annotation.CheckResult
import dagger.hilt.android.qualifiers.ApplicationContext
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.Response
import okhttp3.internal.closeQuietly
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.network.BaseHttpClient
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.parseJson
import java.io.File
import javax.inject.Inject
class TelegramBackupUploader @Inject constructor(
private val settings: AppSettings,
@BaseHttpClient private val client: OkHttpClient,
@ApplicationContext private val context: Context,
) {
private val botToken = context.getString(R.string.tg_backup_bot_token)
val isAvailable: Boolean
get() = botToken.isNotEmpty()
suspend fun uploadBackup(file: File) {
val requestBody = file.asRequestBody("application/zip".toMediaTypeOrNull())
val multipartBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("chat_id", requireChatId())
.addFormDataPart("document", file.name, requestBody)
.build()
val request = Request.Builder()
.url(urlOf("sendDocument").build())
.post(multipartBody)
.build()
client.newCall(request).await().consume()
}
suspend fun sendTestMessage() {
val request = Request.Builder()
.url(urlOf("getMe").build())
.build()
client.newCall(request).await().consume()
sendMessage(context.getString(R.string.backup_tg_echo))
}
@CheckResult
fun openBotInApp(router: AppRouter): Boolean {
val botUsername = context.getString(R.string.tg_backup_bot_name)
return router.openExternalBrowser("tg://resolve?domain=$botUsername") ||
router.openExternalBrowser("https://t.me/$botUsername")
}
private suspend fun sendMessage(message: String) {
val url = urlOf("sendMessage")
.addQueryParameter("chat_id", requireChatId())
.addQueryParameter("text", message)
.build()
val request = Request.Builder()
.url(url)
.build()
client.newCall(request).await().consume()
}
private fun requireChatId() = checkNotNull(settings.backupTelegramChatId) {
"Telegram chat ID not set in settings"
}
private fun Response.consume() {
if (isSuccessful) {
closeQuietly()
return
}
val jo = parseJson()
if (!jo.getBooleanOrDefault("ok", true)) {
throw RuntimeException(jo.getStringOrNull("description"))
}
}
private fun urlOf(method: String) = HttpUrl.Builder()
.scheme("https")
.host("api.telegram.org")
.addPathSegment("bot$botToken")
.addPathSegment(method)
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/restore/BackupEntriesAdapter.kt
================================================
package org.koitharu.kotatsu.backups.ui.restore
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.setChecked
import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_CHECKED_CHANGED
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
class BackupSectionsAdapter(
clickListener: OnListItemClickListener,
) : BaseListAdapter() {
init {
addDelegate(ListItemType.NAV_ITEM, backupSectionAD(clickListener))
}
}
private fun backupSectionAD(
clickListener: OnListItemClickListener,
) = adapterDelegateViewBinding(
{ layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
) {
binding.root.setOnClickListener { v ->
clickListener.onItemClick(item, v)
}
bind { payloads ->
with(binding.root) {
setText(item.titleResId)
setChecked(item.isChecked, PAYLOAD_CHECKED_CHANGED in payloads)
isEnabled = item.isEnabled
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/restore/BackupSectionModel.kt
================================================
package org.koitharu.kotatsu.backups.ui.restore
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.backups.domain.BackupSection
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
data class BackupSectionModel(
val section: BackupSection,
val isChecked: Boolean,
val isEnabled: Boolean,
) : ListModel {
@get:StringRes
val titleResId: Int
get() = when (section) {
BackupSection.INDEX -> 0 // should not appear here
BackupSection.HISTORY -> R.string.history
BackupSection.CATEGORIES -> R.string.favourites_categories
BackupSection.FAVOURITES -> R.string.favourites
BackupSection.SETTINGS -> R.string.settings
BackupSection.SETTINGS_READER_GRID -> R.string.reader_actions
BackupSection.BOOKMARKS -> R.string.bookmarks
BackupSection.SOURCES -> R.string.remote_sources
BackupSection.SCROBBLING -> R.string.tracking
BackupSection.STATS -> R.string.statistics
BackupSection.SAVED_FILTERS -> R.string.saved_filters
}
override fun areItemsTheSame(other: ListModel): Boolean {
return other is BackupSectionModel && other.section == section
}
override fun getChangePayload(previousState: ListModel): Any? {
if (previousState !is BackupSectionModel) {
return null
}
return if (previousState.isEnabled != isEnabled) {
ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED
} else if (previousState.isChecked != isChecked) {
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
} else {
super.getChangePayload(previousState)
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/restore/RestoreDialogFragment.kt
================================================
package org.koitharu.kotatsu.backups.ui.restore
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.DialogRestoreBinding
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Date
@AndroidEntryPoint
class RestoreDialogFragment : AlertDialogFragment(), OnListItemClickListener,
View.OnClickListener {
private val viewModel: RestoreViewModel by viewModels()
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
) = DialogRestoreBinding.inflate(inflater, container, false)
override fun onViewBindingCreated(binding: DialogRestoreBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
val adapter = BackupSectionsAdapter(this)
binding.recyclerView.adapter = adapter
binding.buttonCancel.setOnClickListener(this)
binding.buttonRestore.setOnClickListener(this)
viewModel.availableEntries.observe(viewLifecycleOwner, adapter)
viewModel.onError.observeEvent(viewLifecycleOwner, this::onError)
combine(
viewModel.isLoading,
viewModel.availableEntries,
viewModel.backupDate,
::Triple,
).observe(viewLifecycleOwner, this::onLoadingChanged)
}
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder)
.setTitle(R.string.restore_backup)
.setCancelable(false)
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_cancel -> dismiss()
R.id.button_restore -> {
if (startRestoreService()) {
Toast.makeText(v.context, R.string.backup_restored_background, Toast.LENGTH_SHORT).show()
router.closeWelcomeSheet()
dismiss()
} else {
Toast.makeText(v.context, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
}
}
}
}
override fun onItemClick(item: BackupSectionModel, view: View) {
viewModel.onItemClick(item)
}
private fun onLoadingChanged(value: Triple, Date?>) {
val (isLoading, entries, backupDate) = value
val hasEntries = entries.isNotEmpty()
with(requireViewBinding()) {
progressBar.isVisible = isLoading
recyclerView.isGone = isLoading
textViewSubtitle.textAndVisible =
when {
!isLoading -> backupDate?.formatBackupDate()
hasEntries -> getString(R.string.processing_)
else -> getString(R.string.loading_)
}
buttonRestore.isEnabled = !isLoading && entries.any { it.isChecked }
}
}
private fun startRestoreService(): Boolean {
return RestoreService.start(
context ?: return false,
viewModel.uri ?: return false,
viewModel.getCheckedSections(),
)
}
private fun Date.formatBackupDate(): String {
return getString(
R.string.backup_date_,
SimpleDateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(this),
)
}
private fun onError(e: Throwable) {
MaterialAlertDialogBuilder(context ?: return)
.setNegativeButton(R.string.close, null)
.setTitle(R.string.error)
.setMessage(e.getDisplayMessage(resources))
.show()
dismiss()
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/restore/RestoreService.kt
================================================
package org.koitharu.kotatsu.backups.ui.restore
import android.annotation.SuppressLint
import android.app.Notification
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.net.Uri
import androidx.annotation.CheckResult
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.backups.data.BackupRepository
import org.koitharu.kotatsu.backups.domain.BackupSection
import org.koitharu.kotatsu.backups.ui.BaseBackupRestoreService
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.core.util.ext.powerManager
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock
import org.koitharu.kotatsu.core.util.progress.Progress
import java.io.FileNotFoundException
import java.util.zip.ZipInputStream
import javax.inject.Inject
import androidx.appcompat.R as appcompatR
@AndroidEntryPoint
@SuppressLint("InlinedApi")
class RestoreService : BaseBackupRestoreService() {
override val notificationTag = TAG
override val isRestoreService = true
@Inject
lateinit var repository: BackupRepository
override suspend fun IntentJobContext.processIntent(intent: Intent) {
val notification = buildNotification(Progress.INDETERMINATE)
setForeground(
FOREGROUND_NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
val source = intent.getStringExtra(AppRouter.KEY_DATA)?.toUriOrNull() ?: throw FileNotFoundException()
val sections =
requireNotNull(intent.getSerializableExtraCompat>(AppRouter.KEY_ENTRIES)?.toSet())
powerManager.withPartialWakeLock(TAG) {
val progress = MutableStateFlow(Progress.INDETERMINATE)
val progressUpdateJob = if (checkNotificationPermission(CHANNEL_ID)) {
launch {
progress.collect {
notificationManager.notify(FOREGROUND_NOTIFICATION_ID, buildNotification(it))
}
}
} else {
null
}
val result = ZipInputStream(contentResolver.openInputStream(source)).use { input ->
repository.restoreBackup(input, sections, progress)
}
progressUpdateJob?.cancelAndJoin()
showResultNotification(source, result)
}
}
private fun IntentJobContext.buildNotification(progress: Progress): Notification {
return NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setContentTitle(getString(R.string.restoring_backup))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setDefaults(0)
.setSilent(true)
.setOngoing(true)
.setProgress(
progress.total.coerceAtLeast(0),
progress.progress.coerceAtLeast(0),
progress.isIndeterminate,
)
.setContentText(
if (progress.isIndeterminate) {
getString(R.string.processing_)
} else {
getString(R.string.fraction_pattern, progress.progress, progress.total)
},
)
.setSmallIcon(android.R.drawable.stat_sys_upload)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.addAction(
appcompatR.drawable.abc_ic_clear_material,
applicationContext.getString(android.R.string.cancel),
getCancelIntent(),
).build()
}
companion object {
private const val TAG = "RESTORE"
private const val FOREGROUND_NOTIFICATION_ID = 39
@CheckResult
fun start(context: Context, uri: Uri, sections: Set): Boolean = try {
val intent = Intent(context, RestoreService::class.java)
intent.putExtra(AppRouter.KEY_DATA, uri.toString())
intent.putExtra(AppRouter.KEY_ENTRIES, sections.toTypedArray())
ContextCompat.startForegroundService(context, intent)
true
} catch (e: Exception) {
e.printStackTraceDebug()
false
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/restore/RestoreViewModel.kt
================================================
package org.koitharu.kotatsu.backups.ui.restore
import android.content.Context
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runInterruptible
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import org.koitharu.kotatsu.backups.data.model.BackupIndex
import org.koitharu.kotatsu.backups.domain.BackupSection
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import java.io.FileNotFoundException
import java.io.InputStream
import java.util.Date
import java.util.EnumMap
import java.util.EnumSet
import java.util.zip.ZipInputStream
import javax.inject.Inject
@HiltViewModel
class RestoreViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
@ApplicationContext context: Context,
) : BaseViewModel() {
val uri = savedStateHandle.get(AppRouter.KEY_FILE)?.toUriOrNull()
private val contentResolver = context.contentResolver
val availableEntries = MutableStateFlow>(emptyList())
val backupDate = MutableStateFlow(null)
init {
launchLoadingJob(Dispatchers.Default) {
loadBackupInfo()
}
}
private suspend fun loadBackupInfo() {
val sections = runInterruptible(Dispatchers.IO) {
if (uri == null) throw FileNotFoundException()
ZipInputStream(contentResolver.openInputStream(uri)).use { stream ->
val result = EnumSet.noneOf(BackupSection::class.java)
var entry = stream.nextEntry
while (entry != null) {
val s = BackupSection.of(entry)
if (s != null) {
result.add(s)
if (s == BackupSection.INDEX) {
backupDate.value = stream.readDate()
}
}
stream.closeEntry()
entry = stream.nextEntry
}
result
}
}
availableEntries.value = BackupSection.entries.mapNotNull { entry ->
if (entry == BackupSection.INDEX || entry !in sections) {
return@mapNotNull null
}
BackupSectionModel(
section = entry,
isChecked = true,
isEnabled = true,
)
}
}
fun onItemClick(item: BackupSectionModel) {
val map = availableEntries.value.associateByTo(EnumMap(BackupSection::class.java)) { it.section }
map[item.section] = item.copy(isChecked = !item.isChecked)
map.validate()
availableEntries.value = map.values.sortedBy { it.section.ordinal }
}
fun getCheckedSections(): Set = availableEntries.value
.mapNotNullTo(EnumSet.noneOf(BackupSection::class.java)) {
if (it.isChecked) it.section else null
}
/**
* Check for inconsistent user selection
* Favorites cannot be restored without categories
*/
private fun MutableMap.validate() {
val favorites = this[BackupSection.FAVOURITES] ?: return
val categories = this[BackupSection.CATEGORIES]
if (categories?.isChecked == true) {
if (!favorites.isEnabled) {
this[BackupSection.FAVOURITES] = favorites.copy(isEnabled = true)
}
} else {
if (favorites.isEnabled) {
this[BackupSection.FAVOURITES] = favorites.copy(isEnabled = false, isChecked = false)
}
}
}
private fun InputStream.readDate(): Date? = runCatching {
val index = Json.decodeFromStream>(this)
Date(index.single().createdAt)
}.onFailure { e ->
e.printStackTraceDebug()
}.getOrNull()
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt
================================================
package org.koitharu.kotatsu.bookmarks.data
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import org.koitharu.kotatsu.core.db.entity.MangaEntity
@Entity(
tableName = "bookmarks",
primaryKeys = ["manga_id", "page_id"],
foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE
),
]
)
data class BookmarkEntity(
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "page_id", index = true) val pageId: Long,
@ColumnInfo(name = "chapter_id") val chapterId: Long,
@ColumnInfo(name = "page") val page: Int,
@ColumnInfo(name = "scroll") val scroll: Int,
@ColumnInfo(name = "image") val imageUrl: String,
@ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "percent") val percent: Float,
)
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt
================================================
package org.koitharu.kotatsu.bookmarks.data
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.isActive
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
@Dao
abstract class BookmarksDao {
@Query("SELECT * FROM bookmarks WHERE page_id = :pageId")
abstract suspend fun find(pageId: Long): BookmarkEntity?
@Transaction
@Query(
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent LIMIT :limit OFFSET :offset",
)
abstract suspend fun findAll(offset: Int, limit: Int): Map>
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page ORDER BY percent")
abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId ORDER BY percent")
abstract fun observe(mangaId: Long): Flow>
@Transaction
@Query(
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent",
)
abstract fun observe(): Flow>>
@Insert
abstract suspend fun insert(entity: BookmarkEntity)
@Delete
abstract suspend fun delete(entity: BookmarkEntity)
@Query("DELETE FROM bookmarks WHERE page_id = :pageId")
abstract suspend fun delete(pageId: Long): Int
@Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page")
abstract suspend fun delete(mangaId: Long, chapterId: Long, page: Int): Int
@Upsert
abstract suspend fun upsert(bookmarks: Collection)
fun dump(): Flow>> = flow {
val window = 4
var offset = 0
while (currentCoroutineContext().isActive) {
val list = findAll(offset, window)
if (list.isEmpty()) {
break
}
offset += window
list.forEach { emit(it.key to it.value) }
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt
================================================
package org.koitharu.kotatsu.bookmarks.data
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.parsers.model.Manga
import java.time.Instant
fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
manga = manga,
pageId = pageId,
chapterId = chapterId,
page = page,
scroll = scroll,
imageUrl = imageUrl,
createdAt = Instant.ofEpochMilli(createdAt),
percent = percent,
)
fun Bookmark.toEntity() = BookmarkEntity(
mangaId = manga.id,
pageId = pageId,
chapterId = chapterId,
page = page,
scroll = scroll,
imageUrl = imageUrl,
createdAt = createdAt.toEpochMilli(),
percent = percent,
)
fun Collection.toBookmarks(manga: Manga) = map {
it.toBookmark(manga)
}
@JvmName("bookmarksIds")
fun Collection.ids() = map { it.pageId }
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt
================================================
package org.koitharu.kotatsu.bookmarks.domain
import org.koitharu.kotatsu.core.util.MimeTypes
import org.koitharu.kotatsu.core.util.ext.isImage
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import java.time.Instant
data class Bookmark(
val manga: Manga,
val pageId: Long,
val chapterId: Long,
val page: Int,
val scroll: Int,
val imageUrl: String,
val createdAt: Instant,
val percent: Float,
) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is Bookmark &&
manga.id == other.manga.id &&
chapterId == other.chapterId &&
page == other.page
}
fun toMangaPage() = MangaPage(
id = pageId,
url = imageUrl,
preview = imageUrl.takeIf {
MimeTypes.getMimeTypeFromUrl(it)?.isImage == true
},
source = manga.source,
)
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/domain/BookmarksRepository.kt
================================================
package org.koitharu.kotatsu.bookmarks.domain
import android.database.SQLException
import androidx.room.withTransaction
import dagger.Reusable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.bookmarks.data.toBookmark
import org.koitharu.kotatsu.bookmarks.data.toBookmarks
import org.koitharu.kotatsu.bookmarks.data.toEntity
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.core.util.ext.mapItems
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.Manga
import javax.inject.Inject
@Reusable
class BookmarksRepository @Inject constructor(
private val db: MangaDatabase,
) {
fun observeBookmark(manga: Manga, chapterId: Long, page: Int): Flow {
return db.getBookmarksDao().observe(manga.id, chapterId, page).map { it?.toBookmark(manga) }
}
fun observeBookmarks(manga: Manga): Flow> {
return db.getBookmarksDao().observe(manga.id).mapItems { it.toBookmark(manga) }
}
fun observeBookmarks(): Flow>> {
return db.getBookmarksDao().observe().map { map ->
val res = LinkedHashMap>(map.size)
for ((k, v) in map) {
val manga = k.toManga()
res[manga] = v.toBookmarks(manga)
}
res
}
}
suspend fun addBookmark(bookmark: Bookmark) {
db.withTransaction {
val tags = bookmark.manga.tags.toEntities()
db.getTagsDao().upsert(tags)
db.getMangaDao().upsert(bookmark.manga.toEntity(), tags)
db.getBookmarksDao().insert(bookmark.toEntity())
}
}
suspend fun updateBookmark(bookmark: Bookmark, imageUrl: String) {
val entity = bookmark.toEntity().copy(
imageUrl = imageUrl,
)
db.getBookmarksDao().upsert(listOf(entity))
}
suspend fun removeBookmark(mangaId: Long, chapterId: Long, page: Int) {
check(db.getBookmarksDao().delete(mangaId, chapterId, page) != 0) {
"Bookmark not found"
}
}
suspend fun removeBookmark(bookmark: Bookmark) {
removeBookmark(bookmark.manga.id, bookmark.chapterId, bookmark.page)
}
suspend fun removeBookmarks(ids: Set): ReversibleHandle {
val entities = ArrayList(ids.size)
db.withTransaction {
val dao = db.getBookmarksDao()
for (pageId in ids) {
val e = dao.find(pageId)
if (e != null) {
entities.add(e)
}
dao.delete(pageId)
}
}
return BookmarksRestorer(entities)
}
private inner class BookmarksRestorer(
private val entities: Collection,
) : ReversibleHandle {
override suspend fun reverse() {
db.withTransaction {
for (e in entities) {
try {
db.getBookmarksDao().insert(e)
} catch (e: SQLException) {
e.printStackTraceDebug()
}
}
}
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/AllBookmarksActivity.kt
================================================
package org.koitharu.kotatsu.bookmarks.ui
import org.koitharu.kotatsu.core.ui.FragmentContainerActivity
class AllBookmarksActivity : FragmentContainerActivity(AllBookmarksFragment::class.java)
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/AllBookmarksFragment.kt
================================================
package org.koitharu.kotatsu.bookmarks.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.view.ActionMode
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.nav.ReaderIntent
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.systemBarsInsets
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
import org.koitharu.kotatsu.list.ui.GridSpanResolver
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.PageSaveHelper
import javax.inject.Inject
@AndroidEntryPoint
class AllBookmarksFragment :
BaseFragment(),
ListStateHolderListener,
OnListItemClickListener,
ListSelectionController.Callback,
FastScroller.FastScrollListener, ListHeaderClickListener {
@Inject
lateinit var settings: AppSettings
@Inject
lateinit var pageSaveHelperFactory: PageSaveHelper.Factory
private lateinit var pageSaveHelper: PageSaveHelper
private val viewModel by viewModels()
private var bookmarksAdapter: BookmarksAdapter? = null
private var selectionController: ListSelectionController? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
pageSaveHelper = pageSaveHelperFactory.create(this)
}
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
): FragmentListSimpleBinding {
return FragmentListSimpleBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(
binding: FragmentListSimpleBinding,
savedInstanceState: Bundle?,
) {
super.onViewBindingCreated(binding, savedInstanceState)
selectionController = ListSelectionController(
appCompatDelegate = checkNotNull(findAppCompatDelegate()),
decoration = BookmarksSelectionDecoration(binding.root.context),
registryOwner = this,
callback = this,
)
bookmarksAdapter = BookmarksAdapter(
clickListener = this,
headerClickListener = this,
)
val spanSizeLookup = SpanSizeLookup()
with(binding.recyclerView) {
setHasFixedSize(true)
val spanResolver = GridSpanResolver(resources)
addItemDecoration(TypedListSpacingDecoration(context, false))
adapter = bookmarksAdapter
addOnLayoutChangeListener(spanResolver)
spanResolver.setGridSize(settings.gridSize / 100f, this)
val lm = GridLayoutManager(context, spanResolver.spanCount)
lm.spanSizeLookup = spanSizeLookup
layoutManager = lm
selectionController?.attachToRecyclerView(this)
}
viewModel.content.observe(viewLifecycleOwner) {
bookmarksAdapter?.setItems(it, spanSizeLookup)
}
viewModel.onError.observeEvent(
viewLifecycleOwner,
SnackbarErrorObserver(binding.recyclerView, this),
)
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val barsInsets = insets.systemBarsInsets
val basePadding = resources.getDimensionPixelOffset(R.dimen.list_spacing_normal)
viewBinding?.recyclerView?.setPadding(
barsInsets.left + basePadding,
barsInsets.top + basePadding,
barsInsets.right + basePadding,
barsInsets.bottom + basePadding,
)
return insets.consumeAllSystemBarsInsets()
}
override fun onDestroyView() {
super.onDestroyView()
bookmarksAdapter = null
selectionController = null
}
override fun onItemClick(item: Bookmark, view: View) {
if (selectionController?.onItemClick(item.pageId) != true) {
val intent = ReaderIntent.Builder(view.context)
.bookmark(item)
.incognito()
.build()
router.openReader(intent)
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
}
}
override fun onListHeaderClick(item: ListHeader, view: View) {
val manga = item.payload as? Manga ?: return
router.openDetails(manga)
}
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
return selectionController?.onItemLongClick(view, item.pageId) == true
}
override fun onItemContextClick(item: Bookmark, view: View): Boolean {
return selectionController?.onItemContextClick(view, item.pageId) == true
}
override fun onRetryClick(error: Throwable) = Unit
override fun onEmptyActionClick() = Unit
override fun onFastScrollStart(fastScroller: FastScroller) {
(activity as? AppBarOwner)?.appBar?.setExpanded(false, true)
}
override fun onFastScrollStop(fastScroller: FastScroller) = Unit
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
requireViewBinding().recyclerView.invalidateItemDecorations()
}
override fun onCreateActionMode(
controller: ListSelectionController,
menuInflater: MenuInflater,
menu: Menu,
): Boolean {
menuInflater.inflate(R.menu.mode_bookmarks, menu)
return true
}
override fun onActionItemClicked(
controller: ListSelectionController,
mode: ActionMode?,
item: MenuItem,
): Boolean {
return when (item.itemId) {
R.id.action_remove -> {
val ids = selectionController?.snapshot() ?: return false
viewModel.removeBookmarks(ids)
mode?.finish()
true
}
R.id.action_save -> {
viewModel.savePages(pageSaveHelper, selectionController?.snapshot() ?: return false)
mode?.finish()
true
}
else -> false
}
}
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup(), Runnable {
init {
isSpanIndexCacheEnabled = true
isSpanGroupIndexCacheEnabled = true
}
override fun getSpanSize(position: Int): Int {
val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount
?: return 1
return when (bookmarksAdapter?.getItemViewType(position)) {
ListItemType.PAGE_THUMB.ordinal -> 1
else -> total
}
}
override fun run() {
invalidateSpanGroupIndexCache()
invalidateSpanIndexCache()
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/AllBookmarksViewModel.kt
================================================
package org.koitharu.kotatsu.bookmarks.ui
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.PageSaveHelper
import javax.inject.Inject
@HiltViewModel
class AllBookmarksViewModel @Inject constructor(
private val repository: BookmarksRepository,
) : BaseViewModel() {
val onActionDone = MutableEventFlow()
val content: StateFlow> = repository.observeBookmarks()
.map { list ->
if (list.isEmpty()) {
listOf(
EmptyState(
icon = R.drawable.ic_empty_favourites,
textPrimary = R.string.no_bookmarks_yet,
textSecondary = R.string.no_bookmarks_summary,
actionStringRes = 0,
),
)
} else {
mapList(list)
}
}
.catch { e -> emit(listOf(e.toErrorState(canRetry = false))) }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
fun removeBookmarks(ids: Set) {
launchJob(Dispatchers.Default) {
val handle = repository.removeBookmarks(ids)
onActionDone.call(ReversibleAction(R.string.bookmarks_removed, handle))
}
}
fun savePages(pageSaveHelper: PageSaveHelper, ids: Set) {
launchLoadingJob(Dispatchers.Default) {
val tasks = content.value.mapNotNull {
if (it !is Bookmark || it.pageId !in ids) return@mapNotNull null
PageSaveHelper.Task(
manga = it.manga,
chapterId = it.chapterId,
pageNumber = it.page + 1,
page = it.toMangaPage(),
)
}
val dest = pageSaveHelper.save(tasks)
val msg = if (dest.size == 1) R.string.page_saved else R.string.pages_saved
onActionDone.call(ReversibleAction(msg, null))
}
}
private fun mapList(data: Map>): List {
val result = ArrayList(data.values.sumOf { it.size + 1 })
for ((manga, bookmarks) in data) {
result.add(ListHeader(manga.title, R.string.more, manga))
result.addAll(bookmarks)
}
return result
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/BookmarksSelectionDecoration.kt
================================================
package org.koitharu.kotatsu.bookmarks.ui
import android.content.Context
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.util.ext.getItem
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
class BookmarksSelectionDecoration(context: Context) : MangaSelectionDecoration(context) {
override fun getItemId(parent: RecyclerView, child: View): Long {
val holder = parent.getChildViewHolder(child) ?: return RecyclerView.NO_ID
val item = holder.getItem(Bookmark::class.java) ?: return RecyclerView.NO_ID
return item.pageId
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkLargeAD.kt
================================================
package org.koitharu.kotatsu.bookmarks.ui.adapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemBookmarkLargeBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
fun bookmarkLargeAD(
clickListener: OnListItemClickListener,
) = adapterDelegateViewBinding(
{ inflater, parent -> ItemBookmarkLargeBinding.inflate(inflater, parent, false) },
) {
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
bind {
binding.imageViewThumb.setImageAsync(item)
binding.progressView.setProgress(item.percent, false)
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksAdapter.kt
================================================
package org.koitharu.kotatsu.bookmarks.ui.adapter
import android.content.Context
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
class BookmarksAdapter(
clickListener: OnListItemClickListener,
headerClickListener: ListHeaderClickListener?,
) : BaseListAdapter(), FastScroller.SectionIndexer {
init {
addDelegate(ListItemType.PAGE_THUMB, bookmarkLargeAD(clickListener))
addDelegate(ListItemType.HEADER, listHeaderAD(headerClickListener))
addDelegate(ListItemType.STATE_ERROR, errorStateListAD(null))
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(null))
}
override fun getSectionText(context: Context, position: Int): CharSequence? {
return findHeader(position)?.getText(context)
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/browser/AdListUpdateService.kt
================================================
package org.koitharu.kotatsu.browser
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.core.network.webview.adblock.AdBlock
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import javax.inject.Inject
@AndroidEntryPoint
class AdListUpdateService : CoroutineIntentService() {
@Inject
lateinit var updater: AdBlock.Updater
override suspend fun IntentJobContext.processIntent(intent: Intent) {
updater.updateList()
}
override fun IntentJobContext.onError(error: Throwable) = Unit
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/browser/BaseBrowserActivity.kt
================================================
package org.koitharu.kotatsu.browser
import android.os.Bundle
import android.view.View
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
import org.koitharu.kotatsu.core.network.webview.adblock.AdBlock
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.consumeAll
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import javax.inject.Inject
@AndroidEntryPoint
abstract class BaseBrowserActivity : BaseActivity(), BrowserCallback {
@Inject
lateinit var proxyProvider: ProxyProvider
@Inject
lateinit var mangaRepositoryFactory: MangaRepository.Factory
@Inject
lateinit var adBlock: AdBlock
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
return
}
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
onBackPressedDispatcher.addCallback(onBackPressedCallback)
val mangaSource = MangaSource(intent?.getStringExtra(AppRouter.KEY_SOURCE))
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
val userAgent = intent?.getStringExtra(AppRouter.KEY_USER_AGENT)?.nullIfEmpty()
?: repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
viewBinding.webView.configureForParser(userAgent)
onCreate2(savedInstanceState, mangaSource, repository)
}
protected abstract fun onCreate2(
savedInstanceState: Bundle?,
source: MangaSource,
repository: ParserMangaRepository?
)
override fun onApplyWindowInsets(
v: View,
insets: WindowInsetsCompat
): WindowInsetsCompat {
val type = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime()
val barsInsets = insets.getInsets(type)
viewBinding.webView.updatePadding(
left = barsInsets.left,
right = barsInsets.right,
bottom = barsInsets.bottom,
)
viewBinding.appbar.updatePadding(
left = barsInsets.left,
right = barsInsets.right,
top = barsInsets.top,
)
return insets.consumeAll(type)
}
override fun onPause() {
viewBinding.webView.onPause()
super.onPause()
}
override fun onResume() {
super.onResume()
viewBinding.webView.onResume()
}
override fun onDestroy() {
super.onDestroy()
if (hasViewBinding()) {
viewBinding.webView.stopLoading()
viewBinding.webView.destroy()
}
}
override fun onLoadingStateChanged(isLoading: Boolean) {
viewBinding.progressBar.isVisible = isLoading
}
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
this.title = title
supportActionBar?.subtitle = subtitle
}
override fun onHistoryChanged() {
onBackPressedCallback.onHistoryChanged()
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt
================================================
package org.koitharu.kotatsu.browser
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.activity.result.contract.ActivityResultContract
import androidx.lifecycle.lifecycleScope
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.MangaSource
@AndroidEntryPoint
class BrowserActivity : BaseBrowserActivity() {
override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) {
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true)
viewBinding.webView.webViewClient = BrowserClient(this, adBlock)
lifecycleScope.launch {
try {
proxyProvider.applyWebViewConfig()
} catch (e: Exception) {
e.printStackTraceDebug()
Snackbar.make(viewBinding.webView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
}
if (savedInstanceState == null) {
val url = intent?.dataString
if (url.isNullOrEmpty()) {
finishAfterTransition()
} else {
onTitleChanged(
intent?.getStringExtra(AppRouter.KEY_TITLE) ?: getString(R.string.loading_),
url,
)
viewBinding.webView.loadUrl(url)
}
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
super.onCreateOptionsMenu(menu)
menuInflater.inflate(R.menu.opt_browser, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
android.R.id.home -> {
viewBinding.webView.stopLoading()
finishAfterTransition()
true
}
R.id.action_browser -> {
if (!router.openExternalBrowser(viewBinding.webView.url.orEmpty(), item.title)) {
Snackbar.make(viewBinding.webView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
}
true
}
else -> super.onOptionsItemSelected(item)
}
class Contract : ActivityResultContract() {
override fun createIntent(
context: Context,
input: InteractiveActionRequiredException
): Intent = AppRouter.browserIntent(
context = context,
url = input.url,
source = input.source,
title = null,
)
override fun parseResult(resultCode: Int, intent: Intent?): Unit = Unit
}
companion object {
const val TAG = "BrowserActivity"
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserCallback.kt
================================================
package org.koitharu.kotatsu.browser
interface BrowserCallback : OnHistoryChangedListener {
fun onLoadingStateChanged(isLoading: Boolean)
fun onTitleChanged(title: CharSequence, subtitle: CharSequence?)
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserClient.kt
================================================
package org.koitharu.kotatsu.browser
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.os.Looper
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.annotation.AnyThread
import androidx.annotation.WorkerThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.core.network.webview.adblock.AdBlock
import java.io.ByteArrayInputStream
open class BrowserClient(
private val callback: BrowserCallback,
private val adBlock: AdBlock?,
) : WebViewClient() {
/**
* https://stackoverflow.com/questions/57414530/illegalstateexception-reasonphrase-cant-be-empty-with-android-webview
*/
override fun onPageFinished(webView: WebView, url: String) {
super.onPageFinished(webView, url)
callback.onLoadingStateChanged(isLoading = false)
}
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
callback.onLoadingStateChanged(isLoading = true)
}
override fun onPageCommitVisible(view: WebView, url: String) {
super.onPageCommitVisible(view, url)
callback.onTitleChanged(view.title.orEmpty(), url)
}
override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) {
super.doUpdateVisitedHistory(view, url, isReload)
callback.onHistoryChanged()
}
@WorkerThread
@Deprecated("Deprecated in Java")
override fun shouldInterceptRequest(
view: WebView?,
url: String?
): WebResourceResponse? = if (url.isNullOrEmpty() || adBlock?.shouldLoadUrl(url, view?.getUrlSafe()) ?: true) {
super.shouldInterceptRequest(view, url)
} else {
emptyResponse()
}
@WorkerThread
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?
): WebResourceResponse? =
if (request == null || adBlock?.shouldLoadUrl(request.url.toString(), view?.getUrlSafe()) ?: true) {
super.shouldInterceptRequest(view, request)
} else {
emptyResponse()
}
private fun emptyResponse(): WebResourceResponse =
WebResourceResponse("text/plain", "utf-8", ByteArrayInputStream(byteArrayOf()))
@SuppressLint("WrongThread")
@AnyThread
private fun WebView.getUrlSafe(): String? = if (Looper.myLooper() == Looper.getMainLooper()) {
url
} else {
runBlocking(Dispatchers.Main.immediate) {
url
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/browser/OnHistoryChangedListener.kt
================================================
package org.koitharu.kotatsu.browser
fun interface OnHistoryChangedListener {
fun onHistoryChanged()
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/browser/ProgressChromeClient.kt
================================================
package org.koitharu.kotatsu.browser
import android.webkit.WebChromeClient
import android.webkit.WebView
import androidx.core.view.isVisible
import com.google.android.material.progressindicator.BaseProgressIndicator
private const val PROGRESS_MAX = 100
class ProgressChromeClient(
private val progressIndicator: BaseProgressIndicator<*>,
) : WebChromeClient() {
init {
progressIndicator.max = PROGRESS_MAX
}
override fun onProgressChanged(view: WebView?, newProgress: Int) {
super.onProgressChanged(view, newProgress)
if (!progressIndicator.isVisible) {
return
}
if (newProgress in 1 until PROGRESS_MAX) {
progressIndicator.isIndeterminate = false
progressIndicator.setProgressCompat(newProgress.coerceAtMost(PROGRESS_MAX), true)
} else {
progressIndicator.isIndeterminate = true
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/browser/WebViewBackPressedCallback.kt
================================================
package org.koitharu.kotatsu.browser
import android.webkit.WebView
import androidx.activity.OnBackPressedCallback
class WebViewBackPressedCallback(
private val webView: WebView,
) : OnBackPressedCallback(false), OnHistoryChangedListener {
init {
onHistoryChanged()
}
override fun handleOnBackPressed() {
webView.goBack()
}
override fun onHistoryChanged() {
isEnabled = webView.canGoBack()
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt
================================================
package org.koitharu.kotatsu.browser.cloudflare
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.activity.result.contract.ActivityResultContract
import androidx.core.view.isInvisible
import androidx.lifecycle.lifecycleScope
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.yield
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BaseBrowserActivity
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
@AndroidEntryPoint
class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
private var pendingResult = RESULT_CANCELED
@Inject
lateinit var cookieJar: MutableCookieJar
@Inject
lateinit var captchaHandler: CaptchaHandler
private lateinit var cfClient: CloudFlareClient
override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) {
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true)
val url = intent?.dataString
if (url.isNullOrEmpty()) {
finishAfterTransition()
return
}
cfClient = CloudFlareClient(cookieJar, this, adBlock, url)
viewBinding.webView.webViewClient = cfClient
lifecycleScope.launch {
try {
proxyProvider.applyWebViewConfig()
} catch (e: Exception) {
Snackbar.make(viewBinding.webView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
}
if (savedInstanceState == null) {
onTitleChanged(getString(R.string.loading_), url)
viewBinding.webView.loadUrl(url)
}
}
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.opt_captcha, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
android.R.id.home -> {
viewBinding.webView.stopLoading()
finishAfterTransition()
true
}
R.id.action_retry -> {
restartCheck()
true
}
else -> super.onOptionsItemSelected(item)
}
override fun finish() {
setResult(pendingResult)
super.finish()
}
override fun onLoadingStateChanged(isLoading: Boolean) = Unit
override fun onPageLoaded() {
viewBinding.progressBar.isInvisible = true
}
override fun onLoopDetected() {
restartCheck()
}
override fun onCheckPassed() {
pendingResult = RESULT_OK
lifecycleScope.launch {
val source = intent?.getStringExtra(AppRouter.KEY_SOURCE)
if (source != null) {
runCatchingCancellable {
captchaHandler.discard(MangaSource(source))
}.onFailure {
it.printStackTraceDebug()
}
}
finishAfterTransition()
}
}
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
setTitle(title)
supportActionBar?.subtitle = subtitle?.toString()?.toHttpUrlOrNull()?.host.ifNullOrEmpty { subtitle }
}
private fun restartCheck() {
lifecycleScope.launch {
viewBinding.webView.stopLoading()
yield()
cfClient.reset()
val targetUrl = intent?.dataString?.toHttpUrlOrNull()
if (targetUrl != null) {
clearCfCookies(targetUrl)
viewBinding.webView.loadUrl(targetUrl.toString())
}
}
}
private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) {
cookieJar.removeCookies(url) { cookie ->
CloudFlareHelper.isCloudFlareCookie(cookie.name)
}
}
class Contract : ActivityResultContract() {
override fun createIntent(context: Context, input: CloudFlareProtectedException): Intent {
return AppRouter.cloudFlareResolveIntent(context, input)
}
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
return resultCode == RESULT_OK
}
}
companion object {
const val TAG = "CloudFlareActivity"
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareCallback.kt
================================================
package org.koitharu.kotatsu.browser.cloudflare
import org.koitharu.kotatsu.browser.BrowserCallback
interface CloudFlareCallback : BrowserCallback {
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) = Unit
fun onPageLoaded()
fun onCheckPassed()
fun onLoopDetected()
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt
================================================
package org.koitharu.kotatsu.browser.cloudflare
import android.graphics.Bitmap
import android.webkit.WebView
import org.koitharu.kotatsu.browser.BrowserClient
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.network.webview.adblock.AdBlock
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
private const val LOOP_COUNTER = 3
class CloudFlareClient(
private val cookieJar: MutableCookieJar,
private val callback: CloudFlareCallback,
adBlock: AdBlock,
private val targetUrl: String,
) : BrowserClient(callback, adBlock) {
private val oldClearance = getClearance()
private var counter = 0
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
checkClearance()
}
override fun onPageCommitVisible(view: WebView, url: String) {
super.onPageCommitVisible(view, url)
callback.onPageLoaded()
}
override fun onPageFinished(webView: WebView, url: String) {
super.onPageFinished(webView, url)
callback.onPageLoaded()
}
fun reset() {
counter = 0
}
private fun checkClearance() {
val clearance = getClearance()
if (clearance != null && clearance != oldClearance) {
callback.onCheckPassed()
} else {
counter++
if (counter >= LOOP_COUNTER) {
reset()
callback.onLoopDetected()
}
}
}
private fun getClearance() = CloudFlareHelper.getClearanceCookie(cookieJar, targetUrl)
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt
================================================
package org.koitharu.kotatsu.core
import android.app.Application
import android.content.Context
import android.os.Build
import android.provider.SearchRecentSuggestions
import android.text.Html
import androidx.collection.arraySetOf
import androidx.core.content.ContextCompat
import androidx.room.InvalidationTracker
import androidx.work.WorkManager
import coil3.ImageLoader
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.allowRgb565
import coil3.svg.SvgDecoder
import coil3.util.DebugLogger
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ElementsIntoSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import okhttp3.OkHttpClient
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.backups.domain.BackupObserver
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler
import org.koitharu.kotatsu.core.image.AvifImageDecoder
import org.koitharu.kotatsu.core.image.CbzFetcher
import org.koitharu.kotatsu.core.image.MangaSourceHeaderInterceptor
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.core.util.AcraScreenLogger
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageFetcher
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageKeyer
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.FaviconCache
import org.koitharu.kotatsu.local.data.LocalStorageCache
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.PageCache
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
import org.koitharu.kotatsu.sync.domain.SyncController
import org.koitharu.kotatsu.widget.WidgetUpdater
import javax.inject.Provider
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
interface AppModule {
@Binds
fun bindMangaLoaderContext(mangaLoaderContextImpl: MangaLoaderContextImpl): MangaLoaderContext
@Binds
fun bindImageGetter(coilImageGetter: CoilImageGetter): Html.ImageGetter
companion object {
@Provides
@LocalizedAppContext
fun provideLocalizedContext(
@ApplicationContext context: Context,
): Context = ContextCompat.getContextForLanguage(context)
@Provides
@Singleton
fun provideNetworkState(
@ApplicationContext context: Context,
settings: AppSettings,
) = NetworkState(context.connectivityManager, settings)
@Provides
@Singleton
fun provideMangaDatabase(
@ApplicationContext context: Context,
): MangaDatabase = MangaDatabase(context)
@Provides
@Singleton
fun provideCoil(
@LocalizedAppContext context: Context,
@MangaHttpClient okHttpClientProvider: Provider,
faviconFetcherFactory: FaviconFetcher.Factory,
imageProxyInterceptor: ImageProxyInterceptor,
pageFetcherFactory: MangaPageFetcher.Factory,
coverRestoreInterceptor: CoverRestoreInterceptor,
networkStateProvider: Provider,
captchaHandler: CaptchaHandler,
): ImageLoader {
val diskCacheFactory = {
val rootDir = context.externalCacheDir ?: context.cacheDir
DiskCache.Builder()
.directory(rootDir.resolve(CacheDir.THUMBS.dir))
.build()
}
val okHttpClientLazy = lazy {
okHttpClientProvider.get().newBuilder().cache(null).build()
}
return ImageLoader.Builder(context)
.interceptorCoroutineContext(Dispatchers.Default)
.diskCache(diskCacheFactory)
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
.allowRgb565(context.isLowRamDevice())
.eventListener(captchaHandler)
.components {
add(
OkHttpNetworkFetcherFactory(
callFactory = okHttpClientLazy::value,
connectivityChecker = { networkStateProvider.get() },
),
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
add(AnimatedImageDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
add(CbzFetcher.Factory())
add(AvifImageDecoder.Factory())
add(faviconFetcherFactory)
add(MangaPageKeyer())
add(pageFetcherFactory)
add(imageProxyInterceptor)
add(coverRestoreInterceptor)
add(MangaSourceHeaderInterceptor())
}.build()
}
@Provides
fun provideSearchSuggestions(
@ApplicationContext context: Context,
): SearchRecentSuggestions = MangaSuggestionsProvider.createSuggestions(context)
@Provides
@ElementsIntoSet
fun provideDatabaseObservers(
widgetUpdater: WidgetUpdater,
appShortcutManager: AppShortcutManager,
backupObserver: BackupObserver,
syncController: SyncController,
): Set<@JvmSuppressWildcards InvalidationTracker.Observer> = arraySetOf(
widgetUpdater,
appShortcutManager,
backupObserver,
syncController,
)
@Provides
@ElementsIntoSet
fun provideActivityLifecycleCallbacks(
appProtectHelper: AppProtectHelper,
activityRecreationHandle: ActivityRecreationHandle,
acraScreenLogger: AcraScreenLogger,
screenshotPolicyHelper: ScreenshotPolicyHelper,
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
appProtectHelper,
activityRecreationHandle,
acraScreenLogger,
screenshotPolicyHelper,
)
@Provides
@Singleton
@LocalStorageChanges
fun provideMutableLocalStorageChangesFlow(): MutableSharedFlow = MutableSharedFlow()
@Provides
@LocalStorageChanges
fun provideLocalStorageChangesFlow(
@LocalStorageChanges flow: MutableSharedFlow,
): SharedFlow = flow.asSharedFlow()
@Provides
fun provideWorkManager(
@ApplicationContext context: Context,
): WorkManager = WorkManager.getInstance(context)
@Provides
@Singleton
@PageCache
fun providePageCache(
@ApplicationContext context: Context,
) = LocalStorageCache(
context = context,
dir = CacheDir.PAGES,
defaultSize = FileSize.MEGABYTES.convert(200, FileSize.BYTES),
minSize = FileSize.MEGABYTES.convert(20, FileSize.BYTES),
)
@Provides
@Singleton
@FaviconCache
fun provideFaviconCache(
@ApplicationContext context: Context,
) = LocalStorageCache(
context = context,
dir = CacheDir.FAVICONS,
defaultSize = FileSize.MEGABYTES.convert(8, FileSize.BYTES),
minSize = FileSize.MEGABYTES.convert(2, FileSize.BYTES),
)
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/BaseApp.kt
================================================
package org.koitharu.kotatsu.core
import android.app.Application
import android.content.Context
import android.os.Build
import androidx.annotation.WorkerThread
import androidx.appcompat.app.AppCompatDelegate
import androidx.hilt.work.HiltWorkerFactory
import androidx.room.InvalidationTracker
import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import okhttp3.internal.platform.PlatformRegistry
import org.acra.ACRA
import org.acra.ReportField
import org.acra.config.dialog
import org.acra.config.httpSender
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.acra.sender.HttpSender
import org.conscrypt.Conscrypt
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.os.AppValidator
import org.koitharu.kotatsu.core.os.RomCompat
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrNull
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
import java.security.Security
import javax.inject.Inject
import javax.inject.Provider
@HiltAndroidApp
open class BaseApp : Application(), Configuration.Provider {
@Inject
lateinit var databaseObserversProvider: Provider>
@Inject
lateinit var activityLifecycleCallbacks: Set<@JvmSuppressWildcards ActivityLifecycleCallbacks>
@Inject
lateinit var database: Provider
@Inject
lateinit var settings: AppSettings
@Inject
lateinit var workerFactory: HiltWorkerFactory
@Inject
lateinit var appValidator: AppValidator
@Inject
lateinit var workScheduleManager: WorkScheduleManager
@Inject
lateinit var localMangaIndexProvider: Provider
@Inject
@LocalStorageChanges
lateinit var localStorageChanges: MutableSharedFlow
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
override fun onCreate() {
super.onCreate()
PlatformRegistry.applicationContext = this // TODO replace with OkHttp.initialize
if (ACRA.isACRASenderServiceProcess()) {
return
}
AppCompatDelegate.setDefaultNightMode(settings.theme)
// TLS 1.3 support for Android < 10
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
Security.insertProviderAt(Conscrypt.newProvider(), 1)
}
setupActivityLifecycleCallbacks()
processLifecycleScope.launch {
ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.getOrNull().toString())
ACRA.errorReporter.putCustomData("isMiui", RomCompat.isMiui.getOrNull().toString())
}
processLifecycleScope.launch(Dispatchers.Default) {
setupDatabaseObservers()
localStorageChanges.collect(localMangaIndexProvider.get())
}
workScheduleManager.init()
}
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
if (ACRA.isACRASenderServiceProcess()) {
return
}
initAcra {
buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.JSON
httpSender {
uri = getString(R.string.url_error_report)
basicAuthLogin = getString(R.string.acra_login)
basicAuthPassword = getString(R.string.acra_password)
httpMethod = HttpSender.Method.POST
}
reportContent = listOf(
ReportField.PACKAGE_NAME,
ReportField.INSTALLATION_ID,
ReportField.APP_VERSION_CODE,
ReportField.APP_VERSION_NAME,
ReportField.ANDROID_VERSION,
ReportField.PHONE_MODEL,
ReportField.STACK_TRACE,
ReportField.CRASH_CONFIGURATION,
ReportField.CUSTOM_DATA,
)
dialog {
text = getString(R.string.crash_text)
title = getString(R.string.error_occurred)
positiveButtonText = getString(R.string.send)
resIcon = R.drawable.ic_alert_outline
resTheme = android.R.style.Theme_Material_Light_Dialog_Alert
}
}
}
@WorkerThread
private fun setupDatabaseObservers() {
val tracker = database.get().invalidationTracker
databaseObserversProvider.get().forEach {
tracker.addObserver(it)
}
}
private fun setupActivityLifecycleCallbacks() {
activityLifecycleCallbacks.forEach {
registerActivityLifecycleCallbacks(it)
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ErrorReporterReceiver.kt
================================================
package org.koitharu.kotatsu.core
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.net.toUri
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.report
class ErrorReporterReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val e = intent?.getSerializableExtraCompat(AppRouter.KEY_ERROR) ?: return
val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, 0)
if (notificationId != 0 && context != null) {
val notificationTag = intent.getStringExtra(EXTRA_NOTIFICATION_TAG)
NotificationManagerCompat.from(context).cancel(notificationTag, notificationId)
}
e.report()
}
companion object {
private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR"
private const val EXTRA_NOTIFICATION_ID = "notify.id"
private const val EXTRA_NOTIFICATION_TAG = "notify.tag"
fun getPendingIntent(context: Context, e: Throwable): PendingIntent? = getPendingIntentInternal(
context = context,
e = e,
notificationId = 0,
notificationTag = null,
)
fun getNotificationAction(
context: Context,
e: Throwable,
notificationId: Int,
notificationTag: String?,
): NotificationCompat.Action? {
val intent = getPendingIntentInternal(
context = context,
e = e,
notificationId = notificationId,
notificationTag = notificationTag,
) ?: return null
return NotificationCompat.Action(
R.drawable.ic_alert_outline,
context.getString(R.string.report),
intent,
)
}
private fun getPendingIntentInternal(
context: Context,
e: Throwable,
notificationId: Int,
notificationTag: String?,
): PendingIntent? = runCatching {
val intent = Intent(context, ErrorReporterReceiver::class.java)
intent.setAction(ACTION_REPORT)
intent.setData("err://${e.hashCode()}".toUri())
intent.putExtra(AppRouter.KEY_ERROR, e)
intent.putExtra(EXTRA_NOTIFICATION_ID, notificationId)
intent.putExtra(EXTRA_NOTIFICATION_TAG, notificationTag)
PendingIntentCompat.getBroadcast(context, 0, intent, 0, false)
}.onFailure { e ->
// probably cannot write exception as serializable
e.printStackTraceDebug()
}.getOrNull()
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/LocalizedAppContext.kt
================================================
package org.koitharu.kotatsu.core
import javax.inject.Qualifier
@Qualifier
@Target(
AnnotationTarget.FUNCTION,
AnnotationTarget.PROPERTY_GETTER,
AnnotationTarget.PROPERTY_SETTER,
AnnotationTarget.VALUE_PARAMETER,
AnnotationTarget.FIELD,
)
annotation class LocalizedAppContext
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ExpiringLruCache.kt
================================================
package org.koitharu.kotatsu.core.cache
import org.koitharu.kotatsu.core.util.SynchronizedSieveCache
import org.koitharu.kotatsu.parsers.model.MangaSource
import java.util.concurrent.TimeUnit
import org.koitharu.kotatsu.core.cache.MemoryContentCache.Key as CacheKey
class ExpiringLruCache(
val maxSize: Int,
private val lifetime: Long,
private val timeUnit: TimeUnit,
) {
private val cache = SynchronizedSieveCache>(maxSize)
operator fun get(key: CacheKey): T? {
val value = cache[key] ?: return null
if (value.isExpired) {
cache.remove(key)
}
return value.get()
}
operator fun set(key: CacheKey, value: T) {
val value = ExpiringValue(value, lifetime, timeUnit)
cache.put(key, value)
}
fun clear() {
cache.evictAll()
}
fun trimToSize(size: Int) {
cache.trimToSize(size)
}
fun remove(key: CacheKey) {
cache.remove(key)
}
fun removeAll(source: MangaSource) {
cache.removeIf { key, _ -> key.source == source }
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ExpiringValue.kt
================================================
package org.koitharu.kotatsu.core.cache
import android.os.SystemClock
import java.util.concurrent.TimeUnit
class ExpiringValue(
private val value: T,
lifetime: Long,
timeUnit: TimeUnit,
) {
private val expiresAt = SystemClock.elapsedRealtime() + timeUnit.toMillis(lifetime)
val isExpired: Boolean
get() = SystemClock.elapsedRealtime() >= expiresAt
fun get(): T? = if (isExpired) null else value
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ExpiringValue<*>
if (value != other.value) return false
return expiresAt == other.expiresAt
}
override fun hashCode(): Int {
var result = value?.hashCode() ?: 0
result = 31 * result + expiresAt.hashCode()
return result
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/cache/MemoryContentCache.kt
================================================
package org.koitharu.kotatsu.core.cache
import android.app.Application
import android.content.ComponentCallbacks2
import android.content.res.Configuration
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MemoryContentCache @Inject constructor(application: Application) : ComponentCallbacks2 {
private val isLowRam = application.isLowRamDevice()
private val detailsCache = ExpiringLruCache>(if (isLowRam) 1 else 4, 5, TimeUnit.MINUTES)
private val pagesCache =
ExpiringLruCache>>(if (isLowRam) 1 else 4, 10, TimeUnit.MINUTES)
private val relatedMangaCache =
ExpiringLruCache>>(if (isLowRam) 1 else 3, 10, TimeUnit.MINUTES)
init {
application.registerComponentCallbacks(this)
}
suspend fun getDetails(source: MangaSource, url: String): Manga? {
return detailsCache[Key(source, url)]?.awaitOrNull()
}
fun putDetails(source: MangaSource, url: String, details: SafeDeferred) {
detailsCache[Key(source, url)] = details
}
suspend fun getPages(source: MangaSource, url: String): List? {
return pagesCache[Key(source, url)]?.awaitOrNull()
}
fun putPages(source: MangaSource, url: String, pages: SafeDeferred>) {
pagesCache[Key(source, url)] = pages
}
suspend fun getRelatedManga(source: MangaSource, url: String): List? {
return relatedMangaCache[Key(source, url)]?.awaitOrNull()
}
fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred>) {
relatedMangaCache[Key(source, url)] = related
}
fun clear(source: MangaSource) {
clearCache(detailsCache, source)
clearCache(pagesCache, source)
clearCache(relatedMangaCache, source)
}
override fun onConfigurationChanged(newConfig: Configuration) = Unit
override fun onLowMemory() = Unit
override fun onTrimMemory(level: Int) {
trimCache(detailsCache, level)
trimCache(pagesCache, level)
trimCache(relatedMangaCache, level)
}
private fun trimCache(cache: ExpiringLruCache<*>, level: Int) {
when (level) {
ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL,
ComponentCallbacks2.TRIM_MEMORY_COMPLETE,
ComponentCallbacks2.TRIM_MEMORY_MODERATE -> cache.clear()
ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN,
ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW,
ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> cache.trimToSize(1)
else -> cache.trimToSize(cache.maxSize / 2)
}
}
private fun clearCache(cache: ExpiringLruCache<*>, source: MangaSource) {
cache.removeAll(source)
}
data class Key(
val source: MangaSource,
val url: String,
)
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/cache/SafeDeferred.kt
================================================
package org.koitharu.kotatsu.core.cache
import kotlinx.coroutines.Deferred
class SafeDeferred(
private val delegate: Deferred>,
) {
suspend fun await(): T {
return delegate.await().getOrThrow()
}
suspend fun awaitOrNull(): T? {
return delegate.await().getOrNull()
}
fun cancel() {
delegate.cancel()
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt
================================================
package org.koitharu.kotatsu.core.db
import android.content.res.Resources
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.model.SortOrder
class DatabasePrePopulateCallback(private val resources: Resources) : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
db.execSQL(
"INSERT INTO favourite_categories (created_at, sort_key, title, `order`, track, show_in_lib, `deleted_at`) VALUES (?,?,?,?,?,?,?)",
arrayOf(
System.currentTimeMillis(),
1,
resources.getString(R.string.read_later),
SortOrder.NEWEST.name,
1,
1,
0L,
)
)
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt
================================================
package org.koitharu.kotatsu.core.db
import android.content.Context
import androidx.room.Database
import androidx.room.InvalidationTracker
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.bookmarks.data.BookmarksDao
import org.koitharu.kotatsu.core.db.dao.ChaptersDao
import org.koitharu.kotatsu.core.db.dao.MangaDao
import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao
import org.koitharu.kotatsu.core.db.dao.PreferencesDao
import org.koitharu.kotatsu.core.db.dao.TagsDao
import org.koitharu.kotatsu.core.db.dao.TrackLogsDao
import org.koitharu.kotatsu.core.db.entity.ChapterEntity
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.db.migrations.Migration10To11
import org.koitharu.kotatsu.core.db.migrations.Migration11To12
import org.koitharu.kotatsu.core.db.migrations.Migration12To13
import org.koitharu.kotatsu.core.db.migrations.Migration13To14
import org.koitharu.kotatsu.core.db.migrations.Migration14To15
import org.koitharu.kotatsu.core.db.migrations.Migration15To16
import org.koitharu.kotatsu.core.db.migrations.Migration16To17
import org.koitharu.kotatsu.core.db.migrations.Migration17To18
import org.koitharu.kotatsu.core.db.migrations.Migration18To19
import org.koitharu.kotatsu.core.db.migrations.Migration19To20
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration20To21
import org.koitharu.kotatsu.core.db.migrations.Migration21To22
import org.koitharu.kotatsu.core.db.migrations.Migration22To23
import org.koitharu.kotatsu.core.db.migrations.Migration23To24
import org.koitharu.kotatsu.core.db.migrations.Migration24To23
import org.koitharu.kotatsu.core.db.migrations.Migration24To25
import org.koitharu.kotatsu.core.db.migrations.Migration25To26
import org.koitharu.kotatsu.core.db.migrations.Migration26To27
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
import org.koitharu.kotatsu.core.db.migrations.Migration5To6
import org.koitharu.kotatsu.core.db.migrations.Migration6To7
import org.koitharu.kotatsu.core.db.migrations.Migration7To8
import org.koitharu.kotatsu.core.db.migrations.Migration8To9
import org.koitharu.kotatsu.core.db.migrations.Migration9To10
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.favourites.data.FavouriteCategoriesDao
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.favourites.data.FavouritesDao
import org.koitharu.kotatsu.history.data.HistoryDao
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.local.data.index.LocalMangaIndexDao
import org.koitharu.kotatsu.local.data.index.LocalMangaIndexEntity
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
import org.koitharu.kotatsu.stats.data.StatsDao
import org.koitharu.kotatsu.stats.data.StatsEntity
import org.koitharu.kotatsu.suggestions.data.SuggestionDao
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao
const val DATABASE_VERSION = 27
@Database(
entities = [
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, ChapterEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, TrackEntity::class,
TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class, ScrobblingEntity::class,
MangaSourceEntity::class, StatsEntity::class, LocalMangaIndexEntity::class,
],
version = DATABASE_VERSION,
)
abstract class MangaDatabase : RoomDatabase() {
abstract fun getHistoryDao(): HistoryDao
abstract fun getTagsDao(): TagsDao
abstract fun getMangaDao(): MangaDao
abstract fun getFavouritesDao(): FavouritesDao
abstract fun getPreferencesDao(): PreferencesDao
abstract fun getFavouriteCategoriesDao(): FavouriteCategoriesDao
abstract fun getTracksDao(): TracksDao
abstract fun getTrackLogsDao(): TrackLogsDao
abstract fun getSuggestionDao(): SuggestionDao
abstract fun getBookmarksDao(): BookmarksDao
abstract fun getScrobblingDao(): ScrobblingDao
abstract fun getSourcesDao(): MangaSourcesDao
abstract fun getStatsDao(): StatsDao
abstract fun getLocalMangaIndexDao(): LocalMangaIndexDao
abstract fun getChaptersDao(): ChaptersDao
}
fun getDatabaseMigrations(context: Context): Array = arrayOf(
Migration1To2(),
Migration2To3(),
Migration3To4(),
Migration4To5(),
Migration5To6(),
Migration6To7(),
Migration7To8(),
Migration8To9(),
Migration9To10(),
Migration10To11(),
Migration11To12(),
Migration12To13(),
Migration13To14(),
Migration14To15(),
Migration15To16(),
Migration16To17(context),
Migration17To18(),
Migration18To19(),
Migration19To20(),
Migration20To21(),
Migration21To22(),
Migration22To23(),
Migration23To24(),
Migration24To23(),
Migration24To25(),
Migration25To26(),
Migration26To27(),
)
fun MangaDatabase(context: Context): MangaDatabase = Room
.databaseBuilder(context, MangaDatabase::class.java, "kotatsu-db")
.addMigrations(*getDatabaseMigrations(context))
.addCallback(DatabasePrePopulateCallback(context.resources))
.build()
fun InvalidationTracker.removeObserverAsync(observer: InvalidationTracker.Observer) {
val scope = processLifecycleScope
if (scope.isActive) {
processLifecycleScope.launch(Dispatchers.Default, CoroutineStart.ATOMIC) {
removeObserver(observer)
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaQueryBuilder.kt
================================================
package org.koitharu.kotatsu.core.db
import androidx.sqlite.db.SimpleSQLiteQuery
import org.koitharu.kotatsu.list.domain.ListFilterOption
import java.util.LinkedList
class MangaQueryBuilder(
private val table: String,
private val conditionCallback: ConditionCallback
) {
private var filterOptions: Collection = emptyList()
private var whereConditions = LinkedList()
private var orderBy: String? = null
private var groupBy: String? = null
private var extraJoins: String? = null
private var limit: Int = 0
fun filters(options: Collection) = apply {
filterOptions = options
}
fun where(condition: String) = apply {
whereConditions.add(condition)
}
fun orderBy(orderBy: String?) = apply {
this@MangaQueryBuilder.orderBy = orderBy
}
fun groupBy(groupBy: String?) = apply {
this@MangaQueryBuilder.groupBy = groupBy
}
fun limit(limit: Int) = apply {
this@MangaQueryBuilder.limit = limit
}
fun join(join: String?) = apply {
extraJoins = join
}
fun build() = buildString {
append("SELECT * FROM ")
append(table)
extraJoins?.let {
append(' ')
append(it)
}
if (whereConditions.isNotEmpty()) {
whereConditions.joinTo(
buffer = this,
prefix = " WHERE ",
separator = " AND ",
)
}
if (filterOptions.isNotEmpty()) {
if (whereConditions.isEmpty()) {
append(" WHERE")
} else {
append(" AND")
}
var isFirst = true
val groupedOptions = filterOptions.groupBy { it.groupKey }
for ((_, group) in groupedOptions) {
if (group.isEmpty()) {
continue
}
if (isFirst) {
isFirst = false
append(' ')
} else {
append(" AND ")
}
if (group.size > 1) {
group.joinTo(
buffer = this,
separator = " OR ",
prefix = "(",
postfix = ")",
transform = ::getConditionOrThrow,
)
} else {
append(getConditionOrThrow(group.single()))
}
}
}
groupBy?.let {
append(" GROUP BY ")
append(it)
}
orderBy?.let {
append(" ORDER BY ")
append(it)
}
if (limit > 0) {
append(" LIMIT ")
append(limit)
}
}.let { SimpleSQLiteQuery(it) }
private fun getConditionOrThrow(option: ListFilterOption): String = when (option) {
is ListFilterOption.Inverted -> "NOT(${getConditionOrThrow(option.option)})"
else -> requireNotNull(conditionCallback.getCondition(option)) {
"Unsupported filter option $option"
}
}
fun interface ConditionCallback {
fun getCondition(option: ListFilterOption): String?
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/Tables.kt
================================================
package org.koitharu.kotatsu.core.db
const val TABLE_FAVOURITES = "favourites"
const val TABLE_MANGA = "manga"
const val TABLE_TAGS = "tags"
const val TABLE_FAVOURITE_CATEGORIES = "favourite_categories"
const val TABLE_HISTORY = "history"
const val TABLE_MANGA_TAGS = "manga_tags"
const val TABLE_SOURCES = "sources"
const val TABLE_CHAPTERS = "chapters"
const val TABLE_PREFERENCES = "preferences"
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/ChaptersDao.kt
================================================
package org.koitharu.kotatsu.core.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import org.koitharu.kotatsu.core.db.entity.ChapterEntity
@Dao
abstract class ChaptersDao {
@Query("SELECT * FROM chapters WHERE manga_id = :mangaId ORDER BY `index` ASC")
abstract suspend fun findAll(mangaId: Long): List
@Query("DELETE FROM chapters WHERE manga_id = :mangaId")
abstract suspend fun deleteAll(mangaId: Long)
@Query("DELETE FROM chapters WHERE manga_id NOT IN (SELECT manga_id FROM history WHERE deleted_at = 0) AND manga_id NOT IN (SELECT manga_id FROM favourites WHERE deleted_at = 0)")
abstract suspend fun gc()
@Transaction
open suspend fun replaceAll(mangaId: Long, entities: Collection) {
deleteAll(mangaId)
insert(entities)
}
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract suspend fun insert(entities: Collection)
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaDao.kt
================================================
package org.koitharu.kotatsu.core.db.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 androidx.room.Upsert
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
import org.koitharu.kotatsu.core.db.entity.TagEntity
@Dao
abstract class MangaDao {
@Transaction
@Query("SELECT * FROM manga WHERE manga_id = :id")
abstract suspend fun find(id: Long): MangaWithTags?
@Query("SELECT EXISTS(SELECT * FROM manga WHERE manga_id = :id)")
abstract suspend operator fun contains(id: Long): Boolean
@Transaction
@Query("SELECT * FROM manga WHERE public_url = :publicUrl")
abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags?
@Transaction
@Query("SELECT * FROM manga WHERE source = :source")
abstract suspend fun findAllBySource(source: String): List
@Query("SELECT author FROM manga WHERE author LIKE :query GROUP BY author ORDER BY COUNT(author) DESC LIMIT :limit")
abstract suspend fun findAuthors(query: String, limit: Int): List
@Query("SELECT author FROM manga WHERE manga.source = :source AND author IS NOT NULL AND author != '' GROUP BY author ORDER BY COUNT(author) DESC LIMIT :limit")
abstract suspend fun findAuthorsBySource(source: String, limit: Int): List
@Transaction
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
abstract suspend fun searchByTitle(query: String, limit: Int): List
@Transaction
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND source = :source AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
abstract suspend fun searchByTitle(query: String, source: String, limit: Int): List
@Upsert
protected abstract suspend fun upsert(manga: MangaEntity)
@Update(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun update(manga: MangaEntity): Int
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insertTagRelation(tag: MangaTagsEntity): Long
@Query("DELETE FROM manga_tags WHERE manga_id = :mangaId")
abstract suspend fun clearTagRelation(mangaId: Long)
@Transaction
@Delete
abstract suspend fun delete(subjects: Collection)
@Query(
"""
DELETE FROM manga WHERE NOT EXISTS(SELECT * FROM history WHERE history.manga_id == manga.manga_id)
AND NOT EXISTS(SELECT * FROM favourites WHERE favourites.manga_id == manga.manga_id)
AND NOT EXISTS(SELECT * FROM bookmarks WHERE bookmarks.manga_id == manga.manga_id)
AND NOT EXISTS(SELECT * FROM suggestions WHERE suggestions.manga_id == manga.manga_id)
AND NOT EXISTS(SELECT * FROM scrobblings WHERE scrobblings.manga_id == manga.manga_id)
AND NOT EXISTS(SELECT * FROM local_index WHERE local_index.manga_id == manga.manga_id)
AND manga.manga_id NOT IN (:idsToKeep)
""",
)
abstract suspend fun cleanup(idsToKeep: Set)
@Transaction
open suspend fun upsert(manga: MangaEntity, tags: Iterable? = null) {
upsert(manga)
if (tags != null) {
clearTagRelation(manga.id)
tags.map {
MangaTagsEntity(manga.id, it.id)
}.forEach {
insertTagRelation(it)
}
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt
================================================
package org.koitharu.kotatsu.core.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Transaction
import androidx.room.Upsert
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.isActive
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper.PROTECTION_CAPTCHA
@Dao
abstract class MangaSourcesDao {
@Query("SELECT * FROM sources ORDER BY pinned DESC, sort_key")
abstract suspend fun findAll(): List
@Query("SELECT source FROM sources WHERE enabled = 1")
abstract suspend fun findAllEnabledNames(): List
@Query("SELECT * FROM sources WHERE added_in >= :version")
abstract suspend fun findAllFromVersion(version: Int): List
@Query("SELECT * FROM sources ORDER BY used_at DESC LIMIT :limit")
abstract suspend fun findLastUsed(limit: Int): List
@Query("SELECT * FROM sources ORDER BY pinned DESC, sort_key")
abstract fun observeAll(): Flow>
@Query("SELECT enabled FROM sources WHERE source = :source")
abstract fun observeIsEnabled(source: String): Flow
@Query("SELECT IFNULL(MAX(sort_key),0) FROM sources")
abstract suspend fun getMaxSortKey(): Int
@Query("UPDATE sources SET enabled = 0")
abstract suspend fun disableAllSources()
@Query("UPDATE sources SET sort_key = :sortKey WHERE source = :source")
abstract suspend fun setSortKey(source: String, sortKey: Int)
@Query("UPDATE sources SET used_at = :value WHERE source = :source")
abstract suspend fun setLastUsed(source: String, value: Long)
@Query("UPDATE sources SET pinned = :isPinned WHERE source = :source")
abstract suspend fun setPinned(source: String, isPinned: Boolean)
@Query("UPDATE sources SET cf_state = :state WHERE source = :source")
abstract suspend fun setCfState(source: String, state: Int)
@Insert(onConflict = OnConflictStrategy.IGNORE)
@Transaction
abstract suspend fun insertIfAbsent(entries: Collection)
@Upsert
abstract suspend fun upsert(entry: MangaSourceEntity)
@Query("SELECT * FROM sources WHERE pinned = 1")
abstract suspend fun findAllPinned(): List
@Query("SELECT * FROM sources WHERE cf_state = $PROTECTION_CAPTCHA")
abstract suspend fun findAllCaptchaRequired(): List
fun observeAll(enabledOnly: Boolean, order: SourcesSortOrder): Flow> =
observeImpl(getQuery(enabledOnly, order))
suspend fun findAll(enabledOnly: Boolean, order: SourcesSortOrder): List =
findAllImpl(getQuery(enabledOnly, order))
@Transaction
open suspend fun setEnabled(source: String, isEnabled: Boolean) {
if (updateIsEnabled(source, isEnabled) == 0) {
val entity = MangaSourceEntity(
source = source,
isEnabled = isEnabled,
sortKey = getMaxSortKey() + 1,
addedIn = BuildConfig.VERSION_CODE,
lastUsedAt = 0,
isPinned = false,
cfState = CloudFlareHelper.PROTECTION_NOT_DETECTED,
)
upsert(entity)
}
}
fun dumpEnabled(): Flow = flow {
val window = 10
var offset = 0
while (currentCoroutineContext().isActive) {
val list = findAllEnabled(offset, window)
if (list.isEmpty()) {
break
}
offset += window
list.forEach { emit(it) }
}
}
@Query("UPDATE sources SET enabled = :isEnabled WHERE source = :source")
protected abstract suspend fun updateIsEnabled(source: String, isEnabled: Boolean): Int
@RawQuery(observedEntities = [MangaSourceEntity::class])
protected abstract fun observeImpl(query: SupportSQLiteQuery): Flow>
@RawQuery
protected abstract suspend fun findAllImpl(query: SupportSQLiteQuery): List
@Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY source LIMIT :limit OFFSET :offset")
protected abstract suspend fun findAllEnabled(offset: Int, limit: Int): List
private fun getQuery(enabledOnly: Boolean, order: SourcesSortOrder) = SimpleSQLiteQuery(
buildString {
append("SELECT * FROM sources ")
if (enabledOnly) {
append("WHERE enabled = 1 ")
}
append("ORDER BY pinned DESC, ")
append(getOrderBy(order))
},
)
private fun getOrderBy(order: SourcesSortOrder) = when (order) {
SourcesSortOrder.ALPHABETIC -> "source ASC"
SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC"
SourcesSortOrder.MANUAL -> "sort_key ASC"
SourcesSortOrder.LAST_USED -> "used_at DESC"
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/PreferencesDao.kt
================================================
package org.koitharu.kotatsu.core.db.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
@Dao
abstract class PreferencesDao {
@Query("SELECT * FROM preferences WHERE manga_id = :mangaId")
abstract suspend fun find(mangaId: Long): MangaPrefsEntity?
@Query("SELECT * FROM preferences WHERE manga_id = :mangaId")
abstract fun observe(mangaId: Long): Flow
@Query("SELECT * FROM preferences WHERE title_override IS NOT NULL OR cover_override IS NOT NULL OR content_rating_override IS NOT NULL")
abstract suspend fun getOverrides(): List
@Query("UPDATE preferences SET cf_brightness = 0, cf_contrast = 0, cf_invert = 0, cf_grayscale = 0")
abstract suspend fun resetColorFilters()
@Upsert
abstract suspend fun upsert(pref: MangaPrefsEntity)
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/TagsDao.kt
================================================
package org.koitharu.kotatsu.core.db.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import org.koitharu.kotatsu.core.db.entity.TagEntity
@Dao
abstract class TagsDao {
@Query("SELECT * FROM tags WHERE source = :source")
abstract suspend fun findTags(source: String): List
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE manga_tags.manga_id IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites)
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit""",
)
abstract suspend fun findPopularTags(limit: Int): List
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE tags.source = :source
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit""",
)
abstract suspend fun findPopularTags(source: String, limit: Int): List
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE tags.source = :source
GROUP BY tags.title
ORDER BY COUNT(manga_id) ASC
LIMIT :limit""",
)
abstract suspend fun findRareTags(source: String, limit: Int): List
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE tags.source = :source AND title LIKE :query
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit""",
)
abstract suspend fun findTags(source: String, query: String, limit: Int): List
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE title LIKE :query AND manga_tags.manga_id IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites)
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit""",
)
abstract suspend fun findTags(query: String, limit: Int): List
@Query(
"""
SELECT tags.* FROM manga_tags
LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id
WHERE manga_tags.manga_id IN (SELECT manga_id FROM manga_tags WHERE tag_id = :tagId)
GROUP BY tags.tag_id
ORDER BY COUNT(manga_id) DESC;
""",
)
abstract suspend fun findRelatedTags(tagId: Long): List
@Query(
"""
SELECT tags.* FROM manga_tags
LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id
WHERE manga_tags.manga_id IN (SELECT manga_id FROM manga_tags WHERE tag_id IN (:ids))
GROUP BY tags.tag_id
ORDER BY COUNT(manga_id) DESC;
""",
)
abstract suspend fun findRelatedTags(ids: Set): List
@Upsert
abstract suspend fun upsert(tags: Iterable)
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/TrackLogsDao.kt
================================================
package org.koitharu.kotatsu.core.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Transaction
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.MangaQueryBuilder
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
@Dao
abstract class TrackLogsDao : MangaQueryBuilder.ConditionCallback {
fun observeAll(
limit: Int,
filterOptions: Set,
): Flow> = observeAllImpl(
MangaQueryBuilder("track_logs", this)
.filters(filterOptions)
.limit(limit)
.orderBy("created_at DESC")
.build(),
)
@Query("SELECT COUNT(*) FROM track_logs WHERE unread = 1")
abstract fun observeUnreadCount(): Flow
@Query("DELETE FROM track_logs")
abstract suspend fun clear()
@Query("UPDATE track_logs SET unread = 0 WHERE id = :id")
abstract suspend fun markAsRead(id: Long)
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insert(entity: TrackLogEntity): Long
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
abstract suspend fun gc()
@Query("DELETE FROM track_logs WHERE id IN (SELECT id FROM track_logs ORDER BY created_at DESC LIMIT 0 OFFSET :size)")
abstract suspend fun trim(size: Int)
@Query("SELECT COUNT(*) FROM track_logs")
abstract suspend fun count(): Int
@Transaction
@RawQuery(observedEntities = [TrackLogEntity::class])
protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow>
override fun getCondition(option: ListFilterOption): String? = when (option) {
ListFilterOption.Macro.FAVORITE -> "EXISTS(SELECT * FROM favourites WHERE favourites.manga_id = track_logs.manga_id)"
is ListFilterOption.Favorite -> "EXISTS(SELECT * FROM favourites WHERE favourites.manga_id = track_logs.manga_id AND favourites.category_id = ${option.category.id})"
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE manga_tags.manga_id = track_logs.manga_id AND tag_id = ${option.tagId})"
ListFilterOption.Macro.NSFW -> "(SELECT nsfw FROM manga WHERE manga.manga_id = track_logs.manga_id) = 1"
else -> null
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/ChapterEntity.kt
================================================
package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import org.koitharu.kotatsu.core.db.TABLE_CHAPTERS
@Entity(
tableName = TABLE_CHAPTERS,
primaryKeys = ["manga_id", "chapter_id"],
foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE,
),
],
)
data class ChapterEntity(
@ColumnInfo(name = "chapter_id") val chapterId: Long,
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "name") val title: String,
@ColumnInfo(name = "number") val number: Float,
@ColumnInfo(name = "volume") val volume: Int,
@ColumnInfo(name = "url") val url: String,
@ColumnInfo(name = "scanlator") val scanlator: String?,
@ColumnInfo(name = "upload_date") val uploadDate: Long,
@ColumnInfo(name = "branch") val branch: String?,
@ColumnInfo(name = "source") val source: String,
@ColumnInfo(name = "index") val index: Int,
)
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt
================================================
package org.koitharu.kotatsu.core.db.entity
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.longHashCode
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.parsers.util.toArraySet
import org.koitharu.kotatsu.parsers.util.toTitleCase
private const val VALUES_DIVIDER = '\n'
// Entity to model
fun TagEntity.toMangaTag() = MangaTag(
key = this.key,
title = this.title.toTitleCase(),
source = MangaSource(this.source),
)
fun Collection.toMangaTags() = mapToSet(TagEntity::toMangaTag)
fun Collection.toMangaTagsList() = map(TagEntity::toMangaTag)
fun MangaEntity.toManga(tags: Set, chapters: List?) = Manga(
id = this.id,
title = this.title,
altTitles = this.altTitles?.split(VALUES_DIVIDER)?.toArraySet().orEmpty(),
state = this.state?.let { MangaState(it) },
rating = this.rating,
contentRating = ContentRating(this.contentRating)
?: if (isNsfw) ContentRating.ADULT else null,
url = this.url,
publicUrl = this.publicUrl,
coverUrl = this.coverUrl,
largeCoverUrl = this.largeCoverUrl,
authors = this.authors?.split(VALUES_DIVIDER)?.toArraySet().orEmpty(),
source = MangaSource(this.source),
tags = tags,
chapters = chapters?.toMangaChapters(),
)
fun MangaWithTags.toManga(chapters: List? = null) = manga.toManga(tags.toMangaTags(), chapters)
fun Collection.toMangaList() = map { it.toManga() }
fun ChapterEntity.toMangaChapter() = MangaChapter(
id = chapterId,
title = title.nullIfEmpty(),
number = number,
volume = volume,
url = url,
scanlator = scanlator,
uploadDate = uploadDate,
branch = branch,
source = MangaSource(source),
)
fun Collection.toMangaChapters() = map { it.toMangaChapter() }
// Model to entity
fun Manga.toEntity() = MangaEntity(
id = id,
url = url,
publicUrl = publicUrl,
source = source.name,
largeCoverUrl = largeCoverUrl,
coverUrl = coverUrl.orEmpty(),
altTitles = altTitles.joinToString(VALUES_DIVIDER.toString()),
rating = rating,
isNsfw = isNsfw,
contentRating = contentRating?.name,
state = state?.name,
title = title,
authors = authors.joinToString(VALUES_DIVIDER.toString()),
)
fun MangaTag.toEntity() = TagEntity(
title = title,
key = key,
source = source.name,
id = "${key}_${source.name}".longHashCode(),
isPinned = false, // for future use
)
fun Collection.toEntities() = map(MangaTag::toEntity)
fun Iterable>.toEntities(mangaId: Long) = map { (index, chapter) ->
ChapterEntity(
chapterId = chapter.id,
mangaId = mangaId,
title = chapter.title.orEmpty(),
number = chapter.number,
volume = chapter.volume,
url = chapter.url,
scanlator = chapter.scanlator,
uploadDate = chapter.uploadDate,
branch = chapter.branch,
source = chapter.source.name,
index = index,
)
}
// Other
fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
SortOrder.valueOf(name)
}.getOrDefault(fallback)
fun MangaState(name: String): MangaState? = runCatching {
MangaState.valueOf(name)
}.getOrNull()
fun ContentRating(name: String?): ContentRating? = runCatching {
ContentRating.valueOf(name ?: return@runCatching null)
}.getOrNull()
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt
================================================
package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.TABLE_MANGA
@Entity(tableName = TABLE_MANGA)
data class MangaEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val id: Long,
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "alt_title") val altTitles: String?,
@ColumnInfo(name = "url") val url: String,
@ColumnInfo(name = "public_url") val publicUrl: String,
@ColumnInfo(name = "rating") val rating: Float, // normalized value [0..1] or -1
@ColumnInfo(name = "nsfw") val isNsfw: Boolean,
@ColumnInfo(name = "content_rating") val contentRating: String?,
@ColumnInfo(name = "cover_url") val coverUrl: String,
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
@ColumnInfo(name = "state") val state: String?,
@ColumnInfo(name = "author") val authors: String?,
@ColumnInfo(name = "source") val source: String,
)
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt
================================================
package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.TABLE_PREFERENCES
@Entity(
tableName = TABLE_PREFERENCES,
foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE,
),
],
)
data class MangaPrefsEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id")
val mangaId: Long,
@ColumnInfo(name = "mode") val mode: Int,
@ColumnInfo(name = "cf_brightness") val cfBrightness: Float,
@ColumnInfo(name = "cf_contrast") val cfContrast: Float,
@ColumnInfo(name = "cf_invert") val cfInvert: Boolean,
@ColumnInfo(name = "cf_grayscale") val cfGrayscale: Boolean,
@ColumnInfo(name = "cf_book") val cfBookEffect: Boolean,
@ColumnInfo(name = "title_override") val titleOverride: String?,
@ColumnInfo(name = "cover_override") val coverUrlOverride: String?,
@ColumnInfo(name = "content_rating_override") val contentRatingOverride: String?,
)
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaSourceEntity.kt
================================================
package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.TABLE_SOURCES
@Entity(
tableName = TABLE_SOURCES,
)
data class MangaSourceEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "source")
val source: String,
@ColumnInfo(name = "enabled") val isEnabled: Boolean,
@ColumnInfo(name = "sort_key", index = true) val sortKey: Int,
@ColumnInfo(name = "added_in") val addedIn: Int,
@ColumnInfo(name = "used_at") val lastUsedAt: Long,
@ColumnInfo(name = "pinned") val isPinned: Boolean,
@ColumnInfo(name = "cf_state") val cfState: Int,
)
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt
================================================
package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import org.koitharu.kotatsu.core.db.TABLE_MANGA_TAGS
@Entity(
tableName = TABLE_MANGA_TAGS,
primaryKeys = ["manga_id", "tag_id"],
foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = TagEntity::class,
parentColumns = ["tag_id"],
childColumns = ["tag_id"],
onDelete = ForeignKey.CASCADE,
)
]
)
class MangaTagsEntity(
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "tag_id", index = true) val tagId: Long,
)
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaWithTags.kt
================================================
package org.koitharu.kotatsu.core.db.entity
import androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation
data class MangaWithTags(
@Embedded val manga: MangaEntity,
@Relation(
parentColumn = "manga_id",
entityColumn = "tag_id",
associateBy = Junction(MangaTagsEntity::class)
)
val tags: List,
)
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/TagEntity.kt
================================================
package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.TABLE_TAGS
@Entity(tableName = TABLE_TAGS)
data class TagEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "tag_id") val id: Long,
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "key") val key: String,
@ColumnInfo(name = "source") val source: String,
@ColumnInfo(name = "pinned") val isPinned: Boolean,
)
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration10To11.kt
================================================
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration10To11 : Migration(10, 11) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `bookmarks` (
`manga_id` INTEGER NOT NULL,
`page_id` INTEGER NOT NULL,
`chapter_id` INTEGER NOT NULL,
`page` INTEGER NOT NULL,
`scroll` INTEGER NOT NULL,
`image` TEXT NOT NULL,
`created_at` INTEGER NOT NULL,
PRIMARY KEY(`manga_id`, `page_id`),
FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )
""".trimIndent()
)
db.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_manga_id` ON `bookmarks` (`manga_id`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_page_id` ON `bookmarks` (`page_id`)")
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt
================================================
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration11To12 : Migration(11, 12) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `scrobblings` (
`scrobbler` INTEGER NOT NULL,
`id` INTEGER NOT NULL,
`manga_id` INTEGER NOT NULL,
`target_id` INTEGER NOT NULL,
`status` TEXT,
`chapter` INTEGER NOT NULL,
`comment` TEXT,
`rating` REAL NOT NULL,
PRIMARY KEY(`scrobbler`, `id`, `manga_id`)
)
""".trimIndent()
)
db.execSQL("ALTER TABLE history ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
db.execSQL("ALTER TABLE bookmarks ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration12To13.kt
================================================
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration12To13 : Migration(12, 13) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `show_in_lib` INTEGER NOT NULL DEFAULT 1")
db.execSQL("ALTER TABLE favourites ADD COLUMN `sort_key` INTEGER NOT NULL DEFAULT 0")
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration13To14.kt
================================================
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration13To14 : Migration(13, 14) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE favourites ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE history ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_brightness` REAL NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_contrast` REAL NOT NULL DEFAULT 0")
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration14To15.kt
================================================
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration14To15 : Migration(14, 15) {
override fun migrate(db: SupportSQLiteDatabase) = Unit
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration15To16.kt
================================================
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration15To16 : Migration(15, 16) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_invert` INTEGER NOT NULL DEFAULT 0")
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration16To17.kt
================================================
package org.koitharu.kotatsu.core.db.migrations
import android.content.Context
import androidx.preference.PreferenceManager
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import org.koitharu.kotatsu.parsers.model.MangaParserSource
class Migration16To17(context: Context) : Migration(16, 17) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE `sources` (`source` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, PRIMARY KEY(`source`))")
db.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)")
val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty()
val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty()
val sources = MangaParserSource.entries
for (source in sources) {
val name = source.name
val isHidden = name in hiddenSources
var sortKey = order.indexOf(name)
if (sortKey == -1) {
if (isHidden) {
sortKey = order.size + source.ordinal
} else {
continue
}
}
db.execSQL(
"INSERT INTO `sources` (`source`, `enabled`, `sort_key`) VALUES (?, ?, ?)",
arrayOf(name, (!isHidden).toInt(), sortKey),
)
}
}
private fun Boolean.toInt() = if (this) 1 else 0
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration17To18.kt
================================================
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration17To18 : Migration(17, 18) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_grayscale` INTEGER NOT NULL DEFAULT 0")
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration18To19.kt
================================================
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration18To19 : Migration(18, 19) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE history ADD COLUMN `chapters` INTEGER NOT NULL DEFAULT -1")
db.execSQL("CREATE TABLE IF NOT EXISTS `stats` (`manga_id` INTEGER NOT NULL, `started_at` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `pages` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `started_at`), FOREIGN KEY(`manga_id`) REFERENCES `history`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration19To20.kt
================================================
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration19To20 : Migration(19, 20) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE tracks_bk (manga_id INTEGER NOT NULL, chapters_total INTEGER NOT NULL, last_chapter_id INTEGER NOT NULL, chapters_new INTEGER NOT NULL, last_check INTEGER NOT NULL, last_notified_id INTEGER NOT NULL, PRIMARY KEY(manga_id))")
db.execSQL("INSERT INTO tracks_bk SELECT manga_id, chapters_total, last_chapter_id, chapters_new, last_check, last_notified_id FROM tracks")
db.execSQL("DROP TABLE tracks")
db.execSQL("CREATE TABLE tracks (`manga_id` INTEGER NOT NULL, `last_chapter_id` INTEGER NOT NULL, `chapters_new` INTEGER NOT NULL, `last_check_time` INTEGER NOT NULL, `last_chapter_date` INTEGER NOT NULL, `last_result` INTEGER NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
db.execSQL("INSERT INTO tracks SELECT manga_id, last_chapter_id, chapters_new, last_check AS last_check_time, 0 AS last_chapter_date, 0 AS last_result FROM tracks_bk")
db.execSQL("DROP TABLE tracks_bk")
db.execSQL("ALTER TABLE track_logs ADD COLUMN `unread` INTEGER NOT NULL DEFAULT 0")
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration1To2.kt
================================================
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration1To2 : Migration(1, 2) {
/**
* Adding foreign keys
*/
override fun migrate(db: SupportSQLiteDatabase) {
/* manga_tags */
db.execSQL(
"CREATE TABLE IF NOT EXISTS manga_tags_tmp (manga_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, " +
"PRIMARY KEY(manga_id, tag_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE, " +
"FOREIGN KEY(tag_id) REFERENCES tags(tag_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
)
db.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_manga_id ON manga_tags_tmp (manga_id)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_tag_id ON manga_tags_tmp (tag_id)")
db.execSQL("INSERT INTO manga_tags_tmp (manga_id, tag_id) SELECT manga_id, tag_id FROM manga_tags")
db.execSQL("DROP TABLE manga_tags")
db.execSQL("ALTER TABLE manga_tags_tmp RENAME TO manga_tags")
/* favourites */
db.execSQL(
"CREATE TABLE IF NOT EXISTS favourites_tmp (manga_id INTEGER NOT NULL, category_id INTEGER NOT NULL, created_at INTEGER NOT NULL, " +
"PRIMARY KEY(manga_id, category_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE , " +
"FOREIGN KEY(category_id) REFERENCES favourite_categories(category_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
)
db.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_manga_id ON favourites_tmp (manga_id)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_category_id ON favourites_tmp (category_id)")
db.execSQL("INSERT INTO favourites_tmp (manga_id, category_id, created_at) SELECT manga_id, category_id, created_at FROM favourites")
db.execSQL("DROP TABLE favourites")
db.execSQL("ALTER TABLE favourites_tmp RENAME TO favourites")
/* history */
db.execSQL(
"CREATE TABLE IF NOT EXISTS history_tmp (manga_id INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, chapter_id INTEGER NOT NULL, page INTEGER NOT NULL, " +
"PRIMARY KEY(manga_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
)
db.execSQL("INSERT INTO history_tmp (manga_id, created_at, updated_at, chapter_id, page) SELECT manga_id, created_at, updated_at, chapter_id, page FROM history")
db.execSQL("DROP TABLE history")
db.execSQL("ALTER TABLE history_tmp RENAME TO history")
/* preferences */
db.execSQL(
"CREATE TABLE IF NOT EXISTS preferences_tmp (manga_id INTEGER NOT NULL, mode INTEGER NOT NULL," +
" PRIMARY KEY(manga_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
)
db.execSQL("INSERT INTO preferences_tmp (manga_id, mode) SELECT manga_id, mode FROM preferences")
db.execSQL("DROP TABLE preferences")
db.execSQL("ALTER TABLE preferences_tmp RENAME TO preferences")
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration20To21.kt
================================================
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration20To21 : Migration(20, 21) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE tracks ADD COLUMN `last_error` TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE sources ADD COLUMN `added_in` INTEGER NOT NULL DEFAULT 0")
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration21To22.kt
================================================
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration21To22 : Migration(21, 22) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE sources ADD COLUMN `used_at` INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE sources ADD COLUMN `pinned` INTEGER NOT NULL DEFAULT 0")
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration22To23.kt
================================================
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration22To23 : Migration(22, 23) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS `local_index` (`manga_id` INTEGER NOT NULL, `path` TEXT NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration23To24.kt
================================================
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration23To24 : Migration(23, 24) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS `chapters` (`chapter_id` INTEGER NOT NULL, `manga_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `number` REAL NOT NULL, `volume` INTEGER NOT NULL, `url` TEXT NOT NULL, `scanlator` TEXT, `upload_date` INTEGER NOT NULL, `branch` TEXT, `source` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `chapter_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration24To23.kt
================================================
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration24To23 : Migration(24, 23) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("DROP TABLE IF EXISTS `chapters`")
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration24To25.kt
================================================
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration24To25 : Migration(24, 25) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE manga ADD COLUMN content_rating TEXT DEFAULT NULL")
db.execSQL("UPDATE manga SET content_rating = 'ADULT' WHERE nsfw = 1")
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration25To26.kt
================================================
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration25To26 : Migration(25, 26) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE sources ADD COLUMN cf_state INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE preferences ADD COLUMN title_override TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE preferences ADD COLUMN cover_override TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE preferences ADD COLUMN content_rating_override TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE favourites ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE tags ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0")
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration26To27.kt
================================================
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration26To27 : Migration(26, 27) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE preferences ADD COLUMN cf_book INTEGER NOT NULL DEFAULT 0")
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration2To3.kt
================================================
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration2To3 : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE history ADD COLUMN scroll REAL NOT NULL DEFAULT 0")
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration3To4.kt
================================================
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration3To4 : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS tracks (manga_id INTEGER NOT NULL, chapters_total INTEGER NOT NULL, last_chapter_id INTEGER NOT NULL, chapters_new INTEGER NOT NULL, last_check INTEGER NOT NULL, last_notified_id INTEGER NOT NULL, PRIMARY KEY(manga_id), FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )")
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration4To5.kt
================================================
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration4To5 : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE favourite_categories ADD COLUMN sort_key INTEGER NOT NULL DEFAULT 0")
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration5To6.kt
================================================
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration5To6 : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS track_logs (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, manga_id INTEGER NOT NULL, chapters TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_track_logs_manga_id ON track_logs (manga_id)")
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration6To7.kt
================================================
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration6To7 : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE manga ADD COLUMN public_url TEXT NOT NULL DEFAULT ''")
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration7To8.kt
================================================
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration7To8 : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE manga ADD COLUMN nsfw INTEGER NOT NULL DEFAULT 0")
db.execSQL("CREATE TABLE IF NOT EXISTS suggestions (manga_id INTEGER NOT NULL, relevance REAL NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(manga_id), FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )")
db.execSQL("CREATE INDEX IF NOT EXISTS index_suggestions_manga_id ON suggestions (manga_id)")
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration8To9.kt
================================================
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import org.koitharu.kotatsu.parsers.model.SortOrder
class Migration8To9 : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `order` TEXT NOT NULL DEFAULT ${SortOrder.NEWEST.name}")
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration9To10.kt
================================================
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration9To10 : Migration(9, 10) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `track` INTEGER NOT NULL DEFAULT 1")
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/BadBackupFormatException.kt
================================================
package org.koitharu.kotatsu.core.exceptions
import java.io.IOException
class BadBackupFormatException(cause: Throwable?) : IOException(cause)
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CaughtException.kt
================================================
package org.koitharu.kotatsu.core.exceptions
class CaughtException(
override val cause: Throwable
) : RuntimeException("${cause.javaClass.simpleName}(${cause.message})", cause)
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CloudFlareBlockedException.kt
================================================
package org.koitharu.kotatsu.core.exceptions
import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
class CloudFlareBlockedException(
override val url: String,
source: MangaSource?,
) : CloudFlareException("Blocked by CloudFlare", CloudFlareHelper.PROTECTION_BLOCKED) {
override val source: MangaSource = source ?: UnknownMangaSource
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CloudFlareException.kt
================================================
package org.koitharu.kotatsu.core.exceptions
import okio.IOException
import org.koitharu.kotatsu.parsers.model.MangaSource
abstract class CloudFlareException(
message: String,
val state: Int,
) : IOException(message) {
abstract val url: String
abstract val source: MangaSource
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CloudFlareProtectedException.kt
================================================
package org.koitharu.kotatsu.core.exceptions
import okhttp3.Headers
import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
class CloudFlareProtectedException(
override val url: String,
source: MangaSource?,
@Transient val headers: Headers,
) : CloudFlareException("Protected by CloudFlare", CloudFlareHelper.PROTECTION_CAPTCHA) {
override val source: MangaSource = source ?: UnknownMangaSource
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/EmptyHistoryException.kt
================================================
package org.koitharu.kotatsu.core.exceptions
class EmptyHistoryException : RuntimeException()
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/EmptyMangaException.kt
================================================
package org.koitharu.kotatsu.core.exceptions
import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason
import org.koitharu.kotatsu.parsers.model.Manga
class EmptyMangaException(
val reason: EmptyMangaReason?,
val manga: Manga,
cause: Throwable?
) : IllegalStateException(cause)
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/IncompatiblePluginException.kt
================================================
package org.koitharu.kotatsu.core.exceptions
class IncompatiblePluginException(
val name: String?,
cause: Throwable?,
) : RuntimeException(cause)
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/InteractiveActionRequiredException.kt
================================================
package org.koitharu.kotatsu.core.exceptions
import okio.IOException
import org.koitharu.kotatsu.parsers.model.MangaSource
class InteractiveActionRequiredException(
val source: MangaSource,
val url: String,
) : IOException("Interactive action is required for ${source.name}")
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/NoDataReceivedException.kt
================================================
package org.koitharu.kotatsu.core.exceptions
import okio.IOException
class NoDataReceivedException(
val url: String,
) : IOException("No data has been received from $url")
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/NonFileUriException.kt
================================================
package org.koitharu.kotatsu.core.exceptions
import android.net.Uri
class NonFileUriException(
val uri: Uri,
) : IllegalArgumentException("Cannot resolve file name of \"$uri\"")
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/ProxyConfigException.kt
================================================
package org.koitharu.kotatsu.core.exceptions
import java.net.ProtocolException
class ProxyConfigException : ProtocolException("Wrong proxy configuration")
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/SyncApiException.kt
================================================
package org.koitharu.kotatsu.core.exceptions
class SyncApiException(
message: String,
val code: Int,
) : RuntimeException(message)
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/UnsupportedFileException.kt
================================================
package org.koitharu.kotatsu.core.exceptions
import java.io.IOException
class UnsupportedFileException(message: String? = null) : IOException(message)
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/UnsupportedSourceException.kt
================================================
package org.koitharu.kotatsu.core.exceptions
import org.koitharu.kotatsu.parsers.model.Manga
class UnsupportedSourceException(
message: String?,
val manga: Manga?,
) : IllegalArgumentException(message)
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/WrapperIOException.kt
================================================
package org.koitharu.kotatsu.core.exceptions
import okio.IOException
class WrapperIOException(override val cause: Exception) : IOException(cause)
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/WrongPasswordException.kt
================================================
package org.koitharu.kotatsu.core.exceptions
class WrongPasswordException : IllegalArgumentException()
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/CaptchaHandler.kt
================================================
package org.koitharu.kotatsu.core.exceptions.resolve
import android.Manifest
import android.app.Notification
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.Settings
import androidx.annotation.CheckResult
import androidx.annotation.RequiresPermission
import androidx.collection.MutableScatterMap
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.net.toUri
import androidx.lifecycle.coroutineScope
import coil3.EventListener
import coil3.Extras
import coil3.ImageLoader
import coil3.request.ErrorResult
import coil3.request.ImageRequest
import coil3.request.allowConversionToBitmap
import coil3.request.allowHardware
import coil3.request.lifecycle
import coil3.size.Scale
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.LocalizedAppContext
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.exceptions.CloudFlareException
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.network.webview.WebViewExecutor
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.core.util.ext.getNotificationIconSize
import org.koitharu.kotatsu.core.util.ext.goAsync
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
import org.koitharu.kotatsu.parsers.util.mapToArray
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
@Singleton
class CaptchaHandler @Inject constructor(
@LocalizedAppContext private val context: Context,
private val databaseProvider: Provider,
private val coilProvider: Provider,
private val webViewExecutor: WebViewExecutor,
) : EventListener() {
private val exceptionMap = MutableScatterMap()
private val mutex = Mutex()
@CheckResult
suspend fun handle(exception: CloudFlareException): Boolean = handleException(exception.source, exception, true)
suspend fun discard(source: MangaSource) {
handleException(source, null, true)
}
override fun onError(request: ImageRequest, result: ErrorResult) {
super.onError(request, result)
val e = result.throwable
if (e is CloudFlareException) {
val scope = request.lifecycle?.coroutineScope ?: processLifecycleScope
scope.launch {
if (
handleException(
source = e.source,
exception = e,
notify = request.extras[suppressCaptchaKey] != true,
)
) {
coilProvider.get().enqueue(request) // TODO check if ok
}
}
}
}
private suspend fun handleException(
source: MangaSource,
exception: CloudFlareException?,
notify: Boolean,
): Boolean = withContext(Dispatchers.Default) {
if (source == UnknownMangaSource) {
return@withContext false
}
if (exception != null && webViewExecutor.tryResolveCaptcha(exception, RESOLVE_TIMEOUT)) {
return@withContext true
}
mutex.withLock {
var removedException: CloudFlareProtectedException? = null
if (exception is CloudFlareProtectedException) {
exceptionMap[source] = exception
} else {
removedException = exceptionMap.remove(source)
}
val dao = databaseProvider.get().getSourcesDao()
dao.setCfState(source.name, exception?.state ?: CloudFlareHelper.PROTECTION_NOT_DETECTED)
if (notify && context.checkNotificationPermission(CHANNEL_ID)) {
val exceptions = dao.findAllCaptchaRequired().mapNotNull {
it.source.toMangaSourceOrNull()
}.filterNot {
SourceSettings(context, it).isCaptchaNotificationsDisabled
}.mapNotNull {
exceptionMap[it]
}
if (removedException != null) {
NotificationManagerCompat.from(context).cancel(TAG, removedException.source.hashCode())
}
notify(exceptions)
}
}
false
}
@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
private suspend fun notify(exceptions: List) {
val manager = NotificationManagerCompat.from(context)
val channel = NotificationChannelCompat.Builder(
CHANNEL_ID,
NotificationManagerCompat.IMPORTANCE_LOW,
)
.setName(context.getString(R.string.captcha_required))
.setShowBadge(true)
.setVibrationEnabled(false)
.setSound(null, null)
.setLightsEnabled(false)
.build()
manager.createNotificationChannel(channel)
coroutineScope {
exceptions.map {
async { it to buildNotification(it) }
}.awaitAll()
}.forEach { (exception, notification) ->
manager.notify(TAG, exception.source.hashCode(), notification)
}
if (exceptions.size > 1) {
val groupNotification = NotificationCompat.Builder(context, CHANNEL_ID)
.setGroupSummary(true)
.setContentTitle(context.getString(R.string.captcha_required))
.setPriority(NotificationCompat.PRIORITY_LOW)
.setDefaults(0)
.setOnlyAlertOnce(true)
.setSmallIcon(R.drawable.ic_bot)
.setGroup(GROUP_CAPTCHA)
.setContentIntent(
PendingIntentCompat.getActivities(
context, GROUP_NOTIFICATION_ID,
exceptions.mapToArray { e ->
AppRouter.cloudFlareResolveIntent(context, e)
},
0, false,
),
)
.setContentText(
context.getString(
R.string.captcha_required_summary, context.getString(R.string.app_name),
),
)
.setVisibility(
if (exceptions.any { it.source.isNsfw() }) {
NotificationCompat.VISIBILITY_SECRET
} else {
NotificationCompat.VISIBILITY_PUBLIC
},
)
manager.notify(TAG, GROUP_NOTIFICATION_ID, groupNotification.build())
} else {
manager.cancel(TAG, GROUP_NOTIFICATION_ID)
}
}
private suspend fun buildNotification(exception: CloudFlareProtectedException): Notification {
val intent = AppRouter.cloudFlareResolveIntent(context, exception)
val discardIntent = Intent(ACTION_DISCARD)
.putExtra(AppRouter.KEY_SOURCE, exception.source.name)
.setData("source://${exception.source.name}".toUri())
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(context.getString(R.string.captcha_required))
.setPriority(NotificationCompat.PRIORITY_LOW)
.setDefaults(0)
.setSmallIcon(R.drawable.ic_bot)
.setGroup(GROUP_CAPTCHA)
.setOnlyAlertOnce(true)
.setAutoCancel(true)
.setDeleteIntent(PendingIntentCompat.getBroadcast(context, 0, discardIntent, 0, false))
.setLargeIcon(getFavicon(exception.source))
.setVisibility(
if (exception.source.isNsfw()) {
NotificationCompat.VISIBILITY_SECRET
} else {
NotificationCompat.VISIBILITY_PUBLIC
},
)
.setContentText(
context.getString(
R.string.captcha_required_summary,
exception.source.getTitle(context),
),
)
.setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val actionIntent = PendingIntentCompat.getActivity(
context, SETTINGS_ACTION_CODE,
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
.putExtra(Settings.EXTRA_CHANNEL_ID, CHANNEL_ID),
0, false,
)
notification.addAction(
R.drawable.ic_settings,
context.getString(R.string.notifications_settings),
actionIntent,
)
}
return notification.build()
}
private fun String.toMangaSourceOrNull() = MangaSource(this).takeUnless { it == UnknownMangaSource }
private suspend fun getFavicon(source: MangaSource) = runCatchingCancellable {
coilProvider.get().execute(
ImageRequest.Builder(context)
.data(source.faviconUri())
.allowHardware(false)
.allowConversionToBitmap(true)
.suppressCaptchaErrors()
.mangaSourceExtra(source)
.size(context.resources.getNotificationIconSize())
.scale(Scale.FILL)
.build(),
).toBitmapOrNull()
}.onFailure {
it.printStackTraceDebug()
}.getOrNull()
@AndroidEntryPoint
class DiscardReceiver : BroadcastReceiver() {
@Inject
lateinit var captchaHandler: CaptchaHandler
override fun onReceive(context: Context?, intent: Intent?) {
val sourceName = intent?.getStringExtra(AppRouter.KEY_SOURCE) ?: return
goAsync {
captchaHandler.handleException(MangaSource(sourceName), exception = null, notify = false)
}
}
}
companion object {
fun ImageRequest.Builder.suppressCaptchaErrors() = apply {
extras[suppressCaptchaKey] = true
}
private val suppressCaptchaKey = Extras.Key(false)
private const val CHANNEL_ID = "captcha"
private const val TAG = CHANNEL_ID
private const val GROUP_CAPTCHA = "org.koitharu.kotatsu.CAPTCHA"
private const val GROUP_NOTIFICATION_ID = 34
private const val SETTINGS_ACTION_CODE = 3
private const val ACTION_DISCARD = "org.koitharu.kotatsu.CAPTCHA_DISCARD"
private const val RESOLVE_TIMEOUT = 20_000L
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/DialogErrorObserver.kt
================================================
package org.koitharu.kotatsu.core.exceptions.resolve
import android.content.DialogInterface
import android.view.View
import androidx.core.util.Consumer
import androidx.fragment.app.Fragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.isSerializable
import org.koitharu.kotatsu.parsers.exception.ParseException
class DialogErrorObserver(
host: View,
fragment: Fragment?,
resolver: ExceptionResolver?,
private val onResolved: Consumer?,
) : ErrorObserver(host, fragment, resolver, onResolved) {
constructor(
host: View,
fragment: Fragment?,
) : this(host, fragment, null, null)
override suspend fun emit(value: Throwable) {
val listener = DialogListener(value)
val dialogBuilder = MaterialAlertDialogBuilder(activity ?: host.context)
.setMessage(value.getDisplayMessage(host.context.resources))
.setNegativeButton(R.string.close, listener)
.setOnCancelListener(listener)
if (canResolve(value)) {
dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener)
} else if (value is ParseException) {
val router = router()
if (router != null && value.isSerializable()) {
dialogBuilder.setPositiveButton(R.string.details) { _, _ ->
router.showErrorDialog(value)
}
}
}
val dialog = dialogBuilder.create()
if (activity != null) {
dialog.setOwnerActivity(activity)
}
dialog.show()
}
private inner class DialogListener(
private val error: Throwable,
) : DialogInterface.OnClickListener, DialogInterface.OnCancelListener {
override fun onClick(dialog: DialogInterface?, which: Int) {
when (which) {
DialogInterface.BUTTON_NEGATIVE -> onResolved?.accept(false)
DialogInterface.BUTTON_POSITIVE -> resolve(error)
}
}
override fun onCancel(dialog: DialogInterface?) {
onResolved?.accept(false)
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ErrorObserver.kt
================================================
package org.koitharu.kotatsu.core.exceptions.resolve
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.core.util.Consumer
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.coroutineScope
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.util.ext.findActivity
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
abstract class ErrorObserver(
protected val host: View,
protected val fragment: Fragment?,
private val resolver: ExceptionResolver?,
private val onResolved: Consumer?,
) : FlowCollector {
protected open val activity = host.context.findActivity()
private val lifecycleScope: LifecycleCoroutineScope
get() = checkNotNull(fragment?.viewLifecycleScope ?: (activity as? LifecycleOwner)?.lifecycle?.coroutineScope)
protected val fragmentManager: FragmentManager?
get() = fragment?.childFragmentManager ?: (activity as? AppCompatActivity)?.supportFragmentManager
protected fun canResolve(error: Throwable): Boolean {
return resolver != null && ExceptionResolver.canResolve(error)
}
protected fun router() = fragment?.router ?: (activity as? FragmentActivity)?.router
private fun isAlive(): Boolean {
return when {
fragment != null -> fragment.view != null
activity != null -> activity?.isDestroyed == false
else -> true
}
}
protected fun resolve(error: Throwable) {
if (isAlive()) {
lifecycleScope.launch {
val isResolved = resolver?.resolve(error) == true
if (isActive) {
onResolved?.accept(isResolved)
}
}
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt
================================================
package org.koitharu.kotatsu.core.exceptions.resolve
import android.content.Context
import android.widget.Toast
import androidx.activity.result.ActivityResultCaller
import androidx.annotation.StringRes
import androidx.collection.MutableScatterMap
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.async
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.EmptyMangaException
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
import org.koitharu.kotatsu.core.util.ext.restartApplication
import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException
import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import java.security.cert.CertPathValidatorException
import javax.inject.Inject
import javax.inject.Provider
import javax.net.ssl.SSLException
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class ExceptionResolver private constructor(
private val host: Host,
private val settings: AppSettings,
private val scrobblerAuthHelperProvider: Provider,
) {
private val continuations = MutableScatterMap>(1)
private val browserActionContract = host.registerForActivityResult(BrowserActivity.Contract()) {
handleActivityResult(BrowserActivity.TAG, true)
}
private val sourceAuthContract = host.registerForActivityResult(SourceAuthActivity.Contract()) {
handleActivityResult(SourceAuthActivity.TAG, it)
}
private val cloudflareContract = host.registerForActivityResult(CloudFlareActivity.Contract()) {
handleActivityResult(CloudFlareActivity.TAG, it)
}
fun showErrorDetails(e: Throwable, url: String? = null) {
host.router.showErrorDialog(e, url)
}
suspend fun resolve(e: Throwable): Boolean = host.lifecycleScope.async {
when (e) {
is CloudFlareProtectedException -> resolveCF(e)
is AuthRequiredException -> resolveAuthException(e.source)
is SSLException,
is CertPathValidatorException -> {
showSslErrorDialog()
false
}
is InteractiveActionRequiredException -> resolveBrowserAction(e)
is ProxyConfigException -> {
host.router.openProxySettings()
false
}
is NotFoundException -> {
openInBrowser(e.url)
false
}
is EmptyMangaException -> {
when (e.reason) {
EmptyMangaReason.NO_CHAPTERS -> openAlternatives(e.manga)
EmptyMangaReason.LOADING_ERROR -> Unit
EmptyMangaReason.RESTRICTED -> host.router.openBrowser(e.manga)
else -> Unit
}
false
}
is UnsupportedSourceException -> {
e.manga?.let { openAlternatives(it) }
false
}
is ScrobblerAuthRequiredException -> {
val authHelper = scrobblerAuthHelperProvider.get()
if (authHelper.isAuthorized(e.scrobbler)) {
true
} else {
host.withContext {
authHelper.startAuth(this, e.scrobbler).onFailure(::showErrorDetails)
}
false
}
}
else -> false
}
}.await()
private suspend fun resolveBrowserAction(
e: InteractiveActionRequiredException
): Boolean = suspendCoroutine { cont ->
continuations[BrowserActivity.TAG] = cont
browserActionContract.launch(e)
}
private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont ->
continuations[CloudFlareActivity.TAG] = cont
cloudflareContract.launch(e)
}
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->
continuations[SourceAuthActivity.TAG] = cont
sourceAuthContract.launch(source)
}
private fun openInBrowser(url: String) {
host.router.openBrowser(url, null, null)
}
private fun openAlternatives(manga: Manga) {
host.router.openAlternatives(manga)
}
private fun handleActivityResult(tag: String, result: Boolean) {
continuations.remove(tag)?.resume(result)
}
private fun showSslErrorDialog() {
val ctx = host.context ?: return
if (settings.isSSLBypassEnabled) {
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
return
}
buildAlertDialog(ctx) {
setTitle(R.string.ignore_ssl_errors)
setMessage(R.string.ignore_ssl_errors_summary)
setPositiveButton(R.string.apply) { _, _ ->
settings.isSSLBypassEnabled = true
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_LONG).show()
ctx.restartApplication()
}
setNegativeButton(android.R.string.cancel, null)
}.show()
}
class Factory @Inject constructor(
private val settings: AppSettings,
private val scrobblerAuthHelperProvider: Provider,
) {
fun create(fragment: Fragment) = ExceptionResolver(
host = Host.FragmentHost(fragment),
settings = settings,
scrobblerAuthHelperProvider = scrobblerAuthHelperProvider,
)
fun create(activity: FragmentActivity) = ExceptionResolver(
host = Host.ActivityHost(activity),
settings = settings,
scrobblerAuthHelperProvider = scrobblerAuthHelperProvider,
)
}
private sealed interface Host : ActivityResultCaller, LifecycleOwner {
val context: Context?
val router: AppRouter
val fragmentManager: FragmentManager
inline fun withContext(block: Context.() -> Unit) {
context?.apply(block)
}
class ActivityHost(val activity: FragmentActivity) : Host,
ActivityResultCaller by activity,
LifecycleOwner by activity {
override val context: Context
get() = activity
override val router: AppRouter
get() = activity.router
override val fragmentManager: FragmentManager
get() = activity.supportFragmentManager
}
class FragmentHost(val fragment: Fragment) : Host,
ActivityResultCaller by fragment {
override val context: Context?
get() = fragment.context
override val router: AppRouter
get() = fragment.router
override val fragmentManager: FragmentManager
get() = fragment.childFragmentManager
override val lifecycle: Lifecycle
get() = fragment.viewLifecycleOwner.lifecycle
}
}
companion object {
@StringRes
fun getResolveStringId(e: Throwable) = when (e) {
is CloudFlareProtectedException -> R.string.captcha_solve
is ScrobblerAuthRequiredException,
is AuthRequiredException -> R.string.sign_in
is NotFoundException -> if (e.url.isHttpUrl()) R.string.open_in_browser else 0
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
is SSLException,
is CertPathValidatorException -> R.string.fix
is ProxyConfigException -> R.string.settings
is InteractiveActionRequiredException -> R.string._continue
is EmptyMangaException -> when (e.reason) {
EmptyMangaReason.RESTRICTED -> if (e.manga.publicUrl.isHttpUrl()) R.string.open_in_browser else 0
EmptyMangaReason.NO_CHAPTERS -> R.string.alternatives
else -> 0
}
else -> 0
}
fun canResolve(e: Throwable) = getResolveStringId(e) != 0
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/SnackbarErrorObserver.kt
================================================
package org.koitharu.kotatsu.core.exceptions.resolve
import android.view.View
import androidx.core.util.Consumer
import androidx.fragment.app.Fragment
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.isSerializable
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.main.ui.owners.BottomSheetOwner
import org.koitharu.kotatsu.parsers.exception.ParseException
class SnackbarErrorObserver(
host: View,
fragment: Fragment?,
resolver: ExceptionResolver?,
onResolved: Consumer?,
) : ErrorObserver(host, fragment, resolver, onResolved) {
constructor(
host: View,
fragment: Fragment?,
) : this(host, fragment, null, null)
override suspend fun emit(value: Throwable) {
val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT)
when (activity) {
is BottomNavOwner -> snackbar.anchorView = activity.bottomNav
is BottomSheetOwner -> snackbar.anchorView = activity.bottomSheet
}
if (canResolve(value)) {
snackbar.setAction(ExceptionResolver.getResolveStringId(value)) {
resolve(value)
}
} else if (value is ParseException) {
val router = router()
if (router != null && value.isSerializable()) {
snackbar.setAction(R.string.details) {
router.showErrorDialog(value)
}
}
}
snackbar.show()
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ToastErrorObserver.kt
================================================
package org.koitharu.kotatsu.core.exceptions.resolve
import android.view.View
import android.widget.Toast
import androidx.fragment.app.Fragment
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
class ToastErrorObserver(
host: View,
fragment: Fragment?,
) : ErrorObserver(host, fragment, null, null) {
override suspend fun emit(value: Throwable) {
val toast = Toast.makeText(host.context, value.getDisplayMessage(host.context.resources), Toast.LENGTH_SHORT)
toast.show()
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/fs/FileSequence.kt
================================================
package org.koitharu.kotatsu.core.fs
import android.os.Build
import androidx.annotation.RequiresApi
import org.koitharu.kotatsu.core.util.CloseableSequence
import org.koitharu.kotatsu.core.util.iterator.MappingIterator
import java.io.File
import java.nio.file.Files
import java.nio.file.Path
sealed interface FileSequence : CloseableSequence {
@RequiresApi(Build.VERSION_CODES.O)
class StreamImpl(dir: File) : FileSequence {
private val stream = Files.newDirectoryStream(dir.toPath())
override fun iterator(): Iterator = MappingIterator(stream.iterator(), Path::toFile)
override fun close() = stream.close()
}
class ListImpl(dir: File) : FileSequence {
private val list = dir.listFiles().orEmpty()
override fun iterator(): Iterator = list.iterator()
override fun close() = Unit
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/github/AppUpdateRepository.kt
================================================
package org.koitharu.kotatsu.core.github
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.BaseHttpClient
import org.koitharu.kotatsu.core.os.AppValidator
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.asArrayList
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull
import org.koitharu.kotatsu.parsers.util.parseJsonArray
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrNull
import javax.inject.Inject
import javax.inject.Singleton
private const val CONTENT_TYPE_APK = "application/vnd.android.package-archive"
private const val BUILD_TYPE_RELEASE = "release"
@Singleton
class AppUpdateRepository @Inject constructor(
private val appValidator: AppValidator,
private val settings: AppSettings,
@BaseHttpClient private val okHttp: OkHttpClient,
@ApplicationContext context: Context,
) {
private val availableUpdate = MutableStateFlow(null)
private val releasesUrl = buildString {
append("https://api.github.com/repos/")
append(context.getString(R.string.github_updates_repo))
append("/releases?page=1&per_page=10")
}
val isUpdateAvailable: Boolean
get() = availableUpdate.value != null
fun observeAvailableUpdate() = availableUpdate.asStateFlow()
suspend fun getAvailableVersions(): List {
val request = Request.Builder()
.get()
.url(releasesUrl)
val jsonArray = okHttp.newCall(request.build()).await().parseJsonArray()
return jsonArray.mapJSONNotNull { json ->
val asset = json.optJSONArray("assets")?.find { jo ->
jo.optString("content_type") == CONTENT_TYPE_APK
} ?: return@mapJSONNotNull null
AppVersion(
id = json.getLong("id"),
url = json.getString("html_url"),
name = json.getString("name").removePrefix("v"),
apkSize = asset.getLong("size"),
apkUrl = asset.getString("browser_download_url"),
description = json.getString("body"),
)
}
}
suspend fun fetchUpdate(): AppVersion? = withContext(Dispatchers.Default) {
if (!isUpdateSupported()) {
return@withContext null
}
runCatchingCancellable {
val currentVersion = VersionId(BuildConfig.VERSION_NAME)
val available = getAvailableVersions().asArrayList()
available.sortBy { it.versionId }
if (currentVersion.isStable && !settings.isUnstableUpdatesAllowed) {
available.retainAll { it.versionId.isStable }
}
available.maxByOrNull { it.versionId }
?.takeIf { it.versionId > currentVersion }
}.onFailure {
it.printStackTraceDebug()
}.onSuccess {
availableUpdate.value = it
}.getOrNull()
}
@Suppress("KotlinConstantConditions")
suspend fun isUpdateSupported(): Boolean {
return BuildConfig.BUILD_TYPE != BUILD_TYPE_RELEASE || appValidator.isOriginalApp.getOrNull() == true
}
private inline fun JSONArray.find(predicate: (JSONObject) -> Boolean): JSONObject? {
val size = length()
for (i in 0 until size) {
val jo = getJSONObject(i)
if (predicate(jo)) {
return jo
}
}
return null
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/github/AppVersion.kt
================================================
package org.koitharu.kotatsu.core.github
import android.os.Parcelable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
data class AppVersion(
val id: Long,
val name: String,
val url: String,
val apkSize: Long,
val apkUrl: String,
val description: String,
) : Parcelable {
@IgnoredOnParcel
val versionId = VersionId(name)
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/github/VersionId.kt
================================================
package org.koitharu.kotatsu.core.github
import org.koitharu.kotatsu.parsers.util.digits
import java.util.Locale
data class VersionId(
val major: Int,
val minor: Int,
val build: Int,
val variantType: String,
val variantNumber: Int,
) : Comparable {
override fun compareTo(other: VersionId): Int {
var diff = major.compareTo(other.major)
if (diff != 0) {
return diff
}
diff = minor.compareTo(other.minor)
if (diff != 0) {
return diff
}
diff = build.compareTo(other.build)
if (diff != 0) {
return diff
}
diff = variantWeight(variantType).compareTo(variantWeight(other.variantType))
if (diff != 0) {
return diff
}
return variantNumber.compareTo(other.variantNumber)
}
private fun variantWeight(variantType: String) = when (variantType.lowercase(Locale.ROOT)) {
"a", "alpha" -> 1
"b", "beta" -> 2
"rc" -> 4
"" -> 8
else -> 0
}
}
val VersionId.isStable: Boolean
get() = variantType.isEmpty()
fun VersionId(versionName: String): VersionId {
if (versionName.startsWith('n', ignoreCase = true)) {
// Nightly build
return VersionId(
major = 0,
minor = 0,
build = versionName.digits().toIntOrNull() ?: 0,
variantType = "n",
variantNumber = 0,
)
}
val parts = versionName.substringBeforeLast('-').split('.')
val variant = versionName.substringAfterLast('-', "")
return VersionId(
major = parts.getOrNull(0)?.toIntOrNull() ?: 0,
minor = parts.getOrNull(1)?.toIntOrNull() ?: 0,
build = parts.getOrNull(2)?.toIntOrNull() ?: 0,
variantType = variant.filter(Char::isLetter),
variantNumber = variant.filter(Char::isDigit).toIntOrNull() ?: 0,
)
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/image/AvifImageDecoder.kt
================================================
package org.koitharu.kotatsu.core.image
import android.graphics.Bitmap
import androidx.core.graphics.createBitmap
import androidx.core.graphics.scale
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DecodeResult
import coil3.decode.DecodeUtils
import coil3.decode.Decoder
import coil3.decode.ImageSource
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import coil3.request.maxBitmapSize
import coil3.util.component1
import coil3.util.component2
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
import kotlinx.coroutines.runInterruptible
import org.aomedia.avif.android.AvifDecoder
import org.koitharu.kotatsu.core.util.ext.readByteBuffer
class AvifImageDecoder(
private val source: ImageSource,
private val options: Options,
) : Decoder {
override suspend fun decode(): DecodeResult = runInterruptible {
val bytes = source.source().readByteBuffer()
val decoder = AvifDecoder.create(bytes) ?: throw ImageDecodeException(
uri = source.fileOrNull()?.toString(),
format = "avif",
message = "Requested to decode byte buffer which cannot be handled by AvifDecoder",
)
try {
val config = if (decoder.depth == 8 || decoder.alphaPresent) {
Bitmap.Config.ARGB_8888
} else {
Bitmap.Config.RGB_565
}
val bitmap = createBitmap(decoder.width, decoder.height, config)
val result = decoder.nextFrame(bitmap)
if (result != 0) {
bitmap.recycle()
throw ImageDecodeException(
uri = source.fileOrNull()?.toString(),
format = "avif",
message = AvifDecoder.resultToString(result),
)
}
// downscaling
val (dstWidth, dstHeight) = DecodeUtils.computeDstSize(
srcWidth = bitmap.width,
srcHeight = bitmap.height,
targetSize = options.size,
scale = options.scale,
maxSize = options.maxBitmapSize,
)
if (dstWidth < bitmap.width || dstHeight < bitmap.height) {
val scaled = bitmap.scale(dstWidth, dstHeight)
bitmap.recycle()
DecodeResult(
image = scaled.asImage(),
isSampled = true,
)
} else {
DecodeResult(
image = bitmap.asImage(),
isSampled = false,
)
}
} finally {
decoder.release()
}
}
class Factory : Decoder.Factory {
override fun create(
result: SourceFetchResult,
options: Options,
imageLoader: ImageLoader
): Decoder? = if (isApplicable(result)) {
AvifImageDecoder(result.source, options)
} else {
null
}
override fun equals(other: Any?) = other is Factory
override fun hashCode() = javaClass.hashCode()
private fun isApplicable(result: SourceFetchResult): Boolean {
return result.mimeType == "image/avif"
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/image/BitmapDecoderCompat.kt
================================================
package org.koitharu.kotatsu.core.image
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder
import android.graphics.ImageDecoder
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.core.graphics.createBitmap
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
import okio.IOException
import okio.buffer
import okio.source
import org.aomedia.avif.android.AvifDecoder
import org.aomedia.avif.android.AvifDecoder.Info
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.core.util.MimeTypes
import org.koitharu.kotatsu.core.util.ext.MimeType
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.readByteBuffer
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.io.File
import java.io.InputStream
import java.nio.ByteBuffer
object BitmapDecoderCompat {
private const val FORMAT_AVIF = "avif"
@Blocking
fun decode(file: File): Bitmap = when (val format = probeMimeType(file)?.subtype) {
FORMAT_AVIF -> file.source().buffer().use { decodeAvif(it.readByteBuffer()) }
else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
ImageDecoder.decodeBitmap(ImageDecoder.createSource(file))
} else {
checkBitmapNotNull(BitmapFactory.decodeFile(file.absolutePath), format)
}
}
@Blocking
fun decode(stream: InputStream, type: MimeType?, isMutable: Boolean = false): Bitmap {
val format = type?.subtype
if (format == FORMAT_AVIF) {
return decodeAvif(stream.toByteBuffer())
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
val opts = BitmapFactory.Options()
opts.inMutable = isMutable
return checkBitmapNotNull(BitmapFactory.decodeStream(stream, null, opts), format)
}
val byteBuffer = stream.toByteBuffer()
return if (AvifDecoder.isAvifImage(byteBuffer)) {
decodeAvif(byteBuffer)
} else {
ImageDecoder.decodeBitmap(ImageDecoder.createSource(byteBuffer), DecoderConfigListener(isMutable))
}
}
@Blocking
fun createRegionDecoder(inoutStream: InputStream): BitmapRegionDecoder? = try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
BitmapRegionDecoder.newInstance(inoutStream)
} else {
@Suppress("DEPRECATION")
BitmapRegionDecoder.newInstance(inoutStream, false)
}
} catch (e: IOException) {
e.printStackTraceDebug()
null
}
@Blocking
fun probeMimeType(file: File): MimeType? {
return MimeTypes.probeMimeType(file) ?: detectBitmapType(file)
}
@Blocking
private fun detectBitmapType(file: File): MimeType? = runCatchingCancellable {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeFile(file.path, options)?.recycle()
options.outMimeType?.toMimeTypeOrNull()
}.getOrNull()
private fun checkBitmapNotNull(bitmap: Bitmap?, format: String?): Bitmap =
bitmap ?: throw ImageDecodeException(null, format)
private fun decodeAvif(bytes: ByteBuffer): Bitmap {
val info = Info()
if (!AvifDecoder.getInfo(bytes, bytes.remaining(), info)) {
throw ImageDecodeException(
null,
FORMAT_AVIF,
"Requested to decode byte buffer which cannot be handled by AvifDecoder",
)
}
val config = if (info.depth == 8 || info.alphaPresent) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565
val bitmap = createBitmap(info.width, info.height, config)
if (!AvifDecoder.decode(bytes, bytes.remaining(), bitmap)) {
bitmap.recycle()
throw ImageDecodeException(null, FORMAT_AVIF)
}
return bitmap
}
@RequiresApi(Build.VERSION_CODES.P)
private class DecoderConfigListener(
private val isMutable: Boolean,
) : ImageDecoder.OnHeaderDecodedListener {
override fun onHeaderDecoded(
decoder: ImageDecoder,
info: ImageDecoder.ImageInfo,
source: ImageDecoder.Source
) {
decoder.isMutableRequired = isMutable
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/image/CbzFetcher.kt
================================================
package org.koitharu.kotatsu.core.image
import android.net.Uri
import coil3.ImageLoader
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.fetch.Fetcher
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import coil3.toAndroidUri
import kotlinx.coroutines.runInterruptible
import okio.Path.Companion.toPath
import okio.openZip
import org.koitharu.kotatsu.core.util.MimeTypes
import org.koitharu.kotatsu.core.util.ext.isZipUri
import coil3.Uri as CoilUri
class CbzFetcher(
private val uri: Uri,
private val options: Options,
) : Fetcher {
override suspend fun fetch() = runInterruptible {
val filePath = uri.schemeSpecificPart.toPath()
val entryName = requireNotNull(uri.fragment)
val fs = options.fileSystem.openZip(filePath)
SourceFetchResult(
source = ImageSource(entryName.toPath(), fs),
mimeType = MimeTypes.getMimeTypeFromExtension(entryName)?.toString(),
dataSource = DataSource.DISK,
)
}
class Factory : Fetcher.Factory {
override fun create(
data: CoilUri,
options: Options,
imageLoader: ImageLoader
): Fetcher? {
val androidUri = data.toAndroidUri()
return if (androidUri.isZipUri()) {
CbzFetcher(androidUri, options)
} else {
null
}
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/image/CoilImageView.kt
================================================
package org.koitharu.kotatsu.core.image
import android.content.Context
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import androidx.annotation.AttrRes
import androidx.annotation.DrawableRes
import androidx.core.content.withStyledAttributes
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
import coil3.ImageLoader
import coil3.asImage
import coil3.request.Disposable
import coil3.request.ErrorResult
import coil3.request.ImageRequest
import coil3.request.NullRequestData
import coil3.request.SuccessResult
import coil3.request.allowRgb565
import coil3.request.crossfade
import coil3.request.lifecycle
import coil3.request.target
import coil3.size.Scale
import coil3.size.Size
import coil3.size.SizeResolver
import coil3.size.ViewSizeResolver
import coil3.util.CoilUtils
import com.google.android.material.imageview.ShapeableImageView
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.util.ext.decodeRegion
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.isNetworkError
import java.util.LinkedList
import javax.inject.Inject
@AndroidEntryPoint
open class CoilImageView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0,
) : ShapeableImageView(context, attrs, defStyleAttr), ImageRequest.Listener {
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var networkState: NetworkState
var allowRgb565: Boolean = false
var useExistingDrawable: Boolean = false
var decodeRegion: Boolean = false
var exactImageSize: Size? = null
var crossfadeDurationFactor: Float = 1f
var placeholderDrawable: Drawable? = null
var errorDrawable: Drawable? = null
var fallbackDrawable: Drawable? = null
private var currentRequest: Disposable? = null
private var currentImageData: Any = NullRequestData
private var networkWaitingJob: Job? = null
private var listeners: MutableList? = null
val isFailed: Boolean
get() = CoilUtils.result(this) is ErrorResult
init {
context.withStyledAttributes(attrs, R.styleable.CoilImageView, defStyleAttr) {
allowRgb565 = getBoolean(R.styleable.CoilImageView_allowRgb565, allowRgb565)
useExistingDrawable = getBoolean(R.styleable.CoilImageView_useExistingDrawable, useExistingDrawable)
decodeRegion = getBoolean(R.styleable.CoilImageView_decodeRegion, decodeRegion)
placeholderDrawable = getDrawable(R.styleable.CoilImageView_placeholderDrawable)
errorDrawable = getDrawable(R.styleable.CoilImageView_errorDrawable)
fallbackDrawable = getDrawable(R.styleable.CoilImageView_fallbackDrawable)
crossfadeDurationFactor = if (getBoolean(R.styleable.CoilImageView_crossfadeEnabled, true)) {
crossfadeDurationFactor
} else {
0f
}
}
}
override fun onCancel(request: ImageRequest) {
super.onCancel(request)
listeners?.forEach { it.onCancel(request) }
}
override fun onError(request: ImageRequest, result: ErrorResult) {
super.onError(request, result)
listeners?.forEach { it.onError(request, result) }
if (result.throwable.isNetworkError()) {
waitForNetwork()
}
}
override fun onStart(request: ImageRequest) {
super.onStart(request)
listeners?.forEach { it.onStart(request) }
}
override fun onSuccess(request: ImageRequest, result: SuccessResult) {
super.onSuccess(request, result)
listeners?.forEach { it.onSuccess(request, result) }
}
fun addImageRequestListener(listener: ImageRequest.Listener) {
val list = listeners ?: LinkedList().also { listeners = it }
list.add(listener)
}
fun removeImageRequestListener(listener: ImageRequest.Listener) {
listeners?.remove(listener)
}
fun setImageAsync(@DrawableRes resourceId: Int) = enqueueRequest(
newRequestBuilder()
.data(resourceId)
.build(),
)
fun setImageAsync(url: String?) = enqueueRequest(
newRequestBuilder()
.data(url)
.build(),
)
fun disposeImage() {
networkWaitingJob?.cancel()
networkWaitingJob = null
CoilUtils.dispose(this)
currentRequest = null
currentImageData = NullRequestData
setImageDrawable(null)
}
fun reload() {
CoilUtils.result(this)?.let { result ->
enqueueRequest(result.request, force = true)
}
}
protected fun enqueueRequest(request: ImageRequest, force: Boolean = false): Disposable {
val previous = currentRequest
if (!force && currentImageData == request.data && previous?.job?.isCancelled == false && !isFailed) {
return previous
}
networkWaitingJob?.cancel()
networkWaitingJob = null
currentImageData = request.data
return coil.enqueue(request).also { currentRequest = it }
}
protected open fun newRequestBuilder() = ImageRequest.Builder(context).apply {
lifecycle(findViewTreeLifecycleOwner())
val crossfadeDuration = if (context.isAnimationsEnabled) {
(context.getAnimationDuration(R.integer.config_defaultAnimTime) * crossfadeDurationFactor).toInt()
} else {
0
}
crossfade(crossfadeDuration)
if (useExistingDrawable) {
val previousDrawable = this@CoilImageView.drawable?.asImage()
if (previousDrawable != null) {
fallback(previousDrawable)
placeholder(previousDrawable)
error(previousDrawable)
} else {
setupPlaceholders()
}
} else {
setupPlaceholders()
}
if (decodeRegion) {
decodeRegion(0)
}
size(
exactImageSize?.let {
SizeResolver(it)
} ?: ViewSizeResolver(this@CoilImageView),
)
scale(scaleType.toCoilScale())
listener(this@CoilImageView)
allowRgb565(allowRgb565)
target(this@CoilImageView)
}
private fun ImageRequest.Builder.setupPlaceholders() {
placeholder(placeholderDrawable?.asImage())
error(errorDrawable?.asImage())
fallback(fallbackDrawable?.asImage())
}
private fun ScaleType.toCoilScale(): Scale = if (this == ScaleType.CENTER_CROP) {
Scale.FILL
} else {
Scale.FIT
}
private fun waitForNetwork() {
if (networkWaitingJob?.isActive == true || networkState.isOnline()) {
return
}
networkWaitingJob?.cancel()
networkWaitingJob = findViewTreeLifecycleOwner()?.lifecycleScope?.launch {
networkState.awaitForConnection()
if (isFailed) {
reload()
}
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/image/CoilMemoryCacheKey.kt
================================================
package org.koitharu.kotatsu.core.image
import android.os.Parcel
import android.os.Parcelable
import android.view.View
import androidx.collection.ArrayMap
import coil3.memory.MemoryCache
import coil3.request.SuccessResult
import coil3.util.CoilUtils
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
@Parcelize
class CoilMemoryCacheKey(
val data: MemoryCache.Key
) : Parcelable {
companion object : Parceler {
override fun CoilMemoryCacheKey.write(parcel: Parcel, flags: Int) = with(data) {
parcel.writeString(key)
parcel.writeInt(extras.size)
for (entry in extras.entries) {
parcel.writeString(entry.key)
parcel.writeString(entry.value)
}
}
override fun create(parcel: Parcel): CoilMemoryCacheKey = CoilMemoryCacheKey(
MemoryCache.Key(
key = parcel.readString().orEmpty(),
extras = run {
val size = parcel.readInt()
val map = ArrayMap(size)
repeat(size) {
map.put(parcel.readString(), parcel.readString())
}
map
},
),
)
fun from(view: View): CoilMemoryCacheKey? {
return (CoilUtils.result(view) as? SuccessResult)?.memoryCacheKey?.let {
CoilMemoryCacheKey(it)
}
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/image/MangaSourceHeaderInterceptor.kt
================================================
package org.koitharu.kotatsu.core.image
import coil3.intercept.Interceptor
import coil3.network.httpHeaders
import coil3.request.ImageResult
import org.koitharu.kotatsu.core.model.unwrap
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.util.ext.mangaSourceKey
import org.koitharu.kotatsu.parsers.model.MangaParserSource
class MangaSourceHeaderInterceptor : Interceptor {
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val mangaSource = chain.request.extras[mangaSourceKey]?.unwrap() as? MangaParserSource ?: return chain.proceed()
val request = chain.request
val newHeaders = request.httpHeaders.newBuilder()
.set(CommonHeaders.MANGA_SOURCE, mangaSource.name)
.build()
val newRequest = request.newBuilder()
.httpHeaders(newHeaders)
.build()
return chain.withRequest(newRequest).proceed()
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/image/RegionBitmapDecoder.kt
================================================
package org.koitharu.kotatsu.core.image
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Rect
import android.os.Build
import coil3.Extras
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DecodeResult
import coil3.decode.DecodeUtils
import coil3.decode.Decoder
import coil3.fetch.SourceFetchResult
import coil3.getExtra
import coil3.request.Options
import coil3.request.allowRgb565
import coil3.request.bitmapConfig
import coil3.request.colorSpace
import coil3.request.premultipliedAlpha
import coil3.size.Dimension
import coil3.size.Precision
import coil3.size.Scale
import coil3.size.Size
import coil3.size.isOriginal
import coil3.size.pxOrElse
import org.koitharu.kotatsu.core.util.ext.copyWithNewSource
import kotlin.math.roundToInt
class RegionBitmapDecoder(
private val fetchResult: SourceFetchResult,
private val options: Options,
private val imageLoader: ImageLoader,
) : Decoder {
override suspend fun decode(): DecodeResult? {
val regionDecoder = BitmapDecoderCompat.createRegionDecoder(fetchResult.source.source().inputStream())
if (regionDecoder == null) {
val revivedFetchResult = fetchResult.copyWithNewSource()
return try {
val fallbackDecoder = imageLoader.components.newDecoder(
result = revivedFetchResult,
options = options,
imageLoader = imageLoader,
startIndex = 0,
)?.first
if (fallbackDecoder == null || fallbackDecoder is RegionBitmapDecoder) {
null
} else {
fallbackDecoder.decode()
}
} finally {
revivedFetchResult.source.close()
}
}
val bitmapOptions = BitmapFactory.Options()
return try {
val rect = bitmapOptions.configureScale(regionDecoder.width, regionDecoder.height)
bitmapOptions.configureConfig()
val bitmap = regionDecoder.decodeRegion(rect, bitmapOptions)
bitmap.density = options.context.resources.displayMetrics.densityDpi
DecodeResult(
image = bitmap.asImage(),
isSampled = true,
)
} finally {
regionDecoder.recycle()
}
}
/** Compute and set the scaling properties for [BitmapFactory.Options]. */
private fun BitmapFactory.Options.configureScale(srcWidth: Int, srcHeight: Int): Rect {
val dstWidth = options.size.widthPx(options.scale) { srcWidth }
val dstHeight = options.size.heightPx(options.scale) { srcHeight }
val srcRatio = srcWidth / srcHeight.toDouble()
val dstRatio = dstWidth / dstHeight.toDouble()
val rect = if (srcRatio < dstRatio) {
// probably manga
Rect(0, 0, srcWidth, (srcWidth / dstRatio).toInt().coerceAtLeast(1))
} else {
Rect(0, 0, (srcHeight / dstRatio).toInt().coerceAtLeast(1), srcHeight)
}
val scroll = options.getExtra(regionScrollKey)
if (scroll == SCROLL_UNDEFINED) {
rect.offsetTo(
(srcWidth - rect.width()) / 2,
(srcHeight - rect.height()) / 2,
)
} else {
rect.offsetTo(
(srcWidth - rect.width()) / 2,
(scroll * dstRatio).toInt().coerceAtMost(srcHeight - rect.height()),
)
}
// Calculate the image's sample size.
inSampleSize = DecodeUtils.calculateInSampleSize(
srcWidth = rect.width(),
srcHeight = rect.height(),
dstWidth = dstWidth,
dstHeight = dstHeight,
scale = options.scale,
)
// Calculate the image's density scaling multiple.
var scale = DecodeUtils.computeSizeMultiplier(
srcWidth = rect.width() / inSampleSize.toDouble(),
srcHeight = rect.height() / inSampleSize.toDouble(),
dstWidth = dstWidth.toDouble(),
dstHeight = dstHeight.toDouble(),
scale = options.scale,
)
// Only upscale the image if the options require an exact size.
if (options.precision == Precision.INEXACT) {
scale = scale.coerceAtMost(1.0)
}
inScaled = scale != 1.0
if (inScaled) {
if (scale > 1) {
// Upscale
inDensity = (Int.MAX_VALUE / scale).roundToInt()
inTargetDensity = Int.MAX_VALUE
} else {
// Downscale
inDensity = Int.MAX_VALUE
inTargetDensity = (Int.MAX_VALUE * scale).roundToInt()
}
}
return rect
}
private fun BitmapFactory.Options.configureConfig() {
var config = options.bitmapConfig
inMutable = false
if (Build.VERSION.SDK_INT >= 26 && options.colorSpace != null) {
inPreferredColorSpace = options.colorSpace
}
inPremultiplied = options.premultipliedAlpha
// Decode the image as RGB_565 as an optimization if allowed.
if (options.allowRgb565 && config == Bitmap.Config.ARGB_8888 && outMimeType == "image/jpeg") {
config = Bitmap.Config.RGB_565
}
// High color depth images must be decoded as either RGBA_F16 or HARDWARE.
if (Build.VERSION.SDK_INT >= 26 && outConfig == Bitmap.Config.RGBA_F16 && config != Bitmap.Config.HARDWARE) {
config = Bitmap.Config.RGBA_F16
}
inPreferredConfig = config
}
object Factory : Decoder.Factory {
override fun create(
result: SourceFetchResult,
options: Options,
imageLoader: ImageLoader
): Decoder = RegionBitmapDecoder(result, options, imageLoader)
override fun equals(other: Any?) = other is Factory
override fun hashCode() = javaClass.hashCode()
}
companion object {
const val SCROLL_UNDEFINED = -1
val regionScrollKey = Extras.Key(SCROLL_UNDEFINED)
private inline fun Size.widthPx(scale: Scale, original: () -> Int): Int {
return if (isOriginal) original() else width.toPx(scale)
}
private inline fun Size.heightPx(scale: Scale, original: () -> Int): Int {
return if (isOriginal) original() else height.toPx(scale)
}
private fun Dimension.toPx(scale: Scale) = pxOrElse {
when (scale) {
Scale.FILL -> Int.MIN_VALUE
Scale.FIT -> Int.MAX_VALUE
}
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/io/NullOutputStream.kt
================================================
package org.koitharu.kotatsu.core.io
import java.io.OutputStream
import java.util.Objects
class NullOutputStream : OutputStream() {
override fun write(b: Int) = Unit
override fun write(b: ByteArray, off: Int, len: Int) {
Objects.checkFromIndexSize(off, len, b.size)
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/model/FavouriteCategory.kt
================================================
package org.koitharu.kotatsu.core.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
import java.time.Instant
@Parcelize
data class FavouriteCategory(
val id: Long,
val title: String,
val sortKey: Int,
val order: ListSortOrder,
val createdAt: Instant,
val isTrackingEnabled: Boolean,
val isVisibleInLibrary: Boolean,
) : Parcelable, ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is FavouriteCategory && id == other.id
}
override fun getChangePayload(previousState: ListModel): Any? {
if (previousState !is FavouriteCategory) {
return null
}
return if (isTrackingEnabled != previousState.isTrackingEnabled || isVisibleInLibrary != previousState.isVisibleInLibrary) {
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
} else {
null
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/model/GenericSortOrder.kt
================================================
package org.koitharu.kotatsu.core.model
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.model.SortOrder
@Deprecated("")
enum class GenericSortOrder(
@StringRes val titleResId: Int,
val ascending: SortOrder,
val descending: SortOrder,
) {
UPDATED(R.string.updated, SortOrder.UPDATED_ASC, SortOrder.UPDATED),
RATING(R.string.by_rating, SortOrder.RATING_ASC, SortOrder.RATING),
POPULARITY(R.string.popularity, SortOrder.POPULARITY_ASC, SortOrder.POPULARITY),
DATE(R.string.by_date, SortOrder.NEWEST_ASC, SortOrder.NEWEST),
NAME(R.string.by_name, SortOrder.ALPHABETICAL, SortOrder.ALPHABETICAL_DESC),
;
operator fun get(direction: SortDirection): SortOrder = when (direction) {
SortDirection.ASC -> ascending
SortDirection.DESC -> descending
}
companion object {
fun of(order: SortOrder): GenericSortOrder = entries.first { e ->
e.ascending == order || e.descending == order
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt
================================================
package org.koitharu.kotatsu.core.model
import android.content.res.Resources
import android.net.Uri
import android.text.SpannableStringBuilder
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.collection.MutableObjectIntMap
import androidx.core.net.toUri
import androidx.core.os.LocaleListCompat
import androidx.core.text.buildSpannedString
import androidx.core.text.strikeThrough
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.model.MangaOverride
import org.koitharu.kotatsu.core.util.ext.iterator
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Demographic
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.util.findById
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.util.mapToSet
import com.google.android.material.R as materialR
@JvmName("mangaIds")
fun Collection.ids() = mapToSet { it.id }
fun Collection.distinctById() = distinctBy { it.id }
@JvmName("chaptersIds")
fun Collection.ids() = mapToSet { it.id }
fun Collection.countChaptersByBranch(): Int {
if (size <= 1) {
return size
}
val acc = MutableObjectIntMap()
for (item in this) {
val branch = item.chapter.branch
acc[branch] = acc.getOrDefault(branch, 0) + 1
}
var max = 0
acc.forEachValue { x -> if (x > max) max = x }
return max
}
@get:StringRes
val MangaState.titleResId: Int
get() = when (this) {
MangaState.ONGOING -> R.string.state_ongoing
MangaState.FINISHED -> R.string.state_finished
MangaState.ABANDONED -> R.string.state_abandoned
MangaState.PAUSED -> R.string.state_paused
MangaState.UPCOMING -> R.string.state_upcoming
MangaState.RESTRICTED -> R.string.unavailable
}
@get:DrawableRes
val MangaState.iconResId: Int
get() = when (this) {
MangaState.ONGOING -> R.drawable.ic_play
MangaState.FINISHED -> R.drawable.ic_state_finished
MangaState.ABANDONED -> R.drawable.ic_state_abandoned
MangaState.PAUSED -> R.drawable.ic_action_pause
MangaState.UPCOMING -> materialR.drawable.ic_clock_black_24dp
MangaState.RESTRICTED -> R.drawable.ic_disable
}
@get:StringRes
val ContentRating.titleResId: Int
get() = when (this) {
ContentRating.SAFE -> R.string.rating_safe
ContentRating.SUGGESTIVE -> R.string.rating_suggestive
ContentRating.ADULT -> R.string.rating_adult
}
@get:StringRes
val Demographic.titleResId: Int
get() = when (this) {
Demographic.SHOUNEN -> R.string.demographic_shounen
Demographic.SHOUJO -> R.string.demographic_shoujo
Demographic.SEINEN -> R.string.demographic_seinen
Demographic.JOSEI -> R.string.demographic_josei
Demographic.KODOMO -> R.string.demographic_kodomo
Demographic.NONE -> R.string.none
}
fun Manga.getPreferredBranch(history: MangaHistory?): String? {
val ch = chapters
if (ch.isNullOrEmpty()) {
return null
}
if (history != null) {
val currentChapter = ch.findById(history.chapterId)
if (currentChapter != null) {
return currentChapter.branch
}
}
val groups = ch.groupBy { it.branch }
if (groups.size == 1) {
return groups.keys.first()
}
for (locale in LocaleListCompat.getAdjustedDefault()) {
val displayLanguage = locale.getDisplayLanguage(locale)
val displayName = locale.getDisplayName(locale)
val candidates = HashMap>(3)
for (branch in groups.keys) {
if (branch != null && (
branch.contains(displayLanguage, ignoreCase = true) ||
branch.contains(displayName, ignoreCase = true)
)
) {
candidates[branch] = groups[branch] ?: continue
}
}
if (candidates.isNotEmpty()) {
return candidates.maxBy { it.value.size }.key
}
}
return groups.maxByOrNull { it.value.size }?.key
}
val Manga.isLocal: Boolean
get() = source == LocalMangaSource
val Manga.isBroken: Boolean
get() = source == UnknownMangaSource
val Manga.appUrl: Uri
get() = "https://kotatsu.app/manga".toUri()
.buildUpon()
.appendQueryParameter("source", source.name)
.appendQueryParameter("name", title)
.appendQueryParameter("url", url)
.build()
fun Manga.chaptersCount(): Int {
if (chapters.isNullOrEmpty()) {
return 0
}
val counters = MutableObjectIntMap()
var max = 0
chapters?.forEach { x ->
val c = counters.getOrDefault(x.branch, 0) + 1
counters[x.branch] = c
if (max < c) {
max = c
}
}
return max
}
fun Manga.isNsfw(): Boolean = contentRating == ContentRating.ADULT || source.isNsfw()
fun MangaListFilter.getSummary() = buildSpannedString {
if (!query.isNullOrEmpty()) {
append(query)
if (tags.isNotEmpty() || tagsExclude.isNotEmpty()) {
append(' ')
append('(')
appendTagsSummary(this@getSummary)
append(')')
}
} else {
appendTagsSummary(this@getSummary)
}
}
private fun SpannableStringBuilder.appendTagsSummary(filter: MangaListFilter) {
var isFirst = true
val separator = ", "
for (tag in filter.tags) {
if (isFirst) {
isFirst = false
} else {
append(separator)
}
append(tag.title)
}
for (tag in filter.tagsExclude) {
if (isFirst) {
isFirst = false
} else {
append(separator)
}
strikeThrough {
append(tag.title)
}
}
}
fun MangaChapter.getLocalizedTitle(resources: Resources, index: Int = -1): String {
title?.let {
if (it.isNotBlank()) {
return it
}
}
val num = numberString()
val vol = volumeString()
return when {
num != null && vol != null -> resources.getString(R.string.chapter_volume_number, vol, num)
num != null -> resources.getString(R.string.chapter_number, num)
index > 0 -> resources.getString(
R.string.chapters_time_pattern,
resources.getString(R.string.unnamed_chapter),
index.toString(),
)
else -> resources.getString(R.string.unnamed_chapter)
}
}
fun Manga.withOverride(override: MangaOverride?) = if (override != null) {
copy(
title = override.title.ifNullOrEmpty { title },
coverUrl = override.coverUrl.ifNullOrEmpty { coverUrl },
largeCoverUrl = override.coverUrl.ifNullOrEmpty { largeCoverUrl },
contentRating = override.contentRating ?: contentRating,
)
} else {
this
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaHistory.kt
================================================
package org.koitharu.kotatsu.core.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import java.time.Instant
@Parcelize
data class MangaHistory(
val createdAt: Instant,
val updatedAt: Instant,
val chapterId: Long,
val page: Int,
val scroll: Int,
val percent: Float,
val chaptersCount: Int,
) : Parcelable
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt
================================================
package org.koitharu.kotatsu.core.model
import android.content.Context
import android.os.Build
import android.text.SpannableStringBuilder
import android.text.style.ImageSpan
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.core.text.inSpans
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.core.util.ext.getDisplayName
import org.koitharu.kotatsu.core.util.ext.toLocale
import org.koitharu.kotatsu.core.util.ext.toLocaleOrNull
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.splitTwoParts
import java.util.Locale
data object LocalMangaSource : MangaSource {
override val name = "LOCAL"
}
data object UnknownMangaSource : MangaSource {
override val name = "UNKNOWN"
}
data object TestMangaSource : MangaSource {
override val name = "TEST"
}
fun MangaSource(name: String?): MangaSource {
when (name ?: return UnknownMangaSource) {
UnknownMangaSource.name -> return UnknownMangaSource
LocalMangaSource.name -> return LocalMangaSource
TestMangaSource.name -> return TestMangaSource
}
if (name.startsWith("content:")) {
val parts = name.substringAfter(':').splitTwoParts('/') ?: return UnknownMangaSource
return ExternalMangaSource(packageName = parts.first, authority = parts.second)
}
MangaParserSource.entries.forEach {
if (it.name == name) return it
}
return UnknownMangaSource
}
fun Collection.toMangaSources() = map(::MangaSource)
fun MangaSource.isNsfw(): Boolean = when (this) {
is MangaSourceInfo -> mangaSource.isNsfw()
is MangaParserSource -> contentType == ContentType.HENTAI
else -> false
}
@get:StringRes
val ContentType.titleResId
get() = when (this) {
ContentType.MANGA -> R.string.content_type_manga
ContentType.HENTAI -> R.string.content_type_hentai
ContentType.COMICS -> R.string.content_type_comics
ContentType.OTHER -> R.string.content_type_other
ContentType.MANHWA -> R.string.content_type_manhwa
ContentType.MANHUA -> R.string.content_type_manhua
ContentType.NOVEL -> R.string.content_type_novel
ContentType.ONE_SHOT -> R.string.content_type_one_shot
ContentType.DOUJINSHI -> R.string.content_type_doujinshi
ContentType.IMAGE_SET -> R.string.content_type_image_set
ContentType.ARTIST_CG -> R.string.content_type_artist_cg
ContentType.GAME_CG -> R.string.content_type_game_cg
}
tailrec fun MangaSource.unwrap(): MangaSource = if (this is MangaSourceInfo) {
mangaSource.unwrap()
} else {
this
}
fun MangaSource.getLocale(): Locale? = (unwrap() as? MangaParserSource)?.locale?.toLocaleOrNull()
fun MangaSource.getSummary(context: Context): String? = when (val source = unwrap()) {
is MangaParserSource -> {
val type = context.getString(source.contentType.titleResId)
val locale = source.locale.toLocale().getDisplayName(context)
context.getString(R.string.source_summary_pattern, type, locale)
}
is ExternalMangaSource -> context.getString(R.string.external_source)
else -> null
}
fun MangaSource.getTitle(context: Context): String = when (val source = unwrap()) {
is MangaParserSource -> source.title
LocalMangaSource -> context.getString(R.string.local_storage)
TestMangaSource -> context.getString(R.string.test_parser)
is ExternalMangaSource -> source.resolveName(context)
else -> context.getString(R.string.unknown)
}
fun SpannableStringBuilder.appendIcon(textView: TextView, @DrawableRes resId: Int): SpannableStringBuilder {
val icon = ContextCompat.getDrawable(textView.context, resId) ?: return this
icon.setTintList(textView.textColors)
val size = textView.lineHeight
icon.setBounds(0, 0, size, size)
val alignment = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ImageSpan.ALIGN_CENTER
} else {
ImageSpan.ALIGN_BOTTOM
}
return inSpans(ImageSpan(icon, alignment)) { append(' ') }
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSourceInfo.kt
================================================
package org.koitharu.kotatsu.core.model
import org.koitharu.kotatsu.parsers.model.MangaSource
data class MangaSourceInfo(
val mangaSource: MangaSource,
val isEnabled: Boolean,
val isPinned: Boolean,
) : MangaSource by mangaSource
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSourceSerializer.kt
================================================
package org.koitharu.kotatsu.core.model
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.serialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import org.koitharu.kotatsu.parsers.model.MangaSource
object MangaSourceSerializer : KSerializer {
override val descriptor: SerialDescriptor = serialDescriptor()
override fun serialize(
encoder: Encoder,
value: MangaSource
) = encoder.encodeString(value.name)
override fun deserialize(decoder: Decoder): MangaSource = MangaSource(decoder.decodeString())
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/model/QuickFilter.kt
================================================
package org.koitharu.kotatsu.core.model
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.list.domain.ListFilterOption
fun ListFilterOption.toChipModel(isChecked: Boolean) = ChipsView.ChipModel(
title = titleText,
titleResId = titleResId,
icon = iconResId,
iconData = getIconData(),
isChecked = isChecked,
counter = if (this is ListFilterOption.Branch) chaptersCount else 0,
data = this,
)
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/model/SortDirection.kt
================================================
package org.koitharu.kotatsu.core.model
enum class SortDirection {
ASC, DESC;
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/model/ZoomMode.kt
================================================
package org.koitharu.kotatsu.core.model
enum class ZoomMode {
FIT_CENTER, FIT_HEIGHT, FIT_WIDTH, KEEP_START
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/MangaSourceParceler.kt
================================================
package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import kotlinx.parcelize.Parceler
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaSource
class MangaSourceParceler : Parceler {
override fun create(parcel: Parcel): MangaSource = MangaSource(parcel.readString())
override fun MangaSource.write(parcel: Parcel, flags: Int) {
parcel.writeString(name)
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableChapter.kt
================================================
package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaChapter
@Parcelize
data class ParcelableChapter(
val chapter: MangaChapter,
) : Parcelable {
companion object : Parceler {
override fun create(parcel: Parcel) = ParcelableChapter(
MangaChapter(
id = parcel.readLong(),
title = parcel.readString(),
number = parcel.readFloat(),
volume = parcel.readInt(),
url = parcel.readString().orEmpty(),
scanlator = parcel.readString(),
uploadDate = parcel.readLong(),
branch = parcel.readString(),
source = MangaSource(parcel.readString()),
),
)
override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) {
parcel.writeLong(id)
parcel.writeString(title)
parcel.writeFloat(number)
parcel.writeInt(volume)
parcel.writeString(url)
parcel.writeString(scanlator)
parcel.writeLong(uploadDate)
parcel.writeString(branch)
parcel.writeString(source.name)
}
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableManga.kt
================================================
package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.core.util.ext.readStringSet
import org.koitharu.kotatsu.core.util.ext.writeStringSet
import org.koitharu.kotatsu.parsers.model.Manga
@Parcelize
data class ParcelableManga(
val manga: Manga,
private val withDescription: Boolean = true,
) : Parcelable {
companion object : Parceler {
override fun ParcelableManga.write(parcel: Parcel, flags: Int) = with(manga) {
parcel.writeLong(id)
parcel.writeString(title)
parcel.writeStringSet(altTitles)
parcel.writeString(url)
parcel.writeString(publicUrl)
parcel.writeFloat(rating)
parcel.writeSerializable(contentRating)
parcel.writeString(coverUrl)
parcel.writeString(largeCoverUrl)
parcel.writeString(description.takeIf { withDescription })
parcel.writeParcelable(ParcelableMangaTags(tags), flags)
parcel.writeSerializable(state)
parcel.writeStringSet(authors)
parcel.writeString(source.name)
}
override fun create(parcel: Parcel) = ParcelableManga(
Manga(
id = parcel.readLong(),
title = requireNotNull(parcel.readString()),
altTitles = parcel.readStringSet(),
url = requireNotNull(parcel.readString()),
publicUrl = requireNotNull(parcel.readString()),
rating = parcel.readFloat(),
contentRating = parcel.readSerializableCompat(),
coverUrl = parcel.readString(),
largeCoverUrl = parcel.readString(),
description = parcel.readString(),
tags = requireNotNull(parcel.readParcelableCompat()).tags,
state = parcel.readSerializableCompat(),
authors = parcel.readStringSet(),
chapters = null,
source = MangaSource(parcel.readString()),
),
withDescription = true,
)
}
}
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaListFilter.kt
================================================
package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler
import org.koitharu.kotatsu.core.util.ext.readEnumSet
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.core.util.ext.writeEnumSet
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Demographic
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaState
object MangaListFilterParceler : Parceler {
override fun MangaListFilter.write(parcel: Parcel, flags: Int) {
parcel.writeString(query)
parcel.writeParcelable(ParcelableMangaTags(tags), 0)
parcel.writeParcelable(ParcelableMangaTags(tagsExclude), 0)
parcel.writeSerializable(locale)
parcel.writeSerializable(originalLocale)
parcel.writeEnumSet(states)
parcel.writeEnumSet(contentRating)
parcel.writeEnumSet(types)
parcel.writeEnumSet(demographics)
parcel.writeInt(year)
parcel.writeInt(yearFrom)
parcel.writeInt(yearTo)
parcel.writeString(author)
}
override fun create(parcel: Parcel) = MangaListFilter(
query = parcel.readString(),
tags = parcel.readParcelableCompat()?.tags.orEmpty(),
tagsExclude = parcel.readParcelableCompat()?.tags.orEmpty(),
locale = parcel.readSerializableCompat(),
originalLocale = parcel.readSerializableCompat(),
states = parcel.readEnumSet().orEmpty(),
contentRating = parcel.readEnumSet().orEmpty(),
types = parcel.readEnumSet().orEmpty(),
demographics = parcel.readEnumSet().orEmpty(),
year = parcel.readInt(),
yearFrom = parcel.readInt(),
yearTo = parcel.readInt(),
author = parcel.readString(),
)
}
@Parcelize
@TypeParceler
data class ParcelableMangaListFilter(val filter: MangaListFilter) : Parcelable
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaPage.kt
================================================
package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaPage
object MangaPageParceler : Parceler {
override fun create(parcel: Parcel) = MangaPage(
id = parcel.readLong(),
url = requireNotNull(parcel.readString()),
preview = parcel.readString(),
source = MangaSource(parcel.readString()),
)
override fun MangaPage.write(parcel: Parcel, flags: Int) {
parcel.writeLong(id)
parcel.writeString(url)
parcel.writeString(preview)
parcel.writeString(source.name)
}
}
@Parcelize
@TypeParceler
class ParcelableMangaPage(val page: MangaPage) : Parcelable
================================================
FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaTags.kt
================================================
package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
object MangaTagParceler : Parceler {
override fun create(parcel: Parcel) = MangaTag(
title = requireNotNull(parcel.readString()),
key = requireNotNull(parcel.readString()),
source = MangaSource(parcel.readString()),
)
override fun MangaTag.write(parcel: Parcel, flags: Int) {
parcel.writeString(title)
parcel.writeString(key)
parcel.writeString(source.name)
}
}
@Parcelize
@TypeParceler
data class ParcelableMangaTags(val tags: Set