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 ================================================
xmlns:android ^$
xmlns:.* ^$ BY_NAME
.*:id http://schemas.android.com/apk/res/android
.*:name http://schemas.android.com/apk/res/android
name ^$
style ^$
.* ^$ BY_NAME
.* http://schemas.android.com/apk/res/android ANDROID_ATTRIBUTE_ORDER
.* .* BY_NAME
================================================ 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.** ![Android 6.0](https://img.shields.io/badge/android-6.0+-brightgreen) [![Sources count](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2FKotatsuApp%2Fkotatsu-parsers%2Frefs%2Fheads%2Fmaster%2F.github%2Fsummary.yaml&query=total&label=manga%20sources&color=%23E9321C)](https://github.com/KotatsuApp/kotatsu-parsers) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) [![Telegram](https://img.shields.io/badge/chat-telegram-60ACFF?)](https://t.me/kotatsuapp) [![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu)](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
Mobile view Mobile view Mobile view Mobile view Mobile view Mobile view

Tablet view Tablet view
### Localization Translation status **[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
Kotatsu GitHub Repository Kotatsu-parsers GitHub Repository


**📌 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 [![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](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) : Parcelable ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt ================================================ package org.koitharu.kotatsu.core.nav import android.accounts.Account import android.app.Activity import android.content.ActivityNotFoundException import android.content.Context import android.content.DialogInterface import android.content.Intent import android.net.Uri import android.os.Bundle import android.provider.Settings import android.view.View import androidx.annotation.CheckResult import androidx.annotation.UiContext import androidx.core.app.ShareCompat import androidx.core.content.FileProvider import androidx.core.net.toUri import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentManager import androidx.fragment.app.findFragment import androidx.lifecycle.LifecycleOwner import dagger.hilt.android.EntryPointAccessors import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity import org.koitharu.kotatsu.backups.ui.backup.BackupDialogFragment import org.koitharu.kotatsu.backups.ui.restore.RestoreDialogFragment import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity 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.image.CoilMemoryCacheKey import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.MangaSourceInfo import org.koitharu.kotatsu.core.model.appUrl import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.model.isBroken import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaListFilter import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPage import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.TriStateOption import org.koitharu.kotatsu.core.ui.dialog.BigButtonsAlertDialog import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.util.ext.connectivityManager import org.koitharu.kotatsu.core.util.ext.findActivity import org.koitharu.kotatsu.core.util.ext.getThemeDrawable import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.toFileOrNull import org.koitharu.kotatsu.core.util.ext.toUriOrNull import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet import org.koitharu.kotatsu.details.ui.related.RelatedMangaActivity import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingInfoSheet import org.koitharu.kotatsu.download.ui.dialog.DownloadDialogFragment import org.koitharu.kotatsu.download.ui.list.DownloadsActivity import org.koitharu.kotatsu.favourites.ui.FavouritesActivity import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteDialog import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet import org.koitharu.kotatsu.history.ui.HistoryActivity import org.koitharu.kotatsu.image.ui.ImageActivity import org.koitharu.kotatsu.list.ui.config.ListConfigBottomSheet import org.koitharu.kotatsu.list.ui.config.ListConfigSection import org.koitharu.kotatsu.local.ui.ImportDialogFragment import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog import org.koitharu.kotatsu.main.ui.MainActivity import org.koitharu.kotatsu.main.ui.welcome.WelcomeSheet import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.ellipsize import org.koitharu.kotatsu.parsers.util.isNullOrEmpty import org.koitharu.kotatsu.parsers.util.mapToArray import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity import org.koitharu.kotatsu.reader.ui.config.ReaderConfigSheet import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.ui.config.ScrobblerConfigActivity import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet import org.koitharu.kotatsu.search.domain.SearchKind import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.multi.SearchActivity import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.about.AppUpdateActivity import org.koitharu.kotatsu.settings.override.OverrideConfigActivity import org.koitharu.kotatsu.settings.reader.ReaderTapGridConfigActivity import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity import org.koitharu.kotatsu.settings.storage.MangaDirectorySelectDialog import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity import org.koitharu.kotatsu.settings.tracker.categories.TrackerCategoriesConfigSheet import org.koitharu.kotatsu.stats.ui.StatsActivity import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity import org.koitharu.kotatsu.tracker.ui.updates.UpdatesActivity import java.io.File import androidx.appcompat.R as appcompatR class AppRouter private constructor( private val activity: FragmentActivity?, private val fragment: Fragment?, ) { constructor(activity: FragmentActivity) : this(activity, null) constructor(fragment: Fragment) : this(null, fragment) private val settings: AppSettings by lazy { EntryPointAccessors.fromApplication(checkNotNull(contextOrNull())).settings } /** Activities **/ fun openList(source: MangaSource, filter: MangaListFilter?, sortOrder: SortOrder?) { startActivity(listIntent(contextOrNull() ?: return, source, filter, sortOrder)) } fun openList(tag: MangaTag) = openList(tag.source, MangaListFilter(tags = setOf(tag)), null) fun openSearch(query: String, kind: SearchKind = SearchKind.SIMPLE) { startActivity( Intent(contextOrNull() ?: return, SearchActivity::class.java) .putExtra(KEY_QUERY, query) .putExtra(KEY_KIND, kind), ) } fun openSearch(source: MangaSource, query: String) = openList(source, MangaListFilter(query = query), null) fun openDetails(manga: Manga) { startActivity(detailsIntent(contextOrNull() ?: return, manga)) } fun openDetails(mangaId: Long) { startActivity(detailsIntent(contextOrNull() ?: return, mangaId)) } fun openDetails(link: Uri) { startActivity( Intent(contextOrNull() ?: return, DetailsActivity::class.java) .setData(link), ) } fun openReader(manga: Manga, anchor: View? = null) { openReader( ReaderIntent.Builder(contextOrNull() ?: return) .manga(manga) .build(), anchor, ) } fun openReader(intent: ReaderIntent, anchor: View? = null) { val activityIntent = intent.intent if (settings.isReaderMultiTaskEnabled && activityIntent.data != null) { activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT) } startActivity(activityIntent, anchor?.let { view -> scaleUpActivityOptionsOf(view) }) } fun openAlternatives(manga: Manga) { startActivity( Intent(contextOrNull() ?: return, AlternativesActivity::class.java) .putExtra(KEY_MANGA, ParcelableManga(manga)), ) } fun openRelated(manga: Manga) { startActivity( Intent(contextOrNull(), RelatedMangaActivity::class.java) .putExtra(KEY_MANGA, ParcelableManga(manga)), ) } fun openImage(url: String, source: MangaSource?, anchor: View? = null, preview: CoilMemoryCacheKey? = null) { startActivity( Intent(contextOrNull(), ImageActivity::class.java) .setData(url.toUri()) .putExtra(KEY_SOURCE, source?.name) .putExtra(KEY_PREVIEW, preview), anchor?.let { scaleUpActivityOptionsOf(it) }, ) } fun openBookmarks() = startActivity(AllBookmarksActivity::class.java) fun openAppUpdate() = startActivity(AppUpdateActivity::class.java) fun openSuggestions() { startActivity(suggestionsIntent(contextOrNull() ?: return)) } fun openSourcesCatalog() = startActivity(SourcesCatalogActivity::class.java) fun openDownloads() = startActivity(DownloadsActivity::class.java) fun openDirectoriesSettings() = startActivity(MangaDirectoriesActivity::class.java) fun openBrowser(url: String, source: MangaSource?, title: String?) { startActivity(browserIntent(contextOrNull() ?: return, url, source, title)) } fun openBrowser(manga: Manga) = openBrowser( url = manga.publicUrl, source = manga.source, title = manga.title, ) fun openColorFilterConfig(manga: Manga, page: MangaPage) { startActivity( Intent(contextOrNull(), ColorFilterConfigActivity::class.java) .putExtra(KEY_MANGA, ParcelableManga(manga)) .putExtra(KEY_PAGES, ParcelableMangaPage(page)), ) } fun openHistory() = startActivity(HistoryActivity::class.java) fun openFavorites() = startActivity(FavouritesActivity::class.java) fun openFavorites(category: FavouriteCategory) { startActivity( Intent(contextOrNull() ?: return, FavouritesActivity::class.java) .putExtra(KEY_ID, category.id) .putExtra(KEY_TITLE, category.title), ) } fun openFavoriteCategories() = startActivity(FavouriteCategoriesActivity::class.java) fun openFavoriteCategoryEdit(categoryId: Long) { startActivity( Intent(contextOrNull() ?: return, FavouritesCategoryEditActivity::class.java) .putExtra(KEY_ID, categoryId), ) } fun openFavoriteCategoryCreate() = openFavoriteCategoryEdit(FavouritesCategoryEditActivity.NO_ID) fun openMangaUpdates() { startActivity(mangaUpdatesIntent(contextOrNull() ?: return)) } fun openMangaOverrideConfig(manga: Manga) { val intent = overrideEditIntent(contextOrNull() ?: return, manga) startActivity(intent) } fun openSettings() = startActivity(SettingsActivity::class.java) fun openReaderSettings() { startActivity(readerSettingsIntent(contextOrNull() ?: return)) } fun openProxySettings() { startActivity(proxySettingsIntent(contextOrNull() ?: return)) } fun openDownloadsSetting() { startActivity(downloadsSettingsIntent(contextOrNull() ?: return)) } fun openSourceSettings(source: MangaSource) { startActivity(sourceSettingsIntent(contextOrNull() ?: return, source)) } fun openSuggestionsSettings() { startActivity(suggestionsSettingsIntent(contextOrNull() ?: return)) } fun openSourcesSettings() { startActivity(sourcesSettingsIntent(contextOrNull() ?: return)) } fun openDiscordSettings() { startActivity(discordSettingsIntent(contextOrNull() ?: return)) } fun openReaderTapGridSettings() = startActivity(ReaderTapGridConfigActivity::class.java) fun openScrobblerSettings(scrobbler: ScrobblerService) { startActivity( Intent(contextOrNull() ?: return, ScrobblerConfigActivity::class.java) .putExtra(KEY_ID, scrobbler.id), ) } fun openSourceAuth(source: MangaSource) { startActivity(sourceAuthIntent(contextOrNull() ?: return, source)) } fun openManageSources() { startActivity( manageSourcesIntent(contextOrNull() ?: return), ) } fun openStatistic() = startActivity(StatsActivity::class.java) @CheckResult fun openExternalBrowser(url: String, chooserTitle: CharSequence? = null): Boolean { val intent = Intent(Intent.ACTION_VIEW) intent.data = url.toUriOrNull() ?: return false return startActivitySafe( if (!chooserTitle.isNullOrEmpty()) { Intent.createChooser(intent, chooserTitle) } else { intent }, ) } @CheckResult fun openSystemSyncSettings(account: Account): Boolean { val args = Bundle(1) args.putParcelable(ACCOUNT_KEY, account) val intent = Intent(ACTION_ACCOUNT_SYNC_SETTINGS) intent.putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, args) return startActivitySafe(intent) } /** Dialogs **/ fun showDownloadDialog(manga: Manga, snackbarHost: View?) = showDownloadDialog(setOf(manga), snackbarHost) fun showDownloadDialog(manga: Collection, snackbarHost: View?) { if (manga.isEmpty()) { return } val fm = getFragmentManager() ?: return if (snackbarHost != null) { getLifecycleOwner()?.let { lifecycleOwner -> DownloadDialogFragment.registerCallback(fm, lifecycleOwner, snackbarHost) } } else { DownloadDialogFragment.unregisterCallback(fm) } DownloadDialogFragment().withArgs(1) { putParcelableArray(KEY_MANGA, manga.mapToArray { ParcelableManga(it, withDescription = false) }) }.showDistinct() } fun showLocalInfoDialog(manga: Manga) { LocalInfoDialog().withArgs(1) { putParcelable(KEY_MANGA, ParcelableManga(manga)) }.showDistinct() } fun showDirectorySelectDialog() { MangaDirectorySelectDialog().showDistinct() } fun showFavoriteDialog(manga: Manga) = showFavoriteDialog(setOf(manga)) fun showFavoriteDialog(manga: Collection) { if (manga.isEmpty()) { return } FavoriteDialog().withArgs(1) { putParcelableArrayList( KEY_MANGA_LIST, manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it, withDescription = false) }, ) }.showDistinct() } fun showTagDialog(tag: MangaTag) { buildAlertDialog(contextOrNull() ?: return) { setIcon(R.drawable.ic_tag) setTitle(tag.title) setItems( arrayOf( context.getString(R.string.search_on_s, tag.source.getTitle(context)), context.getString(R.string.search_everywhere), ), ) { _, which -> when (which) { 0 -> openList(tag) 1 -> openSearch(tag.title, SearchKind.TAG) } } setNegativeButton(R.string.close, null) setCancelable(true) }.show() } fun showAuthorDialog(author: String, source: MangaSource) { buildAlertDialog(contextOrNull() ?: return) { setIcon(R.drawable.ic_user) setTitle(author) setItems( arrayOf( context.getString(R.string.search_on_s, source.getTitle(context)), context.getString(R.string.search_everywhere), ), ) { _, which -> when (which) { 0 -> openList(source, MangaListFilter(author = author), null) 1 -> openSearch(author, SearchKind.AUTHOR) } } setNegativeButton(R.string.close, null) setCancelable(true) }.show() } fun showShareDialog(manga: Manga) { if (manga.isBroken) { return } if (manga.isLocal) { manga.url.toUri().toFileOrNull()?.let { shareFile(it) } return } buildAlertDialog(contextOrNull() ?: return) { setIcon(context.getThemeDrawable(appcompatR.attr.actionModeShareDrawable)) setTitle(R.string.share) setItems( arrayOf( context.getString(R.string.link_to_manga_in_app), context.getString(R.string.link_to_manga_on_s, manga.source.getTitle(context)), ), ) { _, which -> val link = when (which) { 0 -> manga.appUrl.toString() 1 -> manga.publicUrl else -> return@setItems } shareLink(link, manga.title) } setNegativeButton(android.R.string.cancel, null) setCancelable(true) }.show() } fun showErrorDialog(error: Throwable, url: String? = null) { ErrorDetailsDialog().withArgs(2) { putSerializable(KEY_ERROR, error) putString(KEY_URL, url) }.show() } fun showBackupRestoreDialog(fileUri: Uri) { RestoreDialogFragment().withArgs(1) { putString(KEY_FILE, fileUri.toString()) }.show() } fun createBackup(destination: Uri) { BackupDialogFragment().withArgs(1) { putParcelable(KEY_DATA, destination) }.showDistinct() } fun showImportDialog() { ImportDialogFragment().showDistinct() } fun showFilterSheet(): Boolean = if (isFilterSupported()) { FilterSheetFragment().showDistinct() } else { false } fun showTagsCatalogSheet(excludeMode: Boolean) { if (!isFilterSupported()) { return } TagsCatalogSheet().withArgs(1) { putBoolean(KEY_EXCLUDE, excludeMode) }.showDistinct() } fun showListConfigSheet(section: ListConfigSection) { ListConfigBottomSheet().withArgs(1) { putParcelable(KEY_LIST_SECTION, section) }.showDistinct() } fun showStatisticSheet(manga: Manga) { MangaStatsSheet().withArgs(1) { putParcelable(KEY_MANGA, ParcelableManga(manga)) }.showDistinct() } fun showReaderConfigSheet(mode: ReaderMode) { ReaderConfigSheet().withArgs(1) { putInt(KEY_READER_MODE, mode.id) }.showDistinct() } fun showWelcomeSheet() { WelcomeSheet().showDistinct() } fun showChapterPagesSheet() { ChaptersPagesSheet().showDistinct() } fun showChapterPagesSheet(defaultTab: Int) { ChaptersPagesSheet().withArgs(1) { putInt(KEY_TAB, defaultTab) }.showDistinct() } fun showScrobblingSelectorSheet(manga: Manga, scrobblerService: ScrobblerService?) { ScrobblingSelectorSheet().withArgs(2) { putParcelable(KEY_MANGA, ParcelableManga(manga)) if (scrobblerService != null) { putInt(KEY_ID, scrobblerService.id) } }.show() } fun showScrobblingInfoSheet(index: Int) { ScrobblingInfoSheet().withArgs(1) { putInt(KEY_INDEX, index) }.showDistinct() } fun showTrackerCategoriesConfigSheet() { TrackerCategoriesConfigSheet().showDistinct() } fun askForDownloadOverMeteredNetwork(onConfirmed: (allow: Boolean) -> Unit) { val context = contextOrNull() ?: return when (settings.allowDownloadOnMeteredNetwork) { TriStateOption.ENABLED -> onConfirmed(true) TriStateOption.DISABLED -> onConfirmed(false) TriStateOption.ASK -> { if (!context.connectivityManager.isActiveNetworkMetered) { onConfirmed(true) return } val listener = DialogInterface.OnClickListener { _, which -> when (which) { DialogInterface.BUTTON_POSITIVE -> { settings.allowDownloadOnMeteredNetwork = TriStateOption.ENABLED onConfirmed(true) } DialogInterface.BUTTON_NEUTRAL -> { onConfirmed(true) } DialogInterface.BUTTON_NEGATIVE -> { settings.allowDownloadOnMeteredNetwork = TriStateOption.DISABLED onConfirmed(false) } } } BigButtonsAlertDialog.Builder(context) .setIcon(R.drawable.ic_network_cellular) .setTitle(R.string.download_cellular_confirm) .setPositiveButton(R.string.allow_always, listener) .setNeutralButton(R.string.allow_once, listener) .setNegativeButton(R.string.dont_allow, listener) .create() .show() } } } /** Public utils **/ fun isFilterSupported(): Boolean = when { fragment != null -> FilterCoordinator.find(fragment) != null activity != null -> activity is FilterCoordinator.Owner else -> false } fun isChapterPagesSheetShown(): Boolean { val sheet = getFragmentManager()?.findFragmentByTag(fragmentTag()) as? ChaptersPagesSheet return sheet?.dialog?.isShowing == true } fun closeWelcomeSheet(): Boolean { val tag = fragmentTag() val sheet = fragment?.findFragmentByTagRecursive(tag) ?: activity?.supportFragmentManager?.findFragmentByTag(tag) ?: return false return if (sheet is WelcomeSheet) { sheet.dismissAllowingStateLoss() true } else { false } } /** Private utils **/ private fun startActivity(intent: Intent, options: Bundle? = null) { fragment?.also { if (it.host != null) { it.startActivity(intent, options) } } ?: activity?.startActivity(intent, options) } private fun startActivitySafe(intent: Intent): Boolean = try { startActivity(intent) true } catch (_: ActivityNotFoundException) { false } private fun startActivity(activityClass: Class) { startActivity(Intent(contextOrNull() ?: return, activityClass)) } private fun getFragmentManager(): FragmentManager? = runCatching { fragment?.childFragmentManager ?: activity?.supportFragmentManager }.onFailure { exception -> exception.printStackTraceDebug() }.getOrNull() private fun shareLink(link: String, title: String) { val context = contextOrNull() ?: return ShareCompat.IntentBuilder(context) .setText(link) .setType(TYPE_TEXT) .setChooserTitle(context.getString(R.string.share_s, title.ellipsize(12))) .startChooser() } private fun shareFile(file: File) { // TODO directory sharing support val context = contextOrNull() ?: return val intentBuilder = ShareCompat.IntentBuilder(context) .setType(TYPE_CBZ) val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", file) intentBuilder.addStream(uri) intentBuilder.setChooserTitle(context.getString(R.string.share_s, file.name)) intentBuilder.startChooser() } @UiContext private fun contextOrNull(): Context? = activity ?: fragment?.context private fun getLifecycleOwner(): LifecycleOwner? = activity ?: fragment?.viewLifecycleOwner private fun DialogFragment.showDistinct(): Boolean { val fm = this@AppRouter.getFragmentManager() ?: return false val tag = javaClass.fragmentTag() val existing = fm.findFragmentByTag(tag) as? DialogFragment? if (existing != null && existing.isVisible && existing.arguments == this.arguments) { return false } show(fm, tag) return true } private fun DialogFragment.show() { show( this@AppRouter.getFragmentManager() ?: return, javaClass.fragmentTag(), ) } private fun Fragment.findFragmentByTagRecursive(fragmentTag: String): Fragment? { childFragmentManager.findFragmentByTag(fragmentTag)?.let { return it } val parent = parentFragment return if (parent != null) { parent.findFragmentByTagRecursive(fragmentTag) } else { parentFragmentManager.findFragmentByTag(fragmentTag) } } companion object { fun from(view: View): AppRouter? = runCatching { AppRouter(view.findFragment()) }.getOrElse { (view.context.findActivity() as? FragmentActivity)?.let(::AppRouter) } fun detailsIntent(context: Context, manga: Manga) = Intent(context, DetailsActivity::class.java) .putExtra(KEY_MANGA, ParcelableManga(manga)) .setData(shortMangaUrl(manga.id)) fun detailsIntent(context: Context, mangaId: Long) = Intent(context, DetailsActivity::class.java) .putExtra(KEY_ID, mangaId) .setData(shortMangaUrl(mangaId)) fun listIntent(context: Context, source: MangaSource, filter: MangaListFilter?, sortOrder: SortOrder?): Intent = Intent(context, MangaListActivity::class.java) .setAction(ACTION_MANGA_EXPLORE) .putExtra(KEY_SOURCE, source.name) .apply { if (!filter.isNullOrEmpty()) { putExtra(KEY_FILTER, ParcelableMangaListFilter(filter)) } if (sortOrder != null) { putExtra(KEY_SORT_ORDER, sortOrder) } } fun cloudFlareResolveIntent(context: Context, exception: CloudFlareProtectedException): Intent = Intent(context, CloudFlareActivity::class.java).apply { data = exception.url.toUri() putExtra(KEY_SOURCE, exception.source.name) exception.headers[CommonHeaders.USER_AGENT]?.let { putExtra(KEY_USER_AGENT, it) } } fun browserIntent( context: Context, url: String, source: MangaSource?, title: String? ): Intent = Intent(context, BrowserActivity::class.java) .setData(url.toUri()) .putExtra(KEY_TITLE, title) .putExtra(KEY_SOURCE, source?.name) fun suggestionsIntent(context: Context) = Intent(context, SuggestionsActivity::class.java) fun homeIntent(context: Context) = Intent(context, MainActivity::class.java) fun mangaUpdatesIntent(context: Context) = Intent(context, UpdatesActivity::class.java) fun readerSettingsIntent(context: Context) = Intent(context, SettingsActivity::class.java) .setAction(ACTION_READER) fun suggestionsSettingsIntent(context: Context) = Intent(context, SettingsActivity::class.java) .setAction(ACTION_SUGGESTIONS) fun trackerSettingsIntent(context: Context) = Intent(context, SettingsActivity::class.java) .setAction(ACTION_TRACKER) fun periodicBackupSettingsIntent(context: Context) = Intent(context, SettingsActivity::class.java) .setAction(ACTION_PERIODIC_BACKUP) fun discordSettingsIntent(context: Context) = Intent(context, SettingsActivity::class.java) .setAction(ACTION_MANAGE_DISCORD) fun proxySettingsIntent(context: Context) = Intent(context, SettingsActivity::class.java) .setAction(ACTION_PROXY) fun historySettingsIntent(context: Context) = Intent(context, SettingsActivity::class.java) .setAction(ACTION_HISTORY) fun sourcesSettingsIntent(context: Context) = Intent(context, SettingsActivity::class.java) .setAction(ACTION_SOURCES) fun manageSourcesIntent(context: Context) = Intent(context, SettingsActivity::class.java) .setAction(ACTION_MANAGE_SOURCES) fun downloadsSettingsIntent(context: Context) = Intent(context, SettingsActivity::class.java) .setAction(ACTION_MANAGE_DOWNLOADS) fun sourceSettingsIntent(context: Context, source: MangaSource): Intent = when (source) { is MangaSourceInfo -> sourceSettingsIntent(context, source.mangaSource) is ExternalMangaSource -> Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) .setData(Uri.fromParts("package", source.packageName, null)) else -> Intent(context, SettingsActivity::class.java) .setAction(ACTION_SOURCE) .putExtra(KEY_SOURCE, source.name) } fun sourceAuthIntent(context: Context, source: MangaSource): Intent { return Intent(context, SourceAuthActivity::class.java) .putExtra(KEY_SOURCE, source.name) } fun overrideEditIntent(context: Context, manga: Manga): Intent = Intent(context, OverrideConfigActivity::class.java) .putExtra(KEY_MANGA, ParcelableManga(manga, withDescription = false)) fun isShareSupported(manga: Manga): Boolean = when { manga.isBroken -> false manga.isLocal -> manga.url.toUri().toFileOrNull() != null else -> true } fun shortMangaUrl(mangaId: Long): Uri = Uri.Builder() .scheme("kotatsu") .path("manga") .appendQueryParameter("id", mangaId.toString()) .build() const val KEY_DATA = "data" const val KEY_ENTRIES = "entries" const val KEY_ERROR = "error" const val KEY_EXCLUDE = "exclude" const val KEY_FILE = "file" const val KEY_FILTER = "filter" const val KEY_ID = "id" const val KEY_INDEX = "index" const val KEY_IS_BOTTOMTAB = "is_btab" const val KEY_KIND = "kind" const val KEY_LIST_SECTION = "list_section" const val KEY_MANGA = "manga" const val KEY_MANGA_LIST = "manga_list" const val KEY_PAGES = "pages" const val KEY_PREVIEW = "preview" const val KEY_QUERY = "query" const val KEY_READER_MODE = "reader_mode" const val KEY_SORT_ORDER = "sort_order" const val KEY_SOURCE = "source" const val KEY_TAB = "tab" const val KEY_TITLE = "title" const val KEY_URL = "url" const val KEY_USER_AGENT = "user_agent" const val ACTION_HISTORY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_HISTORY" const val ACTION_MANAGE_DOWNLOADS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_DOWNLOADS" const val ACTION_MANAGE_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES_LIST" const val ACTION_MANGA_EXPLORE = "${BuildConfig.APPLICATION_ID}.action.EXPLORE_MANGA" const val ACTION_PROXY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_PROXY" const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS" const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS" const val ACTION_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES" const val ACTION_MANAGE_DISCORD = "${BuildConfig.APPLICATION_ID}.action.MANAGE_DISCORD" const val ACTION_SUGGESTIONS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS" const val ACTION_TRACKER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_TRACKER" const val ACTION_PERIODIC_BACKUP = "${BuildConfig.APPLICATION_ID}.action.MANAGE_PERIODIC_BACKUP" private const val ACCOUNT_KEY = "account" private const val ACTION_ACCOUNT_SYNC_SETTINGS = "android.settings.ACCOUNT_SYNC_SETTINGS" private const val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args" private const val TYPE_TEXT = "text/plain" private const val TYPE_IMAGE = "image/*" private const val TYPE_CBZ = "application/x-cbz" private fun Class.fragmentTag() = name // TODO private inline fun fragmentTag() = F::class.java.fragmentTag() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouterEntryPoint.kt ================================================ package org.koitharu.kotatsu.core.nav import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import org.koitharu.kotatsu.core.prefs.AppSettings @EntryPoint @InstallIn(SingletonComponent::class) interface AppRouterEntryPoint { val settings: AppSettings } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/nav/MangaIntent.kt ================================================ package org.koitharu.kotatsu.core.nav import android.content.Intent import android.net.Uri import android.os.Bundle import androidx.lifecycle.SavedStateHandle import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.nav.AppRouter.Companion.KEY_ID import org.koitharu.kotatsu.core.nav.AppRouter.Companion.KEY_MANGA import org.koitharu.kotatsu.core.util.ext.getParcelableCompat import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat import org.koitharu.kotatsu.parsers.model.Manga class MangaIntent private constructor( @JvmField val manga: Manga?, @JvmField val id: Long, @JvmField val uri: Uri?, ) { constructor(intent: Intent?) : this( manga = intent?.getParcelableExtraCompat(KEY_MANGA)?.manga, id = intent?.getLongExtra(KEY_ID, ID_NONE) ?: ID_NONE, uri = intent?.data, ) constructor(savedStateHandle: SavedStateHandle) : this( manga = savedStateHandle.get(KEY_MANGA)?.manga, id = savedStateHandle[KEY_ID] ?: ID_NONE, uri = savedStateHandle[AppRouter.KEY_DATA], ) constructor(args: Bundle?) : this( manga = args?.getParcelableCompat(KEY_MANGA)?.manga, id = args?.getLong(KEY_ID, ID_NONE) ?: ID_NONE, uri = null, ) val mangaId: Long get() = if (id != ID_NONE) id else manga?.id ?: uri?.lastPathSegment?.toLongOrNull() ?: ID_NONE companion object { const val ID_NONE = 0L fun of(manga: Manga) = MangaIntent(manga, manga.id, null) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/nav/NavUtil.kt ================================================ package org.koitharu.kotatsu.core.nav import android.app.ActivityOptions import android.os.Bundle import android.view.View import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.isOnScreen inline val FragmentActivity.router: AppRouter get() = AppRouter(this) inline val Fragment.router: AppRouter get() = AppRouter(this) tailrec fun Fragment.dismissParentDialog(): Boolean { return when (val parent = parentFragment) { null -> return false is DialogFragment -> { parent.dismiss() true } else -> parent.dismissParentDialog() } } fun scaleUpActivityOptionsOf(view: View): Bundle? { if (!view.context.isAnimationsEnabled || !view.isOnScreen()) { return null } return ActivityOptions.makeScaleUpAnimation( /* source = */ view, /* startX = */ 0, /* startY = */ 0, /* width = */ view.width, /* height = */ view.height, ).toBundle() } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/nav/ReaderIntent.kt ================================================ package org.koitharu.kotatsu.core.nav import android.content.Context import android.content.Intent import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderState @JvmInline value class ReaderIntent private constructor( val intent: Intent, ) { class Builder(context: Context) { private val intent = Intent(context, ReaderActivity::class.java) .setAction(ACTION_MANGA_READ) fun manga(manga: Manga) = apply { intent.putExtra(AppRouter.KEY_MANGA, ParcelableManga(manga)) intent.setData(AppRouter.shortMangaUrl(manga.id)) } fun mangaId(mangaId: Long) = apply { intent.putExtra(AppRouter.KEY_ID, mangaId) intent.setData(AppRouter.shortMangaUrl(mangaId)) } fun incognito() = apply { intent.putExtra(EXTRA_INCOGNITO, true) } fun branch(branch: String?) = apply { intent.putExtra(EXTRA_BRANCH, branch) } fun state(state: ReaderState?) = apply { intent.putExtra(EXTRA_STATE, state) } fun bookmark(bookmark: Bookmark) = manga( bookmark.manga, ).state( ReaderState( chapterId = bookmark.chapterId, page = bookmark.page, scroll = bookmark.scroll, ), ) fun build() = ReaderIntent(intent) } companion object { const val ACTION_MANGA_READ = "${BuildConfig.APPLICATION_ID}.action.READ_MANGA" const val EXTRA_STATE = "state" const val EXTRA_BRANCH = "branch" const val EXTRA_INCOGNITO = "incognito" } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/CacheLimitInterceptor.kt ================================================ package org.koitharu.kotatsu.core.network import okhttp3.CacheControl import okhttp3.Interceptor import okhttp3.Response import java.util.concurrent.TimeUnit class CacheLimitInterceptor : Interceptor { private val defaultMaxAge = TimeUnit.HOURS.toSeconds(1) private val defaultCacheControl = CacheControl.Builder() .maxAge(defaultMaxAge.toInt(), TimeUnit.SECONDS) .build() .toString() override fun intercept(chain: Interceptor.Chain): Response { val response = chain.proceed(chain.request()) val responseCacheControl = CacheControl.parse(response.headers) if (responseCacheControl.noStore || responseCacheControl.maxAgeSeconds <= defaultMaxAge) { return response } return response.newBuilder() .header(CommonHeaders.CACHE_CONTROL, defaultCacheControl) .build() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/CloudFlareInterceptor.kt ================================================ package org.koitharu.kotatsu.core.network import okhttp3.Interceptor import okhttp3.Response import okio.IOException import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.network.CloudFlareHelper class CloudFlareInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() val response = chain.proceed(request) return when (CloudFlareHelper.checkResponseForProtection(response)) { CloudFlareHelper.PROTECTION_BLOCKED -> response.closeThrowing( CloudFlareBlockedException( url = request.url.toString(), source = request.tag(MangaSource::class.java), ), ) CloudFlareHelper.PROTECTION_CAPTCHA -> response.closeThrowing( CloudFlareProtectedException( url = request.url.toString(), source = request.tag(MangaSource::class.java), headers = request.headers, ), ) else -> response } } private fun Response.closeThrowing(error: IOException): Nothing { try { close() } catch (e: Exception) { error.addSuppressed(e) } throw error } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeaders.kt ================================================ package org.koitharu.kotatsu.core.network import okhttp3.CacheControl object CommonHeaders { const val REFERER = "Referer" const val USER_AGENT = "User-Agent" const val ACCEPT = "Accept" const val CONTENT_TYPE = "Content-Type" const val CONTENT_DISPOSITION = "Content-Disposition" const val COOKIE = "Cookie" const val CONTENT_ENCODING = "Content-Encoding" const val ACCEPT_ENCODING = "Accept-Encoding" const val AUTHORIZATION = "Authorization" const val CACHE_CONTROL = "Cache-Control" const val PROXY_AUTHORIZATION = "Proxy-Authorization" const val RETRY_AFTER = "Retry-After" const val LAST_MODIFIED = "Last-Modified" const val IF_MODIFIED_SINCE = "If-Modified-Since" const val MANGA_SOURCE = "X-Manga-Source" val CACHE_CONTROL_NO_STORE: CacheControl get() = CacheControl.Builder().noStore().build() const val DATE_FORMAT = "EEE, dd MMM yyyy HH:mm:ss zzz" } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeadersInterceptor.kt ================================================ package org.koitharu.kotatsu.core.network import dagger.Lazy import okhttp3.Headers import okhttp3.Interceptor import okhttp3.Interceptor.Chain import okhttp3.Request import okhttp3.Response import okio.IOException import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.mergeWith import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.net.IDN import javax.inject.Inject import javax.inject.Singleton @Singleton class CommonHeadersInterceptor @Inject constructor( private val mangaRepositoryFactoryLazy: Lazy, private val mangaLoaderContextLazy: Lazy, ) : Interceptor { override fun intercept(chain: Chain): Response { val request = chain.request() val source = request.tag(MangaSource::class.java) ?: request.headers[CommonHeaders.MANGA_SOURCE]?.let { MangaSource(it) } val repository = if (source is MangaParserSource) { mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository } else { if (BuildConfig.DEBUG && source == null) { IllegalArgumentException("Request without source tag: ${request.url}") .printStackTrace() } null } val headersBuilder = request.headers.newBuilder() .removeAll(CommonHeaders.MANGA_SOURCE) repository?.getRequestHeaders()?.let { headersBuilder.mergeWith(it, replaceExisting = false) } if (headersBuilder[CommonHeaders.USER_AGENT] == null) { headersBuilder[CommonHeaders.USER_AGENT] = mangaLoaderContextLazy.get().getDefaultUserAgent() } if (headersBuilder[CommonHeaders.REFERER] == null && repository != null) { val idn = IDN.toASCII(repository.domain) headersBuilder.trySet(CommonHeaders.REFERER, "https://$idn/") } val newRequest = request.newBuilder().headers(headersBuilder.build()).build() return repository?.interceptSafe(ProxyChain(chain, newRequest)) ?: chain.proceed(newRequest) } private fun Headers.Builder.trySet(name: String, value: String) = try { set(name, value) } catch (e: IllegalArgumentException) { e.printStackTraceDebug() } private fun Interceptor.interceptSafe(chain: Chain): Response = runCatchingCancellable { intercept(chain) }.getOrElse { e -> if (e is IOException || e is Error) { throw e } else { // only IOException can be safely thrown from an Interceptor throw IOException("Error in interceptor: ${e.message}", e) } } private class ProxyChain( private val delegate: Chain, private val request: Request, ) : Chain by delegate { override fun request(): Request = request } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/DoHManager.kt ================================================ package org.koitharu.kotatsu.core.network import okhttp3.Cache import okhttp3.Dns import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.dnsoverhttps.DnsOverHttps import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import java.net.InetAddress import java.net.UnknownHostException class DoHManager( cache: Cache, private val settings: AppSettings, ) : Dns { private val bootstrapClient = OkHttpClient.Builder().cache(cache).build() private var cachedDelegate: Dns? = null private var cachedProvider: DoHProvider? = null override fun lookup(hostname: String): List { return try { getDelegate().lookup(hostname) } catch (e: UnknownHostException) { // fallback Dns.SYSTEM.lookup(hostname) } } @Synchronized private fun getDelegate(): Dns { var delegate = cachedDelegate val provider = settings.dnsOverHttps if (delegate == null || provider != cachedProvider) { delegate = createDelegate(provider) cachedDelegate = delegate cachedProvider = provider } return delegate } private fun createDelegate(provider: DoHProvider): Dns = when (provider) { DoHProvider.NONE -> Dns.SYSTEM DoHProvider.GOOGLE -> DnsOverHttps.Builder().client(bootstrapClient) .url("https://dns.google/dns-query".toHttpUrl()) .resolvePrivateAddresses(true) .bootstrapDnsHosts( listOfNotNull( tryGetByIp("8.8.4.4"), tryGetByIp("8.8.8.8"), tryGetByIp("2001:4860:4860::8888"), tryGetByIp("2001:4860:4860::8844"), ), ).build() DoHProvider.CLOUDFLARE -> DnsOverHttps.Builder().client(bootstrapClient) .url("https://cloudflare-dns.com/dns-query".toHttpUrl()) .resolvePrivateAddresses(true) .bootstrapDnsHosts( listOfNotNull( tryGetByIp("162.159.36.1"), tryGetByIp("162.159.46.1"), tryGetByIp("1.1.1.1"), tryGetByIp("1.0.0.1"), tryGetByIp("162.159.132.53"), tryGetByIp("2606:4700:4700::1111"), tryGetByIp("2606:4700:4700::1001"), tryGetByIp("2606:4700:4700::0064"), tryGetByIp("2606:4700:4700::6400"), ), ).build() DoHProvider.ADGUARD -> DnsOverHttps.Builder().client(bootstrapClient) .url("https://dns-unfiltered.adguard.com/dns-query".toHttpUrl()) .resolvePrivateAddresses(true) .bootstrapDnsHosts( listOfNotNull( tryGetByIp("94.140.14.140"), tryGetByIp("94.140.14.141"), tryGetByIp("2a10:50c0::1:ff"), tryGetByIp("2a10:50c0::2:ff"), ), ).build() DoHProvider.ZERO_MS -> DnsOverHttps.Builder().client(bootstrapClient) .url("https://v.recipes/dns-query".toHttpUrl()) .resolvePublicAddresses(true) .build() } private fun tryGetByIp(ip: String): InetAddress? = try { InetAddress.getByName(ip) } catch (e: UnknownHostException) { e.printStackTraceDebug() null } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/DoHProvider.kt ================================================ package org.koitharu.kotatsu.core.network enum class DoHProvider { NONE, GOOGLE, CLOUDFLARE, ADGUARD, ZERO_MS } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/GZipInterceptor.kt ================================================ package org.koitharu.kotatsu.core.network import okhttp3.Interceptor import okhttp3.MultipartBody import okhttp3.Response import okio.IOException import org.koitharu.kotatsu.core.exceptions.WrapperIOException import org.koitharu.kotatsu.core.network.CommonHeaders.CONTENT_ENCODING class GZipInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response = try { val request = chain.request() if (request.body is MultipartBody) { chain.proceed(request) } else { val newRequest = request.newBuilder() newRequest.addHeader(CONTENT_ENCODING, "gzip") chain.proceed(newRequest.build()) } } catch (e: IOException) { throw e } catch (e: Exception) { throw WrapperIOException(e) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/HttpClients.kt ================================================ package org.koitharu.kotatsu.core.network import javax.inject.Qualifier @Qualifier @Retention(AnnotationRetention.BINARY) annotation class BaseHttpClient @Qualifier @Retention(AnnotationRetention.BINARY) annotation class MangaHttpClient ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/NetworkModule.kt ================================================ package org.koitharu.kotatsu.core.network import android.content.Context 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 okhttp3.Cache import okhttp3.CookieJar import okhttp3.OkHttpClient import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.network.cookies.AndroidCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.PreferencesCookieJar import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor import org.koitharu.kotatsu.core.network.imageproxy.RealImageProxyInterceptor import org.koitharu.kotatsu.core.network.proxy.ProxyProvider import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.assertNotInMainThread import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.local.data.LocalStorageManager import java.util.concurrent.TimeUnit import javax.inject.Provider import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) interface NetworkModule { @Binds fun bindCookieJar(androidCookieJar: MutableCookieJar): CookieJar @Binds fun bindImageProxyInterceptor(impl: RealImageProxyInterceptor): ImageProxyInterceptor companion object { @Provides @Singleton fun provideCookieJar( @ApplicationContext context: Context ): MutableCookieJar = runCatching { AndroidCookieJar() }.getOrElse { e -> e.printStackTraceDebug() // WebView is not available PreferencesCookieJar(context) } @Provides @Singleton fun provideHttpCache( localStorageManager: LocalStorageManager, ): Cache = localStorageManager.createHttpCache() @Provides @Singleton @BaseHttpClient fun provideBaseHttpClient( @ApplicationContext contextProvider: Provider, cache: Cache, cookieJar: CookieJar, settings: AppSettings, proxyProvider: ProxyProvider, ): OkHttpClient = OkHttpClient.Builder().apply { assertNotInMainThread() connectTimeout(20, TimeUnit.SECONDS) readTimeout(60, TimeUnit.SECONDS) writeTimeout(20, TimeUnit.SECONDS) cookieJar(cookieJar) proxySelector(proxyProvider.selector) proxyAuthenticator(proxyProvider.authenticator) dns(DoHManager(cache, settings)) if (settings.isSSLBypassEnabled) { disableCertificateVerification() } else { installExtraCertificates(contextProvider.get()) } cache(cache) addInterceptor(GZipInterceptor()) addInterceptor(CloudFlareInterceptor()) addInterceptor(RateLimitInterceptor()) if (BuildConfig.DEBUG) { addInterceptor(CurlLoggingInterceptor()) } }.build() @Provides @Singleton @MangaHttpClient fun provideMangaHttpClient( @BaseHttpClient baseClient: OkHttpClient, commonHeadersInterceptor: CommonHeadersInterceptor, ): OkHttpClient = baseClient.newBuilder().apply { addNetworkInterceptor(CacheLimitInterceptor()) addInterceptor(commonHeadersInterceptor) }.build() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/RateLimitInterceptor.kt ================================================ package org.koitharu.kotatsu.core.network import okhttp3.Interceptor import okhttp3.Response import okhttp3.internal.closeQuietly import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.util.concurrent.TimeUnit class RateLimitInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val response = chain.proceed(chain.request()) if (response.code == 429) { val request = response.request response.closeQuietly() throw TooManyRequestExceptions( url = request.url.toString(), retryAfter = response.header(CommonHeaders.RETRY_AFTER)?.parseRetryAfter() ?: 0L, ) } return response } private fun String.parseRetryAfter(): Long { return toLongOrNull()?.let { TimeUnit.SECONDS.toMillis(it) } ?: ZonedDateTime.parse(this, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant().toEpochMilli() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/SSLUtils.kt ================================================ package org.koitharu.kotatsu.core.network import android.annotation.SuppressLint import android.content.Context import android.content.res.AssetManager import android.util.Log import okhttp3.OkHttpClient import okhttp3.tls.HandshakeCertificates import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import java.security.SecureRandom import java.security.cert.CertificateFactory import java.security.cert.X509Certificate import javax.net.ssl.SSLContext import javax.net.ssl.SSLSocketFactory import javax.net.ssl.X509TrustManager @SuppressLint("CustomX509TrustManager") fun OkHttpClient.Builder.disableCertificateVerification() = also { builder -> runCatching { val trustAllCerts = object : X509TrustManager { override fun checkClientTrusted(chain: Array, authType: String) = Unit override fun checkServerTrusted(chain: Array, authType: String) = Unit override fun getAcceptedIssuers(): Array = emptyArray() } val sslContext = SSLContext.getInstance("SSL") sslContext.init(null, arrayOf(trustAllCerts), SecureRandom()) val sslSocketFactory: SSLSocketFactory = sslContext.socketFactory builder.sslSocketFactory(sslSocketFactory, trustAllCerts) builder.hostnameVerifier { _, _ -> true } }.onFailure { it.printStackTraceDebug() } } fun OkHttpClient.Builder.installExtraCertificates(context: Context) = also { builder -> val certificatesBuilder = HandshakeCertificates.Builder() .addPlatformTrustedCertificates() val assets = context.assets.list("").orEmpty() for (path in assets) { if (path.endsWith(".pem")) { val cert = loadCert(context, path) ?: continue certificatesBuilder.addTrustedCertificate(cert) } } val certificates = certificatesBuilder.build() builder.sslSocketFactory(certificates.sslSocketFactory(), certificates.trustManager) } private fun loadCert(context: Context, path: String): X509Certificate? = runCatching { val cf = CertificateFactory.getInstance("X.509") context.assets.open(path, AssetManager.ACCESS_STREAMING).use { cf.generateCertificate(it) } as X509Certificate }.onFailure { e -> e.printStackTraceDebug() }.onSuccess { if (BuildConfig.DEBUG) { Log.i("ExtraCerts", "Loaded cert $path") } }.getOrNull() ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/cookies/AndroidCookieJar.kt ================================================ package org.koitharu.kotatsu.core.network.cookies import android.webkit.CookieManager import androidx.annotation.WorkerThread import androidx.core.util.Predicate import okhttp3.Cookie import okhttp3.HttpUrl import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine class AndroidCookieJar : MutableCookieJar { private val cookieManager = CookieManager.getInstance() @WorkerThread override fun loadForRequest(url: HttpUrl): List { val rawCookie = cookieManager.getCookie(url.toString()) ?: return emptyList() return rawCookie.split(';').mapNotNull { Cookie.parse(url, it) } } @WorkerThread override fun saveFromResponse(url: HttpUrl, cookies: List) { if (cookies.isEmpty()) { return } val urlString = url.toString() for (cookie in cookies) { cookieManager.setCookie(urlString, cookie.toString()) } } override fun removeCookies(url: HttpUrl, predicate: Predicate?) { val cookies = loadForRequest(url) if (cookies.isEmpty()) { return } val urlString = url.toString() for (c in cookies) { if (predicate != null && !predicate.test(c)) { continue } val nc = c.newBuilder() .expiresAt(System.currentTimeMillis() - 100000) .build() cookieManager.setCookie(urlString, nc.toString()) } } override suspend fun clear() = suspendCoroutine { continuation -> cookieManager.removeAllCookies(continuation::resume) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/cookies/CookieWrapper.kt ================================================ package org.koitharu.kotatsu.core.network.cookies import android.util.Base64 import okhttp3.Cookie import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.ObjectInputStream import java.io.ObjectOutputStream data class CookieWrapper( val cookie: Cookie, ) { constructor(encodedString: String) : this( ObjectInputStream(ByteArrayInputStream(Base64.decode(encodedString, Base64.NO_WRAP))).use { val name = it.readUTF() val value = it.readUTF() val expiresAt = it.readLong() val domain = it.readUTF() val path = it.readUTF() val secure = it.readBoolean() val httpOnly = it.readBoolean() val persistent = it.readBoolean() val hostOnly = it.readBoolean() Cookie.Builder().also { c -> c.name(name) c.value(value) if (persistent) { c.expiresAt(expiresAt) } if (hostOnly) { c.hostOnlyDomain(domain) } else { c.domain(domain) } c.path(path) if (secure) { c.secure() } if (httpOnly) { c.httpOnly() } }.build() }, ) fun encode(): String { val output = ByteArrayOutputStream() ObjectOutputStream(output).use { it.writeUTF(cookie.name) it.writeUTF(cookie.value) it.writeLong(cookie.expiresAt) it.writeUTF(cookie.domain) it.writeUTF(cookie.path) it.writeBoolean(cookie.secure) it.writeBoolean(cookie.httpOnly) it.writeBoolean(cookie.persistent) it.writeBoolean(cookie.hostOnly) } return Base64.encodeToString(output.toByteArray(), Base64.NO_WRAP) } fun isExpired() = cookie.expiresAt < System.currentTimeMillis() fun key(): String { return (if (cookie.secure) "https" else "http") + "://" + cookie.domain + cookie.path + "|" + cookie.name } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/cookies/MutableCookieJar.kt ================================================ package org.koitharu.kotatsu.core.network.cookies import androidx.annotation.WorkerThread import androidx.core.util.Predicate import okhttp3.Cookie import okhttp3.CookieJar import okhttp3.HttpUrl interface MutableCookieJar : CookieJar { @WorkerThread override fun loadForRequest(url: HttpUrl): List @WorkerThread override fun saveFromResponse(url: HttpUrl, cookies: List) @WorkerThread fun removeCookies(url: HttpUrl, predicate: Predicate?) suspend fun clear(): Boolean } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/cookies/PreferencesCookieJar.kt ================================================ package org.koitharu.kotatsu.core.network.cookies import android.content.Context import androidx.annotation.WorkerThread import androidx.collection.ArrayMap import androidx.core.content.edit import androidx.core.util.Predicate import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.Cookie import okhttp3.HttpUrl import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug private const val PREFS_NAME = "cookies" class PreferencesCookieJar( context: Context, ) : MutableCookieJar { private val cache = ArrayMap() private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) private var isLoaded = false @WorkerThread @Synchronized override fun loadForRequest(url: HttpUrl): List { loadPersistent() val expired = HashSet() val result = ArrayList() for ((key, cookie) in cache) { if (cookie.isExpired()) { expired += key } else if (cookie.cookie.matches(url)) { result += cookie.cookie } } if (expired.isNotEmpty()) { cache.removeAll(expired) removePersistent(expired) } return result } @WorkerThread @Synchronized override fun saveFromResponse(url: HttpUrl, cookies: List) { val wrapped = cookies.map { CookieWrapper(it) } prefs.edit(commit = true) { for (cookie in wrapped) { val key = cookie.key() cache[key] = cookie if (cookie.cookie.persistent) { putString(key, cookie.encode()) } } } } @Synchronized @WorkerThread override fun removeCookies(url: HttpUrl, predicate: Predicate?) { loadPersistent() val toRemove = HashSet() for ((key, cookie) in cache) { if (cookie.isExpired() || cookie.cookie.matches(url)) { if (predicate == null || predicate.test(cookie.cookie)) { toRemove += key } } } if (toRemove.isNotEmpty()) { cache.removeAll(toRemove) removePersistent(toRemove) } } override suspend fun clear(): Boolean { cache.clear() withContext(Dispatchers.IO) { prefs.edit(commit = true) { clear() } } return true } @Synchronized private fun loadPersistent() { if (!isLoaded) { val map = prefs.all cache.ensureCapacity(map.size) for ((k, v) in map) { val cookie = try { CookieWrapper(v as String) } catch (e: Exception) { e.printStackTraceDebug() continue } cache[k] = cookie } isLoaded = true } } private fun removePersistent(keys: Collection) { prefs.edit(commit = true) { for (key in keys) { remove(key) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/imageproxy/BaseImageProxyInterceptor.kt ================================================ package org.koitharu.kotatsu.core.network.imageproxy import android.util.Log import androidx.collection.ArraySet import coil3.intercept.Interceptor import coil3.network.HttpException import coil3.request.ErrorResult import coil3.request.ImageRequest import coil3.request.ImageResult import coil3.request.SuccessResult import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import org.jsoup.HttpStatusException import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException import org.koitharu.kotatsu.core.util.ext.ensureSuccess import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.isHttpOrHttps import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.net.HttpURLConnection import java.util.Collections abstract class BaseImageProxyInterceptor : ImageProxyInterceptor { private val blacklist = Collections.synchronizedSet(ArraySet()) final override suspend fun intercept(chain: Interceptor.Chain): ImageResult { val request = chain.request val url: HttpUrl? = when (val data = request.data) { is HttpUrl -> data is String -> data.toHttpUrlOrNull() else -> null } if (url == null || !url.isHttpOrHttps || url.host in blacklist) { return chain.proceed() } val newRequest = onInterceptImageRequest(request, url) return when (val result = chain.withRequest(newRequest).proceed()) { is SuccessResult -> result is ErrorResult -> { logDebug(result.throwable, newRequest.data) chain.proceed().also { if (it is SuccessResult && result.throwable.isBlockedByServer()) { blacklist.add(url.host) } } } } } final override suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response { val newRequest = onInterceptPageRequest(request) return runCatchingCancellable { okHttp.doCall(newRequest) }.recover { error -> logDebug(error, newRequest.url) okHttp.doCall(request).also { if (error.isBlockedByServer()) { blacklist.add(request.url.host) } } }.getOrThrow() } protected abstract suspend fun onInterceptImageRequest(request: ImageRequest, url: HttpUrl): ImageRequest protected abstract suspend fun onInterceptPageRequest(request: Request): Request private suspend fun OkHttpClient.doCall(request: Request): Response { return newCall(request).await().ensureSuccess() } private fun logDebug(e: Throwable, url: Any) { if (BuildConfig.DEBUG) { Log.w("ImageProxy", "${e.message}: $url", e) } } private fun Throwable.isBlockedByServer(): Boolean { return this is CloudFlareBlockedException || (this is HttpException && response.code == HttpURLConnection.HTTP_FORBIDDEN) || (this is HttpStatusException && statusCode == HttpURLConnection.HTTP_FORBIDDEN) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/imageproxy/ImageProxyInterceptor.kt ================================================ package org.koitharu.kotatsu.core.network.imageproxy import coil3.intercept.Interceptor import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response interface ImageProxyInterceptor : Interceptor { suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/imageproxy/RealImageProxyInterceptor.kt ================================================ package org.koitharu.kotatsu.core.network.imageproxy import coil3.intercept.Interceptor import coil3.request.ImageResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.plus import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.parsers.util.await import javax.inject.Inject import javax.inject.Singleton @Singleton class RealImageProxyInterceptor @Inject constructor( private val settings: AppSettings, ) : ImageProxyInterceptor { private val delegate = settings.observeAsStateFlow( scope = processLifecycleScope + Dispatchers.Default, key = AppSettings.KEY_IMAGES_PROXY, valueProducer = { createDelegate() }, ) override suspend fun intercept(chain: Interceptor.Chain): ImageResult { return delegate.value?.intercept(chain) ?: chain.proceed() } override suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response { return delegate.value?.interceptPageRequest(request, okHttp) ?: okHttp.newCall(request).await() } private fun createDelegate(): ImageProxyInterceptor? = when (val proxy = settings.imagesProxy) { -1 -> null 0 -> WsrvNlProxyInterceptor() 1 -> ZeroMsProxyInterceptor() else -> error("Unsupported images proxy $proxy") } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/imageproxy/WsrvNlProxyInterceptor.kt ================================================ package org.koitharu.kotatsu.core.network.imageproxy import coil3.request.ImageRequest import coil3.size.Dimension import coil3.size.isOriginal import okhttp3.HttpUrl import okhttp3.Request class WsrvNlProxyInterceptor : BaseImageProxyInterceptor() { override suspend fun onInterceptImageRequest(request: ImageRequest, url: HttpUrl): ImageRequest { val newUrl = HttpUrl.Builder() .scheme("https") .host("wsrv.nl") .addQueryParameter("url", url.toString()) .addQueryParameter("we", null) val size = request.sizeResolver.size() if (!size.isOriginal) { newUrl.addQueryParameter("crop", "cover") (size.height as? Dimension.Pixels)?.let { newUrl.addQueryParameter("h", it.toString()) } (size.width as? Dimension.Pixels)?.let { newUrl.addQueryParameter("w", it.toString()) } } return request.newBuilder() .data(newUrl.build()) .build() } override suspend fun onInterceptPageRequest(request: Request): Request { val sourceUrl = request.url val targetUrl = HttpUrl.Builder() .scheme("https") .host("wsrv.nl") .addQueryParameter("url", sourceUrl.toString()) .addQueryParameter("we", null) return request.newBuilder() .url(targetUrl.build()) .build() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/imageproxy/ZeroMsProxyInterceptor.kt ================================================ package org.koitharu.kotatsu.core.network.imageproxy import coil3.request.ImageRequest import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.Request class ZeroMsProxyInterceptor : BaseImageProxyInterceptor() { override suspend fun onInterceptImageRequest(request: ImageRequest, url: HttpUrl): ImageRequest { if (url.host == "v.recipes") { return request } val newUrl = ("https://v.recipes/i/$url").toHttpUrl() return request.newBuilder() .data(newUrl) .build() } override suspend fun onInterceptPageRequest(request: Request): Request { val newUrl = ("https://v.recipes/i/${request.url}").toHttpUrl() return request.newBuilder() .url(newUrl) .build() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/proxy/ProxyProvider.kt ================================================ package org.koitharu.kotatsu.core.network.proxy import androidx.webkit.ProxyConfig import androidx.webkit.ProxyController import androidx.webkit.WebViewFeature import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor import okhttp3.Authenticator import okhttp3.Credentials import okhttp3.Request import okhttp3.Response import okhttp3.Route import okio.IOException import org.koitharu.kotatsu.core.exceptions.ProxyConfigException import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import java.net.InetSocketAddress import java.net.PasswordAuthentication import java.net.Proxy import java.net.ProxySelector import java.net.SocketAddress import java.net.URI import javax.inject.Inject import javax.inject.Singleton import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine import java.net.Authenticator as JavaAuthenticator @Singleton class ProxyProvider @Inject constructor( private val settings: AppSettings, ) { private var cachedProxy: Proxy? = null val selector = object : ProxySelector() { override fun select(uri: URI?): List { return listOf(getProxy()) } override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) { ioe?.printStackTraceDebug() } } val authenticator = ProxyAuthenticator() init { ProxySelector.setDefault(selector) JavaAuthenticator.setDefault(authenticator) } suspend fun applyWebViewConfig() { val isProxyEnabled = isProxyEnabled() if (!WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) { if (isProxyEnabled) { throw IllegalArgumentException("Proxy for WebView is not supported") // TODO localize } } else { val controller = ProxyController.getInstance() if (settings.proxyType == Proxy.Type.DIRECT) { suspendCoroutine { cont -> controller.clearProxyOverride( (cont.context[CoroutineDispatcher] ?: Dispatchers.Main).asExecutor(), ) { cont.resume(Unit) } } } else { val url = buildString { when (settings.proxyType) { Proxy.Type.DIRECT -> Unit Proxy.Type.HTTP -> append("http") Proxy.Type.SOCKS -> append("socks") } append("://") append(settings.proxyAddress) append(':') append(settings.proxyPort) } if (settings.proxyType == Proxy.Type.SOCKS) { System.setProperty("java.net.socks.username", settings.proxyLogin) System.setProperty("java.net.socks.password", settings.proxyPassword) } val proxyConfig = ProxyConfig.Builder() .addProxyRule(url) .build() suspendCoroutine { cont -> controller.setProxyOverride( proxyConfig, (cont.context[CoroutineDispatcher] ?: Dispatchers.Main).asExecutor(), ) { cont.resume(Unit) } } } } } private fun isProxyEnabled() = settings.proxyType != Proxy.Type.DIRECT private fun getProxy(): Proxy { val type = settings.proxyType val address = settings.proxyAddress val port = settings.proxyPort if (type == Proxy.Type.DIRECT) { return Proxy.NO_PROXY } if (address.isNullOrEmpty() || port < 0 || port > 0xFFFF) { throw ProxyConfigException() } cachedProxy?.let { val addr = it.address() as? InetSocketAddress if (addr != null && it.type() == type && addr.port == port && addr.hostString == address) { return it } } val proxy = Proxy(type, InetSocketAddress(address, port)) cachedProxy = proxy return proxy } inner class ProxyAuthenticator : Authenticator, JavaAuthenticator() { override fun authenticate(route: Route?, response: Response): Request? { if (!isProxyEnabled()) { return null } if (response.request.header(CommonHeaders.PROXY_AUTHORIZATION) != null) { return null } val login = settings.proxyLogin ?: return null val password = settings.proxyPassword ?: return null val credential = Credentials.basic(login, password) return response.request.newBuilder() .header(CommonHeaders.PROXY_AUTHORIZATION, credential) .build() } public override fun getPasswordAuthentication(): PasswordAuthentication? { if (!isProxyEnabled()) { return null } val login = settings.proxyLogin ?: return null val password = settings.proxyPassword ?: return null return PasswordAuthentication(login, password.toCharArray()) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/webview/CaptchaContinuationClient.kt ================================================ package org.koitharu.kotatsu.core.network.webview import android.graphics.Bitmap import android.webkit.WebView import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.parsers.network.CloudFlareHelper import kotlin.coroutines.Continuation class CaptchaContinuationClient( private val cookieJar: MutableCookieJar, private val targetUrl: String, continuation: Continuation, ) : ContinuationResumeWebViewClient(continuation) { private val oldClearance = CloudFlareHelper.getClearanceCookie(cookieJar, targetUrl) override fun onPageFinished(view: WebView?, url: String?) = Unit override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { super.onPageStarted(view, url, favicon) checkClearance(view) } private fun checkClearance(view: WebView?) { val clearance = CloudFlareHelper.getClearanceCookie(cookieJar, targetUrl) if (clearance != null && clearance != oldClearance) { resumeContinuation(view) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/webview/ContinuationResumeWebViewClient.kt ================================================ package org.koitharu.kotatsu.core.network.webview import android.webkit.WebView import android.webkit.WebViewClient import kotlinx.coroutines.CancellableContinuation import kotlin.coroutines.Continuation import kotlin.coroutines.resume open class ContinuationResumeWebViewClient( private val continuation: Continuation, ) : WebViewClient() { override fun onPageFinished(view: WebView?, url: String?) { resumeContinuation(view) } protected fun resumeContinuation(view: WebView?) { if (continuation !is CancellableContinuation || continuation.isActive) { view?.webViewClient = WebViewClient() // reset to default continuation.resume(Unit) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/webview/WebViewExecutor.kt ================================================ package org.koitharu.kotatsu.core.network.webview import android.content.Context import android.util.AndroidRuntimeException import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient import androidx.annotation.MainThread import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import org.koitharu.kotatsu.core.exceptions.CloudFlareException import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.proxy.ProxyProvider import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.util.ext.configureForParser import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.lang.ref.WeakReference import javax.inject.Inject import javax.inject.Provider import javax.inject.Singleton import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine @Singleton class WebViewExecutor @Inject constructor( @ApplicationContext private val context: Context, private val proxyProvider: ProxyProvider, private val cookieJar: MutableCookieJar, private val mangaRepositoryFactoryProvider: Provider, ) { private var webViewCached: WeakReference? = null private val mutex = Mutex() val defaultUserAgent: String? by lazy { try { WebSettings.getDefaultUserAgent(context) } catch (e: AndroidRuntimeException) { e.printStackTraceDebug() // Probably WebView is not available null } } suspend fun evaluateJs(baseUrl: String?, script: String): String? = mutex.withLock { withContext(Dispatchers.Main.immediate) { val webView = obtainWebView() try { if (!baseUrl.isNullOrEmpty()) { suspendCoroutine { cont -> webView.webViewClient = ContinuationResumeWebViewClient(cont) webView.loadDataWithBaseURL(baseUrl, " ", "text/html", null, null) } } suspendCoroutine { cont -> webView.evaluateJavascript(script) { result -> cont.resume(result?.takeUnless { it == "null" }) } } } finally { webView.reset() } } } suspend fun tryResolveCaptcha(exception: CloudFlareException, timeout: Long): Boolean = mutex.withLock { runCatchingCancellable { withContext(Dispatchers.Main.immediate) { val webView = obtainWebView() try { exception.source.getUserAgent()?.let { webView.settings.userAgentString = it } withTimeout(timeout) { suspendCancellableCoroutine { cont -> webView.webViewClient = CaptchaContinuationClient( cookieJar = cookieJar, targetUrl = exception.url, continuation = cont, ) webView.loadUrl(exception.url) } } } finally { webView.reset() } } }.onFailure { e -> exception.addSuppressed(e) e.printStackTraceDebug() }.isSuccess } private suspend fun obtainWebView(): WebView { webViewCached?.get()?.let { return it } return withContext(Dispatchers.Main.immediate) { webViewCached?.get()?.let { return@withContext it } WebView(context).also { it.configureForParser(null) webViewCached = WeakReference(it) proxyProvider.applyWebViewConfig() it.onResume() it.resumeTimers() } } } private fun MangaSource.getUserAgent(): String? { val repository = mangaRepositoryFactoryProvider.get().create(this) as? ParserMangaRepository return repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT) } @MainThread private fun WebView.reset() { stopLoading() webViewClient = WebViewClient() settings.userAgentString = defaultUserAgent loadDataWithBaseURL(null, " ", "text/html", null, null) clearHistory() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/webview/adblock/AdBlock.kt ================================================ package org.koitharu.kotatsu.core.network.webview.adblock import android.content.Context import android.util.Log import androidx.annotation.WorkerThread import dagger.Reusable import dagger.hilt.android.qualifiers.ApplicationContext import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient import okhttp3.Request import okio.sink import org.koitharu.kotatsu.core.network.BaseHttpClient import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.isNotEmpty import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.requireBody import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.io.File import java.net.HttpURLConnection import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import javax.inject.Inject @Reusable class AdBlock @Inject constructor( @ApplicationContext private val context: Context, private val settings: AppSettings, ) { private var rules: RulesList? = null @WorkerThread fun shouldLoadUrl(url: String, baseUrl: String?): Boolean { return shouldLoadUrl( url.lowercase().toHttpUrlOrNull() ?: return true, baseUrl?.lowercase()?.toHttpUrlOrNull(), ) } @WorkerThread fun shouldLoadUrl(url: HttpUrl, baseUrl: HttpUrl?): Boolean { if (!settings.isAdBlockEnabled) { return true } return synchronized(this) { rules ?: parseRules().also { rules = it } }?.let { val rule = it[url, baseUrl] if (rule != null) { Log.i(TAG, "Blocked $url by $rule") } rule == null } ?: true } @WorkerThread private fun parseRules() = runCatchingCancellable { listFile(context).useLines { lines -> val rules = RulesList() lines.forEach { line -> rules.add(line) } rules.trimToSize() rules } }.onFailure { e -> e.printStackTraceDebug() }.getOrNull() class Updater @Inject constructor( @ApplicationContext private val context: Context, @BaseHttpClient private val okHttpClient: OkHttpClient, ) { suspend fun updateList() { val file = listFile(context) val dateFormat = SimpleDateFormat(CommonHeaders.DATE_FORMAT, Locale.ENGLISH) val requestBuilder = Request.Builder() .url(EASYLIST_URL) .get() if (file.exists() && file.isNotEmpty()) { val lastModified = file.lastModified() requestBuilder.header(CommonHeaders.IF_MODIFIED_SINCE, dateFormat.format(Date(lastModified))) } okHttpClient.newCall( requestBuilder.build(), ).await().use { response -> if (response.code == HttpURLConnection.HTTP_NOT_MODIFIED) { return } val lastModified = response.header(CommonHeaders.LAST_MODIFIED)?.let { runCatching { dateFormat.parse(it) }.getOrNull() }?.time ?: System.currentTimeMillis() response.requireBody().source().use { source -> file.sink().use { sink -> source.readAll(sink) } file.setLastModified(lastModified) } } } } private companion object { fun listFile(context: Context): File { val root = File(context.externalCacheDir ?: context.cacheDir, LIST_DIR) root.mkdir() return File(root, LIST_FILENAME) } private const val LIST_FILENAME = "easylist.txt" private const val LIST_DIR = "adblock" private const val EASYLIST_URL = "https://easylist.to/easylist/easylist.txt" private const val TAG = "AdBlock" } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/webview/adblock/CSSRuleBuilder.kt ================================================ package org.koitharu.kotatsu.core.network.webview.adblock import androidx.collection.ArraySet class CSSRuleBuilder { private val selectors = ArraySet() fun add(selector: String) { selectors.add(selector) } fun build() = buildString { append("") } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/webview/adblock/Rule.kt ================================================ package org.koitharu.kotatsu.core.network.webview.adblock import okhttp3.HttpUrl sealed interface Rule { operator fun invoke(url: HttpUrl, baseUrl: HttpUrl?): Boolean data class Domain(private val domain: String) : Rule { override fun invoke(url: HttpUrl, baseUrl: HttpUrl?): Boolean = (url.topPrivateDomain() ?: url.host) == domain } data class ExactUrl(private val url: HttpUrl) : Rule { override operator fun invoke(url: HttpUrl, baseUrl: HttpUrl?): Boolean = url == this.url } data class Path(private val path: String, private val contains: Boolean) : Rule { override fun invoke(url: HttpUrl, baseUrl: HttpUrl?): Boolean { val fullPath = url.host + "/" + url.encodedPath return if (contains) { fullPath.contains(path) } else { fullPath.endsWith(path) } } } data class WithModifiers( private val baseRule: Rule, private val script: Boolean?, private val thirdParty: Boolean?, private val domains: Set?, private val domainsNot: Set?, ) : Rule { override fun invoke(url: HttpUrl, baseUrl: HttpUrl?): Boolean { if (!baseRule.invoke(url, baseUrl)) { return false } if (baseUrl == null) { return true } thirdParty?.let { val isThirdPartyRequest = (url.topPrivateDomain() ?: url.host) != (baseUrl.topPrivateDomain() ?: baseUrl.host) if (isThirdPartyRequest != it) { return false } } // TODO check other modifiers return true } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/network/webview/adblock/RulesList.kt ================================================ package org.koitharu.kotatsu.core.network.webview.adblock import androidx.annotation.CheckResult import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull /** * Very simple implementation of adblock list parser * Not all features are supported */ class RulesList { private val blockRules = ArrayList() private val allowRules = ArrayList() operator fun get(url: HttpUrl, baseUrl: HttpUrl?): Rule? { val rule = blockRules.find { x -> x(url, baseUrl) } return rule?.takeIf { allowRules.none { x -> x(url, baseUrl) } } } fun add(line: String) { val parts = line.lowercase().trim().split('$') parts.first().addImpl(isWhitelist = false, modifiers = parts.getOrNull(1)) } fun trimToSize() { blockRules.trimToSize() allowRules.trimToSize() } private fun String.addImpl(isWhitelist: Boolean, modifiers: String?) { val list = if (isWhitelist) allowRules else blockRules when { startsWith('!') || startsWith('[') -> { // Comment, do nothing } startsWith("||") -> { // domain list += Rule.Domain(substring(2).substringBefore('^').trim()).withModifiers(modifiers) } startsWith('|') -> { val url = substring(1).substringBefore('^').trim().toHttpUrlOrNull() if (url != null) { list += Rule.ExactUrl(url).withModifiers(modifiers) } } startsWith("@@") -> { substring(2).substringBefore('^').trim().addImpl(!isWhitelist, modifiers) } startsWith("##") -> { // TODO css rules } else -> { if (endsWith('*')) { list += Rule.Path(this.dropLast(1), contains = true).withModifiers(modifiers) } else if (!contains('*')) { // wildcards is not supported yet list += Rule.Path(this, contains = false).withModifiers(modifiers) } } } } @CheckResult private fun Rule.withModifiers(options: String?): Rule { if (options.isNullOrEmpty()) { return this } var script: Boolean? = null var thirdParty: Boolean? = null options.split(',').forEach { val isNot = it.startsWith('~') when (it.removePrefix("~")) { "script" -> script = !isNot "third-party" -> thirdParty = !isNot } } return Rule.WithModifiers( baseRule = this, script = script, thirdParty = thirdParty, domains = null, //TODO domainsNot = null, //TODO ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManager.kt ================================================ package org.koitharu.kotatsu.core.os import android.content.Context import android.content.SharedPreferences import android.content.pm.ShortcutManager import android.os.Build import androidx.annotation.VisibleForTesting import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.toBitmap import androidx.room.InvalidationTracker import coil3.ImageLoader import coil3.request.ImageRequest import coil3.request.transformations import coil3.size.Scale import coil3.size.Size import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.LocalizedAppContext import org.koitharu.kotatsu.core.db.TABLE_HISTORY import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.ReaderIntent import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.image.ThumbnailTransformation import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow 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.history.data.HistoryRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.parsers.util.mapNotNullToSet import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import javax.inject.Inject import javax.inject.Singleton @Singleton class AppShortcutManager @Inject constructor( @LocalizedAppContext private val context: Context, private val coil: ImageLoader, private val historyRepository: HistoryRepository, private val mangaRepository: MangaDataRepository, private val settings: AppSettings, ) : InvalidationTracker.Observer(TABLE_HISTORY), SharedPreferences.OnSharedPreferenceChangeListener { private val iconSize by lazy { Size(ShortcutManagerCompat.getIconMaxWidth(context), ShortcutManagerCompat.getIconMaxHeight(context)) } private var shortcutsUpdateJob: Job? = null init { settings.subscribe(this) } override fun onInvalidated(tables: Set) { if (!settings.isDynamicShortcutsEnabled) { return } val prevJob = shortcutsUpdateJob shortcutsUpdateJob = processLifecycleScope.launch(Dispatchers.Default) { prevJob?.join() updateShortcutsImpl() } } override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { if (key == AppSettings.KEY_SHORTCUTS) { if (settings.isDynamicShortcutsEnabled) { onInvalidated(emptySet()) } else { clearShortcuts() } } } suspend fun requestPinShortcut(manga: Manga): Boolean = try { ShortcutManagerCompat.requestPinShortcut(context, buildShortcutInfo(manga), null) } catch (e: IllegalStateException) { e.printStackTraceDebug() false } suspend fun requestPinShortcut(source: MangaSource): Boolean = try { ShortcutManagerCompat.requestPinShortcut(context, buildShortcutInfo(source), null) } catch (e: IllegalStateException) { e.printStackTraceDebug() false } fun getMangaShortcuts(): Set { val shortcuts = ShortcutManagerCompat.getShortcuts( context, ShortcutManagerCompat.FLAG_MATCH_CACHED or ShortcutManagerCompat.FLAG_MATCH_PINNED or ShortcutManagerCompat.FLAG_MATCH_DYNAMIC, ) return shortcuts.mapNotNullToSet { it.id.toLongOrNull() } } @VisibleForTesting suspend fun await(): Boolean { return shortcutsUpdateJob?.join() != null } fun notifyMangaOpened(mangaId: Long) { ShortcutManagerCompat.reportShortcutUsed(context, mangaId.toString()) } fun isDynamicShortcutsAvailable(): Boolean { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 && context.getSystemService(ShortcutManager::class.java).maxShortcutCountPerActivity > 0 } private suspend fun updateShortcutsImpl() = runCatchingCancellable { val maxShortcuts = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context).coerceAtLeast(5) val shortcuts = historyRepository.getList(0, maxShortcuts) .filter { x -> x.title.isNotEmpty() } .map { buildShortcutInfo(it) } ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts) }.onFailure { it.printStackTraceDebug() } private fun clearShortcuts() { try { ShortcutManagerCompat.removeAllDynamicShortcuts(context) } catch (_: IllegalStateException) { } } private suspend fun buildShortcutInfo(manga: Manga): ShortcutInfoCompat = withContext(Dispatchers.Default) { val icon = runCatchingCancellable { coil.execute( ImageRequest.Builder(context) .data(manga.coverUrl) .size(iconSize) .mangaSourceExtra(manga.source) .scale(Scale.FILL) .transformations(ThumbnailTransformation()) .build(), ).getDrawableOrThrow().toBitmap() }.fold( onSuccess = { IconCompat.createWithAdaptiveBitmap(it) }, onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) }, ) mangaRepository.storeManga(manga, replaceExisting = true) val title = manga.title.ifEmpty { manga.altTitles.firstOrNull() }.ifNullOrEmpty { context.getString(R.string.unknown) } ShortcutInfoCompat.Builder(context, manga.id.toString()) .setShortLabel(title) .setLongLabel(title) .setIcon(icon) .setLongLived(true) .setIntent( ReaderIntent.Builder(context) .mangaId(manga.id) .build() .intent, ).build() } private suspend fun buildShortcutInfo(source: MangaSource): ShortcutInfoCompat = withContext(Dispatchers.Default) { val icon = runCatchingCancellable { coil.execute( ImageRequest.Builder(context) .data(source.faviconUri()) .mangaSourceExtra(source) .size(iconSize) .scale(Scale.FIT) .build(), ).getDrawableOrThrow().toBitmap() }.fold( onSuccess = { IconCompat.createWithAdaptiveBitmap(it) }, onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) }, ) val title = source.getTitle(context) ShortcutInfoCompat.Builder(context, source.name) .setShortLabel(title) .setLongLabel(title) .setIcon(icon) .setLongLived(true) .setIntent(AppRouter.listIntent(context, source, null, null)) .build() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppValidator.kt ================================================ package org.koitharu.kotatsu.core.os import android.annotation.SuppressLint import android.content.Context import android.content.pm.PackageManager import androidx.core.content.pm.PackageInfoCompat import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy import javax.inject.Inject import javax.inject.Singleton @Singleton class AppValidator @Inject constructor( @ApplicationContext private val context: Context, ) { @SuppressLint("InlinedApi") val isOriginalApp = suspendLazy(Dispatchers.Default) { val certificates = mapOf(CERT_SHA256.hexToByteArray() to PackageManager.CERT_INPUT_SHA256) PackageInfoCompat.hasSignatures(context.packageManager, context.packageName, certificates, false) } private companion object { private const val CERT_SHA256 = "67e15100bb809301783edcb6348fa3bbf83034d91e62868a91053dbd70db3f18" } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/os/NetworkManageIntent.kt ================================================ package org.koitharu.kotatsu.core.os import android.content.Intent import android.os.Build import android.provider.Settings @Suppress("FunctionName") fun NetworkManageIntent(): Intent { val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { Settings.Panel.ACTION_INTERNET_CONNECTIVITY } else { Settings.ACTION_WIRELESS_SETTINGS } return Intent(action) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/os/NetworkState.kt ================================================ package org.koitharu.kotatsu.core.os import android.net.ConnectivityManager import android.net.ConnectivityManager.NetworkCallback import android.net.ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest import android.os.Build import coil3.network.ConnectivityChecker import kotlinx.coroutines.flow.first import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.MediatorStateFlow class NetworkState( private val connectivityManager: ConnectivityManager, private val settings: AppSettings, ) : MediatorStateFlow(connectivityManager.isOnline(settings)), ConnectivityChecker { private val callback = NetworkCallbackImpl() override val value: Boolean get() = connectivityManager.isOnline(settings) override fun isOnline(): Boolean { return connectivityManager.isOnline(settings) } @Synchronized override fun onActive() { invalidate() val request = NetworkRequest.Builder() .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) .addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET) .addTransportType(NetworkCapabilities.TRANSPORT_VPN) .build() connectivityManager.registerNetworkCallback(request, callback) } @Synchronized override fun onInactive() { connectivityManager.unregisterNetworkCallback(callback) } fun isMetered(): Boolean { return connectivityManager.isActiveNetworkMetered } fun isDataSaverEnabled(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && connectivityManager.restrictBackgroundStatus == RESTRICT_BACKGROUND_STATUS_ENABLED fun isRestricted() = isMetered() && isDataSaverEnabled() fun isOfflineOrRestricted() = !isOnline() || isRestricted() suspend fun awaitForConnection() { if (value) { return } first { it } } private fun invalidate() { publishValue(connectivityManager.isOnline(settings)) } private inner class NetworkCallbackImpl : NetworkCallback() { override fun onAvailable(network: Network) = invalidate() override fun onLost(network: Network) = invalidate() override fun onUnavailable() = invalidate() } private companion object { fun ConnectivityManager.isOnline(settings: AppSettings): Boolean { if (settings.isOfflineCheckDisabled) { return true } return activeNetwork?.let { isOnline(it) } == true } private fun ConnectivityManager.isOnline(network: Network): Boolean { val capabilities = getNetworkCapabilities(network) ?: return false return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) || capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/os/OpenDocumentTreeHelper.kt ================================================ package org.koitharu.kotatsu.core.os import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build import android.os.storage.StorageManager import android.provider.DocumentsContract import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultCaller import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.core.app.ActivityOptionsCompat // https://stackoverflow.com/questions/77555641/saf-no-activity-found-to-handle-intent-android-intent-action-open-document-tr class OpenDocumentTreeHelper( activityResultCaller: ActivityResultCaller, flags: Int, callback: ActivityResultCallback ) : ActivityResultLauncher() { constructor(activityResultCaller: ActivityResultCaller, callback: ActivityResultCallback) : this( activityResultCaller, 0, callback, ) private val pickFileTreeLauncherPrimaryStorage = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { activityResultCaller.registerForActivityResult(OpenDocumentTreeContractPrimaryStorage(flags), callback) } else { null } private val pickFileTreeLauncherDefault = activityResultCaller.registerForActivityResult( contract = OpenDocumentTreeContractDefault(flags), callback = callback, ) override fun launch(input: Uri?, options: ActivityOptionsCompat?) { try { pickFileTreeLauncherDefault.launch(input, options) } catch (e: Exception) { if (pickFileTreeLauncherPrimaryStorage != null) { try { pickFileTreeLauncherPrimaryStorage.launch(input, options) } catch (e2: Exception) { e.addSuppressed(e2) throw e } } else { throw e } } } override fun unregister() { pickFileTreeLauncherPrimaryStorage?.unregister() pickFileTreeLauncherDefault.unregister() } override val contract: ActivityResultContract get() = pickFileTreeLauncherPrimaryStorage?.contract ?: pickFileTreeLauncherDefault.contract private open class OpenDocumentTreeContractDefault( private val flags: Int, ) : ActivityResultContracts.OpenDocumentTree() { override fun createIntent(context: Context, input: Uri?): Intent { val intent = super.createIntent(context, input) intent.addFlags(flags) return intent } } @RequiresApi(Build.VERSION_CODES.Q) private class OpenDocumentTreeContractPrimaryStorage( private val flags: Int, ) : OpenDocumentTreeContractDefault(flags) { override fun createIntent(context: Context, input: Uri?): Intent { val intent = (context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager) ?.primaryStorageVolume ?.createOpenDocumentTreeIntent() if (intent == null) { // fallback return super.createIntent(context, input) } intent.addFlags(flags) if (input != null) { intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, input) } return intent } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/os/RomCompat.kt ================================================ package org.koitharu.kotatsu.core.os import kotlinx.coroutines.Dispatchers import org.jetbrains.annotations.Blocking import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy import java.io.InputStreamReader object RomCompat { val isMiui = suspendLazy(Dispatchers.IO) { getProp("ro.miui.ui.version.name").isNotEmpty() } @Blocking private fun getProp(propName: String) = Runtime.getRuntime().exec("getprop $propName").inputStream.use { it.reader().use(InputStreamReader::readText).trim() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/os/VoiceInputContract.kt ================================================ package org.koitharu.kotatsu.core.os import android.app.Activity import android.content.Context import android.content.Intent import android.speech.RecognizerIntent import androidx.activity.result.contract.ActivityResultContract import androidx.core.os.ConfigurationCompat import java.util.Locale class VoiceInputContract : ActivityResultContract() { override fun createIntent(context: Context, input: String?): Intent { val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH) intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) val locale = ConfigurationCompat.getLocales(context.resources.configuration).get(0) ?: Locale.getDefault() intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, locale.toLanguageTag()) intent.putExtra(RecognizerIntent.EXTRA_PROMPT, input) return intent } override fun parseResult(resultCode: Int, intent: Intent?): String? { return if (resultCode == Activity.RESULT_OK && intent != null) { val matches = intent.getStringArrayExtra(RecognizerIntent.EXTRA_RESULTS) matches?.firstOrNull() } else { null } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/parser/BitmapWrapper.kt ================================================ package org.koitharu.kotatsu.core.parser import android.graphics.Canvas import androidx.core.graphics.createBitmap import org.koitharu.kotatsu.parsers.bitmap.Bitmap import org.koitharu.kotatsu.parsers.bitmap.Rect import java.io.OutputStream import android.graphics.Bitmap as AndroidBitmap import android.graphics.Rect as AndroidRect class BitmapWrapper private constructor( private val androidBitmap: AndroidBitmap, ) : Bitmap, AutoCloseable { private val canvas by lazy { Canvas(androidBitmap) } // is not always used, so initialized lazily override val height: Int get() = androidBitmap.height override val width: Int get() = androidBitmap.width override fun drawBitmap(sourceBitmap: Bitmap, src: Rect, dst: Rect) { val androidSourceBitmap = (sourceBitmap as BitmapWrapper).androidBitmap canvas.drawBitmap(androidSourceBitmap, src.toAndroidRect(), dst.toAndroidRect(), null) } override fun close() { androidBitmap.recycle() } fun compressTo(output: OutputStream) { androidBitmap.compress(AndroidBitmap.CompressFormat.PNG, 100, output) } companion object { fun create(width: Int, height: Int) = BitmapWrapper( createBitmap(width, height, AndroidBitmap.Config.ARGB_8888), ) fun create(bitmap: AndroidBitmap) = BitmapWrapper( if (bitmap.isMutable) bitmap else bitmap.copy(AndroidBitmap.Config.ARGB_8888, true), ) private fun Rect.toAndroidRect() = AndroidRect(left, top, right, bottom) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/parser/CachingMangaRepository.kt ================================================ package org.koitharu.kotatsu.core.parser import android.util.Log import androidx.collection.MutableLongSet import coil3.request.CachePolicy import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainCoroutineDispatcher import kotlinx.coroutines.async import kotlinx.coroutines.currentCoroutineContext import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.cache.MemoryContentCache import org.koitharu.kotatsu.core.cache.SafeDeferred import org.koitharu.kotatsu.core.util.MultiMutex import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.util.runCatchingCancellable abstract class CachingMangaRepository( private val cache: MemoryContentCache, ) : MangaRepository { private val detailsMutex = MultiMutex() private val relatedMangaMutex = MultiMutex() private val pagesMutex = MultiMutex() final override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED) final override suspend fun getPages(chapter: MangaChapter): List = pagesMutex.withLock(chapter.id) { cache.getPages(source, chapter.url)?.let { return it } val pages = asyncSafe { getPagesImpl(chapter).distinctById() } cache.putPages(source, chapter.url, pages) pages }.await() final override suspend fun getRelated(seed: Manga): List = relatedMangaMutex.withLock(seed.id) { cache.getRelatedManga(source, seed.url)?.let { return it } val related = asyncSafe { getRelatedMangaImpl(seed).filterNot { it.id == seed.id } } cache.putRelatedManga(source, seed.url, related) related }.await() suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga = detailsMutex.withLock(manga.id) { if (cachePolicy.readEnabled) { cache.getDetails(source, manga.url)?.let { return it } } val details = asyncSafe { getDetailsImpl(manga) } if (cachePolicy.writeEnabled) { cache.putDetails(source, manga.url, details) } details }.await() suspend fun peekDetails(manga: Manga): Manga? { return cache.getDetails(source, manga.url) } fun invalidateCache() { cache.clear(source) } protected abstract suspend fun getDetailsImpl(manga: Manga): Manga protected abstract suspend fun getRelatedMangaImpl(seed: Manga): List protected abstract suspend fun getPagesImpl(chapter: MangaChapter): List private suspend fun asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred { var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key] if (dispatcher == null || dispatcher is MainCoroutineDispatcher) { dispatcher = Dispatchers.Default } return SafeDeferred( processLifecycleScope.async(dispatcher) { runCatchingCancellable { block() } }, ) } private fun List.distinctById(): List { if (isEmpty()) { return emptyList() } val result = ArrayList(size) val set = MutableLongSet(size) for (page in this) { if (set.add(page.id)) { result.add(page) } else if (BuildConfig.DEBUG) { Log.w(null, "Duplicate page: $page") } } return result } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/parser/EmptyMangaRepository.kt ================================================ package org.koitharu.kotatsu.core.parser import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException 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.MangaSource import org.koitharu.kotatsu.parsers.model.SortOrder import java.util.EnumSet open class EmptyMangaRepository(override val source: MangaSource) : MangaRepository { override val sortOrders: Set get() = EnumSet.allOf(SortOrder::class.java) override var defaultSortOrder: SortOrder get() = SortOrder.NEWEST set(value) = Unit override val filterCapabilities: MangaListFilterCapabilities get() = MangaListFilterCapabilities() override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List = stub(null) override suspend fun getDetails(manga: Manga): Manga = stub(manga) override suspend fun getPages(chapter: MangaChapter): List = stub(null) override suspend fun getPageUrl(page: MangaPage): String = stub(null) override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null) override suspend fun getRelated(seed: Manga): List = stub(seed) private fun stub(manga: Manga?): Nothing { throw UnsupportedSourceException("This manga source is not supported", manga) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaDataRepository.kt ================================================ package org.koitharu.kotatsu.core.parser import androidx.collection.LongObjectMap import androidx.collection.MutableLongObjectMap import androidx.core.net.toUri import androidx.room.withTransaction import dagger.Reusable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES import org.koitharu.kotatsu.core.db.TABLE_PREFERENCES import org.koitharu.kotatsu.core.db.entity.ContentRating import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity 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.db.entity.toMangaChapters import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.nav.MangaIntent import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.ui.model.MangaOverride import org.koitharu.kotatsu.core.util.ext.toFileOrNull import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.reader.domain.ReaderColorFilter import javax.inject.Inject import javax.inject.Provider @Reusable class MangaDataRepository @Inject constructor( private val db: MangaDatabase, private val resolverProvider: Provider, private val appShortcutManagerProvider: Provider, ) { suspend fun saveReaderMode(manga: Manga, mode: ReaderMode) { db.withTransaction { storeManga(manga, replaceExisting = false) val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id) db.getPreferencesDao().upsert(entity.copy(mode = mode.id)) } } suspend fun saveColorFilter(manga: Manga, colorFilter: ReaderColorFilter?) { db.withTransaction { storeManga(manga, replaceExisting = false) val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id) db.getPreferencesDao().upsert( entity.copy( cfBrightness = colorFilter?.brightness ?: 0f, cfContrast = colorFilter?.contrast ?: 0f, cfInvert = colorFilter?.isInverted == true, cfGrayscale = colorFilter?.isGrayscale == true, ), ) } } suspend fun resetColorFilters() { db.getPreferencesDao().resetColorFilters() } suspend fun getReaderMode(mangaId: Long): ReaderMode? { return db.getPreferencesDao().find(mangaId)?.let { ReaderMode.valueOf(it.mode) } } suspend fun getColorFilter(mangaId: Long): ReaderColorFilter? { return db.getPreferencesDao().find(mangaId)?.getColorFilterOrNull() } suspend fun getOverride(mangaId: Long): MangaOverride? { return db.getPreferencesDao().find(mangaId)?.getOverrideOrNull() } suspend fun getOverrides(): LongObjectMap { val entities = db.getPreferencesDao().getOverrides() val map = MutableLongObjectMap(entities.size) for (entity in entities) { map[entity.mangaId] = entity.getOverrideOrNull() ?: continue } return map } suspend fun setOverride(manga: Manga, override: MangaOverride?) { db.withTransaction { storeManga(manga, replaceExisting = false) val dao = db.getPreferencesDao() val entity = dao.find(manga.id) ?: newEntity(manga.id) dao.upsert( entity.copy( titleOverride = override?.title?.nullIfEmpty(), coverUrlOverride = override?.coverUrl?.nullIfEmpty(), contentRatingOverride = override?.contentRating?.name, ), ) } } fun observeColorFilter(mangaId: Long): Flow { return db.getPreferencesDao().observe(mangaId) .map { it?.getColorFilterOrNull() } .distinctUntilChanged() } suspend fun findMangaById(mangaId: Long, withChapters: Boolean): Manga? { val chapters = if (withChapters) { db.getChaptersDao().findAll(mangaId).takeUnless { it.isEmpty() } } else { null } return db.getMangaDao().find(mangaId)?.toManga(chapters) } suspend fun findMangaByPublicUrl(publicUrl: String): Manga? { return db.getMangaDao().findByPublicUrl(publicUrl)?.toManga() } suspend fun resolveIntent(intent: MangaIntent, withChapters: Boolean): Manga? = when { intent.manga != null -> intent.manga.withCachedChaptersIfNeeded(withChapters) intent.mangaId != 0L -> findMangaById(intent.mangaId, withChapters) intent.uri != null -> resolverProvider.get().resolve(intent.uri).withCachedChaptersIfNeeded(withChapters) else -> null } suspend fun storeManga(manga: Manga, replaceExisting: Boolean) { if (!replaceExisting && db.getMangaDao().find(manga.id) != null) { return } db.withTransaction { // avoid storing local manga if remote one is already stored val existing = if (manga.isLocal) { db.getMangaDao().find(manga.id)?.manga } else { null } if (existing == null || existing.source == manga.source.name) { val tags = manga.tags.toEntities() db.getTagsDao().upsert(tags) db.getMangaDao().upsert(manga.toEntity(), tags) if (!manga.isLocal) { manga.chapters?.let { chapters -> db.getChaptersDao().replaceAll(manga.id, chapters.withIndex().toEntities(manga.id)) } } } } } suspend fun updateChapters(manga: Manga) { val chapters = manga.chapters if (!chapters.isNullOrEmpty() && manga.id in db.getMangaDao()) { db.getChaptersDao().replaceAll(manga.id, chapters.withIndex().toEntities(manga.id)) } } suspend fun gcChaptersCache() { db.getChaptersDao().gc() } suspend fun findTags(source: MangaSource): Set { return db.getTagsDao().findTags(source.name).toMangaTags() } suspend fun cleanupLocalManga() { val dao = db.getMangaDao() val broken = dao.findAllBySource(LocalMangaSource.name) .filter { x -> x.manga.url.toUri().toFileOrNull()?.exists() == false } if (broken.isNotEmpty()) { dao.delete(broken.map { it.manga }) } } suspend fun cleanupDatabase() { db.withTransaction { gcChaptersCache() val idsFromShortcuts = appShortcutManagerProvider.get().getMangaShortcuts() db.getMangaDao().cleanup(idsFromShortcuts) } } fun observeOverridesTrigger(emitInitialState: Boolean) = db.invalidationTracker.createFlow( tables = arrayOf(TABLE_PREFERENCES), emitInitialState = emitInitialState, ) fun observeFavoritesTrigger(emitInitialState: Boolean) = db.invalidationTracker.createFlow( tables = arrayOf(TABLE_FAVOURITES, TABLE_FAVOURITE_CATEGORIES), emitInitialState = emitInitialState, ) private suspend fun Manga.withCachedChaptersIfNeeded(flag: Boolean): Manga = if (flag && !isLocal && chapters.isNullOrEmpty()) { val cachedChapters = db.getChaptersDao().findAll(id) if (cachedChapters.isEmpty()) { this } else { copy(chapters = cachedChapters.toMangaChapters()) } } else { this } private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? { return if (cfBrightness != 0f || cfContrast != 0f || cfInvert || cfGrayscale || cfBookEffect) { ReaderColorFilter( brightness = cfBrightness, contrast = cfContrast, isInverted = cfInvert, isGrayscale = cfGrayscale, isBookBackground = cfBookEffect ) } else { null } } private fun MangaPrefsEntity.getOverrideOrNull(): MangaOverride? { return if (titleOverride.isNullOrEmpty() && coverUrlOverride.isNullOrEmpty() && contentRatingOverride.isNullOrEmpty()) { null } else { MangaOverride( coverUrl = coverUrlOverride?.nullIfEmpty(), title = titleOverride?.nullIfEmpty(), contentRating = ContentRating(contentRatingOverride), ) } } private fun newEntity(mangaId: Long) = MangaPrefsEntity( mangaId = mangaId, mode = -1, cfBrightness = ReaderColorFilter.EMPTY.brightness, cfContrast = ReaderColorFilter.EMPTY.contrast, cfInvert = ReaderColorFilter.EMPTY.isInverted, cfGrayscale = ReaderColorFilter.EMPTY.isGrayscale, cfBookEffect = ReaderColorFilter.EMPTY.isBookBackground, titleOverride = null, coverUrlOverride = null, contentRatingOverride = null, ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLinkResolver.kt ================================================ package org.koitharu.kotatsu.core.parser import android.net.Uri import coil3.request.CachePolicy import dagger.Reusable import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.UnknownMangaSource import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.util.ext.isHttpUrl import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.almostEquals import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.parsers.util.levenshteinDistance import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import javax.inject.Inject @Reusable class MangaLinkResolver @Inject constructor( private val repositoryFactory: MangaRepository.Factory, private val dataRepository: MangaDataRepository, private val context: MangaLoaderContext, ) { suspend fun resolve(uri: Uri): Manga { return if (uri.scheme == "kotatsu" || uri.host == "kotatsu.app") { resolveAppLink(uri) } else { resolveExternalLink(uri.toString()) } ?: throw NotFoundException("Cannot resolve link", uri.toString()) } private suspend fun resolveAppLink(uri: Uri): Manga? { require(uri.pathSegments.singleOrNull() == "manga") { "Invalid url" } uri.getQueryParameter("id")?.let { mangaId -> // short url return dataRepository.findMangaById(mangaId.toLong(), withChapters = false) } val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" } val source = MangaSource(sourceName) require(source != UnknownMangaSource) { "Manga source $sourceName is not supported" } val repo = repositoryFactory.create(source) return repo.findExact( url = uri.getQueryParameter("url"), title = uri.getQueryParameter("name"), ) } private suspend fun resolveExternalLink(uri: String): Manga? { dataRepository.findMangaByPublicUrl(uri)?.let { return it } return context.newLinkResolver(uri).getManga() } private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? { if (!title.isNullOrEmpty()) { val list = getList(0, null, MangaListFilter(query = title)) if (url != null) { list.find { it.url == url }?.let { return it } } list.minByOrNull { it.title.levenshteinDistance(title) } ?.takeIf { it.title.almostEquals(title, 0.2f) } ?.let { return it } } val seed = getDetailsNoCache( getSeedManga(source, url ?: return null, title), ) return runCatchingCancellable { val seedTitle = seed.title.ifEmpty { seed.altTitle }.ifNullOrEmpty { seed.author } ?: return@runCatchingCancellable null val seedList = getList(0, null, MangaListFilter(query = seedTitle)) seedList.first { x -> x.url == url } }.getOrThrow() } private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga = if (this is CachingMangaRepository) { getDetails(manga, CachePolicy.READ_ONLY) } else { getDetails(manga) } private fun getSeedManga(source: MangaSource, url: String, title: String?) = Manga( id = run { var h = 1125899906842597L source.name.forEach { c -> h = 31 * h + c.code } url.forEach { c -> h = 31 * h + c.code } h }, title = title.orEmpty(), altTitle = null, url = url, publicUrl = "", rating = 0.0f, isNsfw = source.isNsfw(), coverUrl = "", tags = emptySet(), state = null, author = null, largeCoverUrl = null, description = null, chapters = null, source = source, ) companion object { fun isValidLink(str: String): Boolean { return str.isHttpUrl() || str.startsWith("kotatsu://", ignoreCase = true) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt ================================================ package org.koitharu.kotatsu.core.parser import android.annotation.SuppressLint import android.content.Context import android.util.Base64 import androidx.core.os.LocaleListCompat import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.withTimeout import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Response import okhttp3.ResponseBody.Companion.asResponseBody import okio.Buffer import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException import org.koitharu.kotatsu.core.image.BitmapDecoderCompat import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.webview.WebViewExecutor import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.core.util.ext.toList import org.koitharu.kotatsu.core.util.ext.toMimeType import org.koitharu.kotatsu.core.util.ext.use import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.bitmap.Bitmap import org.koitharu.kotatsu.parsers.config.MangaSourceConfig import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.network.UserAgents import org.koitharu.kotatsu.parsers.util.map import java.util.Locale import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton @Singleton class MangaLoaderContextImpl @Inject constructor( @MangaHttpClient override val httpClient: OkHttpClient, override val cookieJar: MutableCookieJar, @ApplicationContext private val androidContext: Context, private val webViewExecutor: WebViewExecutor, ) : MangaLoaderContext() { private val jsTimeout = TimeUnit.SECONDS.toMillis(4) @Deprecated("Provide a base url") @SuppressLint("SetJavaScriptEnabled") override suspend fun evaluateJs(script: String): String? = evaluateJs("", script) override suspend fun evaluateJs(baseUrl: String, script: String): String? = withTimeout(jsTimeout) { webViewExecutor.evaluateJs(baseUrl, script) } override fun getDefaultUserAgent(): String = webViewExecutor.defaultUserAgent ?: UserAgents.FIREFOX_MOBILE override fun getConfig(source: MangaSource): MangaSourceConfig { return SourceSettings(androidContext, source) } override fun encodeBase64(data: ByteArray): String { return Base64.encodeToString(data, Base64.NO_WRAP) } override fun decodeBase64(data: String): ByteArray { return Base64.decode(data, Base64.DEFAULT) } override fun getPreferredLocales(): List { return LocaleListCompat.getAdjustedDefault().toList() } override fun requestBrowserAction( parser: MangaParser, url: String, ): Nothing = throw InteractiveActionRequiredException(parser.source, url) override fun redrawImageResponse(response: Response, redraw: (image: Bitmap) -> Bitmap): Response { return response.map { body -> BitmapDecoderCompat.decode(body.byteStream(), body.contentType()?.toMimeType(), isMutable = true) .use { bitmap -> (redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper).use { result -> Buffer().also { result.compressTo(it.outputStream()) }.asResponseBody("image/jpeg".toMediaType()) } } } } override fun createBitmap(width: Int, height: Int): Bitmap = BitmapWrapper.create(width, height) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt ================================================ package org.koitharu.kotatsu.core.parser import android.content.Context import androidx.annotation.AnyThread import androidx.collection.ArrayMap import dagger.hilt.android.qualifiers.ApplicationContext import org.koitharu.kotatsu.core.cache.MemoryContentCache import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.MangaSourceInfo import org.koitharu.kotatsu.core.model.TestMangaSource import org.koitharu.kotatsu.core.model.UnknownMangaSource import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource import org.koitharu.kotatsu.local.data.LocalMangaRepository 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.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.SortOrder import java.lang.ref.WeakReference import javax.inject.Inject import javax.inject.Singleton interface MangaRepository { val source: MangaSource val sortOrders: Set var defaultSortOrder: SortOrder val filterCapabilities: MangaListFilterCapabilities suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List suspend fun getDetails(manga: Manga): Manga suspend fun getPages(chapter: MangaChapter): List suspend fun getPageUrl(page: MangaPage): String suspend fun getFilterOptions(): MangaListFilterOptions suspend fun getRelated(seed: Manga): List suspend fun find(manga: Manga): Manga? { val list = getList(0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title)) return list.find { x -> x.id == manga.id } } @Singleton class Factory @Inject constructor( @ApplicationContext private val context: Context, private val localMangaRepository: LocalMangaRepository, private val loaderContext: MangaLoaderContext, private val contentCache: MemoryContentCache, private val mirrorSwitcher: MirrorSwitcher, ) { private val cache = ArrayMap>() @AnyThread fun create(source: MangaSource): MangaRepository { when (source) { is MangaSourceInfo -> return create(source.mangaSource) LocalMangaSource -> return localMangaRepository UnknownMangaSource -> return EmptyMangaRepository(source) } cache[source]?.get()?.let { return it } return synchronized(cache) { cache[source]?.get()?.let { return it } val repository = createRepository(source) if (repository != null) { cache[source] = WeakReference(repository) repository } else { EmptyMangaRepository(source) } } } private fun createRepository(source: MangaSource): MangaRepository? = when (source) { is MangaParserSource -> ParserMangaRepository( parser = loaderContext.newParserInstance(source), cache = contentCache, mirrorSwitcher = mirrorSwitcher, ) TestMangaSource -> TestMangaRepository( loaderContext = loaderContext, cache = contentCache, ) is ExternalMangaSource -> if (source.isAvailable(context)) { ExternalMangaRepository( contentResolver = context.contentResolver, source = source, cache = contentCache, ) } else { EmptyMangaRepository(source) } else -> null } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MirrorSwitcher.kt ================================================ package org.koitharu.kotatsu.core.parser import android.util.Log import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.OkHttpClient import okhttp3.Request import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.util.EnumSet import javax.inject.Inject class MirrorSwitcher @Inject constructor( private val settings: AppSettings, @MangaHttpClient private val okHttpClient: OkHttpClient, ) { private val blacklist = EnumSet.noneOf(MangaParserSource::class.java) private val mutex: Mutex = Mutex() val isEnabled: Boolean get() = settings.isMirrorSwitchingEnabled suspend fun trySwitchMirror(repository: ParserMangaRepository, loader: suspend () -> T?): T? { val source = repository.source if (!isEnabled || source in blacklist) { return null } val availableMirrors = repository.domains val currentHost = repository.domain if (availableMirrors.size <= 1 || currentHost !in availableMirrors) { return null } mutex.withLock { if (source in blacklist) { return null } logd { "Looking for mirrors for ${source}..." } findRedirect(repository)?.let { mirror -> repository.domain = mirror runCatchingCancellable { loader()?.takeIfValid() }.getOrNull()?.let { logd { "Found redirect for $source: $mirror" } return it } } for (mirror in availableMirrors) { repository.domain = mirror runCatchingCancellable { loader()?.takeIfValid() }.getOrNull()?.let { logd { "Found mirror for $source: $mirror" } return it } } repository.domain = currentHost // rollback blacklist.add(source) logd { "$source blacklisted" } return null } } suspend fun findRedirect(repository: ParserMangaRepository): String? { if (!isEnabled) { return null } val currentHost = repository.domain val newHost = okHttpClient.newCall( Request.Builder() .url("https://$currentHost") .head() .build(), ).await().use { if (it.isSuccessful) { it.request.url.host } else { null } } return if (newHost != currentHost) { newHost } else { null } } private fun T.takeIfValid() = takeIf { when (it) { is Collection<*> -> it.isNotEmpty() else -> true } } private companion object { const val TAG = "MirrorSwitcher" inline fun logd(message: () -> String) { if (BuildConfig.DEBUG) { Log.d(TAG, message()) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/parser/ParserMangaRepository.kt ================================================ package org.koitharu.kotatsu.core.parser import kotlinx.coroutines.Dispatchers import okhttp3.Interceptor import okhttp3.Response import org.koitharu.kotatsu.core.cache.MemoryContentCache import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException import org.koitharu.kotatsu.core.exceptions.ProxyConfigException import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.model.Favicons 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.MangaParserSource import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy class ParserMangaRepository( private val parser: MangaParser, private val mirrorSwitcher: MirrorSwitcher, cache: MemoryContentCache, ) : CachingMangaRepository(cache), Interceptor { private val filterOptionsLazy = suspendLazy(Dispatchers.Default) { withMirrors { parser.getFilterOptions() } } override val source: MangaParserSource get() = parser.source override val sortOrders: Set get() = parser.availableSortOrders override val filterCapabilities: MangaListFilterCapabilities get() = parser.filterCapabilities override var defaultSortOrder: SortOrder get() = getConfig().defaultSortOrder ?: sortOrders.first() set(value) { getConfig().defaultSortOrder = value } var domain: String get() = parser.domain set(value) { getConfig()[parser.configKeyDomain] = value } val domains: Array get() = parser.configKeyDomain.presetValues override fun intercept(chain: Interceptor.Chain): Response = parser.intercept(chain) override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List { return withMirrors { parser.getList(offset, order ?: defaultSortOrder, filter ?: MangaListFilter.EMPTY) } } override suspend fun getPagesImpl( chapter: MangaChapter ): List = withMirrors { parser.getPages(chapter) } override suspend fun getPageUrl(page: MangaPage): String = withMirrors { parser.getPageUrl(page).also { result -> check(result.isNotEmpty()) { "Page url is empty" } } } override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptionsLazy.get() suspend fun getFavicons(): Favicons = withMirrors { parser.getFavicons() } override suspend fun getRelatedMangaImpl(seed: Manga): List = parser.getRelatedManga(seed) override suspend fun getDetailsImpl(manga: Manga): Manga = withMirrors { parser.getDetails(manga) } fun getAuthProvider(): MangaParserAuthProvider? = parser.authorizationProvider fun getRequestHeaders() = parser.getRequestHeaders() fun getConfigKeys(): List> = ArrayList>().also { parser.onCreateConfig(it) } fun getAvailableMirrors(): List { return parser.configKeyDomain.presetValues.toList() } fun isSlowdownEnabled(): Boolean { return getConfig().isSlowdownEnabled } fun getConfig() = parser.config as SourceSettings private suspend fun withMirrors(block: suspend () -> T): T { if (!mirrorSwitcher.isEnabled) { return block() } val initialResult = runCatchingCancellable { block() } if (initialResult.isValidResult()) { return initialResult.getOrThrow() } val newResult = mirrorSwitcher.trySwitchMirror(this, block) return newResult ?: initialResult.getOrThrow() } private fun Result.isValidResult() = fold( onSuccess = { when (it) { is Collection<*> -> it.isNotEmpty() else -> true } }, onFailure = { when (it.cause) { is CloudFlareProtectedException, is AuthRequiredException, is InteractiveActionRequiredException, is ProxyConfigException -> true else -> false } }, ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt ================================================ package org.koitharu.kotatsu.core.parser.external import android.content.ContentResolver import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import org.koitharu.kotatsu.core.cache.MemoryContentCache import org.koitharu.kotatsu.core.parser.CachingMangaRepository import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug 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 org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy import java.util.EnumSet class ExternalMangaRepository( contentResolver: ContentResolver, override val source: ExternalMangaSource, cache: MemoryContentCache, ) : CachingMangaRepository(cache) { private val contentSource = ExternalPluginContentSource(contentResolver, source) private val capabilities by lazy { runCatching { contentSource.getCapabilities() }.onFailure { it.printStackTraceDebug() }.getOrNull() } private val filterOptions = suspendLazy(initializer = contentSource::getListFilterOptions) override val sortOrders: Set get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.POPULARITY) override val filterCapabilities: MangaListFilterCapabilities get() = capabilities?.listFilterCapabilities ?: MangaListFilterCapabilities() override var defaultSortOrder: SortOrder get() = capabilities?.availableSortOrders?.firstOrNull() ?: SortOrder.ALPHABETICAL set(value) = Unit override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptions.get() override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List = runInterruptible(Dispatchers.IO) { contentSource.getList(offset, order ?: defaultSortOrder, filter ?: MangaListFilter.EMPTY) } override suspend fun getDetailsImpl(manga: Manga): Manga = runInterruptible(Dispatchers.IO) { contentSource.getDetails(manga) } override suspend fun getPagesImpl(chapter: MangaChapter): List = runInterruptible(Dispatchers.IO) { contentSource.getPages(chapter) } override suspend fun getPageUrl(page: MangaPage): String = runInterruptible(Dispatchers.IO) { contentSource.getPageUrl(page.url) } override suspend fun getRelatedMangaImpl(seed: Manga): List = emptyList() // TODO } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaSource.kt ================================================ package org.koitharu.kotatsu.core.parser.external import android.content.Context import org.koitharu.kotatsu.parsers.model.MangaSource data class ExternalMangaSource( val packageName: String, val authority: String, ) : MangaSource { override val name: String get() = "content:$packageName/$authority" private var cachedName: String? = null fun isAvailable(context: Context): Boolean { return context.packageManager.resolveContentProvider(authority, 0)?.isEnabled == true } fun resolveName(context: Context): String { cachedName?.let { return it } val pm = context.packageManager val info = pm.resolveContentProvider(authority, 0) return info?.loadLabel(pm)?.toString()?.also { cachedName = it } ?: authority } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt ================================================ package org.koitharu.kotatsu.core.parser.external import android.content.ContentResolver import android.database.Cursor import androidx.annotation.WorkerThread import androidx.collection.ArraySet import androidx.core.net.toUri import org.jetbrains.annotations.Blocking import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException 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.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.MangaState import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.find import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.parsers.util.mapNotNullToSet import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.parsers.util.splitTwoParts import java.util.EnumSet import java.util.Locale class ExternalPluginContentSource( private val contentResolver: ContentResolver, private val source: ExternalMangaSource, ) { @Blocking @WorkerThread fun getListFilterOptions() = MangaListFilterOptions( availableTags = fetchTags(), availableStates = fetchEnumSet(MangaState::class.java, "filter/states"), availableContentRating = fetchEnumSet(ContentRating::class.java, "filter/content_ratings"), availableContentTypes = fetchEnumSet(ContentType::class.java, "filter/content_types"), availableDemographics = fetchEnumSet(Demographic::class.java, "filter/demographics"), availableLocales = fetchLocales(), ) @Blocking @WorkerThread fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List { val uri = "content://${source.authority}/manga".toUri().buildUpon() uri.appendQueryParameter("offset", offset.toString()) filter.tags.forEach { uri.appendQueryParameter("tags_include", "${it.key}=${it.title}") } filter.tagsExclude.forEach { uri.appendQueryParameter("tags_exclude", "${it.key}=${it.title}") } filter.states.forEach { uri.appendQueryParameter("state", it.name) } filter.locale?.let { uri.appendQueryParameter("locale", it.language) } filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) } if (!filter.author.isNullOrEmpty()) { uri.appendQueryParameter("author", filter.author) } if (!filter.query.isNullOrEmpty()) { uri.appendQueryParameter("query", filter.query) } return contentResolver.query(uri.build(), null, null, null, order.name) .safe() .use { cursor -> val result = ArrayList(cursor.count) if (cursor.moveToFirst()) { do { result += cursor.getManga() } while (cursor.moveToNext()) } result } } @Blocking @WorkerThread fun getDetails(manga: Manga): Manga { val chapters = queryChapters(manga.url) val details = queryDetails(manga.url) return Manga( id = manga.id, title = details.title.ifBlank { manga.title }, altTitles = details.altTitles.ifEmpty { manga.altTitles }, url = details.url.ifEmpty { manga.url }, publicUrl = details.publicUrl.ifEmpty { manga.publicUrl }, rating = maxOf(details.rating, manga.rating), contentRating = details.contentRating, coverUrl = details.coverUrl.ifNullOrEmpty { manga.coverUrl }, tags = details.tags + manga.tags, state = details.state ?: manga.state, authors = details.authors.ifEmpty { manga.authors }, largeCoverUrl = details.largeCoverUrl.ifNullOrEmpty { manga.largeCoverUrl }, description = details.description.ifNullOrEmpty { manga.description }, chapters = chapters, source = source, ) } @Blocking @WorkerThread fun getPages(chapter: MangaChapter): List { val uri = "content://${source.authority}/chapters".toUri() .buildUpon() .appendPath(chapter.url) .build() return contentResolver.query(uri, null, null, null, null) .safe() .use { cursor -> val result = ArrayList(cursor.count) if (cursor.moveToFirst()) { do { result += MangaPage( id = cursor.getLong(COLUMN_ID), url = cursor.getString(COLUMN_URL), preview = cursor.getStringOrNull(COLUMN_PREVIEW), source = source, ) } while (cursor.moveToNext()) } result } } @Blocking @WorkerThread private fun fetchTags(): Set { val uri = "content://${source.authority}/filter/tags".toUri() return contentResolver.query(uri, null, null, null, null) .safe() .use { cursor -> val result = ArraySet(cursor.count) if (cursor.moveToFirst()) { do { result += MangaTag( key = cursor.getString(COLUMN_KEY), title = cursor.getString(COLUMN_TITLE), source = source, ) } while (cursor.moveToNext()) } result } } @Blocking @WorkerThread fun getPageUrl(url: String): String { val uri = "content://${source.authority}/manga/pages/0".toUri().buildUpon() .appendQueryParameter("url", url) .build() return contentResolver.query(uri, null, null, null, null) .safe() .use { cursor -> if (cursor.moveToFirst()) { cursor.getString(COLUMN_VALUE) } else { url } } } @Blocking @WorkerThread private fun fetchLocales(): Set { val uri = "content://${source.authority}/filter/locales".toUri() return contentResolver.query(uri, null, null, null, null) .safe() .use { cursor -> val result = ArraySet(cursor.count) if (cursor.moveToFirst()) { do { result += Locale(cursor.getString(COLUMN_NAME)) } while (cursor.moveToNext()) } result } } fun getCapabilities(): MangaSourceCapabilities? { val uri = "content://${source.authority}/capabilities".toUri() return contentResolver.query(uri, null, null, null, null) .safe() .use { cursor -> if (cursor.moveToFirst()) { MangaSourceCapabilities( availableSortOrders = cursor.getStringOrNull(COLUMN_SORT_ORDERS) ?.split(',') ?.mapNotNullTo(EnumSet.noneOf(SortOrder::class.java)) { SortOrder.entries.find(it) }.orEmpty(), listFilterCapabilities = MangaListFilterCapabilities( isMultipleTagsSupported = cursor.getBooleanOrDefault(COLUMN_MULTIPLE_TAGS, false), isTagsExclusionSupported = cursor.getBooleanOrDefault(COLUMN_TAGS_EXCLUSION, false), isSearchSupported = cursor.getBooleanOrDefault(COLUMN_SEARCH, false), isSearchWithFiltersSupported = cursor.getBooleanOrDefault( COLUMN_SEARCH_WITH_FILTERS, false, ), isYearSupported = cursor.getBooleanOrDefault(COLUMN_YEAR, false), isYearRangeSupported = cursor.getBooleanOrDefault(COLUMN_YEAR_RANGE, false), isOriginalLocaleSupported = cursor.getBooleanOrDefault(COLUMN_ORIGINAL_LOCALE, false), isAuthorSearchSupported = cursor.getBooleanOrDefault(COLUMN_AUTHOR, false), ), ) } else { null } } } private fun queryDetails(url: String): Manga { val uri = "content://${source.authority}/manga".toUri() .buildUpon() .appendPath(url) .build() return contentResolver.query(uri, null, null, null, null) .safe() .use { cursor -> cursor.moveToFirst() cursor.getManga() } } private fun queryChapters(url: String): List { val uri = "content://${source.authority}/manga/chapters".toUri() .buildUpon() .appendPath(url) .build() return contentResolver.query(uri, null, null, null, null) .safe() .use { cursor -> val result = ArrayList(cursor.count) if (cursor.moveToFirst()) { do { result += MangaChapter( id = cursor.getLong(COLUMN_ID), title = cursor.getStringOrNull(COLUMN_NAME), number = cursor.getFloatOrDefault(COLUMN_NUMBER, 0f), volume = cursor.getIntOrDefault(COLUMN_VOLUME, 0), url = cursor.getString(COLUMN_URL), scanlator = cursor.getStringOrNull(COLUMN_SCANLATOR), uploadDate = cursor.getLongOrDefault(COLUMN_UPLOAD_DATE, 0L), branch = cursor.getStringOrNull(COLUMN_BRANCH), source = source, ) } while (cursor.moveToNext()) } result } } private fun ExternalPluginCursor.getManga() = Manga( id = getLong(COLUMN_ID), title = getString(COLUMN_TITLE), altTitles = setOfNotNull(getStringOrNull(COLUMN_ALT_TITLE)), url = getString(COLUMN_URL), publicUrl = getString(COLUMN_PUBLIC_URL), rating = getFloat(COLUMN_RATING), contentRating = if (getBooleanOrDefault(COLUMN_IS_NSFW, false)) { ContentRating.ADULT } else { null }, coverUrl = getStringOrNull(COLUMN_COVER_URL), tags = getStringOrNull(COLUMN_TAGS)?.split(':')?.mapNotNullToSet { val parts = it.splitTwoParts('=') ?: return@mapNotNullToSet null MangaTag(key = parts.first, title = parts.second, source = source) }.orEmpty(), state = getStringOrNull(COLUMN_STATE)?.let { MangaState.entries.find(it) }, authors = getStringOrNull(COLUMN_AUTHOR)?.split(',')?.mapNotNullToSet { it.trim().nullIfEmpty() }.orEmpty(), largeCoverUrl = getStringOrNull(COLUMN_LARGE_COVER_URL), description = getStringOrNull(COLUMN_DESCRIPTION), chapters = emptyList(), source = source, ) private fun > fetchEnumSet(cls: Class, path: String): EnumSet { val uri = "content://${source.authority}/$path".toUri() return contentResolver.query(uri, null, null, null, null) .safe() .use { cursor -> val result = EnumSet.noneOf(cls) val enumConstants = cls.enumConstants ?: return@use result if (cursor.moveToFirst()) { do { val name = cursor.getString(COLUMN_NAME) val enumValue = enumConstants.find { it.name == name } if (enumValue != null) { result.add(enumValue) } } while (cursor.moveToNext()) } result } } private fun Cursor?.safe() = ExternalPluginCursor( source = source, cursor = this ?: throw IncompatiblePluginException(source.name, null), ) class MangaSourceCapabilities( val availableSortOrders: Set, val listFilterCapabilities: MangaListFilterCapabilities, ) private companion object { const val COLUMN_SORT_ORDERS = "sort_orders" const val COLUMN_MULTIPLE_TAGS = "multiple_tags" const val COLUMN_TAGS_EXCLUSION = "tags_exclusion" const val COLUMN_SEARCH = "search" const val COLUMN_SEARCH_WITH_FILTERS = "search_with_filters" const val COLUMN_YEAR = "year" const val COLUMN_YEAR_RANGE = "year_range" const val COLUMN_ORIGINAL_LOCALE = "original_locale" const val COLUMN_ID = "id" const val COLUMN_NAME = "name" const val COLUMN_NUMBER = "number" const val COLUMN_VOLUME = "volume" const val COLUMN_URL = "url" const val COLUMN_SCANLATOR = "scanlator" const val COLUMN_UPLOAD_DATE = "upload_date" const val COLUMN_BRANCH = "branch" const val COLUMN_TITLE = "title" const val COLUMN_ALT_TITLE = "alt_title" const val COLUMN_PUBLIC_URL = "public_url" const val COLUMN_RATING = "rating" const val COLUMN_IS_NSFW = "is_nsfw" const val COLUMN_COVER_URL = "cover_url" const val COLUMN_TAGS = "tags" const val COLUMN_STATE = "state" const val COLUMN_AUTHOR = "author" const val COLUMN_LARGE_COVER_URL = "large_cover_url" const val COLUMN_DESCRIPTION = "description" const val COLUMN_PREVIEW = "preview" const val COLUMN_KEY = "key" const val COLUMN_VALUE = "value" } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginCursor.kt ================================================ package org.koitharu.kotatsu.core.parser.external import android.database.Cursor import android.database.CursorWrapper import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException import org.koitharu.kotatsu.core.util.ext.getBoolean class ExternalPluginCursor(private val source: ExternalMangaSource, cursor: Cursor) : CursorWrapper(cursor) { override fun getColumnIndexOrThrow(columnName: String?): Int = try { super.getColumnIndexOrThrow(columnName) } catch (e: Exception) { throw IncompatiblePluginException(source.name, e) } fun getString(columnName: String): String = getString(getColumnIndexOrThrow(columnName)) fun getStringOrNull(columnName: String): String? { val columnIndex = getColumnIndex(columnName) return when { columnIndex < 0 -> null isNull(columnIndex) -> null else -> getString(columnIndex).takeUnless { it == "null" } } } fun getBoolean(columnName: String): Boolean = getBoolean(getColumnIndexOrThrow(columnName)) fun getBooleanOrDefault(columnName: String, defaultValue: Boolean): Boolean { val columnIndex = getColumnIndex(columnName) return when { columnIndex < 0 -> defaultValue isNull(columnIndex) -> defaultValue else -> getBoolean(columnIndex) } } fun getInt(columnName: String): Int = getInt(getColumnIndexOrThrow(columnName)) fun getIntOrDefault(columnName: String, defaultValue: Int): Int { val columnIndex = getColumnIndex(columnName) return when { columnIndex < 0 -> defaultValue isNull(columnIndex) -> defaultValue else -> getInt(columnIndex) } } fun getLong(columnName: String): Long = getLong(getColumnIndexOrThrow(columnName)) fun getLongOrDefault(columnName: String, defaultValue: Long): Long { val columnIndex = getColumnIndex(columnName) return when { columnIndex < 0 -> defaultValue isNull(columnIndex) -> defaultValue else -> getLong(columnIndex) } } fun getFloat(columnName: String): Float = getFloat(getColumnIndexOrThrow(columnName)) fun getFloatOrDefault(columnName: String, defaultValue: Float): Float { val columnIndex = getColumnIndex(columnName) return when { columnIndex < 0 -> defaultValue isNull(columnIndex) -> defaultValue else -> getFloat(columnIndex) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt ================================================ package org.koitharu.kotatsu.core.parser.favicon import android.graphics.Color import android.graphics.drawable.AdaptiveIconDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.LayerDrawable import android.net.Uri import android.os.Build import coil3.ColorImage import coil3.ImageLoader import coil3.asImage import coil3.decode.DataSource import coil3.decode.ImageSource import coil3.fetch.FetchResult import coil3.fetch.Fetcher import coil3.fetch.ImageFetchResult import coil3.fetch.SourceFetchResult import coil3.request.Options import coil3.size.pxOrElse import coil3.toAndroidUri import coil3.toBitmap import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.runInterruptible import okio.FileSystem import okio.IOException import okio.Path.Companion.toOkioPath import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.parser.EmptyMangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.ext.fetch import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull import org.koitharu.kotatsu.local.data.FaviconCache import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalStorageCache import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.io.File import javax.inject.Inject import coil3.Uri as CoilUri class FaviconFetcher( private val uri: Uri, private val options: Options, private val imageLoader: ImageLoader, private val mangaRepositoryFactory: MangaRepository.Factory, private val localStorageCache: LocalStorageCache, ) : Fetcher { override suspend fun fetch(): FetchResult? { val mangaSource = MangaSource(uri.schemeSpecificPart) return when (val repo = mangaRepositoryFactory.create(mangaSource)) { is ParserMangaRepository -> fetchParserFavicon(repo) is ExternalMangaRepository -> fetchPluginIcon(repo) is EmptyMangaRepository -> ImageFetchResult( image = ColorImage(Color.WHITE), isSampled = false, dataSource = DataSource.MEMORY, ) is LocalMangaRepository -> imageLoader.fetch(R.drawable.ic_storage, options) else -> throw IllegalArgumentException("Unsupported repo ${repo.javaClass.simpleName}") } } private suspend fun fetchParserFavicon(repository: ParserMangaRepository): FetchResult { val sizePx = maxOf( options.size.width.pxOrElse { FALLBACK_SIZE }, options.size.height.pxOrElse { FALLBACK_SIZE }, ) val cacheKey = options.diskCacheKey ?: "${repository.source.name}_$sizePx" if (options.diskCachePolicy.readEnabled) { localStorageCache[cacheKey]?.let { file -> return SourceFetchResult( source = ImageSource(file.toOkioPath(), FileSystem.SYSTEM), mimeType = MimeTypes.probeMimeType(file)?.toString(), dataSource = DataSource.DISK, ) } } var favicons = repository.getFavicons() var lastError: Exception? = null while (favicons.isNotEmpty()) { currentCoroutineContext().ensureActive() val icon = favicons.find(sizePx) ?: throwNSEE(lastError) try { val result = imageLoader.fetch(icon.url, options) if (result != null) { return if (options.diskCachePolicy.writeEnabled) { writeToCache(cacheKey, result) } else { result } } else { favicons -= icon } } catch (e: CloudFlareProtectedException) { throw e } catch (e: IOException) { lastError = e favicons -= icon } } throwNSEE(lastError) } private suspend fun fetchPluginIcon(repository: ExternalMangaRepository): FetchResult { val source = repository.source val pm = options.context.packageManager val icon = runInterruptible { val provider = pm.resolveContentProvider(source.authority, 0) provider?.loadIcon(pm) ?: pm.getApplicationIcon(source.packageName) } return ImageFetchResult( image = icon.nonAdaptive().asImage(), isSampled = false, dataSource = DataSource.DISK, ) } private suspend fun writeToCache(key: String, result: FetchResult): FetchResult = runCatchingCancellable { when (result) { is ImageFetchResult -> { if (result.dataSource == DataSource.NETWORK) { localStorageCache.set(key, result.image.toBitmap()).asFetchResult() } else { result } } is SourceFetchResult -> { if (result.dataSource == DataSource.NETWORK) { result.source.source().use { localStorageCache.set(key, it, result.mimeType?.toMimeTypeOrNull()).asFetchResult() } } else { result } } } }.onFailure { it.printStackTraceDebug() }.getOrDefault(result) private fun File.asFetchResult() = SourceFetchResult( source = ImageSource(toOkioPath(), FileSystem.SYSTEM), mimeType = MimeTypes.probeMimeType(this)?.toString(), dataSource = DataSource.DISK, ) class Factory @Inject constructor( private val mangaRepositoryFactory: MangaRepository.Factory, @FaviconCache private val faviconCache: LocalStorageCache, ) : Fetcher.Factory { override fun create( data: CoilUri, options: Options, imageLoader: ImageLoader ): Fetcher? = if (data.scheme == URI_SCHEME_FAVICON) { FaviconFetcher(data.toAndroidUri(), options, imageLoader, mangaRepositoryFactory, faviconCache) } else { null } } private companion object { const val FALLBACK_SIZE = 9999 // largest icon private fun throwNSEE(lastError: Exception?): Nothing { if (lastError != null) { throw lastError } else { throw NoSuchElementException("No favicons found") } } private fun Drawable.nonAdaptive() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && this is AdaptiveIconDrawable) { LayerDrawable(arrayOf(background, foreground)) } else { this } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconUri.kt ================================================ package org.koitharu.kotatsu.core.parser.favicon import android.net.Uri import org.koitharu.kotatsu.parsers.model.MangaSource const val URI_SCHEME_FAVICON = "favicon" fun MangaSource.faviconUri(): Uri = Uri.fromParts(URI_SCHEME_FAVICON, name, null) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt ================================================ package org.koitharu.kotatsu.core.prefs import android.content.Context import android.content.SharedPreferences import android.content.pm.ActivityInfo import android.net.ConnectivityManager import android.net.Uri import android.os.Build import android.provider.Settings import androidx.annotation.FloatRange import androidx.appcompat.app.AppCompatDelegate import androidx.collection.ArraySet import androidx.core.content.edit import androidx.core.os.LocaleListCompat import androidx.documentfile.provider.DocumentFile import androidx.preference.PreferenceManager import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onStart import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.network.DoHProvider import org.koitharu.kotatsu.core.util.ext.connectivityManager import org.koitharu.kotatsu.core.util.ext.getEnumValue import org.koitharu.kotatsu.core.util.ext.observeChanges import org.koitharu.kotatsu.core.util.ext.putAll import org.koitharu.kotatsu.core.util.ext.putEnumValue import org.koitharu.kotatsu.core.util.ext.takeIfReadable import org.koitharu.kotatsu.core.util.ext.toUriOrNull import org.koitharu.kotatsu.explore.data.SourcesSortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.find import org.koitharu.kotatsu.parsers.util.mapNotNullToSet import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.reader.domain.ReaderColorFilter import java.io.File import java.net.Proxy import java.util.EnumSet import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton @Singleton class AppSettings @Inject constructor(@ApplicationContext context: Context) { private val prefs = PreferenceManager.getDefaultSharedPreferences(context) private val connectivityManager = context.connectivityManager private val mangaListBadgesDefault = ArraySet(context.resources.getStringArray(R.array.values_list_badges)) var listMode: ListMode get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.GRID) set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) } val theme: Int get() = prefs.getString(KEY_THEME, null)?.toIntOrNull() ?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM val colorScheme: ColorScheme get() = prefs.getEnumValue(KEY_COLOR_THEME, ColorScheme.default) val isAmoledTheme: Boolean get() = prefs.getBoolean(KEY_THEME_AMOLED, false) var mainNavItems: List get() { val raw = prefs.getString(KEY_NAV_MAIN, null)?.split(',') return if (raw.isNullOrEmpty()) { listOf(NavItem.HISTORY, NavItem.FAVORITES, NavItem.EXPLORE, NavItem.FEED) } else { raw.mapNotNull { x -> NavItem.entries.find(x) }.ifEmpty { listOf(NavItem.EXPLORE) } } } set(value) { prefs.edit { putString(KEY_NAV_MAIN, value.joinToString(",") { it.name }) } } val isNavLabelsVisible: Boolean get() = prefs.getBoolean(KEY_NAV_LABELS, true) val isNavBarPinned: Boolean get() = prefs.getBoolean(KEY_NAV_PINNED, false) val isMainFabEnabled: Boolean get() = prefs.getBoolean(KEY_MAIN_FAB, true) var gridSize: Int get() = prefs.getInt(KEY_GRID_SIZE, 100) set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) } var gridSizePages: Int get() = prefs.getInt(KEY_GRID_SIZE_PAGES, 100) set(value) = prefs.edit { putInt(KEY_GRID_SIZE_PAGES, value) } val isQuickFilterEnabled: Boolean get() = prefs.getBoolean(KEY_QUICK_FILTER, true) val isDescriptionExpanded: Boolean get() = !prefs.getBoolean(KEY_COLLAPSE_DESCRIPTION, true) var historyListMode: ListMode get() = prefs.getEnumValue(KEY_LIST_MODE_HISTORY, listMode) set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_HISTORY, value) } var suggestionsListMode: ListMode get() = prefs.getEnumValue(KEY_LIST_MODE_SUGGESTIONS, listMode) set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_SUGGESTIONS, value) } var favoritesListMode: ListMode get() = prefs.getEnumValue(KEY_LIST_MODE_FAVORITES, listMode) set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_FAVORITES, value) } val isTagsWarningsEnabled: Boolean get() = prefs.getBoolean(KEY_TAGS_WARNINGS, true) var isNsfwContentDisabled: Boolean get() = prefs.getBoolean(KEY_DISABLE_NSFW, false) set(value) = prefs.edit { putBoolean(KEY_DISABLE_NSFW, value) } var appLocales: LocaleListCompat get() { val raw = prefs.getString(KEY_APP_LOCALE, null) return LocaleListCompat.forLanguageTags(raw) } set(value) { prefs.edit { putString(KEY_APP_LOCALE, value.toLanguageTags()) } } var isReaderDoubleOnLandscape: Boolean get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false) set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) } var isReaderDoubleOnFoldable: Boolean get() = prefs.getBoolean(KEY_READER_DOUBLE_FOLDABLE, false) set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_FOLDABLE, value) } @get:FloatRange(0.0, 1.0) var readerDoublePagesSensitivity: Float get() = prefs.getFloat(KEY_READER_DOUBLE_PAGES_SENSITIVITY, 0.5f) set(@FloatRange(0.0, 1.0) value) = prefs.edit { putFloat(KEY_READER_DOUBLE_PAGES_SENSITIVITY, value) } val readerScreenOrientation: Int get() = prefs.getString(KEY_READER_ORIENTATION, null)?.toIntOrNull() ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED val isReaderVolumeButtonsEnabled: Boolean get() = prefs.getBoolean(KEY_READER_VOLUME_BUTTONS, false) val isReaderZoomButtonsEnabled: Boolean get() = prefs.getBoolean(KEY_READER_ZOOM_BUTTONS, false) val isReaderControlAlwaysLTR: Boolean get() = prefs.getBoolean(KEY_READER_CONTROL_LTR, false) val isReaderNavigationInverted: Boolean get() = prefs.getBoolean(KEY_READER_NAVIGATION_INVERTED, false) val isReaderFullscreenEnabled: Boolean get() = prefs.getBoolean(KEY_READER_FULLSCREEN, true) val isReaderOptimizationEnabled: Boolean get() = prefs.getBoolean(KEY_READER_OPTIMIZE, false) val readerControls: Set get() = prefs.getStringSet(KEY_READER_CONTROLS, null)?.mapNotNullTo(EnumSet.noneOf(ReaderControl::class.java)) { ReaderControl.entries.find(it) } ?: ReaderControl.DEFAULT val isOfflineCheckDisabled: Boolean get() = prefs.getBoolean(KEY_OFFLINE_DISABLED, false) var isAllFavouritesVisible: Boolean get() = prefs.getBoolean(KEY_ALL_FAVOURITES_VISIBLE, true) set(value) = prefs.edit { putBoolean(KEY_ALL_FAVOURITES_VISIBLE, value) } val isTrackerEnabled: Boolean get() = prefs.getBoolean(KEY_TRACKER_ENABLED, true) val isTrackerWifiOnly: Boolean get() = prefs.getBoolean(KEY_TRACKER_WIFI_ONLY, false) val trackerFrequencyFactor: Float get() = prefs.getString(KEY_TRACKER_FREQUENCY, null)?.toFloatOrNull() ?: 1f val isTrackerNotificationsEnabled: Boolean get() = prefs.getBoolean(KEY_TRACKER_NOTIFICATIONS, true) val isTrackerNsfwDisabled: Boolean get() = prefs.getBoolean(KEY_TRACKER_NO_NSFW, false) val trackerDownloadStrategy: TrackerDownloadStrategy get() = prefs.getEnumValue(KEY_TRACKER_DOWNLOAD, TrackerDownloadStrategy.DISABLED) var notificationSound: Uri get() = prefs.getString(KEY_NOTIFICATIONS_SOUND, null)?.toUriOrNull() ?: Settings.System.DEFAULT_NOTIFICATION_URI set(value) = prefs.edit { putString(KEY_NOTIFICATIONS_SOUND, value.toString()) } val notificationVibrate: Boolean get() = prefs.getBoolean(KEY_NOTIFICATIONS_VIBRATE, false) val notificationLight: Boolean get() = prefs.getBoolean(KEY_NOTIFICATIONS_LIGHT, true) val readerAnimation: ReaderAnimation get() = prefs.getEnumValue(KEY_READER_ANIMATION, ReaderAnimation.DEFAULT) val readerBackground: ReaderBackground get() = prefs.getEnumValue(KEY_READER_BACKGROUND, ReaderBackground.DEFAULT) val defaultReaderMode: ReaderMode get() = prefs.getEnumValue(KEY_READER_MODE, ReaderMode.STANDARD) val isReaderModeDetectionEnabled: Boolean get() = prefs.getBoolean(KEY_READER_MODE_DETECT, true) var isHistoryGroupingEnabled: Boolean get() = prefs.getBoolean(KEY_HISTORY_GROUPING, true) set(value) = prefs.edit { putBoolean(KEY_HISTORY_GROUPING, value) } var isUpdatedGroupingEnabled: Boolean get() = prefs.getBoolean(KEY_UPDATED_GROUPING, true) set(value) = prefs.edit { putBoolean(KEY_UPDATED_GROUPING, value) } var isFeedHeaderVisible: Boolean get() = prefs.getBoolean(KEY_FEED_HEADER, true) set(value) = prefs.edit { putBoolean(KEY_FEED_HEADER, value) } val progressIndicatorMode: ProgressIndicatorMode get() = prefs.getEnumValue(KEY_PROGRESS_INDICATORS, ProgressIndicatorMode.PERCENT_READ) var incognitoModeForNsfw: TriStateOption get() = prefs.getEnumValue(KEY_INCOGNITO_NSFW, TriStateOption.ASK) set(value) = prefs.edit { putEnumValue(KEY_INCOGNITO_NSFW, value) } var isIncognitoModeEnabled: Boolean get() = prefs.getBoolean(KEY_INCOGNITO_MODE, false) set(value) = prefs.edit { putBoolean(KEY_INCOGNITO_MODE, value) } val isReaderMultiTaskEnabled: Boolean get() = prefs.getBoolean(KEY_READER_MULTITASK, false) var isChaptersReverse: Boolean get() = prefs.getBoolean(KEY_REVERSE_CHAPTERS, false) set(value) = prefs.edit { putBoolean(KEY_REVERSE_CHAPTERS, value) } var isChaptersGridView: Boolean get() = prefs.getBoolean(KEY_GRID_VIEW_CHAPTERS, false) set(value) = prefs.edit { putBoolean(KEY_GRID_VIEW_CHAPTERS, value) } val zoomMode: ZoomMode get() = prefs.getEnumValue(KEY_ZOOM_MODE, ZoomMode.FIT_CENTER) val trackSources: Set get() = prefs.getStringSet(KEY_TRACK_SOURCES, null) ?: setOf(TRACK_FAVOURITES) var appPassword: String? get() = prefs.getString(KEY_APP_PASSWORD, null) set(value) = prefs.edit { if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) } var isAppPasswordNumeric: Boolean get() = prefs.getBoolean(KEY_APP_PASSWORD_NUMERIC, false) set(value) = prefs.edit { putBoolean(KEY_APP_PASSWORD_NUMERIC, value) } val searchSuggestionTypes: Set get() = prefs.getStringSet(KEY_SEARCH_SUGGESTION_TYPES, null)?.let { stringSet -> stringSet.mapNotNullTo(EnumSet.noneOf(SearchSuggestionType::class.java)) { x -> enumValueOf(x) } } ?: EnumSet.allOf(SearchSuggestionType::class.java) var isBiometricProtectionEnabled: Boolean get() = prefs.getBoolean(KEY_PROTECT_APP_BIOMETRIC, true) set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) } val isMirrorSwitchingEnabled: Boolean get() = prefs.getBoolean(KEY_MIRROR_SWITCHING, false) val isExitConfirmationEnabled: Boolean get() = prefs.getBoolean(KEY_EXIT_CONFIRM, false) val isDynamicShortcutsEnabled: Boolean get() = prefs.getBoolean(KEY_SHORTCUTS, true) val isUnstableUpdatesAllowed: Boolean get() = prefs.getBoolean(KEY_UPDATES_UNSTABLE, false) val isPagesTabEnabled: Boolean get() = prefs.getBoolean(KEY_PAGES_TAB, true) val defaultDetailsTab: Int get() = if (isPagesTabEnabled) { val raw = prefs.getString(KEY_DETAILS_TAB, null)?.toIntOrNull() ?: -1 if (raw == -1) { lastDetailsTab } else { raw }.coerceIn(0, 2) } else { 0 } var lastDetailsTab: Int get() = prefs.getInt(KEY_DETAILS_LAST_TAB, 0) set(value) = prefs.edit { putInt(KEY_DETAILS_LAST_TAB, value) } val isContentPrefetchEnabled: Boolean get() { if (isBackgroundNetworkRestricted()) { return false } val policy = NetworkPolicy.from(prefs.getString(KEY_PREFETCH_CONTENT, null), NetworkPolicy.NEVER) return policy.isNetworkAllowed(connectivityManager) } var sourcesSortOrder: SourcesSortOrder get() = prefs.getEnumValue(KEY_SOURCES_ORDER, SourcesSortOrder.MANUAL) set(value) = prefs.edit { putEnumValue(KEY_SOURCES_ORDER, value) } var isSourcesGridMode: Boolean get() = prefs.getBoolean(KEY_SOURCES_GRID, true) set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) } var sourcesVersion: Int get() = prefs.getInt(KEY_SOURCES_VERSION, 0) set(value) = prefs.edit { putInt(KEY_SOURCES_VERSION, value) } var isAllSourcesEnabled: Boolean get() = prefs.getBoolean(KEY_SOURCES_ENABLED_ALL, false) set(value) = prefs.edit { putBoolean(KEY_SOURCES_ENABLED_ALL, value) } val isPagesNumbersEnabled: Boolean get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false) val screenshotsPolicy: ScreenshotsPolicy get() = prefs.getEnumValue(KEY_SCREENSHOTS_POLICY, ScreenshotsPolicy.ALLOW) val isAdBlockEnabled: Boolean get() = prefs.getBoolean(KEY_ADBLOCK, false) var userSpecifiedMangaDirectories: Set get() { val set = prefs.getStringSet(KEY_LOCAL_MANGA_DIRS, emptySet()).orEmpty() return set.mapNotNullToSet { File(it).takeIfReadable() } } set(value) { val set = value.mapToSet { it.absolutePath } prefs.edit { putStringSet(KEY_LOCAL_MANGA_DIRS, set) } } var mangaStorageDir: File? get() = prefs.getString(KEY_LOCAL_STORAGE, null)?.let { File(it) }?.takeIf { it.exists() && it in userSpecifiedMangaDirectories } set(value) = prefs.edit { if (value == null) { remove(KEY_LOCAL_STORAGE) } else { val userDirs = userSpecifiedMangaDirectories if (value !in userDirs) { userSpecifiedMangaDirectories = userDirs + value } putString(KEY_LOCAL_STORAGE, value.path) } } var allowDownloadOnMeteredNetwork: TriStateOption get() = prefs.getEnumValue(KEY_DOWNLOADS_METERED_NETWORK, TriStateOption.ASK) set(value) = prefs.edit { putEnumValue(KEY_DOWNLOADS_METERED_NETWORK, value) } val preferredDownloadFormat: DownloadFormat get() = prefs.getEnumValue(KEY_DOWNLOADS_FORMAT, DownloadFormat.AUTOMATIC) var isSuggestionsEnabled: Boolean get() = prefs.getBoolean(KEY_SUGGESTIONS, false) set(value) = prefs.edit { putBoolean(KEY_SUGGESTIONS, value) } val isSuggestionsWiFiOnly: Boolean get() = prefs.getBoolean(KEY_SUGGESTIONS_WIFI_ONLY, false) val isSuggestionsExcludeNsfw: Boolean get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false) val isSuggestionsIncludeDisabledSources: Boolean get() = prefs.getBoolean(KEY_SUGGESTIONS_DISABLED_SOURCES, false) val isSuggestionsNotificationAvailable: Boolean get() = prefs.getBoolean(KEY_SUGGESTIONS_NOTIFICATIONS, false) val suggestionsTagsBlacklist: Set get() { val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',') if (string.isNullOrEmpty()) { return emptySet() } return string.split(',').mapToSet { it.trim() } } val isReaderBarEnabled: Boolean get() = prefs.getBoolean(KEY_READER_BAR, true) val isReaderBarTransparent: Boolean get() = prefs.getBoolean(KEY_READER_BAR_TRANSPARENT, true) val isReaderChapterToastEnabled: Boolean get() = prefs.getBoolean(KEY_READER_CHAPTER_TOAST, true) val isReaderKeepScreenOn: Boolean get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true) var readerColorFilter: ReaderColorFilter? get() = runCatching { ReaderColorFilter( brightness = prefs.getFloat(KEY_CF_BRIGHTNESS, ReaderColorFilter.EMPTY.brightness), contrast = prefs.getFloat(KEY_CF_CONTRAST, ReaderColorFilter.EMPTY.contrast), isInverted = prefs.getBoolean(KEY_CF_INVERTED, ReaderColorFilter.EMPTY.isInverted), isGrayscale = prefs.getBoolean(KEY_CF_GRAYSCALE, ReaderColorFilter.EMPTY.isGrayscale), isBookBackground = prefs.getBoolean(KEY_CF_BOOK, ReaderColorFilter.EMPTY.isBookBackground), ).takeUnless { it.isEmpty } }.getOrNull() set(value) { prefs.edit { if (value != null) { putFloat(KEY_CF_BRIGHTNESS, value.brightness) putFloat(KEY_CF_CONTRAST, value.contrast) putBoolean(KEY_CF_INVERTED, value.isInverted) putBoolean(KEY_CF_GRAYSCALE, value.isGrayscale) putBoolean(KEY_CF_BOOK, value.isBookBackground) } else { remove(KEY_CF_BRIGHTNESS) remove(KEY_CF_CONTRAST) remove(KEY_CF_INVERTED) remove(KEY_CF_GRAYSCALE) remove(KEY_CF_BOOK) } } } val imagesProxy: Int get() { val raw = prefs.getString(KEY_IMAGES_PROXY, null)?.toIntOrNull() return raw ?: if (prefs.getBoolean(KEY_IMAGES_PROXY_OLD, false)) 0 else -1 } val dnsOverHttps: DoHProvider get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE) var isSSLBypassEnabled: Boolean get() = prefs.getBoolean(KEY_SSL_BYPASS, false) set(value) = prefs.edit { putBoolean(KEY_SSL_BYPASS, value) } val proxyType: Proxy.Type get() { val raw = prefs.getString(KEY_PROXY_TYPE, null) ?: return Proxy.Type.DIRECT return enumValues().find { it.name == raw } ?: Proxy.Type.DIRECT } val proxyAddress: String? get() = prefs.getString(KEY_PROXY_ADDRESS, null) val proxyPort: Int get() = prefs.getString(KEY_PROXY_PORT, null)?.toIntOrNull() ?: 0 val proxyLogin: String? get() = prefs.getString(KEY_PROXY_LOGIN, null)?.nullIfEmpty() val proxyPassword: String? get() = prefs.getString(KEY_PROXY_PASSWORD, null)?.nullIfEmpty() var localListOrder: SortOrder get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST) set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) } var historySortOrder: ListSortOrder get() = prefs.getEnumValue(KEY_HISTORY_ORDER, ListSortOrder.LAST_READ) set(value) = prefs.edit { putEnumValue(KEY_HISTORY_ORDER, value) } var allFavoritesSortOrder: ListSortOrder get() = prefs.getEnumValue(KEY_FAVORITES_ORDER, ListSortOrder.NEWEST) set(value) = prefs.edit { putEnumValue(KEY_FAVORITES_ORDER, value) } val isRelatedMangaEnabled: Boolean get() = prefs.getBoolean(KEY_RELATED_MANGA, true) val isWebtoonZoomEnabled: Boolean get() = prefs.getBoolean(KEY_WEBTOON_ZOOM, true) var isWebtoonGapsEnabled: Boolean get() = prefs.getBoolean(KEY_WEBTOON_GAPS, false) set(value) = prefs.edit { putBoolean(KEY_WEBTOON_GAPS, value) } var isWebtoonPullGestureEnabled: Boolean get() = prefs.getBoolean(KEY_WEBTOON_PULL_GESTURE, false) set(value) = prefs.edit { putBoolean(KEY_WEBTOON_PULL_GESTURE, value) } @get:FloatRange(from = 0.0, to = 0.5) val defaultWebtoonZoomOut: Float get() = prefs.getInt(KEY_WEBTOON_ZOOM_OUT, 0).coerceIn(0, 50) / 100f @get:FloatRange(from = 0.0, to = 1.0) var readerAutoscrollSpeed: Float get() = prefs.getFloat(KEY_READER_AUTOSCROLL_SPEED, 0f) set(@FloatRange(from = 0.0, to = 1.0) value) = prefs.edit { putFloat( KEY_READER_AUTOSCROLL_SPEED, value, ) } var isReaderAutoscrollFabVisible: Boolean get() = prefs.getBoolean(KEY_READER_AUTOSCROLL_FAB, true) set(value) = prefs.edit { putBoolean(KEY_READER_AUTOSCROLL_FAB, value) } val isPagesPreloadEnabled: Boolean get() { if (isBackgroundNetworkRestricted()) { return false } val policy = NetworkPolicy.from( prefs.getString(KEY_PAGES_PRELOAD, null), NetworkPolicy.NON_METERED, ) return policy.isNetworkAllowed(connectivityManager) } val is32BitColorsEnabled: Boolean get() = prefs.getBoolean(KEY_32BIT_COLOR, false) val isDiscordRpcEnabled: Boolean get() = prefs.getBoolean(KEY_DISCORD_RPC, false) val isDiscordRpcSkipNsfw: Boolean get() = prefs.getBoolean(KEY_DISCORD_RPC_SKIP_NSFW, false) var discordToken: String? get() = prefs.getString(KEY_DISCORD_TOKEN, null)?.trim()?.nullIfEmpty() set(value) = prefs.edit { putString(KEY_DISCORD_TOKEN, value?.nullIfEmpty()) } val isPeriodicalBackupEnabled: Boolean get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false) val periodicalBackupFrequency: Float get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toFloatOrNull() ?: 7f val periodicalBackupFrequencyMillis: Long get() = (TimeUnit.DAYS.toMillis(1) * periodicalBackupFrequency).toLong() val periodicalBackupMaxCount: Int get() = if (prefs.getBoolean(KEY_BACKUP_PERIODICAL_TRIM, true)) { prefs.getInt(KEY_BACKUP_PERIODICAL_COUNT, 10) } else { Int.MAX_VALUE } var periodicalBackupDirectory: Uri? get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull() set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) } val isBackupTelegramUploadEnabled: Boolean get() = prefs.getBoolean(KEY_BACKUP_TG_ENABLED, false) val backupTelegramChatId: String? get() = prefs.getString(KEY_BACKUP_TG_CHAT, null)?.nullIfEmpty() val isReadingTimeEstimationEnabled: Boolean get() = prefs.getBoolean(KEY_READING_TIME, true) val isPagesSavingAskEnabled: Boolean get() = prefs.getBoolean(KEY_PAGES_SAVE_ASK, true) val isStatsEnabled: Boolean get() = prefs.getBoolean(KEY_STATS_ENABLED, false) val isAutoLocalChaptersCleanupEnabled: Boolean get() = prefs.getBoolean(KEY_CHAPTERS_CLEAR_AUTO, false) fun isPagesCropEnabled(mode: ReaderMode): Boolean { val rawValue = prefs.getStringSet(KEY_READER_CROP, emptySet()) if (rawValue.isNullOrEmpty()) { return false } val needle = if (mode == ReaderMode.WEBTOON) READER_CROP_WEBTOON else READER_CROP_PAGED return needle.toString() in rawValue } fun isTipEnabled(tip: String): Boolean { return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true } fun closeTip(tip: String) { val closedTips = prefs.getStringSet(KEY_TIPS_CLOSED, emptySet()).orEmpty() if (tip in closedTips) { return } prefs.edit { putStringSet(KEY_TIPS_CLOSED, closedTips + tip) } } fun isIncognitoModeEnabled(isNsfw: Boolean): Boolean { return isIncognitoModeEnabled || (isNsfw && incognitoModeForNsfw == TriStateOption.ENABLED) } fun getPagesSaveDir(context: Context): DocumentFile? = prefs.getString(KEY_PAGES_SAVE_DIR, null)?.toUriOrNull()?.let { DocumentFile.fromTreeUri(context, it)?.takeIf { it.canWrite() } } fun setPagesSaveDir(uri: Uri?) { prefs.edit { putString(KEY_PAGES_SAVE_DIR, uri?.toString()) } } fun getMangaListBadges(): Int { val raw = prefs.getStringSet(KEY_MANGA_LIST_BADGES, mangaListBadgesDefault).orEmpty() var result = 0 for (item in raw) { result = result or item.toInt() } return result } fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) { prefs.registerOnSharedPreferenceChangeListener(listener) } fun unsubscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) { prefs.unregisterOnSharedPreferenceChangeListener(listener) } fun observeChanges() = prefs.observeChanges() fun observe(vararg keys: String): Flow = prefs.observeChanges() .filter { key -> key == null || key in keys } .onStart { emit(null) } .flowOn(Dispatchers.IO) fun getAllValues(): Map = prefs.all fun upsertAll(m: Map) = prefs.edit { clear() putAll(m) } private fun isBackgroundNetworkRestricted(): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { connectivityManager.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED } else { false } } companion object { const val TRACK_HISTORY = "history" const val TRACK_FAVOURITES = "favourites" const val KEY_ADBLOCK = "adblock" const val KEY_LIST_MODE = "list_mode_2" const val KEY_LIST_MODE_HISTORY = "list_mode_history" const val KEY_LIST_MODE_FAVORITES = "list_mode_favorites" const val KEY_LIST_MODE_SUGGESTIONS = "list_mode_suggestions" const val KEY_THEME = "theme" const val KEY_COLOR_THEME = "color_theme" const val KEY_THEME_AMOLED = "amoled_theme" const val KEY_OFFLINE_DISABLED = "no_offline" const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear" const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear" const val KEY_COOKIES_CLEAR = "cookies_clear" const val KEY_CHAPTERS_CLEAR = "chapters_clear" const val KEY_CHAPTERS_CLEAR_AUTO = "chapters_clear_auto" const val KEY_THUMBS_CACHE_CLEAR = "thumbs_cache_clear" const val KEY_SEARCH_HISTORY_CLEAR = "search_history_clear" const val KEY_UPDATES_FEED_CLEAR = "updates_feed_clear" const val KEY_GRID_SIZE = "grid_size" const val KEY_GRID_SIZE_PAGES = "grid_size_pages" const val KEY_REMOTE_SOURCES = "remote_sources" const val KEY_LOCAL_STORAGE = "local_storage" const val KEY_READER_DOUBLE_PAGES = "reader_double_pages" const val KEY_READER_DOUBLE_PAGES_SENSITIVITY = "reader_double_pages_sensitivity_2" const val KEY_READER_DOUBLE_FOLDABLE = "reader_double_foldable" const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons" const val KEY_READER_CONTROL_LTR = "reader_taps_ltr" const val KEY_READER_NAVIGATION_INVERTED = "reader_navigation_inverted" const val KEY_READER_FULLSCREEN = "reader_fullscreen" const val KEY_READER_VOLUME_BUTTONS = "reader_volume_buttons" const val KEY_READER_ORIENTATION = "reader_orientation" const val KEY_TRACKER_ENABLED = "tracker_enabled" const val KEY_TRACKER_WIFI_ONLY = "tracker_wifi" const val KEY_TRACKER_FREQUENCY = "tracker_freq" const val KEY_TRACK_SOURCES = "track_sources" const val KEY_TRACK_CATEGORIES = "track_categories" const val KEY_TRACK_WARNING = "track_warning" const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications" const val KEY_TRACKER_NO_NSFW = "tracker_no_nsfw" const val KEY_TRACKER_DOWNLOAD = "tracker_download" const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings" const val KEY_NOTIFICATIONS_SOUND = "notifications_sound" const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate" const val KEY_NOTIFICATIONS_LIGHT = "notifications_light" const val KEY_NOTIFICATIONS_INFO = "tracker_notifications_info" const val KEY_READER_ANIMATION = "reader_animation2" const val KEY_READER_CONTROLS = "reader_controls" const val KEY_READER_MODE = "reader_mode" const val KEY_READER_MODE_DETECT = "reader_mode_detect" const val KEY_READER_CROP = "reader_crop" const val KEY_APP_PASSWORD = "app_password" const val KEY_APP_PASSWORD_NUMERIC = "app_password_num" const val KEY_PROTECT_APP = "protect_app" const val KEY_PROTECT_APP_BIOMETRIC = "protect_app_bio" const val KEY_ZOOM_MODE = "zoom_mode" const val KEY_BACKUP = "backup" const val KEY_RESTORE = "restore" const val KEY_BACKUP_PERIODICAL_ENABLED = "backup_periodic" const val KEY_BACKUP_PERIODICAL_FREQUENCY = "backup_periodic_freq" const val KEY_BACKUP_PERIODICAL_TRIM = "backup_periodic_trim" const val KEY_BACKUP_PERIODICAL_COUNT = "backup_periodic_count" const val KEY_BACKUP_PERIODICAL_OUTPUT = "backup_periodic_output" const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last" const val KEY_HISTORY_GROUPING = "history_grouping" const val KEY_UPDATED_GROUPING = "updated_grouping" const val KEY_PROGRESS_INDICATORS = "progress_indicators" const val KEY_REVERSE_CHAPTERS = "reverse_chapters" const val KEY_GRID_VIEW_CHAPTERS = "grid_view_chapters" const val KEY_INCOGNITO_NSFW = "incognito_nsfw" const val KEY_PAGES_NUMBERS = "pages_numbers" const val KEY_SCREENSHOTS_POLICY = "screenshots_policy" const val KEY_PAGES_PRELOAD = "pages_preload" const val KEY_SUGGESTIONS = "suggestions" const val KEY_SUGGESTIONS_WIFI_ONLY = "suggestions_wifi" const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw" const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags" const val KEY_SUGGESTIONS_DISABLED_SOURCES = "suggestions_disabled_sources" const val KEY_SUGGESTIONS_NOTIFICATIONS = "suggestions_notifications" const val KEY_SHIKIMORI = "shikimori" const val KEY_ANILIST = "anilist" const val KEY_MAL = "mal" const val KEY_KITSU = "kitsu" const val KEY_DOWNLOADS_METERED_NETWORK = "downloads_metered_network" const val KEY_DOWNLOADS_FORMAT = "downloads_format" const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible" const val KEY_DOH = "doh" const val KEY_EXIT_CONFIRM = "exit_confirm" const val KEY_INCOGNITO_MODE = "incognito" const val KEY_READER_MULTITASK = "reader_multitask" const val KEY_SYNC = "sync" const val KEY_SYNC_SETTINGS = "sync_settings" const val KEY_READER_BAR = "reader_bar" const val KEY_READER_BAR_TRANSPARENT = "reader_bar_transparent" const val KEY_READER_CHAPTER_TOAST = "reader_chapter_toast" const val KEY_READER_BACKGROUND = "reader_background" const val KEY_READER_SCREEN_ON = "reader_screen_on" const val KEY_SHORTCUTS = "dynamic_shortcuts" const val KEY_READER_TAP_ACTIONS = "reader_tap_actions" const val KEY_READER_OPTIMIZE = "reader_optimize" const val KEY_LOCAL_LIST_ORDER = "local_order" const val KEY_HISTORY_ORDER = "history_order" const val KEY_FAVORITES_ORDER = "fav_order" const val KEY_WEBTOON_GAPS = "webtoon_gaps" const val KEY_WEBTOON_ZOOM = "webtoon_zoom" const val KEY_WEBTOON_ZOOM_OUT = "webtoon_zoom_out" const val KEY_WEBTOON_PULL_GESTURE = "webtoon_pull_gesture" const val KEY_PREFETCH_CONTENT = "prefetch_content" const val KEY_APP_LOCALE = "app_locale" const val KEY_SOURCES_GRID = "sources_grid" const val KEY_UPDATES_UNSTABLE = "updates_unstable" const val KEY_TIPS_CLOSED = "tips_closed" const val KEY_SSL_BYPASS = "ssl_bypass" const val KEY_READER_AUTOSCROLL_SPEED = "as_speed" const val KEY_READER_AUTOSCROLL_FAB = "as_fab" const val KEY_MIRROR_SWITCHING = "mirror_switching" const val KEY_PROXY = "proxy" const val KEY_PROXY_TYPE = "proxy_type_2" const val KEY_PROXY_ADDRESS = "proxy_address" const val KEY_PROXY_PORT = "proxy_port" const val KEY_PROXY_AUTH = "proxy_auth" const val KEY_PROXY_LOGIN = "proxy_login" const val KEY_PROXY_PASSWORD = "proxy_password" const val KEY_IMAGES_PROXY = "images_proxy_2" const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs" const val KEY_DISABLE_NSFW = "no_nsfw" const val KEY_RELATED_MANGA = "related_manga" const val KEY_NAV_MAIN = "nav_main" const val KEY_NAV_LABELS = "nav_labels" const val KEY_NAV_PINNED = "nav_pinned" const val KEY_MAIN_FAB = "main_fab" const val KEY_32BIT_COLOR = "enhanced_colors" const val KEY_SOURCES_ORDER = "sources_sort_order" const val KEY_SOURCES_CATALOG = "sources_catalog" const val KEY_CF_BRIGHTNESS = "cf_brightness" const val KEY_CF_CONTRAST = "cf_contrast" const val KEY_CF_INVERTED = "cf_inverted" const val KEY_CF_GRAYSCALE = "cf_grayscale" const val KEY_CF_BOOK = "cf_book" const val KEY_PAGES_TAB = "pages_tab" const val KEY_DETAILS_TAB = "details_tab" const val KEY_DETAILS_LAST_TAB = "details_last_tab" const val KEY_READING_TIME = "reading_time" const val KEY_PAGES_SAVE_DIR = "pages_dir" const val KEY_PAGES_SAVE_ASK = "pages_dir_ask" const val KEY_STATS_ENABLED = "stats_on" const val KEY_FEED_HEADER = "feed_header" const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types" const val KEY_SOURCES_VERSION = "sources_version" const val KEY_SOURCES_ENABLED_ALL = "sources_enabled_all" const val KEY_QUICK_FILTER = "quick_filter" const val KEY_COLLAPSE_DESCRIPTION = "description_collapse" const val KEY_BACKUP_TG_ENABLED = "backup_periodic_tg_enabled" const val KEY_BACKUP_TG_CHAT = "backup_periodic_tg_chat_id" const val KEY_MANGA_LIST_BADGES = "manga_list_badges" const val KEY_TAGS_WARNINGS = "tags_warnings" const val KEY_DISCORD_RPC = "discord_rpc" const val KEY_DISCORD_RPC_SKIP_NSFW = "discord_rpc_skip_nsfw" const val KEY_DISCORD_TOKEN = "discord_token" // keys for non-persistent preferences const val KEY_APP_VERSION = "app_version" const val KEY_IGNORE_DOZE = "ignore_dose" const val KEY_TRACKER_DEBUG = "tracker_debug" const val KEY_LINK_WEBLATE = "about_app_translation" const val KEY_LINK_TELEGRAM = "about_telegram" const val KEY_LINK_GITHUB = "about_github" const val KEY_LINK_MANUAL = "about_help" const val KEY_PROXY_TEST = "proxy_test" const val KEY_OPEN_BROWSER = "open_browser" const val KEY_HANDLE_LINKS = "handle_links" const val KEY_BACKUP_TG = "backup_periodic_tg" const val KEY_BACKUP_TG_OPEN = "backup_periodic_tg_open" const val KEY_BACKUP_TG_TEST = "backup_periodic_tg_test" const val KEY_CLEAR_MANGA_DATA = "manga_data_clear" const val KEY_STORAGE_USAGE = "storage_usage" const val KEY_WEBVIEW_CLEAR = "webview_clear" // old keys are for migration only private const val KEY_IMAGES_PROXY_OLD = "images_proxy" // values private const val READER_CROP_PAGED = 1 private const val READER_CROP_WEBTOON = 2 } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettingsObserver.kt ================================================ package org.koitharu.kotatsu.core.prefs import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transform fun AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() -> T) = flow { var lastValue: T = valueProducer() emit(lastValue) observeChanges().collect { if (it == key) { val value = valueProducer() if (value != lastValue) { emit(value) } lastValue = value } } } fun AppSettings.observeAsStateFlow( scope: CoroutineScope, key: String, valueProducer: AppSettings.() -> T, ): StateFlow = observeChanges().transform { if (it == key) { emit(valueProducer()) } }.stateIn(scope, SharingStarted.Eagerly, valueProducer()) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppWidgetConfig.kt ================================================ package org.koitharu.kotatsu.core.prefs import android.appwidget.AppWidgetProvider import android.content.Context import android.os.Build import androidx.core.content.edit private const val CATEGORY_ID = "cat_id" private const val BACKGROUND = "bg" class AppWidgetConfig( context: Context, cls: Class, val widgetId: Int, ) { private val prefs = context.getSharedPreferences("appwidget_${cls.simpleName}_$widgetId", Context.MODE_PRIVATE) var categoryId: Long get() = prefs.getLong(CATEGORY_ID, 0L) set(value) = prefs.edit { putLong(CATEGORY_ID, value) } var hasBackground: Boolean get() = prefs.getBoolean(BACKGROUND, Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) set(value) = prefs.edit { putBoolean(BACKGROUND, value) } fun clear() { prefs.edit { clear() } } fun copyFrom(other: AppWidgetConfig) { prefs.edit { clear() putLong(CATEGORY_ID, other.categoryId) putBoolean(BACKGROUND, other.hasBackground) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ColorScheme.kt ================================================ package org.koitharu.kotatsu.core.prefs import androidx.annotation.Keep import androidx.annotation.StringRes import androidx.annotation.StyleRes import com.google.android.material.color.DynamicColors import org.koitharu.kotatsu.R import org.koitharu.kotatsu.parsers.util.find @Keep enum class ColorScheme( @StyleRes val styleResId: Int, @StringRes val titleResId: Int, ) { DEFAULT(R.style.ThemeOverlay_Kotatsu_Totoro, R.string.theme_name_totoro), MONET(R.style.ThemeOverlay_Kotatsu_Monet, R.string.theme_name_dynamic), EXPRESSIVE(R.style.ThemeOverlay_Kotatsu_Expressive, R.string.theme_name_expressive), MIKU(R.style.ThemeOverlay_Kotatsu_Miku, R.string.theme_name_miku), RENA(R.style.ThemeOverlay_Kotatsu_Asuka, R.string.theme_name_asuka), FROG(R.style.ThemeOverlay_Kotatsu_Mion, R.string.theme_name_mion), BLUEBERRY(R.style.ThemeOverlay_Kotatsu_Rikka, R.string.theme_name_rikka), SAKURA(R.style.ThemeOverlay_Kotatsu_Sakura, R.string.theme_name_sakura), MAMIMI(R.style.ThemeOverlay_Kotatsu_Mamimi, R.string.theme_name_mamimi), KANADE(R.style.ThemeOverlay_Kotatsu_Kanade, R.string.theme_name_kanade), ITSUKA(R.style.ThemeOverlay_Kotatsu_Itsuka, R.string.theme_name_itsuka), ; companion object { val default: ColorScheme get() = if (DynamicColors.isDynamicColorAvailable()) { MONET } else { DEFAULT } fun getAvailableList(): List { val list = ColorScheme.entries.toMutableList() if (!DynamicColors.isDynamicColorAvailable()) { list.remove(MONET) list.remove(EXPRESSIVE) } return list } fun safeValueOf(name: String): ColorScheme? { return ColorScheme.entries.find(name) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/DownloadFormat.kt ================================================ package org.koitharu.kotatsu.core.prefs import androidx.annotation.Keep @Keep enum class DownloadFormat { AUTOMATIC, SINGLE_CBZ, MULTIPLE_CBZ, } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ListMode.kt ================================================ package org.koitharu.kotatsu.core.prefs import androidx.annotation.Keep @Keep enum class ListMode { LIST, DETAILED_LIST, GRID; } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/NavItem.kt ================================================ package org.koitharu.kotatsu.core.prefs import androidx.annotation.DrawableRes import androidx.annotation.IdRes import androidx.annotation.Keep import androidx.annotation.StringRes import org.koitharu.kotatsu.R @Keep enum class NavItem( @IdRes val id: Int, @StringRes val title: Int, @DrawableRes val icon: Int, ) { HISTORY(R.id.nav_history, R.string.history, R.drawable.ic_history_selector), FAVORITES(R.id.nav_favorites, R.string.favourites, R.drawable.ic_favourites_selector), LOCAL(R.id.nav_local, R.string.on_device, R.drawable.ic_storage_selector), EXPLORE(R.id.nav_explore, R.string.explore, R.drawable.ic_explore_selector), SUGGESTIONS(R.id.nav_suggestions, R.string.suggestions, R.drawable.ic_suggestion_selector), FEED(R.id.nav_feed, R.string.feed, R.drawable.ic_feed_selector), UPDATED(R.id.nav_updated, R.string.updated, R.drawable.ic_updated_selector), BOOKMARKS(R.id.nav_bookmarks, R.string.bookmarks, R.drawable.ic_bookmark_selector), ; fun isAvailable(settings: AppSettings): Boolean = when (this) { SUGGESTIONS -> settings.isSuggestionsEnabled UPDATED, FEED -> settings.isTrackerEnabled else -> true } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/NetworkPolicy.kt ================================================ package org.koitharu.kotatsu.core.prefs import android.net.ConnectivityManager import androidx.annotation.Keep @Keep enum class NetworkPolicy( private val key: Int, ) { NEVER(0), ALWAYS(1), NON_METERED(2); fun isNetworkAllowed(cm: ConnectivityManager) = when (this) { NEVER -> false ALWAYS -> true NON_METERED -> !cm.isActiveNetworkMetered } companion object { fun from(key: String?, default: NetworkPolicy): NetworkPolicy { val intKey = key?.toIntOrNull() ?: return default return NetworkPolicy.entries.find { it.key == intKey } ?: default } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ProgressIndicatorMode.kt ================================================ package org.koitharu.kotatsu.core.prefs import androidx.annotation.Keep @Keep enum class ProgressIndicatorMode { NONE, PERCENT_READ, PERCENT_LEFT, CHAPTERS_READ, CHAPTERS_LEFT; } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderAnimation.kt ================================================ package org.koitharu.kotatsu.core.prefs import androidx.annotation.Keep @Keep enum class ReaderAnimation { // Do not rename this NONE, DEFAULT, ADVANCED; } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderBackground.kt ================================================ package org.koitharu.kotatsu.core.prefs import android.content.Context import android.graphics.drawable.Drawable import android.view.ContextThemeWrapper import androidx.annotation.Keep import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toDrawable import org.koitharu.kotatsu.core.util.ext.getThemeDrawable import org.koitharu.kotatsu.core.util.ext.isNightMode import com.google.android.material.R as materialR @Keep enum class ReaderBackground { DEFAULT, LIGHT, DARK, WHITE, BLACK; fun resolve(context: Context): Drawable? = when (this) { DEFAULT -> context.getThemeDrawable(android.R.attr.windowBackground) LIGHT -> ContextThemeWrapper(context, materialR.style.ThemeOverlay_Material3_Light) .getThemeDrawable(android.R.attr.windowBackground) DARK -> ContextThemeWrapper(context, materialR.style.ThemeOverlay_Material3_Dark) .getThemeDrawable(android.R.attr.windowBackground) WHITE -> ContextCompat.getColor(context, android.R.color.white).toDrawable() BLACK -> ContextCompat.getColor(context, android.R.color.black).toDrawable() } fun isLight(context: Context): Boolean = when (this) { DEFAULT -> !context.resources.isNightMode LIGHT, WHITE -> true DARK, BLACK -> false } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderControl.kt ================================================ package org.koitharu.kotatsu.core.prefs import java.util.EnumSet enum class ReaderControl { PREV_CHAPTER, NEXT_CHAPTER, SLIDER, PAGES_SHEET, SCREEN_ROTATION, SAVE_PAGE, TIMER, BOOKMARK; companion object { val DEFAULT: Set = EnumSet.of( PREV_CHAPTER, NEXT_CHAPTER, SLIDER, PAGES_SHEET, ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderMode.kt ================================================ package org.koitharu.kotatsu.core.prefs import androidx.annotation.Keep @Keep enum class ReaderMode(val id: Int) { STANDARD(1), REVERSED(3), VERTICAL(4), WEBTOON(2), ; companion object { fun valueOf(id: Int) = entries.firstOrNull { it.id == id } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ScreenshotsPolicy.kt ================================================ package org.koitharu.kotatsu.core.prefs import androidx.annotation.Keep @Keep enum class ScreenshotsPolicy { // Do not rename this ALLOW, BLOCK_NSFW, BLOCK_INCOGNITO, BLOCK_ALL; } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SearchSuggestionType.kt ================================================ package org.koitharu.kotatsu.core.prefs import androidx.annotation.Keep import androidx.annotation.StringRes import org.koitharu.kotatsu.R @Keep enum class SearchSuggestionType( @StringRes val titleResId: Int, ) { GENRES(R.string.genres), QUERIES_RECENT(R.string.recent_queries), QUERIES_SUGGEST(R.string.suggested_queries), MANGA(R.string.content_type_manga), SOURCES(R.string.remote_sources), RECENT_SOURCES(R.string.recent_sources), AUTHORS(R.string.authors), } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SourceSettings.kt ================================================ package org.koitharu.kotatsu.core.prefs import android.content.Context import android.content.SharedPreferences.OnSharedPreferenceChangeListener import androidx.core.content.edit import org.koitharu.kotatsu.core.util.ext.getEnumValue import org.koitharu.kotatsu.core.util.ext.putEnumValue import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.MangaSourceConfig import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.settings.utils.validation.DomainValidator import java.io.File class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig { private val prefs = context.getSharedPreferences( source.name.replace(File.separatorChar, '$'), Context.MODE_PRIVATE, ) var defaultSortOrder: SortOrder? get() = prefs.getEnumValue(KEY_SORT_ORDER, SortOrder::class.java) set(value) = prefs.edit { putEnumValue(KEY_SORT_ORDER, value) } val isSlowdownEnabled: Boolean get() = prefs.getBoolean(KEY_SLOWDOWN, false) val isCaptchaNotificationsDisabled: Boolean get() = prefs.getBoolean(KEY_NO_CAPTCHA, false) @Suppress("UNCHECKED_CAST") override fun get(key: ConfigKey): T { return when (key) { is ConfigKey.UserAgent -> prefs.getString(key.key, key.defaultValue) .ifNullOrEmpty { key.defaultValue } .sanitizeHeaderValue() is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue) ?.trim() ?.takeIf { DomainValidator.isValidDomain(it) } ?: key.defaultValue is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue) is ConfigKey.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue) is ConfigKey.PreferredImageServer -> prefs.getString(key.key, key.defaultValue)?.nullIfEmpty() } as T } operator fun set(key: ConfigKey, value: T) = prefs.edit { when (key) { is ConfigKey.Domain -> putString(key.key, value as String?) is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean) is ConfigKey.UserAgent -> putString(key.key, (value as String?)?.sanitizeHeaderValue()) is ConfigKey.SplitByTranslations -> putBoolean(key.key, value as Boolean) is ConfigKey.PreferredImageServer -> putString(key.key, value as String? ?: "") } } fun subscribe(listener: OnSharedPreferenceChangeListener) { prefs.registerOnSharedPreferenceChangeListener(listener) } fun unsubscribe(listener: OnSharedPreferenceChangeListener) { prefs.unregisterOnSharedPreferenceChangeListener(listener) } companion object { const val KEY_DOMAIN = "domain" const val KEY_NO_CAPTCHA = "no_captcha" const val KEY_SLOWDOWN = "slowdown" const val KEY_SORT_ORDER = "sort_order" } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/TrackerDownloadStrategy.kt ================================================ package org.koitharu.kotatsu.core.prefs import androidx.annotation.Keep @Keep enum class TrackerDownloadStrategy { DISABLED, DOWNLOADED; } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/TriStateOption.kt ================================================ package org.koitharu.kotatsu.core.prefs import androidx.annotation.Keep @Keep enum class TriStateOption { ENABLED, ASK, DISABLED; } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/AlertDialogFragment.kt ================================================ package org.koitharu.kotatsu.core.ui import android.app.Dialog import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.annotation.CallSuper import androidx.appcompat.app.AlertDialog import androidx.fragment.app.DialogFragment import androidx.viewbinding.ViewBinding import com.google.android.material.dialog.MaterialAlertDialogBuilder abstract class AlertDialogFragment : DialogFragment() { var viewBinding: B? = null private set final override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val binding = onCreateViewBinding(layoutInflater, null) viewBinding = binding return MaterialAlertDialogBuilder(requireContext(), theme) .setView(binding.root) .run(::onBuildDialog) .create() .also(::onDialogCreated) } final override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ) = viewBinding?.root final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) onViewBindingCreated(requireViewBinding(), savedInstanceState) } @CallSuper override fun onDestroyView() { viewBinding = null super.onDestroyView() } open fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder = builder open fun onDialogCreated(dialog: AlertDialog) = Unit fun requireViewBinding(): B = checkNotNull(viewBinding) { "Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()." } protected abstract fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): B protected open fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) = Unit } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseActivity.kt ================================================ package org.koitharu.kotatsu.core.ui import android.content.Context import android.content.Intent import android.content.res.Configuration import android.os.Build import android.os.Bundle import android.view.KeyEvent import android.view.View import android.widget.Toast import androidx.activity.enableEdgeToEdge import androidx.annotation.CallSuper import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.Toolbar import androidx.core.app.ActivityCompat import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.ViewCompat import androidx.fragment.app.FragmentManager import androidx.viewbinding.ViewBinding import dagger.hilt.android.EntryPointAccessors import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper import androidx.appcompat.R as appcompatR abstract class BaseActivity : AppCompatActivity(), OnApplyWindowInsetsListener, ScreenshotPolicyHelper.ContentContainer { private var isAmoledTheme = false lateinit var viewBinding: B private set protected lateinit var exceptionResolver: ExceptionResolver private set @JvmField val actionModeDelegate = ActionModeDelegate() private lateinit var entryPoint: BaseActivityEntryPoint override fun attachBaseContext(newBase: Context) { entryPoint = EntryPointAccessors.fromApplication(newBase.applicationContext) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { AppCompatDelegate.setApplicationLocales(entryPoint.settings.appLocales) } super.attachBaseContext(newBase) } override fun onCreate(savedInstanceState: Bundle?) { val settings = entryPoint.settings isAmoledTheme = settings.isAmoledTheme setTheme(settings.colorScheme.styleResId) if (isAmoledTheme) { setTheme(R.style.ThemeOverlay_Kotatsu_Amoled) } putDataToExtras(intent) exceptionResolver = entryPoint.exceptionResolverFactory.create(this) enableEdgeToEdge() super.onCreate(savedInstanceState) } override fun onPostCreate(savedInstanceState: Bundle?) { super.onPostCreate(savedInstanceState) onBackPressedDispatcher.addCallback(actionModeDelegate) } override fun onNewIntent(intent: Intent) { putDataToExtras(intent) super.onNewIntent(intent) } @Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR) override fun setContentView(layoutResID: Int) = throw UnsupportedOperationException() @Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR) override fun setContentView(view: View?) = throw UnsupportedOperationException() protected fun setContentView(binding: B) { this.viewBinding = binding super.setContentView(binding.root) ViewCompat.setOnApplyWindowInsetsListener(binding.root, this) val toolbar = (binding.root.findViewById(R.id.toolbar) as? Toolbar) toolbar?.let(this::setSupportActionBar) } protected fun setDisplayHomeAsUp(isEnabled: Boolean, showUpAsClose: Boolean) { supportActionBar?.run { setDisplayHomeAsUpEnabled(isEnabled) if (showUpAsClose) { setHomeAsUpIndicator(appcompatR.drawable.abc_ic_clear_material) } } } override fun onSupportNavigateUp(): Boolean { val fm = supportFragmentManager if (fm.isStateSaved) { return false } if (fm.backStackEntryCount > 0) { fm.popBackStack() } else { dispatchNavigateUp() } return true } override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { if (BuildConfig.DEBUG) { if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) { ActivityCompat.recreate(this) return true } else if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) { throw RuntimeException("Test crash") } } return super.onKeyDown(keyCode, event) } protected fun isDarkAmoledTheme(): Boolean { val uiMode = resources.configuration.uiMode val isNight = uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES return isNight && isAmoledTheme } @CallSuper override fun onSupportActionModeStarted(mode: ActionMode) { super.onSupportActionModeStarted(mode) actionModeDelegate.onSupportActionModeStarted(mode, window) } @CallSuper override fun onSupportActionModeFinished(mode: ActionMode) { super.onSupportActionModeFinished(mode) actionModeDelegate.onSupportActionModeFinished(mode, window) } protected open fun dispatchNavigateUp() { val upIntent = parentActivityIntent if (upIntent != null) { if (!navigateUpTo(upIntent)) { startActivity(upIntent) } } else { finishAfterTransition() } } override fun isNsfwContent(): Flow = flowOf(false) private fun putDataToExtras(intent: Intent?) { intent?.putExtra(AppRouter.KEY_DATA, intent.data) } protected fun setContentViewWebViewSafe(viewBindingProducer: () -> B): Boolean { return try { setContentView(viewBindingProducer()) true } catch (e: Exception) { if (e.isWebViewUnavailable()) { Toast.makeText(this, R.string.web_view_unavailable, Toast.LENGTH_LONG).show() finishAfterTransition() false } else { throw e } } } protected fun hasViewBinding() = ::viewBinding.isInitialized } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseActivityEntryPoint.kt ================================================ package org.koitharu.kotatsu.core.ui import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.prefs.AppSettings @EntryPoint @InstallIn(SingletonComponent::class) interface BaseActivityEntryPoint { val settings: AppSettings val exceptionResolverFactory: ExceptionResolver.Factory } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseAppWidgetProvider.kt ================================================ package org.koitharu.kotatsu.core.ui import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider import android.content.Context import android.widget.RemoteViews import androidx.annotation.CallSuper import org.koitharu.kotatsu.core.prefs.AppWidgetConfig abstract class BaseAppWidgetProvider : AppWidgetProvider() { @CallSuper override fun onUpdate( context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray ) { appWidgetIds.forEach { id -> val config = AppWidgetConfig(context, javaClass, id) val views = onUpdateWidget(context, config) appWidgetManager.updateAppWidget(id, views) } } override fun onDeleted(context: Context, appWidgetIds: IntArray) { super.onDeleted(context, appWidgetIds) for (id in appWidgetIds) { AppWidgetConfig(context, javaClass, id).clear() } } override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) { super.onRestored(context, oldWidgetIds, newWidgetIds) if (oldWidgetIds.size != newWidgetIds.size) { return } for (i in oldWidgetIds.indices) { val oldId = oldWidgetIds[i] val newId = newWidgetIds[i] val oldConfig = AppWidgetConfig(context, javaClass, oldId) val newConfig = AppWidgetConfig(context, javaClass, newId) newConfig.copyFrom(oldConfig) oldConfig.clear() } } protected abstract fun onUpdateWidget( context: Context, config: AppWidgetConfig, ): RemoteViews } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseFragment.kt ================================================ package org.koitharu.kotatsu.core.ui import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.ViewCompat import androidx.fragment.app.Fragment import androidx.viewbinding.ViewBinding import dagger.hilt.android.EntryPointAccessors import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate abstract class BaseFragment : OnApplyWindowInsetsListener, Fragment() { var viewBinding: B? = null private set protected lateinit var exceptionResolver: ExceptionResolver private set protected val actionModeDelegate: ActionModeDelegate get() = (requireActivity() as BaseActivity<*>).actionModeDelegate override fun onAttach(context: Context) { super.onAttach(context) val entryPoint = EntryPointAccessors.fromApplication(context) exceptionResolver = entryPoint.exceptionResolverFactory.create(this) } final override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { val binding = onCreateViewBinding(inflater, container) viewBinding = binding return binding.root } final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) ViewCompat.setOnApplyWindowInsetsListener(view, this) onViewBindingCreated(requireViewBinding(), savedInstanceState) } override fun onDestroyView() { viewBinding = null super.onDestroyView() } fun requireViewBinding(): B = checkNotNull(viewBinding) { "Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()." } protected abstract fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): B protected open fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) = Unit } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseFullscreenActivity.kt ================================================ package org.koitharu.kotatsu.core.ui import android.graphics.Color import android.os.Build import android.os.Bundle import android.view.WindowManager import androidx.core.content.ContextCompat import androidx.viewbinding.ViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.util.SystemUiController abstract class BaseFullscreenActivity : BaseActivity() { protected lateinit var systemUiController: SystemUiController override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) with(window) { systemUiController = SystemUiController(this) statusBarColor = Color.TRANSPARENT navigationBarColor = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) { ContextCompat.getColor(this@BaseFullscreenActivity, R.color.dim) } else { Color.TRANSPARENT } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES } } systemUiController.setSystemUiVisible(true) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseListAdapter.kt ================================================ package org.koitharu.kotatsu.core.ui import androidx.recyclerview.widget.AsyncDifferConfig import androidx.recyclerview.widget.AsyncListDiffer.ListListener import com.hannesdorfmann.adapterdelegates4.AdapterDelegate import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.onStart import org.koitharu.kotatsu.core.util.ContinuationResumeRunnable import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListModel import kotlin.coroutines.suspendCoroutine open class BaseListAdapter : AsyncListDifferDelegationAdapter( AsyncDifferConfig.Builder(ListModelDiffCallback()) .setBackgroundThreadExecutor(Dispatchers.Default.limitedParallelism(2).asExecutor()) .build(), ), FlowCollector?> { override suspend fun emit(value: List?) = suspendCoroutine { cont -> setItems(value.orEmpty(), ContinuationResumeRunnable(cont)) } fun addDelegate(type: ListItemType, delegate: AdapterDelegate>): BaseListAdapter { delegatesManager.addDelegate(type.ordinal, delegate) return this } fun addListListener(listListener: ListListener): BaseListAdapter { differ.addListListener(listListener) return this } fun removeListListener(listListener: ListListener) { differ.removeListListener(listListener) } fun findHeader(position: Int): ListHeader? { val snapshot = items for (i in (0..position).reversed()) { val item = snapshot.getOrNull(i) ?: continue if (item is ListHeader) { return item } } return null } fun observeItems(): Flow> = callbackFlow { val listListener = ListListener { _, list -> trySendBlocking(list) } addListListener(listListener) awaitClose { removeListListener(listListener) } }.onStart { emit(items) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt ================================================ package org.koitharu.kotatsu.core.ui import android.content.Context import android.graphics.drawable.Drawable import android.os.Bundle import android.view.View import androidx.annotation.StringRes import androidx.core.content.ContextCompat import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceScreen import androidx.preference.get import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.EntryPointAccessors import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.container import org.koitharu.kotatsu.core.util.ext.end import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeDrawable import org.koitharu.kotatsu.core.util.ext.parentView import org.koitharu.kotatsu.core.util.ext.start import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.settings.SettingsActivity import javax.inject.Inject import com.google.android.material.R as materialR @AndroidEntryPoint abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : PreferenceFragmentCompat(), OnApplyWindowInsetsListener, RecyclerViewOwner { protected lateinit var exceptionResolver: ExceptionResolver private set @Inject lateinit var settings: AppSettings override val recyclerView: RecyclerView? get() = listView override fun onAttach(context: Context) { super.onAttach(context) val entryPoint = EntryPointAccessors.fromApplication(context) exceptionResolver = entryPoint.exceptionResolverFactory.create(this) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) ViewCompat.setOnApplyWindowInsetsListener(view, this) val themedContext = (view.parentView ?: view).context view.setBackgroundColor(themedContext.getThemeColor(android.R.attr.colorBackground)) listView.clipToPadding = false } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val barsInsets = insets.systemBarsInsets val isTablet = !resources.getBoolean(R.bool.is_tablet) val isMaster = container?.id == R.id.container_master listView.setPaddingRelative( if (isTablet && !isMaster) 0 else barsInsets.start(v), 0, if (isTablet && isMaster) 0 else barsInsets.end(v), barsInsets.bottom, ) return insets.consumeAllSystemBarsInsets() } override fun onResume() { super.onResume() setTitle(if (titleId != 0) getString(titleId) else null) arguments?.getString(SettingsActivity.ARG_PREF_KEY)?.let { focusPreference(it) arguments?.remove(SettingsActivity.ARG_PREF_KEY) } } protected open fun setTitle(title: CharSequence?) { (activity as? SettingsActivity)?.setSectionTitle(title) } protected fun getWarningIcon(): Drawable? = context?.let { ctx -> ContextCompat.getDrawable(ctx, R.drawable.ic_alert_outline)?.also { it.setTint(ContextCompat.getColor(ctx, R.color.warning)) } } private fun focusPreference(key: String) { val pref = findPreference(key) if (pref == null) { scrollToPreference(key) return } scrollToPreference(pref) val prefIndex = preferenceScreen.indexOf(key) val view = if (prefIndex >= 0) { listView.findViewHolderForAdapterPosition(prefIndex)?.itemView ?: return } else { return } view.context.getThemeDrawable(materialR.attr.colorTertiaryContainer)?.let { view.background = it } } private fun PreferenceScreen.indexOf(key: String): Int { for (i in 0 until preferenceCount) { if (get(i).key == key) { return i } } return -1 } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseViewModel.kt ================================================ package org.koitharu.kotatsu.core.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koitharu.kotatsu.core.util.ext.EventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext abstract class BaseViewModel : ViewModel() { @JvmField protected val loadingCounter = MutableStateFlow(0) @JvmField protected val errorEvent = MutableEventFlow() val onError: EventFlow get() = errorEvent val isLoading: StateFlow = loadingCounter.map { it > 0 } .stateIn(viewModelScope, SharingStarted.Lazily, loadingCounter.value > 0) protected fun launchJob( context: CoroutineContext = EmptyCoroutineContext, start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> Unit ): Job = viewModelScope.launch(context.withDefaultExceptionHandler(), start, block) protected fun launchLoadingJob( context: CoroutineContext = EmptyCoroutineContext, start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> Unit ): Job = viewModelScope.launch(context.withDefaultExceptionHandler(), start) { loadingCounter.increment() try { block() } finally { loadingCounter.decrement() } } protected fun Flow.withLoading() = onStart { loadingCounter.increment() }.onCompletion { loadingCounter.decrement() } protected fun Flow.withErrorHandling() = catch { error -> error.printStackTraceDebug() errorEvent.call(error) } protected inline fun withLoading(block: () -> T): T = try { loadingCounter.increment() block() } finally { loadingCounter.decrement() } protected fun MutableStateFlow.increment() = update { it + 1 } protected fun MutableStateFlow.decrement() = update { it - 1 } private fun CoroutineContext.withDefaultExceptionHandler() = if (this[CoroutineExceptionHandler.Key] is EventExceptionHandler) { this } else { this + EventExceptionHandler(errorEvent) } protected object SkipErrors : AbstractCoroutineContextElement(Key) { private object Key : CoroutineContext.Key } protected class EventExceptionHandler( private val event: MutableEventFlow, ) : AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler { override fun handleException(context: CoroutineContext, exception: Throwable) { exception.printStackTraceDebug() if (context[SkipErrors.key] == null && exception !is CancellationException) { event.call(exception) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt ================================================ package org.koitharu.kotatsu.core.ui import android.app.Notification import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.PatternMatcher import androidx.annotation.AnyThread import androidx.annotation.WorkerThread import androidx.core.app.PendingIntentCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat import androidx.core.net.toUri import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug abstract class CoroutineIntentService : BaseService() { private val mutex = Mutex() final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { super.onStartCommand(intent, flags, startId) launchCoroutine(intent, startId) return START_REDELIVER_INTENT } private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch { val intentJobContext = IntentJobContextImpl(startId, this) mutex.withLock { try { if (intent != null) { withContext(Dispatchers.Default) { intentJobContext.processIntent(intent) } } } catch (e: CancellationException) { throw e } catch (e: Throwable) { e.printStackTraceDebug() intentJobContext.onError(e) } finally { intentJobContext.stop() } } } @WorkerThread protected abstract suspend fun IntentJobContext.processIntent(intent: Intent) @AnyThread protected abstract fun IntentJobContext.onError(error: Throwable) interface IntentJobContext : CoroutineScope { val startId: Int fun getCancelIntent(): PendingIntent? fun setForeground(id: Int, notification: Notification, serviceType: Int) } protected inner class IntentJobContextImpl( override val startId: Int, private val scope: CoroutineScope, ) : IntentJobContext, CoroutineScope by scope { private var cancelReceiver: CancelReceiver? = null private var isStopped = false private var isForeground = false override fun getCancelIntent(): PendingIntent? { ensureHasCancelReceiver() return PendingIntentCompat.getBroadcast( applicationContext, 0, createCancelIntent(this@CoroutineIntentService, startId), PendingIntent.FLAG_UPDATE_CURRENT, false, ) } override fun setForeground(id: Int, notification: Notification, serviceType: Int) { ServiceCompat.startForeground(this@CoroutineIntentService, id, notification, serviceType) isForeground = true } fun stop() { synchronized(this) { cancelReceiver?.let { try { unregisterReceiver(it) } catch (e: IllegalArgumentException) { e.printStackTraceDebug() } } isStopped = true } if (isForeground) { ServiceCompat.stopForeground(this@CoroutineIntentService, ServiceCompat.STOP_FOREGROUND_REMOVE) } stopSelf(startId) } private fun ensureHasCancelReceiver() { if (cancelReceiver == null && !isStopped) { synchronized(this) { if (cancelReceiver == null && !isStopped) { val job = coroutineContext[Job] ?: return CancelReceiver(job).let { receiver -> ContextCompat.registerReceiver( applicationContext, receiver, createIntentFilter(this@CoroutineIntentService, startId), ContextCompat.RECEIVER_NOT_EXPORTED, ) cancelReceiver = receiver } } } } } } private class CancelReceiver( private val job: Job ) : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { job.cancel() } } private companion object { private const val SCHEME = "startid" private const val ACTION_SUFFIX_CANCEL = ".ACTION_CANCEL" fun createIntentFilter(service: CoroutineIntentService, startId: Int): IntentFilter { val intentFilter = IntentFilter(cancelAction(service)) intentFilter.addDataScheme(SCHEME) intentFilter.addDataPath(startId.toString(), PatternMatcher.PATTERN_LITERAL) return intentFilter } fun createCancelIntent(service: CoroutineIntentService, startId: Int): Intent { return Intent(cancelAction(service)) .setData("$SCHEME://$startId".toUri()) } private fun cancelAction(service: CoroutineIntentService) = service.javaClass.name + ACTION_SUFFIX_CANCEL } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/DefaultActivityLifecycleCallbacks.kt ================================================ package org.koitharu.kotatsu.core.ui import android.app.Activity import android.app.Application.ActivityLifecycleCallbacks import android.os.Bundle interface DefaultActivityLifecycleCallbacks : ActivityLifecycleCallbacks { override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit override fun onActivityStarted(activity: Activity) = Unit override fun onActivityResumed(activity: Activity) = Unit override fun onActivityPaused(activity: Activity) = Unit override fun onActivityStopped(activity: Activity) = Unit override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit override fun onActivityDestroyed(activity: Activity) = Unit } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/FragmentContainerActivity.kt ================================================ package org.koitharu.kotatsu.core.ui import android.os.Bundle import android.view.View import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.fragment.app.commit import com.google.android.material.appbar.AppBarLayout import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.consumeSystemBarsInsets import org.koitharu.kotatsu.databinding.ActivityContainerBinding import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner @AndroidEntryPoint abstract class FragmentContainerActivity(private val fragmentClass: Class) : BaseActivity(), AppBarOwner, SnackbarOwner { override val appBar: AppBarLayout get() = viewBinding.appbar override val snackbarHost: CoordinatorLayout get() = viewBinding.root override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityContainerBinding.inflate(layoutInflater)) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) val fm = supportFragmentManager if (fm.findFragmentById(R.id.container) == null) { fm.commit { setReorderingAllowed(true) replace(R.id.container, fragmentClass, getFragmentExtras()) } } } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) viewBinding.appbar.updatePadding( left = bars.left, right = bars.right, top = bars.top, ) return insets.consumeSystemBarsInsets(top = true) } protected open fun getFragmentExtras(): Bundle? = intent.extras } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/ReorderableListAdapter.kt ================================================ package org.koitharu.kotatsu.core.ui import androidx.recyclerview.widget.AsyncListDiffer.ListListener import androidx.recyclerview.widget.DiffUtil import com.hannesdorfmann.adapterdelegates4.AdapterDelegate import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.withContext import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.util.move import java.util.LinkedList open class ReorderableListAdapter : ListDelegationAdapter>(), FlowCollector?> { private val listListeners = LinkedList>() override suspend fun emit(value: List?) { val oldList = items.orEmpty() val newList = value.orEmpty() val diffResult = withContext(Dispatchers.Default) { val diffCallback = DiffCallback(oldList, newList) DiffUtil.calculateDiff(diffCallback) } super.setItems(newList) diffResult.dispatchUpdatesTo(this) listListeners.forEach { it.onCurrentListChanged(oldList, newList) } } @Deprecated( message = "Use emit() to dispatch list updates", level = DeprecationLevel.ERROR, replaceWith = ReplaceWith("emit(items)"), ) override fun setItems(items: List?) = super.setItems(items) fun reorderItems(oldPos: Int, newPos: Int) { val reordered = items?.toMutableList() ?: return reordered.move(oldPos, newPos) super.setItems(reordered) notifyItemMoved(oldPos, newPos) } fun addDelegate(type: ListItemType, delegate: AdapterDelegate>): ReorderableListAdapter { delegatesManager.addDelegate(type.ordinal, delegate) return this } fun addListListener(listListener: ListListener) { listListeners.add(listListener) } fun removeListListener(listListener: ListListener) { listListeners.remove(listListener) } protected class DiffCallback( private val oldList: List, private val newList: List, ) : DiffUtil.Callback() { override fun getOldListSize(): Int = oldList.size override fun getNewListSize(): Int = newList.size override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { val oldItem = oldList[oldItemPosition] val newItem = newList[newItemPosition] return newItem.areItemsTheSame(oldItem) } override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { val oldItem = oldList[oldItemPosition] val newItem = newList[newItemPosition] return newItem == oldItem } override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? { val oldItem = oldList[oldItemPosition] val newItem = newList[newItemPosition] return newItem.getChangePayload(oldItem) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/AlertDialogs.kt ================================================ package org.koitharu.kotatsu.core.ui.dialog import android.content.Context import android.view.LayoutInflater import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.view.inputmethod.EditorInfo import android.widget.ArrayAdapter import android.widget.CompoundButton.OnCheckedChangeListener import android.widget.EditText import android.widget.FrameLayout import androidx.annotation.StringRes import androidx.annotation.UiContext import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.AppCompatEditText import androidx.core.view.updatePadding import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.hannesdorfmann.adapterdelegates4.AdapterDelegate import com.hannesdorfmann.adapterdelegates4.AdapterDelegatesManager import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter import org.koitharu.kotatsu.R import org.koitharu.kotatsu.databinding.DialogCheckboxBinding import org.koitharu.kotatsu.databinding.ViewDialogAutocompleteBinding import com.google.android.material.R as materialR inline fun buildAlertDialog( @UiContext context: Context, isCentered: Boolean = false, block: MaterialAlertDialogBuilder.() -> Unit, ): AlertDialog = MaterialAlertDialogBuilder( context, if (isCentered) materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered else 0, ).apply(block).create() fun B.setCheckbox( @StringRes textResId: Int, isChecked: Boolean, onCheckedChangeListener: OnCheckedChangeListener ) = apply { val binding = DialogCheckboxBinding.inflate(LayoutInflater.from(context)) binding.checkbox.setText(textResId) binding.checkbox.isChecked = isChecked binding.checkbox.setOnCheckedChangeListener(onCheckedChangeListener) setView(binding.root) } fun B.setRecyclerViewList( list: List, delegate: AdapterDelegate>, ) = apply { val delegatesManager = AdapterDelegatesManager>() delegatesManager.addDelegate(delegate) setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list }) } fun B.setRecyclerViewList( list: List, vararg delegates: AdapterDelegate>, ) = apply { val delegatesManager = AdapterDelegatesManager>() delegates.forEach { delegatesManager.addDelegate(it) } setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list }) } fun B.setRecyclerViewList(adapter: RecyclerView.Adapter<*>) = apply { val recyclerView = RecyclerView(context) recyclerView.layoutManager = LinearLayoutManager(context) recyclerView.updatePadding( top = context.resources.getDimensionPixelOffset(R.dimen.list_spacing), ) recyclerView.clipToPadding = false recyclerView.adapter = adapter setView(recyclerView) } fun B.setEditText( inputType: Int, singleLine: Boolean, ): EditText { val editText = AppCompatEditText(context) editText.inputType = inputType if (singleLine) { editText.setSingleLine() editText.imeOptions = EditorInfo.IME_ACTION_DONE } val layout = FrameLayout(context) val lp = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) val horizontalMargin = context.resources.getDimensionPixelOffset(R.dimen.screen_padding) lp.setMargins( horizontalMargin, context.resources.getDimensionPixelOffset(R.dimen.margin_small), horizontalMargin, 0, ) layout.addView(editText, lp) setView(layout) return editText } fun B.setEditText( entries: List, inputType: Int, singleLine: Boolean, ): EditText { if (entries.isEmpty()) { return setEditText(inputType, singleLine) } val binding = ViewDialogAutocompleteBinding.inflate(LayoutInflater.from(context)) binding.autoCompleteTextView.setAdapter( ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, entries), ) binding.dropdown.setOnClickListener { binding.autoCompleteTextView.showDropDown() } binding.autoCompleteTextView.inputType = inputType if (singleLine) { binding.autoCompleteTextView.setSingleLine() binding.autoCompleteTextView.imeOptions = EditorInfo.IME_ACTION_DONE } setView(binding.root) return binding.autoCompleteTextView } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/BigButtonsAlertDialog.kt ================================================ package org.koitharu.kotatsu.core.ui.dialog import android.content.Context import android.content.DialogInterface import android.view.LayoutInflater import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.core.view.isGone import androidx.core.view.isVisible import com.google.android.material.button.MaterialButton import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.koitharu.kotatsu.databinding.DialogTwoButtonsBinding class BigButtonsAlertDialog private constructor( private val delegate: AlertDialog ) : DialogInterface by delegate { fun show() = delegate.show() class Builder(context: Context) { private val binding = DialogTwoButtonsBinding.inflate(LayoutInflater.from(context)) private val delegate = MaterialAlertDialogBuilder(context) .setView(binding.root) fun setTitle(@StringRes titleResId: Int): Builder { binding.title.setText(titleResId) return this } fun setTitle(title: CharSequence): Builder { binding.title.text = title return this } fun setIcon(@DrawableRes iconId: Int): Builder { binding.icon.setImageResource(iconId) return this } fun setPositiveButton( @StringRes textId: Int, listener: DialogInterface.OnClickListener, ): Builder { initButton(binding.button1, DialogInterface.BUTTON_POSITIVE, textId, listener) return this } fun setNegativeButton( @StringRes textId: Int, listener: DialogInterface.OnClickListener? = null ): Builder { initButton(binding.button3, DialogInterface.BUTTON_NEGATIVE, textId, listener) return this } fun setNeutralButton( @StringRes textId: Int, listener: DialogInterface.OnClickListener? = null ): Builder { initButton(binding.button2, DialogInterface.BUTTON_NEUTRAL, textId, listener) return this } fun create(): BigButtonsAlertDialog { with(binding) { button1.adjustCorners(isFirst = true, isLast = button2.isGone && button3.isGone) button2.adjustCorners(isFirst = button1.isGone, isLast = button3.isGone) button3.adjustCorners(isFirst = button1.isGone && button2.isGone, isLast = true) } val dialog = delegate.create() binding.root.tag = dialog return BigButtonsAlertDialog(dialog) } private fun MaterialButton.adjustCorners(isFirst: Boolean, isLast: Boolean) { if (!isVisible) { return } shapeAppearanceModel = shapeAppearanceModel.toBuilder().apply { if (!isFirst) { setTopLeftCornerSize(0f) setTopRightCornerSize(0f) } if (!isLast) { setBottomLeftCornerSize(0f) setBottomRightCornerSize(0f) } }.build() } private fun initButton( button: MaterialButton, which: Int, @StringRes textId: Int, listener: DialogInterface.OnClickListener?, ) { button.setText(textId) button.isVisible = true button.setOnClickListener { val dialog = binding.root.tag as DialogInterface listener?.onClick(dialog, which) dialog.dismiss() } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/ErrorDetailsDialog.kt ================================================ package org.koitharu.kotatsu.core.ui.dialog import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.github.AppUpdateRepository import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.AlertDialogFragment import org.koitharu.kotatsu.core.util.ext.copyToClipboard import org.koitharu.kotatsu.core.util.ext.getCauseUrl import org.koitharu.kotatsu.core.util.ext.isHttpUrl import org.koitharu.kotatsu.core.util.ext.isReportable import org.koitharu.kotatsu.core.util.ext.report import org.koitharu.kotatsu.core.util.ext.requireSerializable import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.databinding.DialogErrorDetailsBinding import javax.inject.Inject @AndroidEntryPoint class ErrorDetailsDialog : AlertDialogFragment(), View.OnClickListener { private lateinit var exception: Throwable @Inject lateinit var appUpdateRepository: AppUpdateRepository override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val args = requireArguments() exception = args.requireSerializable(AppRouter.KEY_ERROR) } override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): DialogErrorDetailsBinding { return DialogErrorDetailsBinding.inflate(inflater, container, false) } override fun onViewBindingCreated(binding: DialogErrorDetailsBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) binding.buttonBrowser.setOnClickListener(this) binding.textViewSummary.text = exception.message val isUrlAvailable = exception.getCauseUrl()?.isHttpUrl() == true binding.buttonBrowser.isVisible = isUrlAvailable binding.textViewBrowser.isVisible = isUrlAvailable binding.textViewDescription.setTextAndVisible( if (appUpdateRepository.isUpdateAvailable) { R.string.error_disclaimer_app_outdated } else if (exception.isReportable()) { R.string.error_disclaimer_report } else { 0 }, ) } @Suppress("NAME_SHADOWING") override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { val builder = super.onBuildDialog(builder) .setCancelable(true) .setNegativeButton(R.string.close, null) .setTitle(R.string.error_details) .setNeutralButton(androidx.preference.R.string.copy) { _, _ -> context?.copyToClipboard(getString(R.string.error), exception.stackTraceToString()) } if (appUpdateRepository.isUpdateAvailable) { builder.setPositiveButton(R.string.update) { _, _ -> router.openAppUpdate() dismiss() } } else if (exception.isReportable()) { builder.setPositiveButton(R.string.report) { _, _ -> exception.report(silent = true) dismiss() } } return builder } override fun onClick(v: View) { router.openBrowser( url = exception.getCauseUrl() ?: return, source = null, title = null, ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/RememberCheckListener.kt ================================================ package org.koitharu.kotatsu.core.ui.dialog import android.widget.CompoundButton import android.widget.CompoundButton.OnCheckedChangeListener class RememberCheckListener( initialValue: Boolean, ) : OnCheckedChangeListener { var isChecked: Boolean = initialValue private set override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { this.isChecked = isChecked } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/RememberSelectionDialogListener.kt ================================================ package org.koitharu.kotatsu.core.ui.dialog import android.content.DialogInterface class RememberSelectionDialogListener(initialValue: Int) : DialogInterface.OnClickListener { var selection: Int = initialValue private set override fun onClick(dialog: DialogInterface?, which: Int) { selection = which } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/AnimatedFaviconDrawable.kt ================================================ package org.koitharu.kotatsu.core.ui.image import android.animation.TimeAnimator import android.content.Context import android.graphics.Canvas import android.graphics.drawable.Animatable import androidx.annotation.StyleRes import androidx.interpolator.view.animation.FastOutSlowInInterpolator import coil3.Image import coil3.asImage import coil3.getExtra import coil3.request.ImageRequest import com.google.android.material.animation.ArgbEvaluatorCompat import com.google.android.material.color.MaterialColors import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.util.ext.getAnimationDuration import org.koitharu.kotatsu.core.util.ext.mangaSourceKey import kotlin.math.abs class AnimatedFaviconDrawable( context: Context, @StyleRes styleResId: Int, name: String, ) : FaviconDrawable(context, styleResId, name), Animatable, TimeAnimator.TimeListener { private val interpolator = FastOutSlowInInterpolator() private val period = context.getAnimationDuration(R.integer.config_longAnimTime) * 2 private val timeAnimator = TimeAnimator() private var colorHigh = MaterialColors.harmonize(colorForeground, currentBackgroundColor) private var colorLow = ArgbEvaluatorCompat.getInstance().evaluate(0.3f, colorHigh, currentBackgroundColor) init { timeAnimator.setTimeListener(this) onStateChange(state) } override fun draw(canvas: Canvas) { if (!isRunning && period > 0) { updateColor() start() } super.draw(canvas) } override fun onTimeUpdate(animation: TimeAnimator?, totalTime: Long, deltaTime: Long) { callback?.also { updateColor() it.invalidateDrawable(this) } ?: stop() } override fun start() { timeAnimator.start() } override fun stop() { timeAnimator.end() } override fun isRunning(): Boolean = timeAnimator.isStarted override fun onStateChange(state: IntArray): Boolean { val res = super.onStateChange(state) colorHigh = MaterialColors.harmonize(currentForegroundColor, currentBackgroundColor) colorLow = ArgbEvaluatorCompat.getInstance().evaluate(0.3f, colorHigh, currentBackgroundColor) updateColor() return res } private fun updateColor() { if (period <= 0f) { return } val ph = period / 2 val fraction = abs((System.currentTimeMillis() % period) - ph) / ph.toFloat() currentForegroundColor = ArgbEvaluatorCompat.getInstance() .evaluate(interpolator.getInterpolation(fraction), colorLow, colorHigh) } class Factory( @StyleRes private val styleResId: Int, ) : ((ImageRequest) -> Image?) { override fun invoke(request: ImageRequest): Image? { val source = request.getExtra(mangaSourceKey) ?: return null val context = request.context val title = source.getTitle(context) return AnimatedFaviconDrawable(context, styleResId, title).asImage() } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/AnimatedPlaceholderDrawable.kt ================================================ package org.koitharu.kotatsu.core.ui.image import android.animation.TimeAnimator import android.content.Context import android.graphics.Canvas import android.graphics.ColorFilter import android.graphics.PixelFormat import android.graphics.drawable.Animatable import android.graphics.drawable.Drawable import androidx.core.graphics.ColorUtils import androidx.interpolator.view.animation.FastOutSlowInInterpolator import com.google.android.material.animation.ArgbEvaluatorCompat import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.getAnimationDuration import org.koitharu.kotatsu.core.util.ext.getThemeColor import kotlin.math.abs import com.google.android.material.R as materialR class AnimatedPlaceholderDrawable(context: Context) : Drawable(), Animatable, TimeAnimator.TimeListener { private val colorLow = context.getThemeColor(materialR.attr.colorSurfaceContainerLowest) private val colorHigh = context.getThemeColor(materialR.attr.colorSurfaceContainerHighest) private var currentColor: Int = colorLow private val interpolator = FastOutSlowInInterpolator() private val period = context.getAnimationDuration(R.integer.config_longAnimTime) * 2 private val timeAnimator = TimeAnimator() private var currentAlpha: Int = 255 init { timeAnimator.setTimeListener(this) updateColor() } override fun draw(canvas: Canvas) { if (!isRunning && period > 0) { updateColor() start() } canvas.drawColor(currentColor) } override fun setAlpha(alpha: Int) { currentAlpha = alpha updateColor() } @Suppress("DeprecatedCallableAddReplaceWith") @Deprecated("Deprecated in Java") override fun getOpacity(): Int = PixelFormat.TRANSLUCENT override fun getAlpha(): Int = currentAlpha override fun setColorFilter(colorFilter: ColorFilter?) = Unit override fun onTimeUpdate(animation: TimeAnimator?, totalTime: Long, deltaTime: Long) { callback?.also { updateColor() it.invalidateDrawable(this) } ?: stop() } override fun start() { timeAnimator.start() } override fun stop() { timeAnimator.end() } override fun isRunning(): Boolean = timeAnimator.isStarted private fun updateColor() { if (period <= 0f) { return } val ph = period / 2 val fraction = abs((System.currentTimeMillis() % period) - ph) / ph.toFloat() currentColor = ColorUtils.setAlphaComponent( ArgbEvaluatorCompat.getInstance() .evaluate(interpolator.getInterpolation(fraction), colorLow, colorHigh), currentAlpha ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/ChipIconTarget.kt ================================================ package org.koitharu.kotatsu.core.ui.image import android.graphics.drawable.Drawable import coil3.target.GenericViewTarget import com.google.android.material.chip.Chip class ChipIconTarget(override val view: Chip) : GenericViewTarget() { override var drawable: Drawable? get() = view.chipIcon set(value) { view.chipIcon = value } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/CoilImageGetter.kt ================================================ package org.koitharu.kotatsu.core.ui.image import android.content.Context import android.graphics.drawable.Drawable import android.text.Html import androidx.annotation.WorkerThread import coil3.ImageLoader import coil3.executeBlocking import coil3.request.ImageRequest import coil3.request.allowHardware import dagger.hilt.android.qualifiers.ApplicationContext import org.koitharu.kotatsu.core.util.ext.drawable import javax.inject.Inject class CoilImageGetter @Inject constructor( @ApplicationContext private val context: Context, private val coil: ImageLoader, ) : Html.ImageGetter { @WorkerThread override fun getDrawable(source: String?): Drawable? { return coil.executeBlocking( ImageRequest.Builder(context) .data(source) .allowHardware(false) .build(), ).drawable?.apply { setBounds(0, 0, intrinsicHeight, intrinsicHeight) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/FaviconDrawable.kt ================================================ package org.koitharu.kotatsu.core.ui.image import android.content.Context import android.content.res.ColorStateList import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Path import android.graphics.Rect import android.graphics.RectF import android.os.Build import androidx.annotation.RequiresApi import androidx.annotation.StyleRes import androidx.core.content.withStyledAttributes import androidx.core.graphics.withClip import coil3.Image import coil3.asImage import coil3.getExtra import coil3.request.ImageRequest import com.google.android.material.color.MaterialColors import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.util.KotatsuColors import org.koitharu.kotatsu.core.util.ext.hasFocusStateSpecified import org.koitharu.kotatsu.core.util.ext.mangaSourceKey open class FaviconDrawable( context: Context, @StyleRes styleResId: Int, name: String, ) : PaintDrawable() { override val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG) protected var currentBackgroundColor = Color.WHITE private set private var colorBackground: ColorStateList = ColorStateList.valueOf(currentBackgroundColor) protected var colorForeground = Color.DKGRAY protected var currentForegroundColor = Color.DKGRAY protected var currentStrokeColor = Color.LTGRAY private set private var colorStroke: ColorStateList = ColorStateList.valueOf(currentStrokeColor) private val letter = name.take(1).uppercase() private var cornerSize = 0f private var intrinsicSize = -1 private val textBounds = Rect() private val tempRect = Rect() private val boundsF = RectF() private val clipPath = Path() init { context.withStyledAttributes(styleResId, R.styleable.FaviconFallbackDrawable) { colorBackground = getColorStateList(R.styleable.FaviconFallbackDrawable_backgroundColor) ?: colorBackground colorStroke = getColorStateList(R.styleable.FaviconFallbackDrawable_strokeColor) ?: colorStroke cornerSize = getDimension(R.styleable.FaviconFallbackDrawable_cornerSize, cornerSize) paint.strokeWidth = getDimension(R.styleable.FaviconFallbackDrawable_strokeWidth, 0f) * 2f intrinsicSize = getDimensionPixelSize(R.styleable.FaviconFallbackDrawable_drawableSize, intrinsicSize) } paint.textAlign = Paint.Align.CENTER paint.isFakeBoldText = true colorForeground = KotatsuColors.random(name) currentForegroundColor = MaterialColors.harmonize(colorForeground, colorBackground.defaultColor) onStateChange(state) } override fun draw(canvas: Canvas) { if (cornerSize > 0f) { canvas.withClip(clipPath) { doDraw(canvas) } } else { doDraw(canvas) } } override fun onBoundsChange(bounds: Rect) { super.onBoundsChange(bounds) boundsF.set(bounds) val innerWidth = bounds.width() - (paint.strokeWidth * 2f) paint.textSize = getTextSizeForWidth(innerWidth, letter) * 0.5f paint.getTextBounds(letter, 0, letter.length, textBounds) clipPath.reset() clipPath.addRoundRect(boundsF, cornerSize, cornerSize, Path.Direction.CW) clipPath.close() } override fun getIntrinsicWidth(): Int = intrinsicSize override fun getIntrinsicHeight(): Int = intrinsicSize override fun isOpaque(): Boolean = cornerSize == 0f && colorBackground.isOpaque override fun isStateful(): Boolean = colorStroke.isStateful || colorBackground.isStateful @RequiresApi(Build.VERSION_CODES.S) override fun hasFocusStateSpecified(): Boolean = colorBackground.hasFocusStateSpecified() || colorStroke.hasFocusStateSpecified() override fun onStateChange(state: IntArray): Boolean { val prevStrokeColor = currentStrokeColor val prevBackgroundColor = currentBackgroundColor currentStrokeColor = colorStroke.getColorForState(state, colorStroke.defaultColor) currentBackgroundColor = colorBackground.getColorForState(state, colorBackground.defaultColor) if (currentBackgroundColor != prevBackgroundColor) { currentForegroundColor = MaterialColors.harmonize(colorForeground, currentBackgroundColor) } return prevBackgroundColor != currentBackgroundColor || prevStrokeColor != currentStrokeColor } private fun doDraw(canvas: Canvas) { // background paint.color = currentBackgroundColor paint.style = Paint.Style.FILL canvas.drawPaint(paint) // letter paint.color = currentForegroundColor val cx = (boundsF.left + boundsF.right) * 0.6f val ty = boundsF.bottom * 0.7f + textBounds.height() * 0.5f - textBounds.bottom canvas.drawText(letter, cx, ty, paint) if (paint.strokeWidth > 0f) { // stroke paint.color = currentStrokeColor paint.style = Paint.Style.STROKE canvas.drawPath(clipPath, paint) } } private fun getTextSizeForWidth(width: Float, text: String): Float { val testTextSize = 48f paint.textSize = testTextSize paint.getTextBounds(text, 0, text.length, tempRect) return testTextSize * width / tempRect.width() } class Factory( @StyleRes private val styleResId: Int, ) : ((ImageRequest) -> Image?) { override fun invoke(request: ImageRequest): Image? { val source = request.getExtra(mangaSourceKey) ?: return null val context = request.context val title = source.getTitle(context) return FaviconDrawable(context, styleResId, title).asImage() } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/FaviconView.kt ================================================ package org.koitharu.kotatsu.core.ui.image import android.content.Context import android.util.AttributeSet import androidx.annotation.AttrRes import androidx.annotation.StyleRes import androidx.core.content.withStyledAttributes import coil3.Image import coil3.asImage import coil3.request.Disposable import coil3.request.ImageRequest import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler.Companion.suppressCaptchaErrors import org.koitharu.kotatsu.core.image.CoilImageView import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.parsers.model.MangaSource class FaviconView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0, ) : CoilImageView(context, attrs, defStyleAttr) { @StyleRes private var iconStyle: Int = R.style.FaviconDrawable init { context.withStyledAttributes(attrs, R.styleable.FaviconView, defStyleAttr) { iconStyle = getResourceId(R.styleable.FaviconView_iconStyle, iconStyle) } if (isInEditMode) { setImageDrawable( FaviconDrawable( context = context, styleResId = iconStyle, name = context.getString(R.string.app_name).random().toString(), ), ) } } fun setImageAsync(mangaSource: MangaSource): Disposable { val fallbackFactory: (ImageRequest) -> Image? = { FaviconDrawable(context, iconStyle, mangaSource.name).asImage() } val placeholderFactory: (ImageRequest) -> Image? = if (context.isAnimationsEnabled) { { AnimatedFaviconDrawable(context, iconStyle, mangaSource.name).asImage() } } else { fallbackFactory } return enqueueRequest( newRequestBuilder() .data(mangaSource.faviconUri()) .error(fallbackFactory) .fallback(fallbackFactory) .placeholder(placeholderFactory) .mangaSourceExtra(mangaSource) .suppressCaptchaErrors() .build(), ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/PaintDrawable.kt ================================================ package org.koitharu.kotatsu.core.ui.image import android.graphics.ColorFilter import android.graphics.Paint import android.graphics.PixelFormat import android.graphics.drawable.Drawable @Suppress("OVERRIDE_DEPRECATION") abstract class PaintDrawable : Drawable() { protected abstract val paint: Paint override fun setAlpha(alpha: Int) { paint.alpha = alpha } override fun getAlpha(): Int { return paint.alpha } override fun setColorFilter(colorFilter: ColorFilter?) { paint.colorFilter = colorFilter } override fun getColorFilter(): ColorFilter? { return paint.colorFilter } override fun setDither(dither: Boolean) { paint.isDither = dither } override fun setFilterBitmap(filter: Boolean) { paint.isFilterBitmap = filter } override fun isFilterBitmap(): Boolean { return paint.isFilterBitmap } override fun getOpacity(): Int { if (paint.colorFilter != null) { return PixelFormat.TRANSLUCENT } return when (paint.alpha) { 0 -> PixelFormat.TRANSPARENT 255 -> if (isOpaque()) PixelFormat.OPAQUE else PixelFormat.TRANSLUCENT else -> PixelFormat.TRANSLUCENT } } protected open fun isOpaque() = false } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/TextDrawable.kt ================================================ package org.koitharu.kotatsu.core.ui.image import android.annotation.SuppressLint import android.content.Context import android.content.res.ColorStateList import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.PointF import android.graphics.Rect import android.os.Build import android.widget.TextView import androidx.annotation.AttrRes import androidx.annotation.RequiresApi import androidx.core.graphics.PaintCompat import com.google.android.material.resources.TextAppearance import org.koitharu.kotatsu.core.util.ext.getThemeResId import org.koitharu.kotatsu.core.util.ext.hasFocusStateSpecified class TextDrawable( val text: String, ) : PaintDrawable() { override val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG) private val textBounds = Rect() private val textPoint = PointF() var textSize: Float get() = paint.textSize set(value) { paint.textSize = value measureTextBounds() } var textColor: ColorStateList = ColorStateList.valueOf(Color.BLACK) set(value) { field = value onStateChange(state) } init { onStateChange(state) measureTextBounds() } override fun draw(canvas: Canvas) { canvas.drawText(text, textPoint.x, textPoint.y, paint) } override fun onBoundsChange(bounds: Rect) { textPoint.set( bounds.exactCenterX() - textBounds.exactCenterX(), bounds.exactCenterY() - textBounds.exactCenterY(), ) } override fun getIntrinsicWidth(): Int = textBounds.width() override fun getIntrinsicHeight(): Int = textBounds.height() override fun isStateful(): Boolean = textColor.isStateful @RequiresApi(Build.VERSION_CODES.S) override fun hasFocusStateSpecified(): Boolean = textColor.hasFocusStateSpecified() override fun onStateChange(state: IntArray): Boolean { val prevColor = paint.color paint.color = textColor.getColorForState(state, textColor.defaultColor) return paint.color != prevColor } private fun measureTextBounds() { paint.getTextBounds(text, 0, text.length, textBounds) onBoundsChange(bounds) } companion object { @SuppressLint("RestrictedApi") fun create(context: Context, text: String, @AttrRes textAppearanceAttr: Int): TextDrawable { val drawable = TextDrawable(text) val textAppearance = TextAppearance(context, context.getThemeResId(textAppearanceAttr, androidx.appcompat.R.style.TextAppearance_AppCompat)) drawable.textSize = textAppearance.textSize drawable.textColor = textAppearance.textColor ?: drawable.textColor drawable.paint.typeface = textAppearance.getFont(context) drawable.paint.letterSpacing = textAppearance.letterSpacing return drawable } fun compound(textView: TextView, text: String): TextDrawable? { val drawable = TextDrawable(text) drawable.textSize = textView.textSize drawable.textColor = textView.textColors return drawable.takeIf { PaintCompat.hasGlyph(drawable.paint, text) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/TextViewTarget.kt ================================================ package org.koitharu.kotatsu.core.ui.image import android.graphics.drawable.Drawable import android.view.Gravity import android.widget.TextView import androidx.annotation.GravityInt import coil3.target.GenericViewTarget class TextViewTarget( override val view: TextView, @GravityInt compoundDrawable: Int, ) : GenericViewTarget() { private val drawableIndex: Int = when (compoundDrawable) { Gravity.START -> 0 Gravity.TOP -> 2 Gravity.END -> 3 Gravity.BOTTOM -> 4 else -> -1 } override var drawable: Drawable? get() = if (drawableIndex != -1) { view.compoundDrawablesRelative[drawableIndex] } else { null } set(value) { if (drawableIndex == -1) { return } val drawables = view.compoundDrawablesRelative drawables[drawableIndex] = value view.setCompoundDrawablesRelativeWithIntrinsicBounds( drawables[0], drawables[1], drawables[2], drawables[3], ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/ThumbnailTransformation.kt ================================================ package org.koitharu.kotatsu.core.ui.image import android.graphics.Bitmap import android.media.ThumbnailUtils import coil3.size.Size import coil3.size.pxOrElse import coil3.transform.Transformation class ThumbnailTransformation : Transformation() { override val cacheKey: String = javaClass.name override suspend fun transform(input: Bitmap, size: Size): Bitmap { return ThumbnailUtils.extractThumbnail( input, size.width.pxOrElse { input.width }, size.height.pxOrElse { input.height }, ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/TrimTransformation.kt ================================================ package org.koitharu.kotatsu.core.ui.image import android.graphics.Bitmap import androidx.core.graphics.get import coil3.size.Size import coil3.transform.Transformation import org.koitharu.kotatsu.reader.domain.EdgeDetector.Companion.isColorTheSame class TrimTransformation( private val tolerance: Int = 20, ) : Transformation() { override val cacheKey: String = "${javaClass.name}-$tolerance" override suspend fun transform(input: Bitmap, size: Size): Bitmap { var left = 0 var top = 0 var right = 0 var bottom = 0 // Left for (x in 0 until input.width) { var isColBlank = true val prevColor = input[x, 0] for (y in 1 until input.height) { if (!isColorTheSame(input[x, y], prevColor, tolerance)) { isColBlank = false break } } if (isColBlank) { left++ } else { break } } if (left == input.width) { return input } // Right for (x in (left until input.width).reversed()) { var isColBlank = true val prevColor = input[x, 0] for (y in 1 until input.height) { if (!isColorTheSame(input[x, y], prevColor, tolerance)) { isColBlank = false break } } if (isColBlank) { right++ } else { break } } // Top for (y in 0 until input.height) { var isRowBlank = true val prevColor = input[0, y] for (x in 1 until input.width) { if (!isColorTheSame(input[x, y], prevColor, tolerance)) { isRowBlank = false break } } if (isRowBlank) { top++ } else { break } } // Bottom for (y in (top until input.height).reversed()) { var isRowBlank = true val prevColor = input[0, y] for (x in 1 until input.width) { if (!isColorTheSame(input[x, y], prevColor, tolerance)) { isRowBlank = false break } } if (isRowBlank) { bottom++ } else { break } } return if (left != 0 || right != 0 || top != 0 || bottom != 0) { Bitmap.createBitmap(input, left, top, input.width - left - right, input.height - top - bottom) } else { input } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/AdapterDelegateClickListenerAdapter.kt ================================================ package org.koitharu.kotatsu.core.ui.list import android.view.View import android.view.View.OnClickListener import android.view.View.OnContextClickListener import android.view.View.OnLongClickListener import androidx.core.util.Function import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder class AdapterDelegateClickListenerAdapter( private val adapterDelegate: AdapterDelegateViewBindingViewHolder, private val clickListener: OnListItemClickListener, private val itemMapper: Function, ) : OnClickListener, OnLongClickListener, OnContextClickListener { override fun onClick(v: View) { clickListener.onItemClick(mappedItem(), v) } override fun onLongClick(v: View): Boolean { return clickListener.onItemLongClick(mappedItem(), v) } override fun onContextClick(v: View): Boolean { return clickListener.onItemContextClick(mappedItem(), v) } private fun mappedItem(): O = itemMapper.apply(adapterDelegate.item) fun attach() = attach(adapterDelegate.itemView) fun attach(itemView: View) { itemView.setOnClickListener(this) itemView.setOnLongClickListener(this) itemView.setOnContextClickListener(this) } companion object { operator fun invoke( adapterDelegate: AdapterDelegateViewBindingViewHolder, clickListener: OnListItemClickListener ): AdapterDelegateClickListenerAdapter = AdapterDelegateClickListenerAdapter( adapterDelegate = adapterDelegate, clickListener = clickListener, itemMapper = { x -> x }, ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/BaseListSelectionCallback.kt ================================================ package org.koitharu.kotatsu.core.ui.list import androidx.recyclerview.widget.RecyclerView abstract class BaseListSelectionCallback( protected val recyclerView: RecyclerView, ) : ListSelectionController.Callback { override fun onSelectionChanged(controller: ListSelectionController, count: Int) { recyclerView.invalidateItemDecorations() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/BoundsScrollListener.kt ================================================ package org.koitharu.kotatsu.core.ui.list import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView abstract class BoundsScrollListener( @JvmField protected val offsetTop: Int, @JvmField protected val offsetBottom: Int ) : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { super.onScrolled(recyclerView, dx, dy) if (recyclerView.hasPendingAdapterUpdates()) { return } val layoutManager = (recyclerView.layoutManager as? LinearLayoutManager) ?: return val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition() if (firstVisibleItemPosition == RecyclerView.NO_POSITION) { return } val visibleItemCount = layoutManager.childCount val totalItemCount = layoutManager.itemCount if (visibleItemCount + firstVisibleItemPosition >= totalItemCount - offsetBottom) { onScrolledToEnd(recyclerView) } if (firstVisibleItemPosition <= offsetTop) { onScrolledToStart(recyclerView) } onPostScrolled(recyclerView, firstVisibleItemPosition, visibleItemCount) } abstract fun onScrolledToStart(recyclerView: RecyclerView) abstract fun onScrolledToEnd(recyclerView: RecyclerView) protected open fun onPostScrolled( recyclerView: RecyclerView, firstVisibleItemPosition: Int, visibleItemCount: Int ) = Unit fun invalidate(recyclerView: RecyclerView) { onScrolled(recyclerView, 0, 0) } fun postInvalidate(recyclerView: RecyclerView) = recyclerView.post { invalidate(recyclerView) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/FitHeightGridLayoutManager.kt ================================================ package org.koitharu.kotatsu.core.ui.list import android.content.Context import android.util.AttributeSet import android.view.View import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView class FitHeightGridLayoutManager : GridLayoutManager { constructor(context: Context?, spanCount: Int) : super(context, spanCount) constructor( context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int, ) : super(context, attrs, defStyleAttr, defStyleRes) constructor( context: Context?, spanCount: Int, orientation: Int, reverseLayout: Boolean, ) : super(context, spanCount, orientation, reverseLayout) override fun layoutDecoratedWithMargins(child: View, left: Int, top: Int, right: Int, bottom: Int) { if (orientation == RecyclerView.VERTICAL && child.layoutParams.height == LayoutParams.MATCH_PARENT) { val parentBottom = height - paddingBottom val offset = parentBottom - bottom super.layoutDecoratedWithMargins(child, left, top, right, bottom + offset) } else { super.layoutDecoratedWithMargins(child, left, top, right, bottom) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/FitHeightLinearLayoutManager.kt ================================================ package org.koitharu.kotatsu.core.ui.list import android.content.Context import android.util.AttributeSet import android.view.View import androidx.annotation.AttrRes import androidx.annotation.StyleRes import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.LayoutParams class FitHeightLinearLayoutManager : LinearLayoutManager { constructor(context: Context) : super(context) constructor( context: Context, @RecyclerView.Orientation orientation: Int, reverseLayout: Boolean, ) : super(context, orientation, reverseLayout) constructor( context: Context, attrs: AttributeSet?, @AttrRes defStyleAttr: Int, @StyleRes defStyleRes: Int, ) : super(context, attrs, defStyleAttr, defStyleRes) override fun layoutDecoratedWithMargins(child: View, left: Int, top: Int, right: Int, bottom: Int) { if (orientation == RecyclerView.VERTICAL && child.layoutParams.height == LayoutParams.MATCH_PARENT) { val parentBottom = height - paddingBottom val offset = parentBottom - bottom super.layoutDecoratedWithMargins(child, left, top, right, bottom + offset) } else { super.layoutDecoratedWithMargins(child, left, top, right, bottom) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/ListSelectionController.kt ================================================ package org.koitharu.kotatsu.core.ui.list import android.os.Bundle import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.PopupMenu import androidx.collection.LongSet import androidx.collection.longSetOf import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.RecyclerView import androidx.savedstate.SavedStateRegistry import androidx.savedstate.SavedStateRegistryOwner import kotlinx.coroutines.Dispatchers import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration import org.koitharu.kotatsu.core.util.ext.toLongArray import org.koitharu.kotatsu.core.util.ext.toSet import kotlin.coroutines.EmptyCoroutineContext private const val KEY_SELECTION = "selection" private const val PROVIDER_NAME = "selection_decoration" class ListSelectionController( private val appCompatDelegate: AppCompatDelegate, private val decoration: AbstractSelectionItemDecoration, private val registryOwner: SavedStateRegistryOwner, private val callback: Callback, ) : ActionMode.Callback, SavedStateRegistry.SavedStateProvider { private var actionMode: ActionMode? = null private var focusedItemId: LongSet? = null var useActionMode: Boolean = true val count: Int get() = if (focusedItemId != null) 1 else decoration.checkedItemsCount init { registryOwner.lifecycle.addObserver(StateEventObserver()) } fun snapshot(): Set = (focusedItemId ?: peekCheckedIds()).toSet() fun peekCheckedIds(): LongSet { return focusedItemId ?: decoration.checkedItemsIds } fun clear() { decoration.clearSelection() notifySelectionChanged() } fun addAll(ids: Collection) { if (ids.isEmpty()) { return } startActionMode() decoration.checkAll(ids) notifySelectionChanged() } fun attachToRecyclerView(recyclerView: RecyclerView) { recyclerView.addItemDecoration(decoration) } override fun saveState(): Bundle { val bundle = Bundle(1) bundle.putLongArray(KEY_SELECTION, peekCheckedIds().toLongArray()) return bundle } fun onItemClick(id: Long): Boolean { if (decoration.checkedItemsCount != 0) { decoration.toggleItemChecked(id) if (decoration.checkedItemsCount == 0) { actionMode?.finish() } else { actionMode?.invalidate() } notifySelectionChanged() return true } return false } fun onItemLongClick(view: View, id: Long): Boolean { return if (useActionMode) { startSelection(id) } else { onItemContextClick(view, id) } } fun onItemContextClick(view: View, id: Long): Boolean { focusedItemId = longSetOf(id) val menu = PopupMenu(view.context, view) callback.onCreateActionMode(this, menu.menuInflater, menu.menu) callback.onPrepareActionMode(this, null, menu.menu) menu.setForceShowIcon(true) if (menu.menu.hasVisibleItems()) { menu.setOnMenuItemClickListener { menuItem -> callback.onActionItemClicked(this, null, menuItem) } menu.setOnDismissListener { focusedItemId = null } menu.show() return true } else { focusedItemId = null return false } } fun startSelection(id: Long): Boolean = startActionMode()?.also { decoration.setItemIsChecked(id, true) notifySelectionChanged() } != null override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { return callback.onCreateActionMode(this, mode.menuInflater, menu) } override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { return callback.onPrepareActionMode(this, mode, menu) } override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { return callback.onActionItemClicked(this, mode, item) } override fun onDestroyActionMode(mode: ActionMode) { callback.onDestroyActionMode(this, mode) clear() actionMode = null } private fun startActionMode(): ActionMode? { focusedItemId = null return actionMode ?: appCompatDelegate.startSupportActionMode(this).also { actionMode = it } } private fun notifySelectionChanged() { val count = decoration.checkedItemsCount callback.onSelectionChanged(this, count) if (count == 0) { actionMode?.finish() } else { actionMode?.invalidate() } } private fun restoreState(ids: Collection) { if (ids.isEmpty() || decoration.checkedItemsCount != 0) { return } decoration.checkAll(ids) startActionMode() notifySelectionChanged() } interface Callback { fun onSelectionChanged(controller: ListSelectionController, count: Int) fun onCreateActionMode(controller: ListSelectionController, menuInflater: MenuInflater, menu: Menu): Boolean fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode?, menu: Menu): Boolean { mode?.title = controller.count.toString() return true } fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean fun onDestroyActionMode(controller: ListSelectionController, mode: ActionMode) = Unit } private inner class StateEventObserver : LifecycleEventObserver { override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { if (event == Lifecycle.Event.ON_CREATE) { source.lifecycle.removeObserver(this) val registry = registryOwner.savedStateRegistry registry.registerSavedStateProvider(PROVIDER_NAME, this@ListSelectionController) val state = registry.consumeRestoredStateForKey(PROVIDER_NAME) if (state != null) { Dispatchers.Main.dispatch(EmptyCoroutineContext) { // == Handler.post if (source.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) { restoreState(state.getLongArray(KEY_SELECTION)?.toList().orEmpty()) } } } } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/OnListItemClickListener.kt ================================================ package org.koitharu.kotatsu.core.ui.list import android.view.View fun interface OnListItemClickListener { fun onItemClick(item: I, view: View) fun onItemLongClick(item: I, view: View): Boolean = false fun onItemContextClick(item: I, view: View): Boolean = onItemLongClick(item, view) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/OnTipCloseListener.kt ================================================ package org.koitharu.kotatsu.core.ui.list interface OnTipCloseListener { fun onCloseTip(tip: T) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/PaginationScrollListener.kt ================================================ package org.koitharu.kotatsu.core.ui.list import androidx.recyclerview.widget.RecyclerView class PaginationScrollListener(offset: Int, private val callback: Callback) : BoundsScrollListener(0, offset) { override fun onScrolledToStart(recyclerView: RecyclerView) = Unit override fun onScrolledToEnd(recyclerView: RecyclerView) { callback.onScrolledToEnd() } interface Callback { fun onScrolledToEnd() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/RecyclerScrollKeeper.kt ================================================ package org.koitharu.kotatsu.core.ui.list import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver class RecyclerScrollKeeper( private val rv: RecyclerView, ) : AdapterDataObserver() { private val scrollUpRunnable = Runnable { (rv.layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset(0, 0) } fun attach() { rv.adapter?.registerAdapterDataObserver(this) } override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { super.onItemRangeInserted(positionStart, itemCount) if (positionStart == 0 && isScrolledToTop()) { postScrollUp() } } override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) { super.onItemRangeMoved(fromPosition, toPosition, itemCount) if (toPosition == 0 && isScrolledToTop()) { postScrollUp() } } private fun postScrollUp() { rv.postDelayed(scrollUpRunnable, 500L) } private fun isScrolledToTop(): Boolean { return (rv.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition() == 0 } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/SectionedSelectionController.kt ================================================ package org.koitharu.kotatsu.core.ui.list private const val PROVIDER_NAME = "selection_decoration_sectioned" ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/decor/AbstractSelectionItemDecoration.kt ================================================ package org.koitharu.kotatsu.core.ui.list.decor import android.graphics.Canvas import android.graphics.Rect import android.graphics.RectF import android.view.View import androidx.collection.LongSet import androidx.collection.MutableLongSet import androidx.core.view.children import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.NO_ID abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() { private val bounds = Rect() private val boundsF = RectF() protected val selection = MutableLongSet() protected var hasBackground: Boolean = true protected var hasForeground: Boolean = false protected var isIncludeDecorAndMargins: Boolean = true val checkedItemsCount: Int get() = selection.size val checkedItemsIds: LongSet get() = selection fun toggleItemChecked(id: Long) { if (!selection.remove(id)) { selection.add(id) } } fun setItemIsChecked(id: Long, isChecked: Boolean) { if (isChecked) { selection.add(id) } else { selection.remove(id) } } fun checkAll(ids: Collection) { for (id in ids) { selection.add(id) } } fun clearSelection() { selection.clear() } override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { if (hasBackground) { doDraw(canvas, parent, state, false) } else { super.onDraw(canvas, parent, state) } } override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { if (hasForeground) { doDraw(canvas, parent, state, true) } else { super.onDrawOver(canvas, parent, state) } } private fun doDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State, isOver: Boolean) { val checkpoint = canvas.save() if (parent.clipToPadding) { canvas.clipRect( parent.paddingLeft, parent.paddingTop, parent.width - parent.paddingRight, parent.height - parent.paddingBottom, ) } for (child in parent.children) { val itemId = getItemId(parent, child) if (itemId != NO_ID && itemId in selection) { if (isIncludeDecorAndMargins) { parent.getDecoratedBoundsWithMargins(child, bounds) } else { bounds.set(child.left, child.top, child.right, child.bottom) } boundsF.set(bounds) boundsF.offset(child.translationX, child.translationY) if (isOver) { onDrawForeground(canvas, parent, child, boundsF, state) } else { onDrawBackground(canvas, parent, child, boundsF, state) } } } canvas.restoreToCount(checkpoint) } abstract fun getItemId(parent: RecyclerView, child: View): Long protected open fun onDrawBackground( canvas: Canvas, parent: RecyclerView, child: View, bounds: RectF, state: RecyclerView.State, ) = Unit protected open fun onDrawForeground( canvas: Canvas, parent: RecyclerView, child: View, bounds: RectF, state: RecyclerView.State, ) = Unit } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/decor/SpacingItemDecoration.kt ================================================ package org.koitharu.kotatsu.core.ui.list.decor import android.graphics.Rect import android.view.View import androidx.annotation.Px import androidx.recyclerview.widget.RecyclerView class SpacingItemDecoration( @Px private val spacing: Int, private val withBottomPadding: Boolean, ) : RecyclerView.ItemDecoration() { override fun getItemOffsets( outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State, ) { outRect.set(spacing, spacing, spacing, if (withBottomPadding) spacing else 0) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/BubbleAnimator.kt ================================================ package org.koitharu.kotatsu.core.ui.list.fastscroll import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.view.View import android.view.ViewAnimationUtils import android.view.animation.AccelerateInterpolator import android.view.animation.DecelerateInterpolator import androidx.core.view.isInvisible import androidx.core.view.isVisible import org.koitharu.kotatsu.core.util.ext.animatorDurationScale import org.koitharu.kotatsu.core.util.ext.measureWidth import kotlin.math.hypot class BubbleAnimator( private val bubble: View, ) { private val animationDuration = ( bubble.resources.getInteger(android.R.integer.config_shortAnimTime) * bubble.context.animatorDurationScale ).toLong() private var animator: Animator? = null private var isHiding = false fun show() { if (bubble.isVisible && !isHiding) { return } isHiding = false animator?.cancel() animator = ViewAnimationUtils.createCircularReveal( bubble, bubble.measureWidth(), bubble.measuredHeight, 0f, hypot(bubble.width.toDouble(), bubble.height.toDouble()).toFloat(), ).apply { bubble.isVisible = true duration = animationDuration interpolator = DecelerateInterpolator() start() } } fun hide() { if (!bubble.isVisible || isHiding) { return } animator?.cancel() isHiding = true animator = ViewAnimationUtils.createCircularReveal( bubble, bubble.width, bubble.height, hypot(bubble.width.toDouble(), bubble.height.toDouble()).toFloat(), 0f, ).apply { duration = animationDuration interpolator = AccelerateInterpolator() addListener(HideListener()) start() } } private inner class HideListener : AnimatorListenerAdapter() { private var isCancelled = false override fun onAnimationCancel(animation: Animator) { super.onAnimationCancel(animation) isCancelled = true } override fun onAnimationEnd(animation: Animator) { super.onAnimationEnd(animation) if (!isCancelled && animation === this@BubbleAnimator.animator) { bubble.isInvisible = true isHiding = false this@BubbleAnimator.animator = null } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/FastScrollRecyclerView.kt ================================================ package org.koitharu.kotatsu.core.ui.list.fastscroll import android.content.Context import android.util.AttributeSet import android.view.ViewGroup import androidx.annotation.AttrRes import androidx.core.view.ancestors import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.widget.ViewPager2 import org.koitharu.kotatsu.R class FastScrollRecyclerView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = androidx.recyclerview.R.attr.recyclerViewStyle, ) : RecyclerView(context, attrs, defStyleAttr) { val fastScroller = FastScroller(context, attrs) var isVP2BugWorkaroundEnabled = false set(value) { field = value if (value && isAttachedToWindow) { checkIfInVP2() } else if (!value) { applyVP2Workaround = false } } private var applyVP2Workaround = false var isFastScrollerEnabled: Boolean = true set(value) { field = value fastScroller.isVisible = value && isVisible } init { fastScroller.id = R.id.fast_scroller fastScroller.layoutParams = ViewGroup.LayoutParams( LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT, ) } override fun setAdapter(adapter: Adapter<*>?) { super.setAdapter(adapter) fastScroller.setSectionIndexer(adapter as? FastScroller.SectionIndexer) } override fun setVisibility(visibility: Int) { super.setVisibility(visibility) fastScroller.visibility = if (isFastScrollerEnabled) visibility else GONE } override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) { super.setPadding(left, top, right, bottom) fastScroller.setPadding(left, top, right, bottom) } override fun setPaddingRelative(start: Int, top: Int, end: Int, bottom: Int) { super.setPaddingRelative(start, top, end, bottom) fastScroller.setPaddingRelative(start, top, end, bottom) } override fun onAttachedToWindow() { super.onAttachedToWindow() fastScroller.attachRecyclerView(this) if (isVP2BugWorkaroundEnabled) { checkIfInVP2() } } override fun onDetachedFromWindow() { fastScroller.detachRecyclerView() super.onDetachedFromWindow() applyVP2Workaround = false } override fun isLayoutRequested(): Boolean { return if (applyVP2Workaround) false else super.isLayoutRequested() } override fun requestLayout() { super.requestLayout() if (applyVP2Workaround && parent?.isLayoutRequested == true) { parent?.requestLayout() } } private fun checkIfInVP2() { applyVP2Workaround = ancestors.any { it is ViewPager2 } == true } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/FastScroller.kt ================================================ package org.koitharu.kotatsu.core.ui.list.fastscroll import android.annotation.SuppressLint import android.content.Context import android.content.res.TypedArray import android.graphics.Color import android.graphics.drawable.Drawable import android.util.AttributeSet import android.util.TypedValue import android.view.LayoutInflater import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.RelativeLayout import androidx.annotation.AttrRes import androidx.annotation.ColorInt import androidx.annotation.DimenRes import androidx.annotation.DrawableRes import androidx.annotation.Px import androidx.annotation.StyleableRes import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintSet import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.ContextCompat import androidx.core.content.withStyledAttributes import androidx.core.view.GravityCompat import androidx.core.view.ancestors import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.recyclerview.widget.RecyclerView import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.isLayoutReversed import org.koitharu.kotatsu.databinding.FastScrollerBinding import kotlin.math.roundToInt import androidx.appcompat.R as appcompatR import com.google.android.material.R as materialR private const val SCROLLBAR_HIDE_DELAY = 1000L private const val TRACK_SNAP_RANGE = 5 @Suppress("MemberVisibilityCanBePrivate", "unused") class FastScroller @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = R.attr.fastScrollerStyle, ) : LinearLayout(context, attrs, defStyleAttr) { enum class BubbleSize(@DrawableRes val drawableId: Int, @DimenRes val textSizeId: Int) { NORMAL(R.drawable.fastscroll_bubble, R.dimen.fastscroll_bubble_text_size), SMALL(R.drawable.fastscroll_bubble_small, R.dimen.fastscroll_bubble_text_size_small) } private val binding = FastScrollerBinding.inflate(LayoutInflater.from(context), this) private val scrollbarPaddingEnd = context.resources.getDimension(R.dimen.fastscroll_scrollbar_padding_end) @ColorInt private var bubbleColor = 0 @ColorInt private var handleColor = 0 private var bubbleHeight = 0 private var handleHeight = 0 private var viewHeight = 0 private var offset = 0 private var hideScrollbar = true private var showBubble = true private var showBubbleAlways = false private var bubbleSize = BubbleSize.SMALL private var bubbleImage: Drawable? = null private var handleImage: Drawable? = null private var trackImage: Drawable? = null private var recyclerView: RecyclerView? = null private val scrollbarAnimator = ScrollbarAnimator(binding.scrollbar, scrollbarPaddingEnd) private val bubbleAnimator = BubbleAnimator(binding.bubble) private var fastScrollListener: FastScrollListener? = null private var sectionIndexer: SectionIndexer? = null private val scrollbarHider = Runnable { hideBubble() hideScrollbar() } private val scrollListener: RecyclerView.OnScrollListener = object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { if (!binding.thumb.isSelected && isEnabled) { val y = recyclerView.scrollProportion setViewPositions(y) if (showBubbleAlways) { val targetPos = getRecyclerViewTargetPosition(y) sectionIndexer?.let { bindBubble(it.getSectionText(recyclerView.context, targetPos)) } } } } override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { super.onScrollStateChanged(recyclerView, newState) if (isEnabled) { when (newState) { RecyclerView.SCROLL_STATE_DRAGGING -> { handler.removeCallbacks(scrollbarHider) showScrollbar() if (showBubbleAlways && sectionIndexer != null) showBubble() } RecyclerView.SCROLL_STATE_IDLE -> if (hideScrollbar && !binding.thumb.isSelected) { handler.postDelayed(scrollbarHider, SCROLLBAR_HIDE_DELAY) } } } } } private val RecyclerView.scrollProportion: Float get() { val rangeDiff = computeVerticalScrollRange() - computeVerticalScrollExtent() val proportion = computeVerticalScrollOffset() / if (rangeDiff > 0) rangeDiff.toFloat() else 1f return viewHeight * proportion } val isScrollbarVisible: Boolean get() = binding.scrollbar.isVisible init { clipChildren = false orientation = HORIZONTAL @ColorInt var bubbleColor = context.getThemeColor(appcompatR.attr.colorControlNormal, Color.DKGRAY) @ColorInt var handleColor = bubbleColor @ColorInt var trackColor = context.getThemeColor(materialR.attr.colorOutline, Color.LTGRAY) @ColorInt var textColor = context.getThemeColor(android.R.attr.textColorPrimaryInverse, Color.WHITE) var showTrack = false context.withStyledAttributes(attrs, R.styleable.FastScrollRecyclerView, defStyleAttr) { bubbleColor = getColor(R.styleable.FastScrollRecyclerView_bubbleColor, bubbleColor) handleColor = getColor(R.styleable.FastScrollRecyclerView_thumbColor, handleColor) trackColor = getColor(R.styleable.FastScrollRecyclerView_trackColor, trackColor) textColor = getColor(R.styleable.FastScrollRecyclerView_bubbleTextColor, textColor) hideScrollbar = getBoolean(R.styleable.FastScrollRecyclerView_hideScrollbar, hideScrollbar) showBubble = getBoolean(R.styleable.FastScrollRecyclerView_showBubble, showBubble) showBubbleAlways = getBoolean(R.styleable.FastScrollRecyclerView_showBubbleAlways, showBubbleAlways) showTrack = getBoolean(R.styleable.FastScrollRecyclerView_showTrack, showTrack) bubbleSize = getBubbleSize(R.styleable.FastScrollRecyclerView_bubbleSize, bubbleSize) val textSize = getDimension(R.styleable.FastScrollRecyclerView_bubbleTextSize, bubbleSize.textSize) binding.bubble.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize) offset = getDimensionPixelOffset(R.styleable.FastScrollRecyclerView_scrollerOffset, offset) } setTrackColor(trackColor) setHandleColor(handleColor) setBubbleColor(bubbleColor) setBubbleTextColor(textColor) setHideScrollbar(hideScrollbar) setBubbleVisible(showBubble, showBubbleAlways) setTrackVisible(showTrack) } override fun onSizeChanged(w: Int, h: Int, oldW: Int, oldH: Int) { super.onSizeChanged(w, h, oldW, oldH) viewHeight = h - paddingTop - paddingBottom } @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { val setYPositions: () -> Unit = { val y = event.y setViewPositions(y) setRecyclerViewPosition(y) } when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { if (!isScrollbarVisible || event.x.toInt() !in binding.scrollbar.left..binding.scrollbar.right) { return false } requestDisallowInterceptTouchEvent(true) setHandleSelected(true) handler.removeCallbacks(scrollbarHider) showScrollbar() if (showBubble && sectionIndexer != null) showBubble() fastScrollListener?.onFastScrollStart(this) setYPositions() return true } MotionEvent.ACTION_MOVE -> { setYPositions() return true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { requestDisallowInterceptTouchEvent(false) setHandleSelected(false) if (hideScrollbar) handler.postDelayed(scrollbarHider, SCROLLBAR_HIDE_DELAY) if (!showBubbleAlways) hideBubble() fastScrollListener?.onFastScrollStop(this) return true } } return super.onTouchEvent(event) } /** * Set the enabled state of this view. * * @param enabled True if this view is enabled, false otherwise */ override fun setEnabled(enabled: Boolean) { super.setEnabled(enabled) isVisible = enabled } /** * Set the [ViewGroup.LayoutParams] associated with this view. These supply * parameters to the *parent* of this view specifying how it should be arranged. * * @param params The [ViewGroup.LayoutParams] for this view, cannot be null */ @Suppress("RemoveRedundantQualifierName") override fun setLayoutParams(params: ViewGroup.LayoutParams) { params.width = LayoutParams.WRAP_CONTENT super.setLayoutParams(params) } /** * Set the [ViewGroup.LayoutParams] associated with this view. These supply * parameters to the *parent* of this view specifying how it should be arranged. * * @param viewGroup The parent [ViewGroup] for this view, cannot be null */ fun setLayoutParams(viewGroup: ViewGroup) { val recyclerViewId = recyclerView?.id ?: NO_ID val offsetTop = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_top) val offsetBottom = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_bottom) require(recyclerViewId != NO_ID) { "RecyclerView must have a view ID" } when (viewGroup) { is ConstraintLayout -> { val endId = if (recyclerView?.parent === parent) recyclerViewId else ConstraintSet.PARENT_ID val startId = id ConstraintSet().apply { clone(viewGroup) connect(startId, ConstraintSet.TOP, endId, ConstraintSet.TOP) connect(startId, ConstraintSet.BOTTOM, endId, ConstraintSet.BOTTOM) connect(startId, ConstraintSet.END, endId, ConstraintSet.END) applyTo(viewGroup) } updateLayoutParams { height = 0 marginStart = offset marginEnd = offset topMargin = offsetTop bottomMargin = offsetBottom } } is CoordinatorLayout -> updateLayoutParams { height = LayoutParams.MATCH_PARENT anchorGravity = GravityCompat.END anchorId = recyclerViewId marginStart = offset marginEnd = offset topMargin = offsetTop bottomMargin = offsetBottom } is FrameLayout -> updateLayoutParams { height = LayoutParams.MATCH_PARENT gravity = GravityCompat.END marginStart = offset marginEnd = offset topMargin = offsetTop bottomMargin = offsetBottom } is RelativeLayout -> updateLayoutParams { height = 0 addRule(RelativeLayout.ALIGN_TOP, recyclerViewId) addRule(RelativeLayout.ALIGN_BOTTOM, recyclerViewId) addRule(RelativeLayout.ALIGN_END, recyclerViewId) marginStart = offset marginEnd = offset topMargin = offsetTop bottomMargin = offsetBottom } else -> throw IllegalArgumentException("Parent ViewGroup must be a ConstraintLayout, CoordinatorLayout, FrameLayout, or RelativeLayout") } updateViewHeights() } /** * Set the [RecyclerView] associated with this [FastScroller]. This allows the * FastScroller to set its layout parameters and listen for scroll changes. * * @param recyclerView The [RecyclerView] to attach, cannot be null * @see detachRecyclerView */ fun attachRecyclerView(recyclerView: RecyclerView) { if (this.recyclerView != null) { detachRecyclerView() } this.recyclerView = recyclerView if (parent is ViewGroup) { setLayoutParams(parent as ViewGroup) } else { val viewGroup = findValidParent(recyclerView) if (viewGroup != null) { viewGroup.addView(this) setLayoutParams(viewGroup) } } recyclerView.addOnScrollListener(scrollListener) // set initial positions for bubble and thumb post { setViewPositions(this.recyclerView?.scrollProportion ?: 0f) } } /** * Clears references to the attached [RecyclerView] and stops listening for scroll changes. * * @see attachRecyclerView */ fun detachRecyclerView() { recyclerView?.removeOnScrollListener(scrollListener) recyclerView = null } /** * Set a new [FastScrollListener] that will listen to fast scroll events. * * @param fastScrollListener The new [FastScrollListener] to set, or null to set none */ fun setFastScrollListener(fastScrollListener: FastScrollListener?) { this.fastScrollListener = fastScrollListener } /** * Set a new [SectionIndexer] that provides section text for this [FastScroller]. * * @param sectionIndexer The new [SectionIndexer] to set, or null to set none */ fun setSectionIndexer(sectionIndexer: SectionIndexer?) { this.sectionIndexer = sectionIndexer } /** * Hide the scrollbar when not scrolling. * * @param hideScrollbar True to hide the scrollbar, false to show */ fun setHideScrollbar(hideScrollbar: Boolean) { if (this.hideScrollbar != hideScrollbar) { this.hideScrollbar = hideScrollbar binding.scrollbar.isGone = hideScrollbar } } /** * Show the scroll track while scrolling. * * @param visible True to show scroll track, false to hide */ fun setTrackVisible(visible: Boolean) { binding.track.isVisible = visible } /** * Set the color of the scroll track. * * @param color The color for the scroll track */ fun setTrackColor(@ColorInt color: Int) { if (trackImage == null) { trackImage = ContextCompat.getDrawable(context, R.drawable.fastscroll_track) } trackImage?.let { it.setTint(color) binding.track.setImageDrawable(it) } } /** * Set the color of the scroll thumb. * * @param color The color for the scroll thumb */ fun setHandleColor(@ColorInt color: Int) { handleColor = color if (handleImage == null) { handleImage = ContextCompat.getDrawable(context, R.drawable.fastscroll_handle) } handleImage?.let { it.setTint(handleColor) binding.thumb.setImageDrawable(it) } } /** * Show the section bubble while scrolling. * * @param visible True to show the bubble, false to hide * @param always True to always show the bubble, false to only show on thumb touch */ @JvmOverloads fun setBubbleVisible(visible: Boolean, always: Boolean = false) { showBubble = visible showBubbleAlways = visible && always } /** * Set the background color of the section bubble. * * @param color The background color for the section bubble */ fun setBubbleColor(@ColorInt color: Int) { bubbleColor = color if (bubbleImage == null) { bubbleImage = ContextCompat.getDrawable(context, bubbleSize.drawableId) } bubbleImage?.let { it.setTint(bubbleColor) binding.bubble.background = it } } /** * Set the text color of the section bubble. * * @param color The text color for the section bubble */ fun setBubbleTextColor(@ColorInt color: Int) = binding.bubble.setTextColor(color) /** * Set the scaled pixel text size of the section bubble. * * @param size The scaled pixel text size for the section bubble */ fun setBubbleTextSize(size: Int) { binding.bubble.textSize = size.toFloat() } private fun getRecyclerViewTargetPosition(y: Float) = recyclerView?.let { recyclerView -> val itemCount = recyclerView.adapter?.itemCount ?: 0 val proportion = when { binding.thumb.y == 0f -> 0f binding.thumb.y + handleHeight >= viewHeight - TRACK_SNAP_RANGE -> 1f else -> y / viewHeight.toFloat() } var scrolledItemCount = (proportion * itemCount).roundToInt() if (recyclerView.layoutManager.isLayoutReversed) { scrolledItemCount = itemCount - scrolledItemCount } if (itemCount > 0) scrolledItemCount.coerceIn(0, itemCount - 1) else 0 } ?: 0 private fun setRecyclerViewPosition(y: Float) { val layoutManager = recyclerView?.layoutManager ?: return val targetPos = getRecyclerViewTargetPosition(y) layoutManager.scrollToPosition(targetPos) if (showBubble) sectionIndexer?.let { bindBubble(it.getSectionText(context, targetPos)) } } private fun setViewPositions(y: Float) { bubbleHeight = binding.bubble.measuredHeight handleHeight = binding.thumb.measuredHeight val bubbleHandleHeight = bubbleHeight + handleHeight / 2f if (showBubble && viewHeight >= bubbleHandleHeight) { binding.bubble.y = (y - bubbleHeight).coerceIn(0f, viewHeight - bubbleHandleHeight) } if (viewHeight >= handleHeight) { binding.thumb.y = (y - handleHeight / 2).coerceIn(0f, viewHeight - handleHeight.toFloat()) } } private fun updateViewHeights() { val measureSpec = MeasureSpec.makeMeasureSpec(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED) binding.bubble.measure(measureSpec, measureSpec) bubbleHeight = binding.bubble.measuredHeight binding.thumb.measure(measureSpec, measureSpec) handleHeight = binding.thumb.measuredHeight } private fun showBubble() { bubbleAnimator.show() } private fun hideBubble() { bubbleAnimator.hide() } private fun showScrollbar() { if (recyclerView?.run { canScrollVertically(1) || canScrollVertically(-1) } == true) { scrollbarAnimator.show() } } private fun hideScrollbar() { scrollbarAnimator.hide() } private fun setHandleSelected(selected: Boolean) { binding.thumb.isSelected = selected handleImage?.setTint(if (selected) bubbleColor else handleColor) } private fun TypedArray.getBubbleSize(@StyleableRes index: Int, defaultValue: BubbleSize): BubbleSize { val ordinal = getInt(index, -1) return BubbleSize.entries.getOrNull(ordinal) ?: defaultValue } private fun findValidParent(view: View): ViewGroup? = view.ancestors.firstNotNullOfOrNull { p -> if (p is FrameLayout || p is ConstraintLayout || p is CoordinatorLayout || p is RelativeLayout) { p } else { null } } private fun bindBubble(text: CharSequence?) { binding.bubble.text = text binding.bubble.alpha = if (text.isNullOrEmpty()) 0f else 1f } private val BubbleSize.textSize @Px get() = resources.getDimension(textSizeId) interface FastScrollListener { fun onFastScrollStart(fastScroller: FastScroller) fun onFastScrollStop(fastScroller: FastScroller) } interface SectionIndexer { fun getSectionText(context: Context, position: Int): CharSequence? } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/ScrollbarAnimator.kt ================================================ package org.koitharu.kotatsu.core.ui.list.fastscroll import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.view.View import android.view.ViewPropertyAnimator import androidx.core.view.isInvisible import androidx.core.view.isVisible import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.animatorDurationScale class ScrollbarAnimator( private val scrollbar: View, private val scrollbarPaddingEnd: Float, ) { private val animationDuration = ( scrollbar.resources.getInteger(R.integer.config_defaultAnimTime) * scrollbar.context.animatorDurationScale ).toLong() private var animator: ViewPropertyAnimator? = null private var isHiding = false fun show() { if (scrollbar.isVisible && !isHiding) { return } isHiding = false animator?.cancel() scrollbar.translationX = scrollbarPaddingEnd scrollbar.isVisible = true animator = scrollbar .animate() .translationX(0f) .alpha(1f) .setListener(null) .setDuration(animationDuration) } fun hide() { if (!scrollbar.isVisible || isHiding) { return } animator?.cancel() isHiding = true animator = scrollbar.animate().apply { translationX(scrollbarPaddingEnd) alpha(0f) duration = animationDuration setListener(HideListener(this)) } } private inner class HideListener( private val viewPropertyAnimator: ViewPropertyAnimator, ) : AnimatorListenerAdapter() { private var isCancelled = false override fun onAnimationCancel(animation: Animator) { super.onAnimationCancel(animation) isCancelled = true } override fun onAnimationEnd(animation: Animator) { super.onAnimationEnd(animation) if (!isCancelled && this@ScrollbarAnimator.animator === viewPropertyAnimator) { scrollbar.isInvisible = true isHiding = false this@ScrollbarAnimator.animator = null } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/lifecycle/LifecycleAwareViewHolder.kt ================================================ package org.koitharu.kotatsu.core.ui.list.lifecycle import android.view.View import androidx.annotation.CallSuper import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import androidx.recyclerview.widget.RecyclerView abstract class LifecycleAwareViewHolder( itemView: View, private val parentLifecycleOwner: LifecycleOwner, ) : RecyclerView.ViewHolder(itemView), LifecycleOwner { @Suppress("LeakingThis") final override val lifecycle = LifecycleRegistry(this) private var isCurrent = false init { itemView.post { parentLifecycleOwner.lifecycle.addObserver(ParentLifecycleObserver()) } } fun setIsCurrent(value: Boolean) { isCurrent = value dispatchResumed() } @CallSuper open fun onCreate() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) @CallSuper open fun onStart() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START) @CallSuper open fun onResume() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) @CallSuper open fun onPause() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) @CallSuper open fun onStop() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_STOP) @CallSuper open fun onDestroy() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) private fun dispatchResumed() { val isParentResumed = parentLifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) if (isCurrent && isParentResumed) { if (!isResumed()) { onResume() } } else { if (isResumed()) { onPause() } } } protected fun isResumed(): Boolean { return lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) } private inner class ParentLifecycleObserver : DefaultLifecycleObserver { override fun onCreate(owner: LifecycleOwner) = this@LifecycleAwareViewHolder.onCreate() override fun onStart(owner: LifecycleOwner) = this@LifecycleAwareViewHolder.onStart() override fun onResume(owner: LifecycleOwner) = this@LifecycleAwareViewHolder.dispatchResumed() override fun onPause(owner: LifecycleOwner) = this@LifecycleAwareViewHolder.dispatchResumed() override fun onStop(owner: LifecycleOwner) = this@LifecycleAwareViewHolder.onStop() override fun onDestroy(owner: LifecycleOwner) { this@LifecycleAwareViewHolder.onDestroy() owner.lifecycle.removeObserver(this) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/lifecycle/PagerLifecycleDispatcher.kt ================================================ package org.koitharu.kotatsu.core.ui.list.lifecycle import android.view.View import androidx.core.view.children import androidx.core.view.isEmpty import androidx.viewpager2.widget.ViewPager2 import org.koitharu.kotatsu.core.util.ext.recyclerView class PagerLifecycleDispatcher( private val pager: ViewPager2, ) : ViewPager2.OnPageChangeCallback() { private var pendingUpdate: OneShotLayoutListener? = null override fun onPageSelected(position: Int) { setResumedPage(position) } fun invalidate() { setResumedPage(pager.currentItem) } fun postInvalidate() = pager.post { invalidate() } private fun setResumedPage(position: Int) { pendingUpdate?.cancel() pendingUpdate = null var hasResumedItem = false val rv = pager.recyclerView ?: return if (rv.isEmpty()) { return } for (child in rv.children) { val wh = rv.getChildViewHolder(child) ?: continue val isCurrent = wh.absoluteAdapterPosition == position (wh as? LifecycleAwareViewHolder)?.setIsCurrent(isCurrent) if (isCurrent) { hasResumedItem = true } } if (!hasResumedItem) { rv.addOnLayoutChangeListener(OneShotLayoutListener(rv, position).also { pendingUpdate = it }) } } private inner class OneShotLayoutListener( private val view: View, private val targetPosition: Int, ) : View.OnLayoutChangeListener { override fun onLayoutChange( v: View?, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int ) { view.removeOnLayoutChangeListener(this) setResumedPage(targetPosition) } fun cancel() { view.removeOnLayoutChangeListener(this) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/lifecycle/RecyclerViewLifecycleDispatcher.kt ================================================ package org.koitharu.kotatsu.core.ui.list.lifecycle import androidx.core.view.children import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.NO_POSITION class RecyclerViewLifecycleDispatcher : RecyclerView.OnScrollListener() { private var prevFirst = NO_POSITION private var prevLast = NO_POSITION override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { super.onScrolled(recyclerView, dx, dy) invalidate(recyclerView) } fun invalidate(recyclerView: RecyclerView) { val lm = recyclerView.layoutManager as? LinearLayoutManager ?: return val first = lm.findFirstVisibleItemPosition() val last = lm.findLastVisibleItemPosition() if (first == prevFirst && last == prevLast) { return } prevFirst = first prevLast = last if (first == NO_POSITION || last == NO_POSITION) { return } for (child in recyclerView.children) { val wh = recyclerView.getChildViewHolder(child) ?: continue (wh as? LifecycleAwareViewHolder)?.setIsCurrent(wh.absoluteAdapterPosition in first..last) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/DateTimeAgo.kt ================================================ package org.koitharu.kotatsu.core.ui.model import android.content.Context import android.text.format.DateUtils import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.toMillis import java.time.LocalDate sealed class DateTimeAgo { abstract fun format(context: Context): String object JustNow : DateTimeAgo() { override fun format(context: Context): String { return context.getString(R.string.just_now) } override fun toString() = "just_now" override fun equals(other: Any?): Boolean = other === JustNow } data class MinutesAgo(val minutes: Int) : DateTimeAgo() { override fun format(context: Context): String { return context.resources.getQuantityStringSafe( R.plurals.minutes_ago, minutes, minutes, ) } override fun toString() = "minutes_ago_$minutes" } data class HoursAgo(val hours: Int) : DateTimeAgo() { override fun format(context: Context): String { return context.resources.getQuantityStringSafe( R.plurals.hours_ago, hours, hours, ) } override fun toString() = "hours_ago_$hours" } object Today : DateTimeAgo() { override fun format(context: Context): String { return context.getString(R.string.today) } override fun toString() = "today" override fun equals(other: Any?): Boolean = other === Today } object Yesterday : DateTimeAgo() { override fun format(context: Context): String { return context.getString(R.string.yesterday) } override fun toString() = "yesterday" override fun equals(other: Any?): Boolean = other === Yesterday } data class DaysAgo(val days: Int) : DateTimeAgo() { override fun format(context: Context): String { return context.resources.getQuantityStringSafe(R.plurals.days_ago, days, days) } override fun toString() = "days_ago_$days" } data class MonthsAgo(val months: Int) : DateTimeAgo() { override fun format(context: Context): String { return if (months == 0) { context.getString(R.string.this_month) } else { context.resources.getQuantityStringSafe( R.plurals.months_ago, months, months, ) } } } data class Absolute(private val date: LocalDate) : DateTimeAgo() { override fun format(context: Context): String { return if (date == EPOCH_DATE) { context.getString(R.string.unknown) } else { DateUtils.formatDateTime(context, date.toMillis(), DateUtils.FORMAT_SHOW_DATE) } } override fun toString() = "abs_${date.toEpochDay()}" private companion object { val EPOCH_DATE: LocalDate = LocalDate.of(1970, 1, 1) } } object LongAgo : DateTimeAgo() { override fun format(context: Context): String { return context.getString(R.string.long_ago) } override fun toString() = "long_ago" override fun equals(other: Any?): Boolean = other === LongAgo } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/MangaOverride.kt ================================================ package org.koitharu.kotatsu.core.ui.model import org.koitharu.kotatsu.parsers.model.ContentRating data class MangaOverride( val coverUrl: String?, val title: String?, val contentRating: ContentRating?, ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/SortOrder.kt ================================================ package org.koitharu.kotatsu.core.ui.model import androidx.annotation.StringRes import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.SortDirection import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder.ADDED import org.koitharu.kotatsu.parsers.model.SortOrder.ADDED_ASC import org.koitharu.kotatsu.parsers.model.SortOrder.ALPHABETICAL import org.koitharu.kotatsu.parsers.model.SortOrder.ALPHABETICAL_DESC import org.koitharu.kotatsu.parsers.model.SortOrder.NEWEST import org.koitharu.kotatsu.parsers.model.SortOrder.NEWEST_ASC import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_ASC import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_HOUR import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_MONTH import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_TODAY import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_WEEK import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_YEAR import org.koitharu.kotatsu.parsers.model.SortOrder.RATING import org.koitharu.kotatsu.parsers.model.SortOrder.RATING_ASC import org.koitharu.kotatsu.parsers.model.SortOrder.RELEVANCE import org.koitharu.kotatsu.parsers.model.SortOrder.UPDATED import org.koitharu.kotatsu.parsers.model.SortOrder.UPDATED_ASC @get:StringRes val SortOrder.titleRes: Int get() = when (this) { UPDATED -> R.string.updated POPULARITY -> R.string.popular RATING -> R.string.by_rating NEWEST -> R.string.newest ALPHABETICAL -> R.string.by_name ALPHABETICAL_DESC -> R.string.by_name_reverse UPDATED_ASC -> R.string.updated_long_ago POPULARITY_ASC -> R.string.unpopular RATING_ASC -> R.string.low_rating NEWEST_ASC -> R.string.order_oldest ADDED -> R.string.recently_added ADDED_ASC -> R.string.added_long_ago RELEVANCE -> R.string.by_relevance POPULARITY_HOUR -> R.string.popular_in_hour POPULARITY_TODAY -> R.string.popular_today POPULARITY_WEEK -> R.string.popular_in_week POPULARITY_MONTH -> R.string.popular_in_month POPULARITY_YEAR -> R.string.popular_in_year } val SortOrder.direction: SortDirection get() = when (this) { UPDATED_ASC, POPULARITY_ASC, RATING_ASC, NEWEST_ASC, ADDED_ASC, ALPHABETICAL -> SortDirection.ASC UPDATED, POPULARITY, POPULARITY_HOUR, POPULARITY_TODAY, POPULARITY_WEEK, POPULARITY_MONTH, POPULARITY_YEAR, RATING, NEWEST, ADDED, RELEVANCE, ALPHABETICAL_DESC -> SortDirection.DESC } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/AdaptiveSheetBehavior.kt ================================================ package org.koitharu.kotatsu.core.ui.sheet import android.app.Dialog import android.view.View import android.view.ViewParent import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.ancestors import androidx.fragment.app.DialogFragment import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.sidesheet.SideSheetBehavior import com.google.android.material.sidesheet.SideSheetCallback import com.google.android.material.sidesheet.SideSheetDialog import java.util.LinkedList sealed class AdaptiveSheetBehavior { @JvmField protected val callbacks = LinkedList() abstract var state: Int abstract var isDraggable: Boolean open val isHideable: Boolean = true fun addCallback(callback: AdaptiveSheetCallback) { callbacks.add(callback) } fun removeCallback(callback: AdaptiveSheetCallback) { callbacks.remove(callback) } class Bottom( private val delegate: BottomSheetBehavior<*>, ) : AdaptiveSheetBehavior() { override var state: Int get() = delegate.state set(value) { delegate.state = value } override var isDraggable: Boolean get() = delegate.isDraggable set(value) { delegate.isDraggable = value } override val isHideable: Boolean get() = delegate.isHideable var isFitToContents: Boolean get() = delegate.isFitToContents set(value) { delegate.isFitToContents = value } init { delegate.addBottomSheetCallback( object : BottomSheetCallback() { override fun onStateChanged(bottomSheet: View, newState: Int) { callbacks.forEach { it.onStateChanged(bottomSheet, newState) } } override fun onSlide(bottomSheet: View, slideOffset: Float) { callbacks.forEach { it.onSlide(bottomSheet, slideOffset) } } }, ) } } class Side( private val delegate: SideSheetBehavior<*>, ) : AdaptiveSheetBehavior() { override var state: Int get() = delegate.state set(value) { delegate.state = value } override var isDraggable: Boolean get() = delegate.isDraggable set(value) { delegate.isDraggable = value } init { delegate.addCallback( object : SideSheetCallback() { override fun onStateChanged(sheet: View, newState: Int) { callbacks.forEach { it.onStateChanged(sheet, newState) } } override fun onSlide(sheet: View, slideOffset: Float) { callbacks.forEach { it.onSlide(sheet, slideOffset) } } }, ) } } companion object { const val STATE_EXPANDED = SideSheetBehavior.STATE_EXPANDED const val STATE_COLLAPSED = BottomSheetBehavior.STATE_COLLAPSED const val STATE_SETTLING = SideSheetBehavior.STATE_SETTLING const val STATE_DRAGGING = SideSheetBehavior.STATE_DRAGGING const val STATE_HIDDEN = SideSheetBehavior.STATE_HIDDEN fun from(fragment: DialogFragment): AdaptiveSheetBehavior? { from(fragment.dialog)?.let { return it } val rootView = fragment.view ?: return null for (parent in rootView.ancestors) { from(parent)?.let { return it } } return null } private fun from(dialog: Dialog?): AdaptiveSheetBehavior? = when (dialog) { is BottomSheetDialog -> Bottom(dialog.behavior) is SideSheetDialog -> Side(dialog.behavior) else -> null } fun from(lp: CoordinatorLayout.LayoutParams): AdaptiveSheetBehavior? = when (val behavior = lp.behavior) { is BottomSheetBehavior<*> -> Bottom(behavior) is SideSheetBehavior<*> -> Side(behavior) else -> null } private fun from(parent: ViewParent): AdaptiveSheetBehavior? { val lp = ((parent as? View)?.layoutParams as? CoordinatorLayout.LayoutParams) ?: return null return from(lp) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/AdaptiveSheetCallback.kt ================================================ package org.koitharu.kotatsu.core.ui.sheet import android.view.View interface AdaptiveSheetCallback { /** * Called when the sheet changes its state. * * @param sheet The sheet view. * @param newState The new state. */ fun onStateChanged(sheet: View, newState: Int) /** * Called when the sheet is being dragged. * * @param sheet The sheet view. * @param slideOffset The new offset of this sheet. */ fun onSlide(sheet: View, slideOffset: Float) = Unit } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/AdaptiveSheetHeaderBar.kt ================================================ package org.koitharu.kotatsu.core.ui.sheet import android.content.Context import android.util.AttributeSet import android.view.InputDevice import android.view.LayoutInflater import android.view.MotionEvent import android.view.View import android.widget.LinearLayout import androidx.annotation.AttrRes import androidx.annotation.StringRes import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.withStyledAttributes import androidx.core.view.ancestors import androidx.core.view.isGone import androidx.core.view.isVisible import org.koitharu.kotatsu.R import org.koitharu.kotatsu.databinding.LayoutSheetHeaderAdaptiveBinding class AdaptiveSheetHeaderBar @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0, ) : LinearLayout(context, attrs, defStyleAttr), AdaptiveSheetCallback { private val binding = LayoutSheetHeaderAdaptiveBinding.inflate(LayoutInflater.from(context), this) private var sheetBehavior: AdaptiveSheetBehavior? = null var title: CharSequence? get() = binding.shTextViewTitle.text set(value) { binding.shTextViewTitle.text = value } val isTitleVisible: Boolean get() = binding.shLayoutSidesheet.isVisible init { orientation = VERTICAL binding.shButtonClose.setOnClickListener { dismissSheet() } context.withStyledAttributes( attrs, R.styleable.AdaptiveSheetHeaderBar, defStyleAttr, ) { title = getText(R.styleable.AdaptiveSheetHeaderBar_title) } } override fun onAttachedToWindow() { super.onAttachedToWindow() if (isInEditMode) { val isTabled = resources.getBoolean(R.bool.is_tablet) binding.shDragHandle.isGone = isTabled binding.shLayoutSidesheet.isVisible = isTabled } else { setBottomSheetBehavior(findParentSheetBehavior()) } } override fun onDetachedFromWindow() { setBottomSheetBehavior(null) super.onDetachedFromWindow() } override fun onGenericMotionEvent(event: MotionEvent): Boolean { val behavior = sheetBehavior ?: return super.onGenericMotionEvent(event) if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) { if (event.actionMasked == MotionEvent.ACTION_SCROLL) { if (event.getAxisValue(MotionEvent.AXIS_VSCROLL) < 0f) { behavior.state = if ( behavior is AdaptiveSheetBehavior.Bottom && behavior.state == AdaptiveSheetBehavior.STATE_EXPANDED ) { AdaptiveSheetBehavior.STATE_COLLAPSED } else { AdaptiveSheetBehavior.STATE_HIDDEN } } else { behavior.state = AdaptiveSheetBehavior.STATE_EXPANDED } return true } } return super.onGenericMotionEvent(event) } override fun onStateChanged(sheet: View, newState: Int) { } fun setTitle(@StringRes resId: Int) { binding.shTextViewTitle.setText(resId) } private fun setBottomSheetBehavior(behavior: AdaptiveSheetBehavior?) { binding.shDragHandle.isVisible = behavior is AdaptiveSheetBehavior.Bottom binding.shLayoutSidesheet.isVisible = behavior is AdaptiveSheetBehavior.Side sheetBehavior?.removeCallback(this) sheetBehavior = behavior behavior?.addCallback(this) } private fun dismissSheet() { sheetBehavior?.state = AdaptiveSheetBehavior.STATE_HIDDEN } private fun findParentSheetBehavior(): AdaptiveSheetBehavior? { return ancestors.firstNotNullOfOrNull { ((it as? View)?.layoutParams as? CoordinatorLayout.LayoutParams) ?.let { params -> AdaptiveSheetBehavior.from(params) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/BaseAdaptiveSheet.kt ================================================ package org.koitharu.kotatsu.core.ui.sheet import android.app.Dialog import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.ViewGroup.LayoutParams import androidx.activity.ComponentDialog import androidx.activity.OnBackPressedDispatcher import androidx.annotation.CallSuper import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDialog import androidx.appcompat.app.AppCompatDialogFragment import androidx.appcompat.view.ActionMode import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.ViewCompat import androidx.core.view.updateLayoutParams import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.viewbinding.ViewBinding import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.sidesheet.SideSheetDialog import dagger.hilt.android.EntryPointAccessors import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivityEntryPoint import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate import com.google.android.material.R as materialR abstract class BaseAdaptiveSheet : AppCompatDialogFragment(), OnApplyWindowInsetsListener { private var waitingForDismissAllowingStateLoss = false private var isFitToContentsDisabled = false protected lateinit var exceptionResolver: ExceptionResolver private set var viewBinding: B? = null private set protected val behavior: AdaptiveSheetBehavior? get() = AdaptiveSheetBehavior.from(this) var actionModeDelegate: ActionModeDelegate? = null private set val isExpanded: Boolean get() = behavior?.state == AdaptiveSheetBehavior.STATE_EXPANDED val onBackPressedDispatcher: OnBackPressedDispatcher get() = (dialog as? ComponentDialog)?.onBackPressedDispatcher ?: requireActivity().onBackPressedDispatcher var isLocked = false private set private var lockCounter = 0 override fun onAttach(context: Context) { super.onAttach(context) val entryPoint = EntryPointAccessors.fromApplication(context) exceptionResolver = entryPoint.exceptionResolverFactory.create(this) } final override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { val binding = onCreateViewBinding(inflater, container) viewBinding = binding return binding.root } final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) ViewCompat.setOnApplyWindowInsetsListener(view, this) val binding = requireViewBinding() if (actionModeDelegate == null) { actionModeDelegate = (activity as? BaseActivity<*>)?.actionModeDelegate } onViewBindingCreated(binding, savedInstanceState) } override fun onDestroyView() { viewBinding = null actionModeDelegate = null super.onDestroyView() } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val context = requireContext() val dialog = if (context.resources.getBoolean(R.bool.is_tablet)) { SideSheetDialogImpl(context, theme) } else { BottomSheetDialogImpl(context, theme) } actionModeDelegate = ActionModeDelegate().also { dialog.onBackPressedDispatcher.addCallback(it) } return dialog } @CallSuper protected open fun dispatchSupportActionModeStarted(mode: ActionMode) { actionModeDelegate?.onSupportActionModeStarted(mode, dialog?.window) } @CallSuper protected open fun dispatchSupportActionModeFinished(mode: ActionMode) { actionModeDelegate?.onSupportActionModeFinished(mode, dialog?.window) } fun addSheetCallback(callback: AdaptiveSheetCallback, lifecycleOwner: LifecycleOwner): Boolean { val b = behavior ?: return false b.addCallback(callback) val rootView = dialog?.findViewById(materialR.id.design_bottom_sheet) ?: dialog?.findViewById(materialR.id.coordinator) ?: view if (rootView != null) { callback.onStateChanged(rootView, b.state) } lifecycleOwner.lifecycle.addObserver(CallbackRemoveObserver(b, callback)) return true } protected abstract fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): B protected open fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) = Unit fun startSupportActionMode(callback: ActionMode.Callback): ActionMode? { val delegate = (dialog as? AppCompatDialog)?.delegate ?: (activity as? AppCompatActivity)?.delegate ?: return null return delegate.startSupportActionMode(callback) } protected fun setExpanded(isExpanded: Boolean, isLocked: Boolean) { this.isLocked = isLocked if (!isLocked) { lockCounter = 0 } val b = behavior ?: return if (isExpanded) { b.state = BottomSheetBehavior.STATE_EXPANDED } if (b is AdaptiveSheetBehavior.Bottom) { b.isFitToContents = !isFitToContentsDisabled && !isExpanded val rootView = dialog?.findViewById(materialR.id.design_bottom_sheet) rootView?.updateLayoutParams { height = if (isFitToContentsDisabled || isExpanded) { LayoutParams.MATCH_PARENT } else { LayoutParams.WRAP_CONTENT } } } b.isDraggable = !isLocked } protected fun disableFitToContents() { isFitToContentsDisabled = true val b = behavior as? AdaptiveSheetBehavior.Bottom ?: return b.isFitToContents = false dialog?.findViewById(materialR.id.design_bottom_sheet)?.updateLayoutParams { height = LayoutParams.MATCH_PARENT } } @CallSuper open fun expandAndLock() { lockCounter++ setExpanded(isExpanded = true, isLocked = true) } @CallSuper open fun unlock() { lockCounter-- if (lockCounter <= 0) { setExpanded(isExpanded, false) } } fun requireViewBinding(): B = checkNotNull(viewBinding) { "Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()." } override fun dismiss() { if (!tryDismissWithAnimation(false)) { super.dismiss() } } override fun dismissAllowingStateLoss() { if (!tryDismissWithAnimation(true)) { super.dismissAllowingStateLoss() } } /** * Tries to dismiss the dialog fragment with the bottom sheet animation. Returns true if possible, * false otherwise. */ private fun tryDismissWithAnimation(allowingStateLoss: Boolean): Boolean { val shouldDismissWithAnimation = when (val dialog = dialog) { is BottomSheetDialog -> dialog.dismissWithAnimation is SideSheetDialog -> dialog.isDismissWithSheetAnimationEnabled else -> false } val behavior = behavior ?: return false return if (shouldDismissWithAnimation && behavior.isHideable) { dismissWithAnimation(behavior, allowingStateLoss) true } else { false } } private fun dismissWithAnimation(behavior: AdaptiveSheetBehavior, allowingStateLoss: Boolean) { waitingForDismissAllowingStateLoss = allowingStateLoss if (behavior.state == AdaptiveSheetBehavior.STATE_HIDDEN) { dismissAfterAnimation() } else { behavior.addCallback(SheetDismissCallback()) behavior.state = AdaptiveSheetBehavior.STATE_HIDDEN } } private fun dismissAfterAnimation() { if (waitingForDismissAllowingStateLoss) { super.dismissAllowingStateLoss() } else { super.dismiss() } } private inner class SheetDismissCallback : AdaptiveSheetCallback { override fun onStateChanged(sheet: View, newState: Int) { if (newState == BottomSheetBehavior.STATE_HIDDEN) { dismissAfterAnimation() } } override fun onSlide(sheet: View, slideOffset: Float) {} } private inner class SideSheetDialogImpl(context: Context, theme: Int) : SideSheetDialog(context, theme) { override fun onSupportActionModeStarted(mode: ActionMode?) { super.onSupportActionModeStarted(mode) if (mode != null) { dispatchSupportActionModeStarted(mode) } } override fun onSupportActionModeFinished(mode: ActionMode?) { super.onSupportActionModeFinished(mode) if (mode != null) { dispatchSupportActionModeFinished(mode) } } } private inner class BottomSheetDialogImpl(context: Context, theme: Int) : BottomSheetDialog(context, theme) { override fun onSupportActionModeStarted(mode: ActionMode?) { super.onSupportActionModeStarted(mode) if (mode != null) { dispatchSupportActionModeStarted(mode) } } override fun onSupportActionModeFinished(mode: ActionMode?) { super.onSupportActionModeFinished(mode) if (mode != null) { dispatchSupportActionModeFinished(mode) } } } private class CallbackRemoveObserver( private val behavior: AdaptiveSheetBehavior, private val callback: AdaptiveSheetCallback, ) : DefaultLifecycleObserver { override fun onDestroy(owner: LifecycleOwner) { super.onDestroy(owner) owner.lifecycle.removeObserver(this) behavior.removeCallback(callback) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/BottomSheetCollapseCallback.kt ================================================ package org.koitharu.kotatsu.core.ui.sheet import android.annotation.SuppressLint import android.view.View import android.view.ViewGroup import androidx.activity.BackEventCompat import androidx.activity.OnBackPressedCallback import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HALF_EXPANDED import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN class BottomSheetCollapseCallback( private val sheet: ViewGroup, private val behavior: BottomSheetBehavior<*> = BottomSheetBehavior.from(sheet), ) : OnBackPressedCallback(behavior.state == STATE_EXPANDED || behavior.state == STATE_HALF_EXPANDED) { init { behavior.addBottomSheetCallback( object : BottomSheetBehavior.BottomSheetCallback() { @SuppressLint("SwitchIntDef") override fun onStateChanged(view: View, state: Int) = onStateChanged(state) override fun onSlide(p0: View, p1: Float) = Unit }, ) onStateChanged(behavior.state) } override fun handleOnBackPressed() = behavior.handleBackInvoked() override fun handleOnBackCancelled() = behavior.cancelBackProgress() override fun handleOnBackProgressed(backEvent: BackEventCompat) = behavior.updateBackProgress(backEvent) override fun handleOnBackStarted(backEvent: BackEventCompat) = behavior.startBackProgress(backEvent) private fun onStateChanged(state: Int) { when (state) { STATE_EXPANDED, STATE_HALF_EXPANDED -> isEnabled = true STATE_COLLAPSED, STATE_HIDDEN -> isEnabled = false } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ActionModeDelegate.kt ================================================ package org.koitharu.kotatsu.core.ui.util import android.graphics.Color import android.view.ViewGroup import android.view.Window import androidx.activity.OnBackPressedCallback import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.ActionBarContextView import androidx.core.content.ContextCompat import androidx.core.graphics.ColorUtils import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateLayoutParams import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import org.koitharu.kotatsu.core.util.ext.getThemeColor import com.google.android.material.R as materialR class ActionModeDelegate : OnBackPressedCallback(false) { private var activeActionMode: ActionMode? = null private var listeners: MutableList? = null private var defaultStatusBarColor = Color.TRANSPARENT val isActionModeStarted: Boolean get() = activeActionMode != null override fun handleOnBackPressed() { finishActionMode() } fun onSupportActionModeStarted(mode: ActionMode, window: Window?) { activeActionMode = mode isEnabled = true listeners?.forEach { it.onActionModeStarted(mode) } if (window != null) { val ctx = window.context val actionModeColor = ColorUtils.compositeColors( ContextCompat.getColor(ctx, materialR.color.m3_appbar_overlay_color), ctx.getThemeColor(materialR.attr.colorSurface), ) defaultStatusBarColor = window.statusBarColor window.statusBarColor = actionModeColor val insets = ViewCompat.getRootWindowInsets(window.decorView) ?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return window.decorView.findViewById(androidx.appcompat.R.id.action_mode_bar)?.apply { setBackgroundColor(actionModeColor) updateLayoutParams { topMargin = insets.top } } } } fun onSupportActionModeFinished(mode: ActionMode, window: Window?) { activeActionMode = null isEnabled = false listeners?.forEach { it.onActionModeFinished(mode) } if (window != null) { window.statusBarColor = defaultStatusBarColor } } fun addListener(listener: ActionModeListener) { if (listeners == null) { listeners = ArrayList() } checkNotNull(listeners).add(listener) } fun removeListener(listener: ActionModeListener) { listeners?.remove(listener) } fun addListener(listener: ActionModeListener, owner: LifecycleOwner) { addListener(listener) owner.lifecycle.addObserver(ListenerLifecycleObserver(listener)) } fun finishActionMode() { activeActionMode?.finish() } private inner class ListenerLifecycleObserver( private val listener: ActionModeListener, ) : DefaultLifecycleObserver { override fun onDestroy(owner: LifecycleOwner) { super.onDestroy(owner) removeListener(listener) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ActionModeListener.kt ================================================ package org.koitharu.kotatsu.core.ui.util import androidx.appcompat.view.ActionMode interface ActionModeListener { fun onActionModeStarted(mode: ActionMode) fun onActionModeFinished(mode: ActionMode) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ActivityRecreationHandle.kt ================================================ package org.koitharu.kotatsu.core.ui.util import android.app.Activity import android.os.Bundle import androidx.core.app.ActivityCompat import org.koitharu.kotatsu.core.ui.DefaultActivityLifecycleCallbacks import java.util.WeakHashMap import javax.inject.Inject import javax.inject.Singleton @Singleton class ActivityRecreationHandle @Inject constructor() : DefaultActivityLifecycleCallbacks { private val activities = WeakHashMap() override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { activities[activity] = Unit } override fun onActivityDestroyed(activity: Activity) { activities.remove(activity) } fun recreateAll() { val snapshot = activities.keys.toList() snapshot.forEach { ActivityCompat.recreate(it) } } fun recreate(cls: Class) { val activity = activities.keys.find { x -> x.javaClass == cls } ?: return ActivityCompat.recreate(activity) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/CollapseActionViewCallback.kt ================================================ package org.koitharu.kotatsu.core.ui.util import android.view.MenuItem import android.view.MenuItem.OnActionExpandListener import androidx.activity.OnBackPressedCallback class CollapseActionViewCallback( private val menuItem: MenuItem ) : OnBackPressedCallback(menuItem.isActionViewExpanded), OnActionExpandListener { override fun handleOnBackPressed() { menuItem.collapseActionView() } override fun onMenuItemActionExpand(item: MenuItem): Boolean { isEnabled = true return true } override fun onMenuItemActionCollapse(item: MenuItem): Boolean { isEnabled = false return true } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/DefaultTextWatcher.kt ================================================ package org.koitharu.kotatsu.core.ui.util import android.text.Editable import android.text.TextWatcher interface DefaultTextWatcher : TextWatcher { override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit override fun afterTextChanged(s: Editable?) = Unit } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/FadingAppbarMediator.kt ================================================ package org.koitharu.kotatsu.core.ui.util import android.view.View import com.google.android.material.appbar.AppBarLayout class FadingAppbarMediator( private val appBarLayout: AppBarLayout, private val target: View ) : AppBarLayout.OnOffsetChangedListener { private var isBound: Boolean = false fun bind() { if (!isBound) { appBarLayout.addOnOffsetChangedListener(this) isBound = true } } fun unbind() { if (isBound) { appBarLayout.removeOnOffsetChangedListener(this) isBound = false } target.alpha = 1f } override fun onOffsetChanged(appBarLayout: AppBarLayout?, verticalOffset: Int) { val scrollRange = (appBarLayout ?: return).totalScrollRange if (scrollRange <= 0) { return } target.alpha = 1f + verticalOffset / (scrollRange / 2f) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/MenuInvalidator.kt ================================================ package org.koitharu.kotatsu.core.ui.util import androidx.core.view.MenuHost import kotlinx.coroutines.flow.FlowCollector class MenuInvalidator( private val host: MenuHost, ) : FlowCollector { override suspend fun emit(value: Any?) = host.invalidateMenu() } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/OptionsMenuBadgeHelper.kt ================================================ package org.koitharu.kotatsu.core.ui.util import androidx.annotation.IdRes import androidx.appcompat.widget.Toolbar import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeUtils import com.google.android.material.badge.ExperimentalBadgeUtils @androidx.annotation.OptIn(ExperimentalBadgeUtils::class) class OptionsMenuBadgeHelper( private val toolbar: Toolbar, @IdRes private val itemId: Int, ) { private var badge: BadgeDrawable? = null fun setBadgeVisible(isVisible: Boolean) { if (isVisible) { showBadge() } else { hideBadge() } } private fun hideBadge() { badge?.let { BadgeUtils.detachBadgeDrawable(it, toolbar, itemId) } badge = null } private fun showBadge() { val badgeDrawable = badge ?: BadgeDrawable.create(toolbar.context).also { badge = it } BadgeUtils.attachBadgeDrawable(badgeDrawable, toolbar, itemId) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/PagerNestedScrollHelper.kt ================================================ package org.koitharu.kotatsu.core.ui.util import android.view.View import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.ancestors import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle.State.RESUMED import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetBehavior class PagerNestedScrollHelper( private val recyclerView: RecyclerView, ) : DefaultLifecycleObserver { fun bind(lifecycleOwner: LifecycleOwner) { lifecycleOwner.lifecycle.addObserver(this) recyclerView.isNestedScrollingEnabled = lifecycleOwner.lifecycle.currentState.isAtLeast(RESUMED) } override fun onPause(owner: LifecycleOwner) { recyclerView.isNestedScrollingEnabled = false invalidateBottomSheetScrollTarget() } override fun onResume(owner: LifecycleOwner) { recyclerView.isNestedScrollingEnabled = true } override fun onDestroy(owner: LifecycleOwner) { owner.lifecycle.removeObserver(this) } /** * Here we need to invalidate the `nestedScrollingChildRef` of the [BottomSheetBehavior] */ private fun invalidateBottomSheetScrollTarget() { var handleCoordinator = false for (parent in recyclerView.ancestors) { if (handleCoordinator && parent is CoordinatorLayout) { parent.requestLayout() break } val lp = (parent as? View)?.layoutParams ?: continue if (lp is CoordinatorLayout.LayoutParams && lp.behavior is BottomSheetBehavior<*>) { handleCoordinator = true } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/PopupMenuMediator.kt ================================================ package org.koitharu.kotatsu.core.ui.util import android.view.MenuItem import android.view.View import androidx.appcompat.widget.PopupMenu import androidx.core.view.MenuProvider class PopupMenuMediator( private val provider: MenuProvider, ) : View.OnLongClickListener, View.OnContextClickListener, PopupMenu.OnMenuItemClickListener, PopupMenu.OnDismissListener { override fun onContextClick(v: View): Boolean = onLongClick(v) override fun onLongClick(v: View): Boolean { val menu = PopupMenu(v.context, v) provider.onCreateMenu(menu.menu, menu.menuInflater) provider.onPrepareMenu(menu.menu) if (!menu.menu.hasVisibleItems()) { return false } menu.setOnMenuItemClickListener(this) menu.setOnDismissListener(this) menu.show() return true } override fun onMenuItemClick(item: MenuItem): Boolean { return provider.onMenuItemSelected(item) } override fun onDismiss(menu: PopupMenu) { provider.onMenuClosed(menu.menu) } fun attach(view: View) { view.setOnLongClickListener(this) view.setOnContextClickListener(this) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/RecyclerViewOwner.kt ================================================ package org.koitharu.kotatsu.core.ui.util import androidx.recyclerview.widget.RecyclerView interface RecyclerViewOwner { val recyclerView: RecyclerView? } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ReversibleAction.kt ================================================ package org.koitharu.kotatsu.core.ui.util import androidx.annotation.StringRes class ReversibleAction( @StringRes val stringResId: Int, val handle: ReversibleHandle?, ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ReversibleActionObserver.kt ================================================ package org.koitharu.kotatsu.core.ui.util import android.view.View import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.flow.FlowCollector import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.findActivity import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner import org.koitharu.kotatsu.main.ui.owners.BottomSheetOwner class ReversibleActionObserver( private val snackbarHost: View, ) : FlowCollector { override suspend fun emit(value: ReversibleAction) { val handle = value.handle val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG val snackbar = Snackbar.make(snackbarHost, value.stringResId, length) when (val activity = snackbarHost.context.findActivity()) { is BottomNavOwner -> snackbar.anchorView = activity.bottomNav is BottomSheetOwner -> snackbar.anchorView = activity.bottomSheet } if (handle != null) { snackbar.setAction(R.string.undo) { handle.reverseAsync() } } snackbar.show() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ReversibleHandle.kt ================================================ package org.koitharu.kotatsu.core.ui.util import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.parsers.util.runCatchingCancellable fun interface ReversibleHandle { suspend fun reverse() } fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.Default, CoroutineStart.ATOMIC) { runCatchingCancellable { withContext(NonCancellable) { reverse() } }.onFailure { it.printStackTraceDebug() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ShrinkOnScrollBehavior.kt ================================================ package org.koitharu.kotatsu.core.ui.util import android.content.Context import android.util.AttributeSet import android.view.View import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout.Behavior import androidx.core.view.ViewCompat import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton open class ShrinkOnScrollBehavior : Behavior { constructor() : super() constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) override fun onStartNestedScroll( coordinatorLayout: CoordinatorLayout, child: ExtendedFloatingActionButton, directTargetChild: View, target: View, axes: Int, type: Int ): Boolean { return axes == ViewCompat.SCROLL_AXIS_VERTICAL } override fun onNestedScroll( coordinatorLayout: CoordinatorLayout, child: ExtendedFloatingActionButton, target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int, type: Int, consumed: IntArray ) { if (dyConsumed > 0) { if (child.isExtended) { child.shrink() } } else if (dyConsumed < 0) { if (!child.isExtended) { child.extend() } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/SpanSizeResolver.kt ================================================ package org.koitharu.kotatsu.core.ui.util import android.view.View import androidx.annotation.Px import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import org.koitharu.kotatsu.parsers.util.toIntUp import kotlin.math.abs class SpanSizeResolver( private val recyclerView: RecyclerView, @Px private val minItemWidth: Int, ) : View.OnLayoutChangeListener { fun attach() { recyclerView.addOnLayoutChangeListener(this) } fun detach() { recyclerView.removeOnLayoutChangeListener(this) } override fun onLayoutChange( v: View?, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int, ) { invalidateInternal(abs(right - left)) } fun invalidate() { invalidateInternal(recyclerView.width) } private fun invalidateInternal(width: Int) { if (width <= 0) { return } val lm = recyclerView.layoutManager as? GridLayoutManager ?: return val estimatedCount = (width / minItemWidth.toFloat()).toIntUp() if (lm.spanCount != estimatedCount) { lm.spanCount = estimatedCount lm.spanSizeLookup?.run { invalidateSpanGroupIndexCache() invalidateSpanIndexCache() } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/SystemUiController.kt ================================================ package org.koitharu.kotatsu.core.ui.util import android.os.Build import android.view.View import android.view.Window import android.view.WindowInsets import android.view.WindowInsetsController import androidx.annotation.RequiresApi sealed class SystemUiController( protected val window: Window, ) { abstract fun setSystemUiVisible(value: Boolean) @RequiresApi(Build.VERSION_CODES.S) private class Api30Impl(window: Window) : SystemUiController(window) { private val insetsController = checkNotNull(window.decorView.windowInsetsController) override fun setSystemUiVisible(value: Boolean) { if (value) { insetsController.show(WindowInsets.Type.systemBars()) insetsController.systemBarsBehavior = WindowInsetsController.BEHAVIOR_DEFAULT } else { insetsController.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE insetsController.hide(WindowInsets.Type.systemBars()) } } } @Suppress("DEPRECATION") private class LegacyImpl(window: Window) : SystemUiController(window) { override fun setSystemUiVisible(value: Boolean) { val flags = window.decorView.systemUiVisibility window.decorView.systemUiVisibility = if (value) { (flags and LEGACY_FLAGS_HIDDEN.inv()) or LEGACY_FLAGS_VISIBLE } else { (flags and LEGACY_FLAGS_VISIBLE.inv()) or LEGACY_FLAGS_HIDDEN } } } companion object { @Suppress("DEPRECATION") private const val LEGACY_FLAGS_VISIBLE = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN @Suppress("DEPRECATION") private const val LEGACY_FLAGS_HIDDEN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY operator fun invoke(window: Window): SystemUiController = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { Api30Impl(window) } else { LegacyImpl(window) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/BadgeView.kt ================================================ package org.koitharu.kotatsu.core.ui.widgets import android.content.Context import android.os.Parcel import android.os.Parcelable import android.os.Parcelable.Creator import android.util.AttributeSet import androidx.core.content.withStyledAttributes import androidx.customview.view.AbsSavedState import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.ShapeAppearanceModel import com.google.android.material.textview.MaterialTextView import org.koitharu.kotatsu.R class BadgeView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : MaterialTextView(context, attrs, R.attr.badgeViewStyle) { private var maxCharacterCount = Int.MAX_VALUE var number: Int = 0 set(value) { field = value updateText() } init { context.withStyledAttributes(attrs, R.styleable.BadgeView, R.attr.badgeViewStyle) { maxCharacterCount = getInt(R.styleable.BadgeView_maxCharacterCount, maxCharacterCount) number = getInt(R.styleable.BadgeView_number, number) val shape = ShapeAppearanceModel.builder( context, getResourceId(R.styleable.BadgeView_shapeAppearance, 0), 0, ).build() background = MaterialShapeDrawable(shape).also { bg -> bg.fillColor = getColorStateList(R.styleable.BadgeView_backgroundColor) } } } override fun onSaveInstanceState(): Parcelable? { val superState = super.onSaveInstanceState() ?: return null return SavedState(superState, number) } override fun onRestoreInstanceState(state: Parcelable?) { if (state is SavedState) { super.onRestoreInstanceState(state.superState) number = state.number } else { super.onRestoreInstanceState(state) } } private fun updateText() { if (number <= 0) { text = null return } val numberString = number.toString() text = if (numberString.length > maxCharacterCount) { buildString(maxCharacterCount) { repeat(maxCharacterCount - 1) { append('9') } append('+') } } else { numberString } } private class SavedState : AbsSavedState { val number: Int constructor(superState: Parcelable, number: Int) : super(superState) { this.number = number } constructor(source: Parcel, classLoader: ClassLoader?) : super(source, classLoader) { number = source.readInt() } override fun writeToParcel(out: Parcel, flags: Int) { super.writeToParcel(out, flags) out.writeInt(number) } companion object { @Suppress("unused") @JvmField val CREATOR: Creator = object : Creator { override fun createFromParcel(`in`: Parcel) = SavedState(`in`, SavedState::class.java.classLoader) override fun newArray(size: Int): Array = arrayOfNulls(size) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/CheckableImageButton.kt ================================================ package org.koitharu.kotatsu.core.ui.widgets import android.content.Context import android.os.Parcel import android.os.Parcelable import android.os.Parcelable.Creator import android.util.AttributeSet import android.widget.Checkable import androidx.annotation.AttrRes import androidx.appcompat.widget.AppCompatImageButton import androidx.core.os.ParcelCompat import androidx.customview.view.AbsSavedState class CheckableImageButton @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0, ) : AppCompatImageButton(context, attrs, defStyleAttr), Checkable { private var isCheckedInternal = false private var isBroadcasting = false var onCheckedChangeListener: OnCheckedChangeListener? = null override fun isChecked() = isCheckedInternal override fun toggle() { isChecked = !isCheckedInternal } override fun setChecked(checked: Boolean) { if (checked != isCheckedInternal) { isCheckedInternal = checked refreshDrawableState() if (!isBroadcasting) { isBroadcasting = true onCheckedChangeListener?.onCheckedChanged(this, checked) isBroadcasting = false } } } override fun onCreateDrawableState(extraSpace: Int): IntArray { val state = super.onCreateDrawableState(extraSpace + 1) if (isCheckedInternal) { mergeDrawableStates(state, intArrayOf(android.R.attr.state_checked)) } return state } override fun onSaveInstanceState(): Parcelable? { val superState = super.onSaveInstanceState() ?: return null return SavedState(superState, isChecked) } override fun onRestoreInstanceState(state: Parcelable?) { if (state is SavedState) { super.onRestoreInstanceState(state.superState) isChecked = state.isChecked } else { super.onRestoreInstanceState(state) } } fun interface OnCheckedChangeListener { fun onCheckedChanged(view: CheckableImageButton, isChecked: Boolean) } private class SavedState : AbsSavedState { val isChecked: Boolean constructor(superState: Parcelable, checked: Boolean) : super(superState) { isChecked = checked } constructor(source: Parcel, classLoader: ClassLoader?) : super(source, classLoader) { isChecked = ParcelCompat.readBoolean(source) } override fun writeToParcel(out: Parcel, flags: Int) { super.writeToParcel(out, flags) ParcelCompat.writeBoolean(out, isChecked) } companion object { @Suppress("unused") @JvmField val CREATOR: Creator = object : Creator { override fun createFromParcel(`in`: Parcel) = SavedState(`in`, SavedState::class.java.classLoader) override fun newArray(size: Int): Array = arrayOfNulls(size) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/CheckableImageView.kt ================================================ package org.koitharu.kotatsu.core.ui.widgets import android.content.Context import android.os.Parcel import android.os.Parcelable import android.os.Parcelable.Creator import android.util.AttributeSet import android.widget.Checkable import androidx.annotation.AttrRes import androidx.appcompat.widget.AppCompatImageView import androidx.core.os.ParcelCompat import androidx.customview.view.AbsSavedState class CheckableImageView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0, ) : AppCompatImageView(context, attrs, defStyleAttr), Checkable { private var isCheckedInternal = false private var isBroadcasting = false var onCheckedChangeListener: OnCheckedChangeListener? = null override fun isChecked() = isCheckedInternal override fun toggle() { isChecked = !isCheckedInternal } override fun setChecked(checked: Boolean) { if (checked != isCheckedInternal) { isCheckedInternal = checked refreshDrawableState() if (!isBroadcasting) { isBroadcasting = true onCheckedChangeListener?.onCheckedChanged(this, checked) isBroadcasting = false } } } override fun onCreateDrawableState(extraSpace: Int): IntArray { val state = super.onCreateDrawableState(extraSpace + 1) if (isCheckedInternal) { mergeDrawableStates(state, intArrayOf(android.R.attr.state_checked)) } return state } override fun onSaveInstanceState(): Parcelable? { val superState = super.onSaveInstanceState() ?: return null return SavedState(superState, isChecked) } override fun onRestoreInstanceState(state: Parcelable?) { if (state is SavedState) { super.onRestoreInstanceState(state.superState) isChecked = state.isChecked } else { super.onRestoreInstanceState(state) } } fun interface OnCheckedChangeListener { fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean) } private class SavedState : AbsSavedState { val isChecked: Boolean constructor(superState: Parcelable, checked: Boolean) : super(superState) { isChecked = checked } constructor(source: Parcel, classLoader: ClassLoader?) : super(source, classLoader) { isChecked = ParcelCompat.readBoolean(source) } override fun writeToParcel(out: Parcel, flags: Int) { super.writeToParcel(out, flags) ParcelCompat.writeBoolean(out, isChecked) } companion object { @Suppress("unused") @JvmField val CREATOR: Creator = object : Creator { override fun createFromParcel(`in`: Parcel) = SavedState(`in`, SavedState::class.java.classLoader) override fun newArray(size: Int): Array = arrayOfNulls(size) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt ================================================ package org.koitharu.kotatsu.core.ui.widgets import android.content.Context import android.graphics.Color import android.text.style.ForegroundColorSpan import android.text.style.RelativeSizeSpan import android.util.AttributeSet import android.view.View import androidx.annotation.ColorRes import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.core.content.ContextCompat import androidx.core.text.buildSpannedString import androidx.core.text.inSpans import androidx.core.view.children import androidx.lifecycle.findViewTreeLifecycleOwner import coil3.ImageLoader import coil3.request.Disposable 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.google.android.material.chip.Chip import com.google.android.material.chip.ChipDrawable import com.google.android.material.chip.ChipGroup import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.image.ChipIconTarget import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.setProgressIcon import org.koitharu.kotatsu.parsers.util.ifZero import javax.inject.Inject import com.google.android.material.R as materialR @AndroidEntryPoint class ChipsView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = materialR.attr.chipGroupStyle, ) : ChipGroup(context, attrs, defStyleAttr) { @Inject lateinit var coil: ImageLoader private var isLayoutSuppressedCompat = false private var isLayoutCalledOnSuppressed = false private val chipOnClickListener = InternalChipClickListener() private val chipOnCloseListener = OnClickListener { val chip = it as Chip val data = it.tag onChipCloseClickListener?.onChipCloseClick(chip, data) ?: onChipClickListener?.onChipClick(chip, data) } private val chipOnLongClickListener = OnLongClickListener { val chip = it as Chip val data = it.tag onChipLongClickListener?.onChipLongClick(chip, data) ?: false } private val chipStyle: Int private val iconsVisible: Boolean var onChipClickListener: OnChipClickListener? = null set(value) { field = value val isChipClickable = value != null children.forEach { it.isClickable = isChipClickable } } var onChipCloseClickListener: OnChipCloseClickListener? = null var onChipLongClickListener: OnChipLongClickListener? = null init { val ta = context.obtainStyledAttributes(attrs, R.styleable.ChipsView, defStyleAttr, 0) chipStyle = ta.getResourceId(R.styleable.ChipsView_chipStyle, R.style.Widget_Kotatsu_Chip) iconsVisible = ta.getBoolean(R.styleable.ChipsView_chipIconVisible, true) ta.recycle() if (isInEditMode) { setChips( List(5) { ChipModel(title = "Chip $it") }, ) } } override fun requestLayout() { if (isLayoutSuppressedCompat) { isLayoutCalledOnSuppressed = true } else { super.requestLayout() } } fun setChips(items: Collection) { suppressLayoutCompat(true) try { for ((i, model) in items.withIndex()) { val chip = getChildAt(i) as DataChip? ?: addChip() chip.bind(model) } if (childCount > items.size) { removeViews(items.size, childCount - items.size) } } finally { suppressLayoutCompat(false) } } private fun addChip() = DataChip(context).also { addView(it) } private fun suppressLayoutCompat(suppress: Boolean) { isLayoutSuppressedCompat = suppress if (!suppress) { if (isLayoutCalledOnSuppressed) { requestLayout() isLayoutCalledOnSuppressed = false } } } data class ChipModel( val title: CharSequence? = null, @StringRes val titleResId: Int = 0, @DrawableRes val icon: Int = 0, val iconData: Any? = null, @ColorRes val tint: Int = 0, val counter: Int = 0, val isChecked: Boolean = false, val isLoading: Boolean = false, val isDropdown: Boolean = false, val isCloseable: Boolean = false, val data: Any? = null, ) private inner class DataChip(context: Context) : Chip(context) { private var model: ChipModel? = null private var imageRequest: Disposable? = null private val defaultStrokeColor = chipStrokeColor private val defaultTextColor = textColors init { val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle) setChipDrawable(drawable) isChipIconVisible = false setOnCloseIconClickListener(chipOnCloseListener) setEnsureMinTouchTargetSize(false) setOnClickListener(chipOnClickListener) setOnLongClickListener(chipOnLongClickListener) isElegantTextHeight = false } fun bind(model: ChipModel) { if (this.model == model) { return } this.model = model if (model.counter > 0) { text = buildSpannedString { if (model.titleResId == 0) { append(model.title) } else { append(context.getString(model.titleResId)) } append(' ') append(' ') inSpans( ForegroundColorSpan( context.getThemeColor( android.R.attr.textColorSecondary, Color.LTGRAY, ), ), RelativeSizeSpan(0.74f), ) { append(model.counter.toString()) } } } else if (model.titleResId == 0) { text = model.title } else { setText(model.titleResId) } isClickable = onChipClickListener != null if (model.isChecked) { isCheckable = true isChecked = true } else { isChecked = false isCheckable = false } if (model.tint == 0) { chipStrokeColor = defaultStrokeColor setTextColor(defaultTextColor) } else { val tint = ContextCompat.getColorStateList(context, model.tint) chipStrokeColor = tint setTextColor(tint) } bindIcon(model) isCheckedIconVisible = model.isChecked isCloseIconVisible = if (model.isCloseable || model.isDropdown) { setCloseIconResource( if (model.isDropdown) R.drawable.ic_expand_more else materialR.drawable.ic_m3_chip_close, ) true } else { false } tag = model.data } override fun toggle() = Unit private fun bindIcon(model: ChipModel) { when { model.isChecked -> disposeIcon() model.isLoading -> { imageRequest?.dispose() imageRequest = null isChipIconVisible = true setProgressIcon() } !iconsVisible -> disposeIcon() model.iconData != null -> { val placeholder = model.icon.ifZero { materialR.drawable.navigation_empty_icon } imageRequest = ImageRequest.Builder(context) .data(model.iconData) .crossfade(false) .size(resources.getDimensionPixelSize(materialR.dimen.m3_chip_icon_size)) .target(ChipIconTarget(this)) .placeholder(placeholder) .fallback(placeholder) .lifecycle(this@ChipsView.findViewTreeLifecycleOwner()) .error(placeholder) .transformations(RoundedCornersTransformation(resources.getDimension(R.dimen.chip_icon_corner))) .allowRgb565(true) .enqueueWith(coil) isChipIconVisible = true } model.icon != 0 -> { imageRequest?.dispose() imageRequest = null setChipIconResource(model.icon) isChipIconVisible = true } else -> disposeIcon() } } private fun disposeIcon() { imageRequest?.dispose() imageRequest = null chipIcon = null isChipIconVisible = false } } private inner class InternalChipClickListener : OnClickListener { override fun onClick(v: View?) { val chip = v as? DataChip ?: return onChipClickListener?.onChipClick(chip, chip.tag) } } fun interface OnChipClickListener { fun onChipClick(chip: Chip, data: Any?) } fun interface OnChipCloseClickListener { fun onChipCloseClick(chip: Chip, data: Any?) } fun interface OnChipLongClickListener { fun onChipLongClick(chip: Chip, data: Any?): Boolean } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/CubicSlider.kt ================================================ package org.koitharu.kotatsu.core.ui.widgets import android.content.Context import android.util.AttributeSet import androidx.collection.MutableScatterMap import com.google.android.material.slider.Slider import kotlin.math.cbrt import kotlin.math.pow class CubicSlider @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, ) : Slider(context, attrs) { private val changeListeners = MutableScatterMap(1) override fun setValue(value: Float) { super.setValue(value.unmap()) } override fun getValue(): Float { return super.getValue().map() } override fun getValueFrom(): Float { return super.getValueFrom().map() } override fun setValueFrom(valueFrom: Float) { super.setValueFrom(valueFrom.unmap()) } override fun getValueTo(): Float { return super.getValueTo().map() } override fun setValueTo(valueTo: Float) { super.setValueTo(valueTo.unmap()) } override fun addOnChangeListener(listener: OnChangeListener) { val mapper = OnChangeListenerMapper(listener) super.addOnChangeListener(mapper) changeListeners[listener] = mapper } override fun removeOnChangeListener(listener: OnChangeListener) { changeListeners.remove(listener)?.let { super.removeOnChangeListener(it) } } override fun clearOnChangeListeners() { super.clearOnChangeListeners() changeListeners.clear() } private fun Float.map(): Float { return this.pow(3) } private fun Float.unmap(): Float { return cbrt(this) } private inner class OnChangeListenerMapper( private val delegate: OnChangeListener, ) : OnChangeListener { override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { delegate.onValueChange(slider, value.map(), fromUser) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/DotsIndicator.kt ================================================ package org.koitharu.kotatsu.core.ui.widgets import android.content.Context import android.content.res.ColorStateList import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.util.AttributeSet import android.view.View import androidx.core.content.withStyledAttributes import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver import androidx.viewpager2.widget.ViewPager2 import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList import org.koitharu.kotatsu.core.util.ext.measureDimension import org.koitharu.kotatsu.core.util.ext.resolveDp import org.koitharu.kotatsu.parsers.util.toIntUp import com.google.android.material.R as materialR class DotsIndicator @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = R.attr.dotIndicatorStyle, ) : View(context, attrs, defStyleAttr) { private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private var indicatorSize = context.resources.resolveDp(12f) private var dotSpacing = 0f private var smallDotScale = 0.33f private var smallDotAlpha = 0.6f private var positionOffset: Float = 0f private var position: Int = 0 private var dotsColor: ColorStateList = ColorStateList.valueOf(Color.DKGRAY) private val inset = context.resources.resolveDp(1f) var max: Int = 6 set(value) { if (field != value) { field = value requestLayout() invalidate() } } var progress: Int get() = position set(value) { if (position != value) { position = value invalidate() } } init { paint.style = Paint.Style.FILL context.withStyledAttributes(attrs, R.styleable.DotsIndicator, defStyleAttr) { dotsColor = getColorStateList(R.styleable.DotsIndicator_dotColor) ?: context.getThemeColorStateList(materialR.attr.colorOnBackground) ?: dotsColor paint.color = dotsColor.getColorForState(drawableState, dotsColor.defaultColor) indicatorSize = getDimension(R.styleable.DotsIndicator_dotSize, indicatorSize) dotSpacing = getDimension(R.styleable.DotsIndicator_dotSpacing, dotSpacing) smallDotScale = getFloat(R.styleable.DotsIndicator_dotScale, smallDotScale).coerceIn(0f, 1f) smallDotAlpha = getFloat(R.styleable.DotsIndicator_dotAlpha, smallDotAlpha).coerceIn(0f, 1f) max = getInt(R.styleable.DotsIndicator_android_max, max) position = getInt(R.styleable.DotsIndicator_android_progress, position) } } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) val dotSize = getDotSize() val y = paddingTop + (height - paddingTop - paddingBottom) / 2f var x = paddingLeft + dotSize / 2f val radius = dotSize / 2f - inset val spacing = (width - paddingLeft - paddingRight) / max.toFloat() - dotSize x += spacing / 2f for (i in 0 until max) { val scale = when (i) { position -> (1f - smallDotScale) * (1f - positionOffset) + smallDotScale position + 1 -> (1f - smallDotScale) * positionOffset + smallDotScale else -> smallDotScale } paint.alpha = (255 * when (i) { position -> (1f - smallDotAlpha) * (1f - positionOffset) + smallDotAlpha position + 1 -> (1f - smallDotAlpha) * positionOffset + smallDotAlpha else -> smallDotAlpha }).toInt() canvas.drawCircle(x, y, radius * scale, paint) x += spacing + dotSize } } override fun drawableStateChanged() { if (dotsColor.isStateful) { paint.color = dotsColor.getColorForState(drawableState, dotsColor.defaultColor) } super.drawableStateChanged() } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val dotSize = getDotSize() val desiredHeight = (dotSize + paddingTop + paddingBottom).toIntUp() val desiredWidth = ((dotSize + dotSpacing) * max).toIntUp() + paddingLeft + paddingRight setMeasuredDimension( measureDimension(desiredWidth, widthMeasureSpec), measureDimension(desiredHeight, heightMeasureSpec), ) } fun bindToViewPager(pager: ViewPager2) { pager.registerOnPageChangeCallback(ViewPagerCallback()) pager.adapter?.let { it.registerAdapterDataObserver(AdapterObserver(it)) } } private fun getDotSize() = if (indicatorSize <= 0) { (height - paddingTop - paddingBottom).toFloat() } else { indicatorSize } private inner class ViewPagerCallback : ViewPager2.OnPageChangeCallback() { override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) { super.onPageScrolled(position, positionOffset, positionOffsetPixels) this@DotsIndicator.position = position this@DotsIndicator.positionOffset = positionOffset invalidate() } } private inner class AdapterObserver( private val adapter: RecyclerView.Adapter<*>, ) : AdapterDataObserver() { override fun onChanged() { super.onChanged() max = adapter.itemCount } override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { super.onItemRangeInserted(positionStart, itemCount) max = adapter.itemCount } override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) { super.onItemRangeRemoved(positionStart, itemCount) max = adapter.itemCount } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/HideBottomNavigationOnScrollBehavior.kt ================================================ package org.koitharu.kotatsu.core.ui.widgets import android.animation.ValueAnimator import android.content.Context import android.util.AttributeSet import android.view.View import android.view.animation.DecelerateInterpolator import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.ViewCompat import com.google.android.material.appbar.AppBarLayout import com.google.android.material.navigation.NavigationBarView import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.getAnimationDuration import org.koitharu.kotatsu.core.util.ext.measureHeight class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor( context: Context? = null, attrs: AttributeSet? = null, ) : CoordinatorLayout.Behavior(context, attrs) { @ViewCompat.NestedScrollType private var lastStartedType: Int = 0 private var offsetAnimator: ValueAnimator? = null private var dyRatio = 1F var isPinned: Boolean = false set(value) { field = value if (value) { offsetAnimator?.cancel() offsetAnimator = null } } override fun layoutDependsOn(parent: CoordinatorLayout, child: NavigationBarView, dependency: View): Boolean { return dependency is AppBarLayout } override fun onDependentViewChanged( parent: CoordinatorLayout, child: NavigationBarView, dependency: View, ): Boolean { val appBarSize = dependency.measureHeight() dyRatio = if (appBarSize > 0) { child.measureHeight().toFloat() / appBarSize } else { 1F } return false } override fun onStartNestedScroll( coordinatorLayout: CoordinatorLayout, child: NavigationBarView, directTargetChild: View, target: View, axes: Int, type: Int, ): Boolean { if (isPinned || axes != ViewCompat.SCROLL_AXIS_VERTICAL) { return false } lastStartedType = type offsetAnimator?.cancel() return true } override fun onNestedPreScroll( coordinatorLayout: CoordinatorLayout, child: NavigationBarView, target: View, dx: Int, dy: Int, consumed: IntArray, type: Int, ) { super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type) if (!isPinned) { child.translationY = (child.translationY + (dy * dyRatio)).coerceIn(0F, child.height.toFloat()) } } override fun onStopNestedScroll( coordinatorLayout: CoordinatorLayout, child: NavigationBarView, target: View, type: Int, ) { if (!isPinned && (lastStartedType == ViewCompat.TYPE_TOUCH || type == ViewCompat.TYPE_NON_TOUCH)) { animateBottomNavigationVisibility(child, child.translationY < child.height / 2) } } private fun animateBottomNavigationVisibility(child: NavigationBarView, isVisible: Boolean) { offsetAnimator?.cancel() offsetAnimator = ValueAnimator().apply { interpolator = DecelerateInterpolator() duration = child.context.getAnimationDuration(R.integer.config_shorterAnimTime) addUpdateListener { child.translationY = it.animatedValue as Float } } offsetAnimator?.setFloatValues( child.translationY, if (isVisible) 0F else child.height.toFloat(), ) offsetAnimator?.start() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/IconsView.kt ================================================ package org.koitharu.kotatsu.core.ui.widgets import android.content.Context import android.graphics.drawable.Drawable import android.util.AttributeSet import android.widget.ImageView import android.widget.LinearLayout import androidx.annotation.DrawableRes import androidx.core.content.withStyledAttributes import androidx.core.view.isNotEmpty import androidx.core.view.isVisible import org.koitharu.kotatsu.R class IconsView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, ) : LinearLayout(context, attrs) { private var iconSize = LayoutParams.WRAP_CONTENT private var iconSpacing = 0 val iconsCount: Int get() { var count = 0 repeat(childCount) { i -> if (getChildAt(i).isVisible) { count++ } } return count } init { context.withStyledAttributes(attrs, R.styleable.IconsView) { iconSize = getDimensionPixelSize(R.styleable.IconsView_iconSize, iconSize) iconSpacing = getDimensionPixelOffset(R.styleable.IconsView_iconSpacing, iconSpacing) } } fun setIcons(icons: Iterable) { var index = 0 for (icon in icons) { val imageView = (getChildAt(index) as ImageView?) ?: addImageView() imageView.setImageDrawable(icon) imageView.isVisible = true index++ } for (i in index until childCount) { val imageView = getChildAt(i) as? ImageView ?: continue imageView.setImageDrawable(null) imageView.isVisible = false } } fun clearIcons() { repeat(childCount) { i -> getChildAt(i).isVisible = false } } fun addIcon(drawable: Drawable) { val imageView = getNextImageView() imageView.setImageDrawable(drawable) imageView.isVisible = true } fun addIcon(@DrawableRes resId: Int) { val imageView = getNextImageView() imageView.setImageResource(resId) imageView.isVisible = true } private fun getNextImageView(): ImageView { repeat(childCount) { i -> val child = getChildAt(i) if (child is ImageView && !child.isVisible) { return child } } return addImageView() } private fun addImageView() = ImageView(context).also { it.scaleType = ImageView.ScaleType.FIT_CENTER val lp = LayoutParams(iconSize, iconSize) if (isNotEmpty()) { lp.marginStart = iconSpacing } addView(it, lp) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ListItemTextView.kt ================================================ package org.koitharu.kotatsu.core.ui.widgets import android.annotation.SuppressLint import android.content.Context import android.content.res.ColorStateList import android.content.res.TypedArray import android.graphics.Color import android.graphics.drawable.Drawable import android.graphics.drawable.InsetDrawable import android.graphics.drawable.RippleDrawable import android.graphics.drawable.ShapeDrawable import android.graphics.drawable.shapes.RoundRectShape import android.util.AttributeSet import androidx.annotation.AttrRes import androidx.appcompat.widget.AppCompatCheckedTextView import androidx.core.content.ContextCompat import androidx.core.content.withStyledAttributes import com.google.android.material.ripple.RippleUtils import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.ShapeAppearanceModel import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.resolveDp @SuppressLint("RestrictedApi") class ListItemTextView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = R.attr.listItemTextViewStyle, ) : AppCompatCheckedTextView(context, attrs, defStyleAttr) { private var checkedDrawableStart: Drawable? = null private var checkedDrawableEnd: Drawable? = null private var isInitialized = false private var isCheckDrawablesVisible: Boolean = false private var defaultPaddingStart: Int = 0 private var defaultPaddingEnd: Int = 0 init { context.withStyledAttributes(attrs, R.styleable.ListItemTextView, defStyleAttr) { val itemRippleColor = getRippleColor(context) val shape = createShapeDrawable(this) val roundCorners = FloatArray(8) { resources.resolveDp(32f) } background = RippleDrawable( RippleUtils.sanitizeRippleDrawableColor(itemRippleColor), shape, ShapeDrawable(RoundRectShape(roundCorners, null, null)), ) checkedDrawableStart = getDrawable(R.styleable.ListItemTextView_checkedDrawableStart) checkedDrawableEnd = getDrawable(R.styleable.ListItemTextView_checkedDrawableEnd) } checkedDrawableStart?.setTintList(textColors) checkedDrawableEnd?.setTintList(textColors) defaultPaddingStart = paddingStart defaultPaddingEnd = paddingEnd isInitialized = true adjustCheckDrawables() } override fun refreshDrawableState() { super.refreshDrawableState() adjustCheckDrawables() } override fun setTextColor(colors: ColorStateList?) { checkedDrawableStart?.setTintList(colors) checkedDrawableEnd?.setTintList(colors) super.setTextColor(colors) } override fun setPaddingRelative(start: Int, top: Int, end: Int, bottom: Int) { defaultPaddingStart = start defaultPaddingEnd = end super.setPaddingRelative(start, top, end, bottom) } override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) { val isRtl = layoutDirection == LAYOUT_DIRECTION_RTL defaultPaddingStart = if (isRtl) right else left defaultPaddingEnd = if (isRtl) left else right super.setPadding(left, top, right, bottom) } private fun adjustCheckDrawables() { if (isInitialized && isCheckDrawablesVisible != isChecked) { setCompoundDrawablesRelativeWithIntrinsicBounds( if (isChecked) checkedDrawableStart else null, null, if (isChecked) checkedDrawableEnd else null, null, ) super.setPaddingRelative( if (isChecked && checkedDrawableStart != null) { defaultPaddingStart + compoundDrawablePadding } else defaultPaddingStart, paddingTop, if (isChecked && checkedDrawableEnd != null) { defaultPaddingEnd + compoundDrawablePadding } else defaultPaddingEnd, paddingBottom, ) isCheckDrawablesVisible = isChecked } } private fun createShapeDrawable(ta: TypedArray): InsetDrawable { val shapeAppearance = ShapeAppearanceModel.builder( context, ta.getResourceId(R.styleable.ListItemTextView_shapeAppearance, 0), ta.getResourceId(R.styleable.ListItemTextView_shapeAppearanceOverlay, 0), ).build() val shapeDrawable = MaterialShapeDrawable(shapeAppearance) shapeDrawable.fillColor = ta.getColorStateList(R.styleable.ListItemTextView_backgroundFillColor) return InsetDrawable( shapeDrawable, ta.getDimensionPixelOffset(R.styleable.ListItemTextView_android_insetLeft, 0), ta.getDimensionPixelOffset(R.styleable.ListItemTextView_android_insetTop, 0), ta.getDimensionPixelOffset(R.styleable.ListItemTextView_android_insetRight, 0), ta.getDimensionPixelOffset(R.styleable.ListItemTextView_android_insetBottom, 0), ) } private fun getRippleColor(context: Context): ColorStateList { return ContextCompat.getColorStateList(context, R.color.selector_overlay) ?: ColorStateList.valueOf(Color.TRANSPARENT) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/MultilineEllipsizeTextView.kt ================================================ package org.koitharu.kotatsu.core.ui.widgets import android.content.Context import android.util.AttributeSet import androidx.annotation.AttrRes import com.google.android.material.textview.MaterialTextView class MultilineEllipsizeTextView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = android.R.attr.textViewStyle, ) : MaterialTextView(context, attrs, defStyleAttr) { override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) val lh = lineHeight maxLines = if (lh > 0) h / lh else 1 } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/NestedRecyclerView.kt ================================================ package org.koitharu.kotatsu.core.ui.widgets import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet import android.view.MotionEvent import androidx.core.content.withStyledAttributes import androidx.recyclerview.widget.RecyclerView import org.koitharu.kotatsu.R class NestedRecyclerView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : RecyclerView(context, attrs) { private var maxHeight: Int = 0 init { context.withStyledAttributes(attrs, R.styleable.NestedRecyclerView) { maxHeight = getDimensionPixelSize(R.styleable.NestedRecyclerView_maxHeight, maxHeight) } } @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(e: MotionEvent?): Boolean { if (e?.actionMasked == MotionEvent.ACTION_UP) { requestDisallowInterceptTouchEvent(false) } else { requestDisallowInterceptTouchEvent(true) } return super.onTouchEvent(e) } override fun onMeasure(widthSpec: Int, heightSpec: Int) { super.onMeasure( widthSpec, if (maxHeight == 0) { heightSpec } else { MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST) }, ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/SegmentedBarView.kt ================================================ package org.koitharu.kotatsu.core.ui.widgets import android.animation.Animator import android.animation.ValueAnimator import android.content.Context import android.graphics.Canvas import android.graphics.Outline import android.graphics.Paint import android.util.AttributeSet import android.view.View import android.view.ViewOutlineProvider import androidx.annotation.ColorInt import androidx.annotation.FloatRange import androidx.collection.MutableFloatList import androidx.interpolator.view.animation.FastOutSlowInInterpolator import org.koitharu.kotatsu.core.util.ext.getAnimationDuration import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.resolveDp import org.koitharu.kotatsu.parsers.util.replaceWith class SegmentedBarView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, ) : View(context, attrs, defStyleAttr), ValueAnimator.AnimatorUpdateListener, Animator.AnimatorListener { private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val segmentsData = ArrayList() private val segmentsSizes = MutableFloatList() private var cornerSize = 0f private var scaleFactor = 1f private var scaleAnimator: ValueAnimator? = null init { paint.strokeWidth = context.resources.resolveDp(0f) outlineProvider = OutlineProvider() clipToOutline = true } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) cornerSize = h / 2f updateSizes() } override fun onDraw(canvas: Canvas) { if (segmentsSizes.isEmpty()) { return } val w = width.toFloat() var x = w - segmentsSizes.last() for (i in (0 until segmentsData.size).reversed()) { val segment = segmentsData[i] paint.color = segment.color paint.style = Paint.Style.FILL val segmentWidth = segmentsSizes[i] canvas.drawRoundRect(0f, 0f, x + cornerSize, height.toFloat(), cornerSize, cornerSize, paint) paint.style = Paint.Style.STROKE canvas.drawRoundRect(0f, 0f, x + cornerSize, height.toFloat(), cornerSize, cornerSize, paint) x -= segmentWidth } paint.style = Paint.Style.STROKE canvas.drawRoundRect(0f, 0f, w, height.toFloat(), cornerSize, cornerSize, paint) } override fun onAnimationStart(animation: Animator) = Unit override fun onAnimationEnd(animation: Animator) { if (scaleAnimator === animation) { scaleAnimator = null } } override fun onAnimationUpdate(animation: ValueAnimator) { scaleFactor = animation.animatedValue as Float updateSizes() invalidate() } override fun onAnimationCancel(animation: Animator) = Unit override fun onAnimationRepeat(animation: Animator) = Unit fun animateSegments(value: List) { scaleAnimator?.cancel() segmentsData.replaceWith(value) if (!context.isAnimationsEnabled) { scaleAnimator = null scaleFactor = 1f updateSizes() invalidate() return } scaleFactor = 0f updateSizes() invalidate() val animator = ValueAnimator.ofFloat(0f, 1f) animator.duration = context.getAnimationDuration(android.R.integer.config_longAnimTime) animator.interpolator = FastOutSlowInInterpolator() animator.addUpdateListener(this@SegmentedBarView) animator.addListener(this@SegmentedBarView) scaleAnimator = animator animator.start() } private fun updateSizes() { segmentsSizes.clear() segmentsSizes.ensureCapacity(segmentsData.size + 1) var w = width.toFloat() val maxScale = (scaleFactor * (segmentsData.size - 1)).coerceAtLeast(1f) for ((index, segment) in segmentsData.withIndex()) { val scale = (scaleFactor * (index + 1) / maxScale).coerceAtMost(1f) val segmentWidth = (w * segment.percent).coerceAtLeast( if (index == 0) height.toFloat() else cornerSize, ) * scale segmentsSizes.add(segmentWidth) w -= segmentWidth } segmentsSizes.add(w) } data class Segment( @FloatRange(from = 0.0, to = 1.0) val percent: Float, @ColorInt val color: Int, ) private class OutlineProvider : ViewOutlineProvider() { override fun getOutline(view: View, outline: Outline) { outline.setRoundRect(0, 0, view.width, view.height, view.height / 2f) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/SelectableTextView.kt ================================================ package org.koitharu.kotatsu.core.ui.widgets import android.content.Context import android.os.Build import android.text.Selection import android.text.Spannable import android.util.AttributeSet import android.view.MotionEvent import android.view.PointerIcon import androidx.annotation.AttrRes import androidx.core.view.PointerIconCompat import com.google.android.material.textview.MaterialTextView class SelectableTextView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = android.R.attr.textViewStyle, ) : MaterialTextView(context, attrs, defStyleAttr) { init { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { pointerIcon = PointerIcon.getSystemIcon(context, PointerIconCompat.TYPE_TEXT) } } override fun dispatchTouchEvent(event: MotionEvent?): Boolean { fixSelectionRange() return super.dispatchTouchEvent(event) } // https://stackoverflow.com/questions/22810147/error-when-selecting-text-from-textview-java-lang-indexoutofboundsexception-se private fun fixSelectionRange() { if (selectionStart < 0 || selectionEnd < 0) { val spannableText = text as? Spannable ?: return Selection.setSelection(spannableText, spannableText.length) } } override fun scrollTo(x: Int, y: Int) { super.scrollTo(0, 0) } fun selectAll() { val spannableText = text as? Spannable ?: return Selection.selectAll(spannableText) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ShapeView.kt ================================================ package org.koitharu.kotatsu.core.ui.widgets import android.annotation.SuppressLint import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Outline import android.graphics.Paint import android.graphics.Path import android.util.AttributeSet import android.view.View import android.view.ViewOutlineProvider import androidx.core.content.withStyledAttributes import androidx.core.graphics.withClip import com.google.android.material.drawable.DrawableUtils import org.koitharu.kotatsu.R class ShapeView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, ) : View(context, attrs, defStyleAttr) { private val corners = FloatArray(8) private val outlinePath = Path() private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG) init { context.withStyledAttributes(attrs, R.styleable.ShapeView, defStyleAttr) { val cornerSize = getDimension(R.styleable.ShapeView_cornerSize, 0f) corners[0] = getDimension(R.styleable.ShapeView_cornerSizeTopLeft, cornerSize) corners[1] = corners[0] corners[2] = getDimension(R.styleable.ShapeView_cornerSizeTopRight, cornerSize) corners[3] = corners[2] corners[4] = getDimension(R.styleable.ShapeView_cornerSizeBottomRight, cornerSize) corners[5] = corners[4] corners[6] = getDimension(R.styleable.ShapeView_cornerSizeBottomLeft, cornerSize) corners[7] = corners[6] strokePaint.color = getColor(R.styleable.ShapeView_strokeColor, Color.TRANSPARENT) strokePaint.strokeWidth = getDimension(R.styleable.ShapeView_strokeWidth, 0f) strokePaint.style = Paint.Style.STROKE } outlineProvider = OutlineProvider() } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) if (w != oldw || h != oldh) { rebuildPath() } } override fun draw(canvas: Canvas) { canvas.withClip(outlinePath) { super.draw(canvas) } } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) if (strokePaint.strokeWidth > 0f) { canvas.drawPath(outlinePath, strokePaint) } } private fun rebuildPath() { outlinePath.reset() val w = width val h = height if (w > 0 && h > 0) { outlinePath.addRoundRect(0f, 0f, w.toFloat(), h.toFloat(), corners, Path.Direction.CW) } } private inner class OutlineProvider : ViewOutlineProvider() { @SuppressLint("RestrictedApi") override fun getOutline(view: View?, outline: Outline) { val corner = corners[0] var isRoundRect = true for (item in corners) { if (item != corner) { isRoundRect = false break } } if (isRoundRect) { outline.setRoundRect(0, 0, width, height, corner) } else { DrawableUtils.setOutlineToPath(outline, outlinePath) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/SlidingBottomNavigationView.kt ================================================ package org.koitharu.kotatsu.core.ui.widgets import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.animation.TimeInterpolator import android.annotation.SuppressLint import android.content.Context import android.os.Parcel import android.os.Parcelable import android.util.AttributeSet import android.view.MotionEvent import android.view.ViewPropertyAnimator import androidx.annotation.AttrRes import androidx.annotation.StyleRes import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.isVisible import androidx.customview.view.AbsSavedState import androidx.interpolator.view.animation.FastOutLinearInInterpolator import androidx.interpolator.view.animation.LinearOutSlowInInterpolator import com.google.android.material.bottomnavigation.BottomNavigationMenuView import com.google.android.material.navigation.NavigationBarView import org.koitharu.kotatsu.core.util.ext.applySystemAnimatorScale import org.koitharu.kotatsu.core.util.ext.measureHeight import kotlin.math.max import com.google.android.material.R as materialR private const val STATE_DOWN = 1 private const val STATE_UP = 2 private const val SLIDE_UP_ANIMATION_DURATION = 225L private const val SLIDE_DOWN_ANIMATION_DURATION = 175L private const val MAX_ITEM_COUNT = 6 class SlidingBottomNavigationView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = materialR.attr.bottomNavigationStyle, @StyleRes defStyleRes: Int = materialR.style.Widget_Design_BottomNavigationView, ) : NavigationBarView(context, attrs, defStyleAttr, defStyleRes), CoordinatorLayout.AttachedBehavior { private var currentAnimator: ViewPropertyAnimator? = null private var currentState = STATE_UP private var behavior = HideBottomNavigationOnScrollBehavior() var isPinned: Boolean get() = behavior.isPinned set(value) { behavior.isPinned = value if (value) { translationX = 0f } } val isShownOrShowing: Boolean get() = isVisible && currentState == STATE_UP override fun getBehavior(): CoordinatorLayout.Behavior<*> { return behavior } /** From BottomNavigationView **/ @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { super.onTouchEvent(event) // Consume all events to avoid views under the BottomNavigationView from receiving touch events. return true } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val minHeightSpec = makeMinHeightSpec(heightMeasureSpec) super.onMeasure(widthMeasureSpec, minHeightSpec) if (MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY) { setMeasuredDimension( measuredWidth, max( measuredHeight, suggestedMinimumHeight + paddingTop + paddingBottom, ), ) } } private fun makeMinHeightSpec(measureSpec: Int): Int { var minHeight = suggestedMinimumHeight if (MeasureSpec.getMode(measureSpec) != MeasureSpec.EXACTLY && minHeight > 0) { minHeight += paddingTop + paddingBottom return MeasureSpec.makeMeasureSpec( max(MeasureSpec.getSize(measureSpec), minHeight), MeasureSpec.AT_MOST, ) } return measureSpec } override fun getMaxItemCount(): Int = MAX_ITEM_COUNT @SuppressLint("RestrictedApi") override fun createNavigationBarMenuView(context: Context) = BottomNavigationMenuView(context) /** End **/ override fun onSaveInstanceState(): Parcelable { val superState = super.onSaveInstanceState() return SavedState(superState, currentState, translationY) } override fun onRestoreInstanceState(state: Parcelable?) { if (state is SavedState) { super.onRestoreInstanceState(state.superState) super.setTranslationY(state.translationY) currentState = state.currentState } else { super.onRestoreInstanceState(state) } } override fun setTranslationY(translationY: Float) { // Disallow translation change when state down if (currentState != STATE_DOWN) { super.setTranslationY(translationY) } } override fun setMinimumHeight(minHeight: Int) { super.setMinimumHeight(minHeight) getChildAt(0)?.minimumHeight = minHeight } fun show() { if (currentState == STATE_UP) { return } currentAnimator?.cancel() clearAnimation() currentState = STATE_UP animateTranslation( 0F, SLIDE_UP_ANIMATION_DURATION, LinearOutSlowInInterpolator(), ) } fun hide() { if (currentState == STATE_DOWN) { return } currentAnimator?.cancel() clearAnimation() currentState = STATE_DOWN val target = measureHeight() if (target == 0) { return } animateTranslation( target.toFloat(), SLIDE_DOWN_ANIMATION_DURATION, FastOutLinearInInterpolator(), ) } fun showOrHide(show: Boolean) { if (show) { show() } else { hide() } } private fun animateTranslation(targetY: Float, duration: Long, interpolator: TimeInterpolator) { currentAnimator = animate() .translationY(targetY) .setInterpolator(interpolator) .setDuration(duration) .applySystemAnimatorScale(context) .setListener( object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { currentAnimator = null postInvalidate() } }, ) } internal class SavedState : AbsSavedState { var currentState = STATE_UP var translationY = 0F constructor(superState: Parcelable, currentState: Int, translationY: Float) : super(superState) { this.currentState = currentState this.translationY = translationY } constructor(source: Parcel, loader: ClassLoader?) : super(source, loader) { currentState = source.readInt() translationY = source.readFloat() } override fun writeToParcel(out: Parcel, flags: Int) { super.writeToParcel(out, flags) out.writeInt(currentState) out.writeFloat(translationY) } companion object { @Suppress("unused") @JvmField val CREATOR: Parcelable.Creator = object : Parcelable.Creator { override fun createFromParcel(`in`: Parcel) = SavedState(`in`, SavedState::class.java.classLoader) override fun newArray(size: Int): Array = arrayOfNulls(size) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/StackLayout.kt ================================================ package org.koitharu.kotatsu.core.ui.widgets import android.content.Context import android.util.AttributeSet import android.view.View import android.view.ViewGroup import androidx.annotation.AttrRes import androidx.core.view.children import androidx.core.view.isEmpty import androidx.core.view.isGone open class StackLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0, ) : ViewGroup(context, attrs, defStyleAttr) { private val visibleChildren = ArrayList() override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { val w = r - l - paddingLeft - paddingRight val h = b - t - paddingTop - paddingBottom visibleChildren.clear() children.filterNotTo(visibleChildren) { it.isGone } if (w <= 0 || h <= 0 || visibleChildren.isEmpty()) { return } val xStep = w / (visibleChildren.size + 1) val yStep = h / (visibleChildren.size + 1) val maxW = w val maxH = h val total = visibleChildren.size for ((index, child) in visibleChildren.withIndex()) { var cx = paddingLeft + xStep * (total - index) var cy = paddingTop + yStep * (index + 1) val rx = child.measuredWidth.coerceAtMost(maxW) / 2 val ry = child.measuredHeight.coerceAtMost(maxH) / 2 if (cx < rx) { cx = rx } if (cy < ry) { cy = ry } if (cx + rx > width) { cx = width - rx } if (cy + ry > height) { cy = height - ry } child.layout(cx - rx, cy - ry, cx + rx, cy + ry) } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { measureChildren(widthMeasureSpec, heightMeasureSpec) if (isEmpty()) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) return } var h = 0 var w = 0 for (i in 0 until childCount) { val child = getChildAt(i) if (child.isGone) { continue } val mw = child.measuredWidth val mh = child.measuredHeight if (h == 0 || w == 0) { h = mh w = mw } else { h += mh / 2 w += mw / 2 } } h += paddingTop + paddingBottom w += paddingLeft + paddingRight setMeasuredDimension( resolveSizeAndState(w, widthMeasureSpec, 0), resolveSizeAndState(h, heightMeasureSpec, 0), ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/TipView.kt ================================================ package org.koitharu.kotatsu.core.ui.widgets import android.content.Context import android.graphics.Outline import android.graphics.drawable.Drawable import android.util.AttributeSet import android.view.LayoutInflater import android.view.View import android.view.ViewOutlineProvider import android.widget.LinearLayout import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.core.content.ContextCompat import androidx.core.content.withStyledAttributes import androidx.core.view.isVisible import androidx.core.view.setPadding import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.ShapeAppearanceModel import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.drawableStart import org.koitharu.kotatsu.core.util.ext.getDrawableCompat import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ViewTipBinding class TipView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = R.attr.tipViewStyle, ) : LinearLayout(context, attrs, defStyleAttr), View.OnClickListener { private val binding = ViewTipBinding.inflate(LayoutInflater.from(context), this) var title: CharSequence? get() = binding.textViewTitle.text set(value) { binding.textViewTitle.text = value } var text: CharSequence? get() = binding.textViewBody.text set(value) { binding.textViewBody.text = value } var icon: Drawable? get() = binding.textViewTitle.drawableStart set(value) { binding.textViewTitle.drawableStart = value } var primaryButtonText: CharSequence? get() = binding.buttonPrimary.textAndVisible set(value) { binding.buttonPrimary.textAndVisible = value } var secondaryButtonText: CharSequence? get() = binding.buttonSecondary.textAndVisible set(value) { binding.buttonSecondary.textAndVisible = value } var onButtonClickListener: OnButtonClickListener? = null init { orientation = VERTICAL setPadding(context.resources.getDimensionPixelOffset(R.dimen.margin_normal)) context.withStyledAttributes(attrs, R.styleable.TipView, defStyleAttr) { title = getText(R.styleable.TipView_title) text = getText(R.styleable.TipView_android_text) icon = getDrawableCompat(context, R.styleable.TipView_icon) primaryButtonText = getString(R.styleable.TipView_primaryButtonText) secondaryButtonText = getString(R.styleable.TipView_secondaryButtonText) val shapeAppearanceModel = ShapeAppearanceModel.builder(context, attrs, defStyleAttr, 0).build() background = MaterialShapeDrawable(shapeAppearanceModel).also { it.fillColor = getColorStateList(R.styleable.TipView_cardBackgroundColor) ?: context.getThemeColorStateList(com.google.android.material.R.attr.colorSurfaceContainerHigh) it.strokeWidth = getDimension(R.styleable.TipView_strokeWidth, 0f) it.strokeColor = getColorStateList(R.styleable.TipView_strokeColor) it.elevation = getDimension(R.styleable.TipView_elevation, 0f) } outlineProvider = OutlineProvider(shapeAppearanceModel) } binding.buttonPrimary.setOnClickListener(this) binding.buttonSecondary.setOnClickListener(this) } override fun onClick(v: View) { when (v.id) { R.id.button_primary -> onButtonClickListener?.onPrimaryButtonClick(this) R.id.button_secondary -> onButtonClickListener?.onSecondaryButtonClick(this) } } fun setTitle(@StringRes resId: Int) { binding.textViewTitle.setText(resId) } fun setText(@StringRes resId: Int) { binding.textViewBody.setText(resId) } fun setPrimaryButtonText(@StringRes resId: Int) { binding.buttonPrimary.setTextAndVisible(resId) updateButtonsLayout() } fun setSecondaryButtonText(@StringRes resId: Int) { binding.buttonSecondary.setTextAndVisible(resId) updateButtonsLayout() } fun setIcon(@DrawableRes resId: Int) { icon = ContextCompat.getDrawable(context, resId) } private fun updateButtonsLayout() { binding.layoutButtons.isVisible = binding.buttonPrimary.isVisible || binding.buttonSecondary.isVisible } interface OnButtonClickListener { fun onPrimaryButtonClick(tipView: TipView) fun onSecondaryButtonClick(tipView: TipView) } private class OutlineProvider( shapeAppearanceModel: ShapeAppearanceModel, ) : ViewOutlineProvider() { private val shapeDrawable = MaterialShapeDrawable(shapeAppearanceModel) override fun getOutline(view: View, outline: Outline) { shapeDrawable.setBounds(0, 0, view.width, view.height) shapeDrawable.getOutline(outline) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/TouchBlockLayout.kt ================================================ package org.koitharu.kotatsu.core.ui.widgets import android.content.Context import android.util.AttributeSet import android.view.MotionEvent import android.widget.FrameLayout class TouchBlockLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : FrameLayout(context, attrs) { var isTouchEventsAllowed = true override fun onInterceptTouchEvent( ev: MotionEvent? ): Boolean = if (isTouchEventsAllowed) { super.onInterceptTouchEvent(ev) } else { true } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/TwoLinesItemView.kt ================================================ package org.koitharu.kotatsu.core.ui.widgets import android.annotation.SuppressLint import android.content.Context import android.content.res.ColorStateList import android.content.res.TypedArray import android.graphics.Color import android.graphics.drawable.InsetDrawable import android.graphics.drawable.RippleDrawable import android.graphics.drawable.ShapeDrawable import android.graphics.drawable.shapes.RoundRectShape import android.util.AttributeSet import android.view.LayoutInflater import android.widget.Checkable import android.widget.LinearLayout import androidx.annotation.AttrRes import androidx.annotation.DrawableRes import androidx.core.content.ContextCompat import androidx.core.content.withStyledAttributes import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.widget.ImageViewCompat import androidx.core.widget.TextViewCompat import com.google.android.material.ripple.RippleUtils import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.ShapeAppearanceModel import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.getDrawableCompat import org.koitharu.kotatsu.core.util.ext.resolveDp import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ViewTwoLinesItemBinding @SuppressLint("RestrictedApi") class TwoLinesItemView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0, ) : LinearLayout(context, attrs, defStyleAttr), Checkable { private val binding = ViewTwoLinesItemBinding.inflate(LayoutInflater.from(context), this) var title: CharSequence? get() = binding.title.text set(value) { binding.title.text = value } var subtitle: CharSequence? get() = binding.subtitle.textAndVisible set(value) { binding.subtitle.textAndVisible = value } var isButtonEnabled: Boolean get() = binding.button.isEnabled set(value) { binding.button.isEnabled = value } init { var textColors: ColorStateList? = null context.withStyledAttributes( set = attrs, attrs = R.styleable.TwoLinesItemView, defStyleAttr = defStyleAttr, defStyleRes = R.style.Widget_Kotatsu_TwoLinesItemView, ) { val itemRippleColor = getRippleColor(context) val shape = createShapeDrawable(this) val roundCorners = FloatArray(8) { resources.resolveDp(16f) } background = RippleDrawable( RippleUtils.sanitizeRippleDrawableColor(itemRippleColor), shape, ShapeDrawable(RoundRectShape(roundCorners, null, null)), ) val drawablePadding = getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_drawablePadding, 0) binding.layoutText.updateLayoutParams { marginStart = drawablePadding } setIconResource(getResourceId(R.styleable.TwoLinesItemView_icon, 0)) binding.title.text = getText(R.styleable.TwoLinesItemView_title) binding.subtitle.textAndVisible = getText(R.styleable.TwoLinesItemView_subtitle) textColors = getColorStateList(R.styleable.TwoLinesItemView_android_textColor) val textAppearanceFallback = androidx.appcompat.R.style.TextAppearance_AppCompat TextViewCompat.setTextAppearance( binding.title, getResourceId(R.styleable.TwoLinesItemView_titleTextAppearance, textAppearanceFallback), ) TextViewCompat.setTextAppearance( binding.subtitle, getResourceId(R.styleable.TwoLinesItemView_subtitleTextAppearance, textAppearanceFallback), ) binding.icon.isChecked = getBoolean(R.styleable.TwoLinesItemView_android_checked, false) val button = getDrawableCompat(context, R.styleable.TwoLinesItemView_android_button) binding.button.setImageDrawable(button) binding.button.isVisible = button != null } if (textColors == null) { textColors = binding.title.textColors } binding.title.setTextColor(textColors) binding.subtitle.setTextColor(textColors) ImageViewCompat.setImageTintList(binding.icon, textColors) } override fun isChecked() = binding.icon.isChecked override fun toggle() = binding.icon.toggle() override fun setChecked(checked: Boolean) { binding.icon.isChecked = checked } fun setOnButtonClickListener(listener: OnClickListener?) = binding.button.setOnClickListener(listener) fun setIconResource(@DrawableRes resId: Int) { binding.icon.setImageResource(resId) } private fun createShapeDrawable(ta: TypedArray): InsetDrawable { val shapeAppearance = ShapeAppearanceModel.builder( context, ta.getResourceId(R.styleable.TwoLinesItemView_shapeAppearance, 0), ta.getResourceId(R.styleable.TwoLinesItemView_shapeAppearanceOverlay, 0), ).build() val shapeDrawable = MaterialShapeDrawable(shapeAppearance) shapeDrawable.fillColor = ta.getColorStateList(R.styleable.TwoLinesItemView_backgroundFillColor) return InsetDrawable( shapeDrawable, ta.getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_insetLeft, 0), ta.getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_insetTop, 0), ta.getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_insetRight, 0), ta.getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_insetBottom, 0), ) } private fun getRippleColor(context: Context): ColorStateList { return ContextCompat.getColorStateList(context, R.color.selector_overlay) ?: ColorStateList.valueOf(Color.TRANSPARENT) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/WindowInsetHolder.kt ================================================ package org.koitharu.kotatsu.core.ui.widgets import android.content.Context import android.util.AttributeSet import android.view.Gravity import android.view.View import android.view.WindowInsets import android.widget.FrameLayout import android.widget.LinearLayout import androidx.annotation.AttrRes import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.WindowInsetsCompat import org.koitharu.kotatsu.core.util.ext.end import org.koitharu.kotatsu.core.util.ext.start class WindowInsetHolder @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0, ) : View(context, attrs, defStyleAttr) { private var desiredHeight = 0 private var desiredWidth = 0 override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { val barsInsets = WindowInsetsCompat.toWindowInsetsCompat(insets, this) .getInsets(WindowInsetsCompat.Type.systemBars()) val gravity = getLayoutGravity() val newWidth = when (gravity and Gravity.RELATIVE_HORIZONTAL_GRAVITY_MASK) { Gravity.START -> barsInsets.start(this) Gravity.END -> barsInsets.end(this) else -> 0 } val newHeight = when (gravity and Gravity.VERTICAL_GRAVITY_MASK) { Gravity.TOP -> barsInsets.top Gravity.BOTTOM -> barsInsets.bottom else -> 0 } if (newWidth != desiredWidth || newHeight != desiredHeight) { desiredWidth = newWidth desiredHeight = newHeight requestLayout() } return super.onApplyWindowInsets(insets) } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val widthMode = MeasureSpec.getMode(widthMeasureSpec) val widthSize = MeasureSpec.getSize(widthMeasureSpec) val heightMode = MeasureSpec.getMode(heightMeasureSpec) val heightSize = MeasureSpec.getSize(heightMeasureSpec) val width: Int = when (widthMode) { MeasureSpec.EXACTLY -> widthSize MeasureSpec.AT_MOST -> minOf(desiredWidth, widthSize) else -> desiredWidth } val height = when (heightMode) { MeasureSpec.EXACTLY -> heightSize MeasureSpec.AT_MOST -> minOf(desiredHeight, heightSize) else -> desiredHeight } setMeasuredDimension(width, height) } private fun getLayoutGravity(): Int { return when (val lp = layoutParams) { is FrameLayout.LayoutParams -> lp.gravity is LinearLayout.LayoutParams -> lp.gravity is CoordinatorLayout.LayoutParams -> lp.gravity else -> Gravity.NO_GRAVITY } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ZoomControl.kt ================================================ package org.koitharu.kotatsu.core.ui.widgets import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.View import com.google.android.material.button.MaterialButtonGroup import org.koitharu.kotatsu.R import org.koitharu.kotatsu.databinding.ViewZoomBinding class ZoomControl @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, ) : MaterialButtonGroup(context, attrs), View.OnClickListener { private val binding = ViewZoomBinding.inflate(LayoutInflater.from(context), this) var listener: ZoomControlListener? = null init { binding.buttonZoomIn.setOnClickListener(this) binding.buttonZoomOut.setOnClickListener(this) } override fun onClick(v: View) { when (v.id) { R.id.button_zoom_in -> listener?.onZoomIn() R.id.button_zoom_out -> listener?.onZoomOut() } } interface ZoomControlListener { fun onZoomIn() fun onZoomOut() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/AcraCoroutineErrorHandler.kt ================================================ package org.koitharu.kotatsu.core.util import kotlinx.coroutines.CoroutineExceptionHandler import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.report import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.CoroutineContext class AcraCoroutineErrorHandler : AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler { override fun handleException(context: CoroutineContext, exception: Throwable) { exception.printStackTraceDebug() exception.report() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/AcraScreenLogger.kt ================================================ package org.koitharu.kotatsu.core.util import android.app.Activity import android.content.Context import android.os.Bundle import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks import org.acra.ACRA import org.koitharu.kotatsu.core.ui.DefaultActivityLifecycleCallbacks import java.time.LocalTime import java.time.temporal.ChronoUnit import java.util.WeakHashMap import javax.inject.Inject import javax.inject.Singleton @Singleton class AcraScreenLogger @Inject constructor() : FragmentLifecycleCallbacks(), DefaultActivityLifecycleCallbacks { private val keys = WeakHashMap() override fun onFragmentAttached(fm: FragmentManager, f: Fragment, context: Context) { super.onFragmentAttached(fm, f, context) ACRA.errorReporter.putCustomData(f.key(), f.arguments.contentToString()) } override fun onFragmentDetached(fm: FragmentManager, f: Fragment) { super.onFragmentDetached(fm, f) ACRA.errorReporter.removeCustomData(f.key()) keys.remove(f) } override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { super.onActivityCreated(activity, savedInstanceState) ACRA.errorReporter.putCustomData(activity.key(), activity.intent.extras.contentToString()) (activity as? FragmentActivity)?.supportFragmentManager?.registerFragmentLifecycleCallbacks(this, true) } override fun onActivityDestroyed(activity: Activity) { super.onActivityDestroyed(activity) ACRA.errorReporter.removeCustomData(activity.key()) keys.remove(activity) } private fun Any.key() = keys.getOrPut(this) { val time = LocalTime.now().truncatedTo(ChronoUnit.SECONDS) "$time: ${javaClass.simpleName}" } @Suppress("DEPRECATION") private fun Bundle?.contentToString() = this?.keySet()?.joinToString { k -> val v = get(k) "$k=$v" } ?: toString() } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/AlphanumComparator.kt ================================================ package org.koitharu.kotatsu.core.util class AlphanumComparator : Comparator { override fun compare(s1: String?, s2: String?): Int { if (s1 == null || s2 == null) { return 0 } var thisMarker = 0 var thatMarker = 0 val s1Length = s1.length val s2Length = s2.length while (thisMarker < s1Length && thatMarker < s2Length) { val thisChunk = getChunk(s1, s1Length, thisMarker) thisMarker += thisChunk.length val thatChunk = getChunk(s2, s2Length, thatMarker) thatMarker += thatChunk.length // If both chunks contain numeric characters, sort them numerically var result: Int if (thisChunk[0].isDigit() && thatChunk[0].isDigit()) { // Simple chunk comparison by length. val thisChunkLength = thisChunk.length result = thisChunkLength - thatChunk.length // If equal, the first different number counts if (result == 0) { for (i in 0 until thisChunkLength) { result = thisChunk[i] - thatChunk[i] if (result != 0) { return result } } } } else { result = thisChunk.compareTo(thatChunk) } if (result != 0) return result } return s1Length - s2Length } private fun getChunk(s: String, slength: Int, cmarker: Int): String { var marker = cmarker val chunk = StringBuilder() var c = s[marker] chunk.append(c) marker++ if (c.isDigit()) { while (marker < slength) { c = s[marker] if (!c.isDigit()) break chunk.append(c) marker++ } } else { while (marker < slength) { c = s[marker] if (c.isDigit()) break chunk.append(c) marker++ } } return chunk.toString() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/CancellableSource.kt ================================================ package org.koitharu.kotatsu.core.util import kotlinx.coroutines.Job import kotlinx.coroutines.ensureActive import okio.Buffer import okio.ForwardingSource import okio.Source class CancellableSource( private val job: Job?, delegate: Source, ) : ForwardingSource(delegate) { override fun read(sink: Buffer, byteCount: Long): Long { job?.ensureActive() return super.read(sink, byteCount) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/CloseableSequence.kt ================================================ package org.koitharu.kotatsu.core.util interface CloseableSequence : Sequence, AutoCloseable ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/CompositeResult.kt ================================================ package org.koitharu.kotatsu.core.util class CompositeResult private constructor( private var successCount: Int, private val errors: List, ) { val size: Int get() = successCount + errors.size val failures: List get() = errors val isEmpty: Boolean get() = errors.isEmpty() && successCount == 0 val isAllSuccess: Boolean get() = errors.isEmpty() val isAllFailed: Boolean get() = successCount == 0 && errors.isNotEmpty() operator fun plus(result: Result<*>): CompositeResult = CompositeResult( successCount = successCount + if (result.isSuccess) 1 else 0, errors = errors + listOfNotNull(result.exceptionOrNull()), ) operator fun plus(other: CompositeResult): CompositeResult = CompositeResult( successCount = successCount + other.successCount, errors = errors + other.errors, ) override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as CompositeResult if (successCount != other.successCount) return false if (errors != other.errors) return false return true } override fun hashCode(): Int { var result = successCount result = 31 * result + errors.hashCode() return result } companion object { val EMPTY = CompositeResult(0, emptyList()) fun success() = CompositeResult(1, emptyList()) fun failure(error: Throwable) = CompositeResult(0, listOf(error)) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ContinuationResumeRunnable.kt ================================================ package org.koitharu.kotatsu.core.util import kotlin.coroutines.Continuation import kotlin.coroutines.resume class ContinuationResumeRunnable( private val continuation: Continuation, ) : Runnable { override fun run() { continuation.resume(Unit) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/EditTextValidator.kt ================================================ package org.koitharu.kotatsu.core.util import android.content.Context import android.text.Editable import android.widget.EditText import androidx.annotation.CallSuper import org.koitharu.kotatsu.core.ui.util.DefaultTextWatcher import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import java.lang.ref.WeakReference abstract class EditTextValidator : DefaultTextWatcher { private var editTextRef: WeakReference? = null protected val context: Context get() = checkNotNull(editTextRef?.get()?.context) { "EditTextValidator is not attached to EditText" } @CallSuper override fun afterTextChanged(s: Editable?) { val editText = editTextRef?.get() ?: return val newText = s?.toString().orEmpty() val result = runCatching { validate(newText) }.getOrElse { e -> ValidationResult.Failed(e.getDisplayMessage(editText.resources)) } editText.error = when (result) { is ValidationResult.Failed -> result.message ValidationResult.Success -> null } } fun attachToEditText(editText: EditText) { editTextRef = WeakReference(editText) editText.removeTextChangedListener(this) editText.addTextChangedListener(this) afterTextChanged(editText.text) } abstract fun validate(text: String): ValidationResult sealed class ValidationResult { object Success : ValidationResult() class Failed(val message: CharSequence) : ValidationResult() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/Event.kt ================================================ package org.koitharu.kotatsu.core.util import kotlinx.coroutines.flow.FlowCollector class Event( private val data: T, ) { private var isConsumed = false suspend fun consume(collector: FlowCollector) { if (!isConsumed) { isConsumed = true collector.emit(data) } } override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as Event<*> if (data != other.data) return false return isConsumed == other.isConsumed } override fun hashCode(): Int { var result = data?.hashCode() ?: 0 result = 31 * result + isConsumed.hashCode() return result } override fun toString(): String { return "Event(data=$data, isConsumed=$isConsumed)" } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/FileSize.kt ================================================ package org.koitharu.kotatsu.core.util import android.content.Context import org.koitharu.kotatsu.R import java.text.DecimalFormat import kotlin.math.log10 import kotlin.math.pow enum class FileSize(private val multiplier: Int) { BYTES(1), KILOBYTES(1024), MEGABYTES(1024 * 1024); fun convert(amount: Long, target: FileSize): Long = amount * multiplier / target.multiplier fun format(context: Context, amount: Long): String { val bytes = amount * multiplier val units = context.getString(R.string.text_file_sizes).split('|') if (bytes <= 0) { return "0 ${units.first()}" } val digitGroups = (log10(bytes.toDouble()) / log10(1024.0)).toInt() return buildString { append( DecimalFormat("#,##0.#").format( bytes / 1024.0.pow(digitGroups.toDouble()), ), ) val unit = units.getOrNull(digitGroups) if (unit != null) { append(' ') append(unit) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/IdlingDetector.kt ================================================ package org.koitharu.kotatsu.core.util import android.os.Handler import android.os.Looper import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner class IdlingDetector( private val timeoutMs: Long, private val callback: Callback, ) : DefaultLifecycleObserver { private val handler = Handler(Looper.getMainLooper()) private val idleRunnable = Runnable { callback.onIdle() } fun bindToLifecycle(owner: LifecycleOwner) { owner.lifecycle.addObserver(this) } fun onUserInteraction() { handler.removeCallbacks(idleRunnable) handler.postDelayed(idleRunnable, timeoutMs) } override fun onDestroy(owner: LifecycleOwner) { super.onDestroy(owner) owner.lifecycle.removeObserver(this) handler.removeCallbacks(idleRunnable) } fun interface Callback { fun onIdle() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/KotatsuColors.kt ================================================ package org.koitharu.kotatsu.core.util import android.content.Context import androidx.annotation.AttrRes import androidx.annotation.ColorInt import androidx.core.graphics.ColorUtils import com.google.android.material.R import com.google.android.material.color.MaterialColors import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.parsers.model.Manga import kotlin.math.absoluteValue object KotatsuColors { @ColorInt @Deprecated("") fun segmentColor(context: Context, @AttrRes resId: Int): Int { val colorHex = String.format("%06x", context.getThemeColor(resId)) val hue = getHue(colorHex) val color = ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f)) val backgroundColor = context.getThemeColor(R.attr.colorSurfaceContainerHigh) return MaterialColors.harmonize(color, backgroundColor) } @ColorInt fun segmentColorRandom(context: Context, seed: Any): Int { val color = random(seed) val backgroundColor = context.getThemeColor(R.attr.colorSurfaceContainerHigh) return MaterialColors.harmonize(color, backgroundColor) } @ColorInt fun random(seed: Any): Int { val hue = (seed.hashCode() % 360).absoluteValue.toFloat() return ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f)) } @ColorInt fun ofManga(context: Context, manga: Manga?): Int { val color = if (manga != null) { val hue = (manga.id.absoluteValue % 360).toFloat() ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f)) } else { context.getThemeColor(R.attr.colorOutline) } val backgroundColor = context.getThemeColor(R.attr.colorSurfaceContainerHigh) return MaterialColors.harmonize(color, backgroundColor) } private fun getHue(hex: String): Float { val r = (hex.substring(0, 2).toInt(16)).toFloat() val g = (hex.substring(2, 4).toInt(16)).toFloat() val b = (hex.substring(4, 6).toInt(16)).toFloat() var hue = 0F if ((r >= g) && (g >= b)) { hue = 60 * (g - b) / (r - b) } else if ((g > r) && (r >= b)) { hue = 60 * (2 - (r - b) / (g - b)) } return hue } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/LocaleComparator.kt ================================================ package org.koitharu.kotatsu.core.util import androidx.core.os.LocaleListCompat import org.koitharu.kotatsu.core.util.ext.iterator import java.util.Locale class LocaleComparator : Comparator { private val deviceLocales: List init { val localeList = LocaleListCompat.getAdjustedDefault() deviceLocales = buildList(localeList.size() + 1) { add("") val set = HashSet(localeList.size() + 1) set.add("") for (locale in localeList) { val lang = locale.language if (set.add(lang)) { add(lang) } } } } override fun compare(a: Locale, b: Locale): Int { val indexA = deviceLocales.indexOf(a.language) val indexB = deviceLocales.indexOf(b.language) return when { indexA < 0 && indexB < 0 -> compareValues(a.language, b.language) indexA < 0 -> 1 indexB < 0 -> -1 else -> compareValues(indexA, indexB) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/LocaleStringComparator.kt ================================================ package org.koitharu.kotatsu.core.util import androidx.core.os.LocaleListCompat import org.koitharu.kotatsu.core.util.ext.indexOfContains import org.koitharu.kotatsu.core.util.ext.iterator class LocaleStringComparator : Comparator { private val deviceLocales: List init { val localeList = LocaleListCompat.getAdjustedDefault() deviceLocales = buildList(localeList.size() + 1) { add(null) val set = HashSet(localeList.size() + 1) set.add(null) for (locale in localeList) { val lang = locale.getDisplayLanguage(locale) if (set.add(lang)) { add(lang) } } } } override fun compare(a: String?, b: String?): Int { val indexA = deviceLocales.indexOfContains(a, true) val indexB = deviceLocales.indexOfContains(b, true) return when { indexA < 0 && indexB < 0 -> compareValues(a, b) indexA < 0 -> 1 indexB < 0 -> -1 else -> compareValues(indexA, indexB) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/LocaleUtils.kt ================================================ package org.koitharu.kotatsu.core.util import android.graphics.Paint import androidx.core.graphics.PaintCompat import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import java.util.Locale object LocaleUtils { private val paint = Paint() fun getEmojiFlag(locale: Locale): String? { val code = when (val c = locale.country.ifNullOrEmpty { locale.toLanguageTag() }.uppercase(Locale.ENGLISH)) { "EN" -> "GB" "JA" -> "JP" "VI" -> "VN" "ZH" -> "CN" "AR" -> "SA" else -> c } val emoji = countryCodeToEmojiFlag(code) return if (PaintCompat.hasGlyph(paint, emoji)) { emoji } else { null } } private fun countryCodeToEmojiFlag(countryCode: String): String { return countryCode.map { char -> Character.codePointAt("$char", 0) - 0x41 + 0x1F1E6 }.map { codePoint -> Character.toChars(codePoint) }.joinToString(separator = "") { charArray -> String(charArray) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/MediatorStateFlow.kt ================================================ package org.koitharu.kotatsu.core.util import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import java.util.concurrent.atomic.AtomicInteger abstract class MediatorStateFlow(initialValue: T) : StateFlow { private val delegate = MutableStateFlow(initialValue) private val collectors = AtomicInteger(0) final override val replayCache: List get() = delegate.replayCache override val value: T get() = delegate.value final override suspend fun collect(collector: FlowCollector): Nothing { try { if (collectors.getAndIncrement() == 0) { onActive() } delegate.collect(collector) } finally { if (collectors.decrementAndGet() == 0) { onInactive() } } } protected fun publishValue(v: T) { delegate.value = v } protected abstract fun onActive() protected abstract fun onInactive() } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/MimeTypes.kt ================================================ package org.koitharu.kotatsu.core.util import android.os.Build import android.webkit.MimeTypeMap import org.jetbrains.annotations.Blocking import org.koitharu.kotatsu.core.util.ext.MimeType import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.parsers.util.removeSuffix import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.io.File import java.nio.file.Files import coil3.util.MimeTypeMap as CoilMimeTypeMap object MimeTypes { fun getMimeTypeFromExtension(fileName: String): MimeType? { return CoilMimeTypeMap.getMimeTypeFromExtension(getNormalizedExtension(fileName) ?: return null) ?.toMimeTypeOrNull() } fun getMimeTypeFromUrl(url: String): MimeType? { return CoilMimeTypeMap.getMimeTypeFromUrl(url)?.toMimeTypeOrNull() } fun getExtension(mimeType: MimeType?): String? { return MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType?.toString() ?: return null)?.nullIfEmpty() } @Blocking fun probeMimeType(file: File): MimeType? { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { runCatchingCancellable { Files.probeContentType(file.toPath())?.toMimeTypeOrNull() }.getOrNull()?.let { return it } } return getMimeTypeFromExtension(file.name) } fun getNormalizedExtension(name: String): String? = name .lowercase() .removeSuffix('~') .removeSuffix(".tmp") .substringAfterLast('.', "") .takeIf { it.length in 2..5 } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/MultiMutex.kt ================================================ package org.koitharu.kotatsu.core.util import androidx.annotation.VisibleForTesting import kotlinx.coroutines.sync.Mutex import java.util.concurrent.ConcurrentHashMap import kotlin.contracts.InvocationKind import kotlin.contracts.contract open class MultiMutex { private val delegates = ConcurrentHashMap() @VisibleForTesting val size: Int get() = delegates.count { it.value.isLocked } fun isNotEmpty() = delegates.any { it.value.isLocked } fun isEmpty() = delegates.none { it.value.isLocked } suspend fun lock(element: T) { val mutex = delegates.computeIfAbsent(element) { Mutex() } mutex.lock() } fun unlock(element: T) { delegates[element]?.unlock() } suspend inline fun withLock(element: T, block: () -> R): R { contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } lock(element) return try { block() } finally { unlock(element) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/RecyclerViewScrollCallback.kt ================================================ package org.koitharu.kotatsu.core.util import androidx.annotation.Px import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import java.lang.ref.WeakReference class RecyclerViewScrollCallback( recyclerView: RecyclerView, private val position: Int, @Px private val offset: Int, ) : Runnable { private val recyclerViewRef = WeakReference(recyclerView) override fun run() { val rv = recyclerViewRef.get() ?: return val lm = rv.layoutManager ?: return rv.stopScroll() if (lm is LinearLayoutManager) { lm.scrollToPositionWithOffset(position, offset) } else { lm.scrollToPosition(position) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/RetainedLifecycleCoroutineScope.kt ================================================ package org.koitharu.kotatsu.core.util import dagger.hilt.android.lifecycle.RetainedLifecycle import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlin.coroutines.CoroutineContext class RetainedLifecycleCoroutineScope( val lifecycle: RetainedLifecycle, ) : CoroutineScope, RetainedLifecycle.OnClearedListener { override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Main.immediate init { launch(Dispatchers.Main.immediate) { lifecycle.addOnClearedListener(this@RetainedLifecycleCoroutineScope) } } override fun onCleared() { coroutineContext.cancel() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ShareHelper.kt ================================================ package org.koitharu.kotatsu.core.util import android.content.Context import android.content.Intent import android.net.Uri import androidx.core.app.ShareCompat import androidx.core.content.FileProvider import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.appUrl import org.koitharu.kotatsu.parsers.model.Manga import java.io.File private const val TYPE_TEXT = "text/plain" private const val TYPE_IMAGE = "image/*" private const val TYPE_CBZ = "application/x-cbz" @Deprecated("") class ShareHelper(private val context: Context) { fun shareMangaLink(manga: Manga) { val text = buildString { append(manga.title) append("\n \n") append(manga.publicUrl) append("\n \n") append(manga.appUrl) } ShareCompat.IntentBuilder(context) .setText(text) .setType(TYPE_TEXT) .setChooserTitle(context.getString(R.string.share_s, manga.title)) .startChooser() } fun shareMangaLinks(manga: Collection) { if (manga.isEmpty()) { return } if (manga.size == 1) { shareMangaLink(manga.first()) return } val text = manga.joinToString("\n \n") { "${it.title} - ${it.publicUrl}" } ShareCompat.IntentBuilder(context) .setText(text) .setType(TYPE_TEXT) .setChooserTitle(R.string.share) .startChooser() } fun shareCbz(files: Collection) { if (files.isEmpty()) { return } val intentBuilder = ShareCompat.IntentBuilder(context) .setType(TYPE_CBZ) for (file in files) { val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", file) intentBuilder.addStream(uri) } files.singleOrNull()?.let { intentBuilder.setChooserTitle(context.getString(R.string.share_s, it.name)) } ?: run { intentBuilder.setChooserTitle(R.string.share) } intentBuilder.startChooser() } fun shareImage(uri: Uri) { ShareCompat.IntentBuilder(context) .setStream(uri) .setType(context.contentResolver.getType(uri) ?: TYPE_IMAGE) .setChooserTitle(R.string.share_image) .startChooser() } fun getShareTextIntent(text: String): Intent = ShareCompat.IntentBuilder(context) .setText(text) .setType(TYPE_TEXT) .setChooserTitle(R.string.share) .createChooserIntent() } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/SynchronizedSieveCache.kt ================================================ package org.koitharu.kotatsu.core.util import androidx.collection.SieveCache class SynchronizedSieveCache( private val delegate: SieveCache, ) { constructor(maxSize: Int) : this(SieveCache(maxSize)) private val lock = Any() operator fun get(key: K): V? = synchronized(lock) { delegate[key] } fun put(key: K, value: V): V? = synchronized(lock) { delegate.put(key, value) } fun remove(key: K) = synchronized(lock) { delegate.remove(key) } fun evictAll() = synchronized(lock) { delegate.evictAll() } fun trimToSize(maxSize: Int) = synchronized(lock) { delegate.trimToSize(maxSize) } fun removeIf(predicate: (K, V) -> Boolean) = synchronized(lock) { delegate.removeIf(predicate) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/Throttler.kt ================================================ package org.koitharu.kotatsu.core.util import android.os.SystemClock class Throttler( private val timeoutMs: Long, ) { private var lastTick = 0L fun throttle(): Boolean { val now = SystemClock.elapsedRealtime() return if (lastTick + timeoutMs <= now) { lastTick = now true } else { false } } fun reset() { lastTick = 0L } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ViewBadge.kt ================================================ package org.koitharu.kotatsu.core.util import android.view.View import androidx.annotation.OptIn import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeUtils import com.google.android.material.badge.ExperimentalBadgeUtils @OptIn(ExperimentalBadgeUtils::class) class ViewBadge( private val anchor: View, lifecycleOwner: LifecycleOwner, ) : View.OnLayoutChangeListener, DefaultLifecycleObserver { private var badgeDrawable: BadgeDrawable? = null private var maxCharacterCount: Int = -1 var counter: Int get() = badgeDrawable?.number ?: 0 set(value) { val badge = badgeDrawable ?: initBadge() if (maxCharacterCount != 0) { badge.number = value } else { badge.clearNumber() } badge.isVisible = value > 0 } init { lifecycleOwner.lifecycle.addObserver(this) } override fun onLayoutChange( v: View?, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int, ) { val badge = badgeDrawable ?: return BadgeUtils.setBadgeDrawableBounds(badge, anchor, null) } override fun onDestroy(owner: LifecycleOwner) { super.onDestroy(owner) clearBadge() } fun setMaxCharacterCount(value: Int) { maxCharacterCount = value badgeDrawable?.let { if (value == 0) { it.clearNumber() } else { it.maxCharacterCount = value } } } private fun initBadge(): BadgeDrawable { val badge = BadgeDrawable.create(anchor.context) if (maxCharacterCount > 0) { badge.maxCharacterCount = maxCharacterCount } anchor.addOnLayoutChangeListener(this) BadgeUtils.attachBadgeDrawable(badge, anchor) badgeDrawable = badge return badge } private fun clearBadge() { val badge = badgeDrawable ?: return anchor.removeOnLayoutChangeListener(this) BadgeUtils.detachBadgeDrawable(badge, anchor) badgeDrawable = null } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt ================================================ package org.koitharu.kotatsu.core.util.ext import android.Manifest import android.app.Activity import android.app.ActivityManager import android.app.ActivityManager.MemoryInfo import android.app.LocaleConfig import android.content.ClipData import android.content.ClipboardManager import android.content.ComponentName import android.content.Context import android.content.Context.ACTIVITY_SERVICE import android.content.Context.POWER_SERVICE import android.content.ContextWrapper import android.content.Intent import android.content.OperationApplicationException import android.content.SyncResult import android.content.pm.PackageManager.PERMISSION_GRANTED import android.content.pm.ResolveInfo import android.database.SQLException import android.graphics.Bitmap import android.net.ConnectivityManager import android.os.Build import android.os.PowerManager import android.provider.Settings import android.view.ViewPropertyAnimator import android.webkit.CookieManager import android.webkit.WebView import androidx.activity.result.ActivityResultLauncher import androidx.annotation.CheckResult import androidx.annotation.IntegerRes import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDialog import androidx.core.app.ActivityOptionsCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.core.os.LocaleListCompat import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import androidx.work.CoroutineWorker import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runInterruptible import okio.IOException import okio.use import org.json.JSONException import org.jsoup.internal.StringUtil.StringJoiner import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.main.ui.MainActivity import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParserException import java.io.File import java.util.concurrent.TimeUnit import kotlin.math.roundToLong val Context.activityManager: ActivityManager? get() = getSystemService(ACTIVITY_SERVICE) as? ActivityManager val Context.powerManager: PowerManager? get() = getSystemService(POWER_SERVICE) as? PowerManager val Context.connectivityManager: ConnectivityManager get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatchingCancellable { val info = getForegroundInfo() setForeground(info) }.isSuccess @CheckResult fun ActivityResultLauncher.resolve(context: Context, input: I): ResolveInfo? { val pm = context.packageManager val intent = contract.createIntent(context, input) return pm.resolveActivity(intent, 0) } @CheckResult fun ActivityResultLauncher.tryLaunch( input: I, options: ActivityOptionsCompat? = null, ): Boolean = runCatching { launch(input, options) }.onFailure { e -> e.printStackTraceDebug() }.isSuccess fun Lifecycle.postDelayed(delay: Long, runnable: Runnable) { coroutineScope.launch { delay(delay) runnable.run() } } fun SyncResult.onError(error: Throwable) { when (error) { is IOException -> stats.numIoExceptions++ is OperationApplicationException, is SQLException -> databaseError = true is JSONException -> stats.numParseExceptions++ else -> if (BuildConfig.DEBUG) throw error } error.printStackTraceDebug() } val Context.animatorDurationScale: Float get() = Settings.Global.getFloat(this.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) val Context.isAnimationsEnabled: Boolean get() = animatorDurationScale > 0f fun ViewPropertyAnimator.applySystemAnimatorScale(context: Context): ViewPropertyAnimator = apply { this.duration = (this.duration * context.animatorDurationScale).toLong() } fun Context.getAnimationDuration(@IntegerRes resId: Int): Long { return (resources.getInteger(resId) * animatorDurationScale).roundToLong() } fun Context.isLowRamDevice(): Boolean { return activityManager?.isLowRamDevice == true } fun Context.isPowerSaveMode(): Boolean { return powerManager?.isPowerSaveMode == true } val Context.ramAvailable: Long get() { val result = MemoryInfo() activityManager?.getMemoryInfo(result) return result.availMem } fun Context.getLocalesConfig(): LocaleListCompat { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { LocaleConfig(this).supportedLocales?.let { return LocaleListCompat.wrap(it) } } val tagsList = StringJoiner(",") try { val xpp: XmlPullParser = resources.getXml(R.xml.locales_config) while (xpp.eventType != XmlPullParser.END_DOCUMENT) { if (xpp.eventType == XmlPullParser.START_TAG) { if (xpp.name == "locale") { tagsList.add(xpp.getAttributeValue(0)) } } xpp.next() } } catch (e: XmlPullParserException) { e.printStackTraceDebug() } catch (e: IOException) { e.printStackTraceDebug() } return LocaleListCompat.forLanguageTags(tagsList.complete()) } fun Context.findActivity(): Activity? = when (this) { is Activity -> this is ContextWrapper -> baseContext.findActivity() else -> null } fun Fragment.findAppCompatDelegate(): AppCompatDelegate? { ((this as? DialogFragment)?.dialog as? AppCompatDialog)?.run { return delegate } return parentFragment?.findAppCompatDelegate() ?: (activity as? AppCompatActivity)?.delegate } fun Context.checkNotificationPermission(channelId: String?): Boolean { val hasPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PERMISSION_GRANTED } else { NotificationManagerCompat.from(this).areNotificationsEnabled() } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && hasPermission && channelId != null) { val channel = NotificationManagerCompat.from(this).getNotificationChannel(channelId) if (channel != null && channel.importance == NotificationManagerCompat.IMPORTANCE_NONE) { return false } } return hasPermission } suspend fun Bitmap.compressToPNG(output: File) = runInterruptible(Dispatchers.IO) { output.outputStream().use { os -> if (!compress(Bitmap.CompressFormat.PNG, 100, os)) { throw IOException("Failed to encode bitmap into PNG format") } } } fun Context.ensureRamAtLeast(requiredSize: Long) { if (ramAvailable < requiredSize) { throw IllegalStateException("Not enough free memory") } } fun WebView.configureForParser(userAgentOverride: String?) = with(settings) { javaScriptEnabled = true domStorageEnabled = true mediaPlaybackRequiresUserGesture = false if (WebViewFeature.isFeatureSupported(WebViewFeature.MUTE_AUDIO)) { WebViewCompat.setAudioMuted(this@configureForParser, true) } databaseEnabled = true allowContentAccess = false if (userAgentOverride != null) { userAgentString = userAgentOverride } val cookieManager = CookieManager.getInstance() cookieManager.setAcceptCookie(true) cookieManager.setAcceptThirdPartyCookies(this@configureForParser, true) } fun Context.restartApplication() { val activity = findActivity() val intent = Intent.makeRestartActivityTask(ComponentName(this, MainActivity::class.java)) startActivity(intent) activity?.finishAndRemoveTask() } internal inline fun PowerManager?.withPartialWakeLock(tag: String, body: (PowerManager.WakeLock?) -> R): R { val wakeLock = newPartialWakeLock(tag) return try { wakeLock?.acquire(TimeUnit.HOURS.toMillis(1)) body(wakeLock) } finally { wakeLock?.release() } } private fun PowerManager?.newPartialWakeLock(tag: String): PowerManager.WakeLock? { return if (this != null && isWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK)) { newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, tag) } else { null } } fun Context.copyToClipboard(label: String, content: String) { val clipboardManager = getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return clipboardManager.setPrimaryClip(ClipData.newPlainText(label, content)) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Bundle.kt ================================================ @file:Suppress("DEPRECATION") package org.koitharu.kotatsu.core.util.ext import android.content.Intent import android.os.Build import android.os.Bundle import android.os.Parcel import android.os.Parcelable import androidx.core.content.IntentCompat import androidx.core.os.BundleCompat import androidx.core.os.ParcelCompat import androidx.lifecycle.SavedStateHandle import org.koitharu.kotatsu.parsers.util.toArraySet import java.io.Serializable import java.util.EnumSet // https://issuetracker.google.com/issues/240585930 inline fun Bundle.getParcelableCompat(key: String): T? { return BundleCompat.getParcelable(this, key, T::class.java) } inline fun Bundle.requireParcelable(key: String): T = checkNotNull(getParcelableCompat(key)) { "Parcelable of type \"${T::class.java.name}\" not found at \"$key\"" } inline fun Intent.getParcelableExtraCompat(key: String): T? { return IntentCompat.getParcelableExtra(this, key, T::class.java) } inline fun Intent.getSerializableExtraCompat(key: String): T? { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { getSerializableExtra(key, T::class.java) } else { getSerializableExtra(key) as T? } } inline fun Bundle.getSerializableCompat(key: String): T? { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { getSerializable(key, T::class.java) } else { getSerializable(key) as T? } } inline fun Parcel.readParcelableCompat(): T? { return ParcelCompat.readParcelable(this, T::class.java.classLoader, T::class.java) } inline fun Parcel.readSerializableCompat(): T? { return ParcelCompat.readSerializable(this, T::class.java.classLoader, T::class.java) } inline fun Bundle.requireSerializable(key: String): T { return checkNotNull(getSerializableCompat(key)) { "Serializable of type \"${T::class.java.name}\" not found at \"$key\"" } } fun > Parcel.writeEnumSet(set: Set?) { if (set == null) { writeValue(null) } else { val array = IntArray(set.size) set.forEachIndexed { i, e -> array[i] = e.ordinal } writeIntArray(array) } } inline fun > Parcel.readEnumSet(): Set? = readEnumSet(E::class.java) fun > Parcel.readEnumSet(cls: Class): Set? { val array = createIntArray() ?: return null if (array.isEmpty()) { return emptySet() } val enumValues = cls.enumConstants ?: return null val set = EnumSet.noneOf(cls) array.forEach { e -> set.add(enumValues[e]) } return set } fun Parcel.writeStringSet(set: Set?) { writeStringArray(set?.toTypedArray().orEmpty()) } fun Parcel.readStringSet(): Set { return this.createStringArray()?.toArraySet().orEmpty() } fun SavedStateHandle.require(key: String): T { return checkNotNull(get(key)) { "Value $key not found in SavedStateHandle or has a wrong type" } } fun Parcelable.marshall(): ByteArray { val parcel = Parcel.obtain() return try { this.writeToParcel(parcel, 0) parcel.marshall() } finally { parcel.recycle() } } fun Parcelable.Creator.unmarshall(bytes: ByteArray): T { val parcel = Parcel.obtain() return try { parcel.unmarshall(bytes, 0, bytes.size) parcel.setDataPosition(0) createFromParcel(parcel) } finally { parcel.recycle() } } inline fun buildBundle(capacity: Int, block: Bundle.() -> Unit): Bundle = Bundle(capacity).apply(block) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coil.kt ================================================ package org.koitharu.kotatsu.core.util.ext import android.graphics.drawable.Drawable import androidx.annotation.CheckResult import coil3.Extras import coil3.ImageLoader import coil3.asDrawable import coil3.decode.ImageSource import coil3.fetch.FetchResult import coil3.fetch.SourceFetchResult import coil3.request.ErrorResult import coil3.request.ImageRequest import coil3.request.ImageResult import coil3.request.Options import coil3.request.SuccessResult import coil3.request.bitmapConfig import coil3.toBitmap import okio.buffer import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.core.image.RegionBitmapDecoder import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource fun ImageRequest.Builder.enqueueWith(loader: ImageLoader) = loader.enqueue(build()) fun ImageResult.getDrawableOrThrow() = when (this) { is SuccessResult -> image.asDrawable(request.context.resources) is ErrorResult -> throw throwable } val ImageResult.drawable: Drawable? get() = image?.asDrawable(request.context.resources) fun ImageResult.toBitmapOrNull() = when (this) { is SuccessResult -> try { image.toBitmap(image.width, image.height, request.bitmapConfig) } catch (_: Throwable) { null } is ErrorResult -> null } fun ImageRequest.Builder.decodeRegion( scroll: Int = RegionBitmapDecoder.SCROLL_UNDEFINED, ): ImageRequest.Builder = apply { decoderFactory(RegionBitmapDecoder.Factory) extras[RegionBitmapDecoder.regionScrollKey] = scroll } fun ImageRequest.Builder.mangaSourceExtra(source: MangaSource?): ImageRequest.Builder = apply { extras[mangaSourceKey] = source } fun ImageRequest.Builder.mangaExtra(manga: Manga?): ImageRequest.Builder = apply { extras[mangaKey] = manga mangaSourceExtra(manga?.source) } fun ImageRequest.Builder.bookmarkExtra(bookmark: Bookmark): ImageRequest.Builder = apply { extras[bookmarkKey] = bookmark mangaSourceExtra(bookmark.manga.source) } suspend fun ImageLoader.fetch(data: Any, options: Options): FetchResult? { val mappedData = components.map(data, options) val fetcher = components.newFetcher(mappedData, options, this)?.first return fetcher?.fetch() } val mangaKey = Extras.Key(null) val bookmarkKey = Extras.Key(null) val mangaSourceKey = Extras.Key(null) @CheckResult fun SourceFetchResult.copyWithNewSource(): SourceFetchResult = SourceFetchResult( source = ImageSource( source = source.fileSystem.source(source.file()).buffer(), fileSystem = source.fileSystem, metadata = source.metadata, ), mimeType = mimeType, dataSource = dataSource, ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Collections.kt ================================================ package org.koitharu.kotatsu.core.util.ext import androidx.collection.ArrayMap import androidx.collection.ArraySet import androidx.collection.LongSet import org.koitharu.kotatsu.BuildConfig import java.util.EnumSet fun Collection.asArrayList(): ArrayList = if (this is ArrayList<*>) { this as ArrayList } else { ArrayList(this) } fun > Set.asEnumSet(cls: Class): EnumSet = if (this is EnumSet<*>) { this as EnumSet } else { EnumSet.noneOf(cls).apply { addAll(this@asEnumSet) } } fun Map.findKeyByValue(value: V): K? { for ((k, v) in entries) { if (v == value) { return k } } return null } fun Sequence.toListSorted(comparator: Comparator): List { return toMutableList().apply { sortWith(comparator) } } fun List.takeMostFrequent(limit: Int): List { val map = ArrayMap(size) for (item in this) { map[item] = map.getOrDefault(item, 0) + 1 } val entries = map.entries.sortedByDescending { it.value } val count = minOf(limit, entries.size) return buildList(count) { repeat(count) { i -> add(entries[i].key) } } } inline fun > Collection.toEnumSet(): EnumSet = if (isEmpty()) { EnumSet.noneOf(E::class.java) } else { EnumSet.copyOf(this) } fun > Collection.sortedByOrdinal() = sortedBy { it.ordinal } fun Iterable.sortedWithSafe(comparator: Comparator): List = try { sortedWith(comparator) } catch (e: IllegalArgumentException) { if (BuildConfig.DEBUG) { throw e } else { toList() } } fun LongSet.toLongArray(): LongArray { val result = LongArray(size) var i = 0 forEach { result[i++] = it } return result } fun LongSet.toSet(): Set = toCollection(ArraySet(size)) fun > LongSet.toCollection(out: R): R = out.also { result -> forEach(result::add) } fun Collection.mapSortedByCount(isDescending: Boolean = true, mapper: (T) -> R): List { val grouped = groupBy(mapper).toList() val sortSelector: (Pair>) -> Int = { it.second.size } val sorted = if (isDescending) { grouped.sortedByDescending(sortSelector) } else { grouped.sortedBy(sortSelector) } return sorted.map { it.first } } fun Collection.contains(element: CharSequence?, ignoreCase: Boolean): Boolean = any { x -> (x == null && element == null) || (x != null && element != null && x.contains(element, ignoreCase)) } fun Collection.indexOfContains(element: CharSequence?, ignoreCase: Boolean): Int = indexOfFirst { x -> (x == null && element == null) || (x != null && element != null && x.contains(element, ignoreCase)) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/ContentResolver.kt ================================================ package org.koitharu.kotatsu.core.util.ext import android.content.ContentResolver import android.content.Context import android.net.Uri import android.os.Build import android.os.storage.StorageManager import android.provider.DocumentsContract import android.provider.OpenableColumns import androidx.annotation.RequiresApi import androidx.core.net.toFile import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.parsers.util.removeSuffix import java.io.File import java.lang.reflect.Array as ArrayReflect private const val PRIMARY_VOLUME_NAME = "primary" fun Uri.resolveFile(context: Context): File? { val volumeId = getVolumeIdFromTreeUri(this) ?: return null val volumePath = getVolumePath(volumeId, context)?.removeSuffix(File.separatorChar) ?: return null val documentPath = getDocumentPathFromTreeUri(this)?.removeSuffix(File.separatorChar) ?: return null return File( if (documentPath.isNotEmpty()) { if (documentPath.startsWith(File.separator)) { volumePath + documentPath } else { volumePath + File.separator + documentPath } } else { volumePath }, ) } fun ContentResolver.getFileDisplayName(uri: Uri): String? = runCatching { if (uri.isFileUri()) { return@runCatching uri.toFile().name } query(uri, null, null, null, null)?.use { cursor -> if (cursor.moveToFirst()) { cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)) } else { null } } }.onFailure { e -> e.printStackTraceDebug() }.getOrNull() private fun getVolumePath(volumeId: String, context: Context): String? { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { getVolumePathForAndroid11AndAbove(volumeId, context) } else { getVolumePathBeforeAndroid11(volumeId, context) } } private fun getVolumePathBeforeAndroid11(volumeId: String, context: Context): String? = runCatching { val mStorageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager val storageVolumeClazz = Class.forName("android.os.storage.StorageVolume") val getVolumeList = mStorageManager.javaClass.getMethod("getVolumeList") val getUuid = storageVolumeClazz.getMethod("getUuid") val getPath = storageVolumeClazz.getMethod("getPath") val isPrimary = storageVolumeClazz.getMethod("isPrimary") val result = getVolumeList.invoke(mStorageManager) val length = ArrayReflect.getLength(checkNotNull(result)) (0 until length).firstNotNullOfOrNull { i -> val storageVolumeElement = ArrayReflect.get(result, i) val uuid = getUuid.invoke(storageVolumeElement) as String? val primary = isPrimary.invoke(storageVolumeElement) as Boolean when { primary && volumeId == PRIMARY_VOLUME_NAME -> getPath.invoke(storageVolumeElement) as String uuid == volumeId -> getPath.invoke(storageVolumeElement) as String else -> null } } }.onFailure { it.printStackTraceDebug() }.getOrNull() @RequiresApi(Build.VERSION_CODES.R) private fun getVolumePathForAndroid11AndAbove(volumeId: String, context: Context): String? = runCatching { val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager storageManager.storageVolumes.firstNotNullOfOrNull { volume -> if (volume.isPrimary && volumeId == PRIMARY_VOLUME_NAME) { volume.directory?.path } else { val uuid = volume.uuid if (uuid != null && uuid == volumeId) volume.directory?.path else null } } }.onFailure { it.printStackTraceDebug() }.getOrNull() private fun getVolumeIdFromTreeUri(treeUri: Uri): String? { val docId = DocumentsContract.getTreeDocumentId(treeUri) val split = docId.split(":".toRegex()) return split.firstOrNull()?.nullIfEmpty() } private fun getDocumentPathFromTreeUri(treeUri: Uri): String? { val docId = DocumentsContract.getTreeDocumentId(treeUri) val split: Array = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() return if (split.size >= 2 && split[1] != null) split[1] else File.separator } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coroutines.kt ================================================ package org.koitharu.kotatsu.core.util.ext import android.content.BroadcastReceiver import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.lifecycleScope import dagger.hilt.android.lifecycle.RetainedLifecycle import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.Job import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.util.AcraCoroutineErrorHandler import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope import org.koitharu.kotatsu.parsers.util.cancelAll import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.cancellation.CancellationException val processLifecycleScope: CoroutineScope get() = ProcessLifecycleOwner.get().lifecycleScope + AcraCoroutineErrorHandler() val RetainedLifecycle.lifecycleScope: RetainedLifecycleCoroutineScope inline get() = RetainedLifecycleCoroutineScope(this) fun Deferred.getCompletionResultOrNull(): Result? = if (isCompleted) { getCompletionExceptionOrNull()?.let { error -> Result.failure(error) } ?: Result.success(getCompleted()) } else { null } fun Deferred.peek(): T? = if (isCompleted) { runCatchingCancellable { getCompleted() }.getOrNull() } else { null } @Suppress("SuspendFunctionOnCoroutineScope") suspend fun CoroutineScope.cancelChildrenAndJoin(cause: CancellationException? = null) { val jobs = coroutineContext[Job]?.children?.toList() ?: return jobs.cancelAll(cause) jobs.joinAll() } fun BroadcastReceiver.goAsync(context: CoroutineContext = EmptyCoroutineContext, block: suspend () -> Unit) { val pendingResult = goAsync() processLifecycleScope.launch(context) { try { block() } finally { pendingResult.finish() } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Cursor.kt ================================================ package org.koitharu.kotatsu.core.util.ext import android.content.ContentValues import android.database.Cursor import androidx.collection.ArraySet fun Cursor.getBoolean(columnIndex: Int) = getInt(columnIndex) > 0 inline fun Cursor.map(mapper: (Cursor) -> T): List = mapTo(ArrayList(count), mapper) inline fun Cursor.mapToSet(mapper: (Cursor) -> T): Set = mapTo(ArraySet(count), mapper) inline fun > Cursor.mapTo(destination: C, mapper: (Cursor) -> T): C = use { c -> if (c.moveToFirst()) { do { destination.add(mapper(c)) } while (c.moveToNext()) } destination } inline fun buildContentValues(capacity: Int, block: ContentValues.() -> Unit): ContentValues { return ContentValues(capacity).apply(block) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Date.kt ================================================ package org.koitharu.kotatsu.core.util.ext import android.content.res.Resources import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.model.DateTimeAgo import java.time.Instant import java.time.LocalDate import java.time.LocalDateTime import java.time.ZoneId import java.time.temporal.ChronoUnit import java.util.concurrent.TimeUnit fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo? { val localDate = LocalDateTime.ofInstant(instant, ZoneId.systemDefault()).toLocalDate() val now = LocalDate.now() val diffDays = localDate.until(now, ChronoUnit.DAYS) return when { diffDays < 0 -> null // in future, probably a bug, not supported diffDays == 0L -> { if (instant.until(Instant.now(), ChronoUnit.MINUTES) < 3) DateTimeAgo.JustNow else DateTimeAgo.Today } diffDays == 1L -> DateTimeAgo.Yesterday diffDays < 6 -> DateTimeAgo.DaysAgo(diffDays.toInt()) else -> { val diffMonths = localDate.until(now, ChronoUnit.MONTHS) if (showMonths && diffMonths <= 6) { DateTimeAgo.MonthsAgo(diffMonths.toInt()) } else { DateTimeAgo.Absolute(localDate) } } } } fun Long.toInstantOrNull() = if (this == 0L) null else Instant.ofEpochMilli(this) fun Resources.formatDurationShort(millis: Long): String? { val hours = TimeUnit.MILLISECONDS.toHours(millis).toInt() val minutes = (TimeUnit.MILLISECONDS.toMinutes(millis) % 60).toInt() val seconds = (TimeUnit.MILLISECONDS.toSeconds(millis) % 60).toInt() return when { hours == 0 && minutes == 0 && seconds == 0 -> null hours != 0 && minutes != 0 -> getString(R.string.hours_minutes_short, hours, minutes) hours != 0 -> getString(R.string.hours_short, hours) minutes != 0 && seconds != 0 -> getString(R.string.minutes_seconds_short, minutes, seconds) minutes != 0 -> getString(R.string.minutes_short, minutes) else -> getString(R.string.seconds_short, seconds) } } fun LocalDate.toMillis(): Long = atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/EventFlow.kt ================================================ package org.koitharu.kotatsu.core.util.ext import androidx.annotation.AnyThread import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.koitharu.kotatsu.core.util.Event @Suppress("FunctionName") fun MutableEventFlow() = MutableStateFlow?>(null) typealias EventFlow = StateFlow?> typealias MutableEventFlow = MutableStateFlow?> @AnyThread fun MutableEventFlow.call(data: T) { value = Event(data) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt ================================================ package org.koitharu.kotatsu.core.util.ext import android.content.ContentResolver import android.content.Context import android.net.Uri import android.os.Build import android.os.Environment import android.os.storage.StorageManager import android.provider.OpenableColumns import androidx.core.database.getStringOrNull import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import org.jetbrains.annotations.Blocking import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.fs.FileSequence import org.koitharu.kotatsu.core.util.MimeTypes import java.io.BufferedReader import java.io.File import java.nio.file.attribute.BasicFileAttributes import java.util.zip.ZipEntry import java.util.zip.ZipFile import kotlin.io.path.ExperimentalPathApi import kotlin.io.path.PathWalkOption import kotlin.io.path.readAttributes import kotlin.io.path.walk fun File.subdir(name: String) = File(this, name).also { if (!it.exists()) it.mkdirs() } fun File.takeIfReadable() = takeIf { it.isReadable() } fun File.takeIfWriteable() = takeIf { it.isWriteable() } fun File.isNotEmpty() = length() != 0L @Blocking fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).use { output -> output.bufferedReader().use(BufferedReader::readText) } fun File.getStorageName(context: Context): String = runCatching { val manager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { manager.getStorageVolume(this)?.getDescription(context)?.let { return@runCatching it } } when { Environment.isExternalStorageEmulated(this) -> context.getString(R.string.internal_storage) Environment.isExternalStorageRemovable(this) -> context.getString(R.string.external_storage) else -> null } }.getOrNull() ?: context.getString(R.string.other_storage) fun Uri.toFileOrNull() = if (isFileUri()) path?.let(::File) else null suspend fun File.deleteAwait() = runInterruptible(Dispatchers.IO) { delete() || deleteRecursively() } fun ContentResolver.resolveName(uri: Uri): String? { val fallback = uri.lastPathSegment if (uri.scheme != "content") { return fallback } query(uri, null, null, null, null)?.use { if (it.moveToFirst()) { it.getStringOrNull(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))?.let { name -> return name } } } return fallback } suspend fun File.computeSize(): Long = runInterruptible(Dispatchers.IO) { walkCompat(includeDirectories = false).sumOf { it.length() } } inline fun File.withChildren(block: (children: Sequence) -> R): R = FileSequence(this).use(block) fun FileSequence(dir: File): FileSequence = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { FileSequence.StreamImpl(dir) } else { FileSequence.ListImpl(dir) } val File.creationTime get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { toPath().readAttributes().creationTime().toMillis() } else { lastModified() } @OptIn(ExperimentalPathApi::class) fun File.walkCompat(includeDirectories: Boolean): Sequence = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // Use lazy loading on Android 8.0 and later val walk = if (includeDirectories) { toPath().walk(PathWalkOption.INCLUDE_DIRECTORIES) } else { toPath().walk() } walk.map { it.toFile() } } else { // Directories are excluded by default in Path.walk(), so do it here as well val walk = walk() if (includeDirectories) walk else walk.filter { it.isFile } } val File.normalizedExtension: String? get() = MimeTypes.getNormalizedExtension(name) fun File.isReadable() = runCatching { canRead() }.getOrDefault(false) fun File.isWriteable() = runCatching { canWrite() }.getOrDefault(false) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt ================================================ package org.koitharu.kotatsu.core.util.ext import android.os.SystemClock import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformWhile import kotlinx.coroutines.flow.update import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.suspendlazy.SuspendLazy import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger fun Flow.onFirst(action: suspend (T) -> Unit): Flow { var isFirstCall = true return onEach { if (isFirstCall) { action(it) isFirstCall = false } }.onCompletion { isFirstCall = true } } fun Flow.onEachWhile(action: suspend (T) -> Boolean): Flow { var isCalled = false return onEach { if (!isCalled) { isCalled = action(it) } }.onCompletion { isCalled = false } } fun Flow.onEachIndexed(action: suspend (index: Int, T) -> Unit): Flow { val counter = AtomicInteger(0) return transform { value -> action(counter.getAndIncrement(), value) return@transform emit(value) } } inline fun Flow>.mapItems(crossinline transform: (T) -> R): Flow> { return map { list -> list.map(transform) } } fun Flow.throttle(timeoutMillis: Long): Flow = throttle { timeoutMillis } fun Flow.throttle(timeoutMillis: (T) -> Long): Flow { var lastEmittedAt = 0L return transformLatest { value -> val delay = timeoutMillis(value) val now = SystemClock.elapsedRealtime() if (delay > 0L) { if (lastEmittedAt + delay < now) { delay(lastEmittedAt + delay - now) } } emit(value) lastEmittedAt = now } } fun StateFlow.requireValue(): T = checkNotNull(value) { "StateFlow value is null" } fun Flow>.flatten(): Flow = flow { collect { value -> for (item in value) { emit(item) } } } fun Flow.zipWithPrevious(): Flow> = flow { var previous: T? = null collect { value -> val result = previous to value previous = value emit(result) } } fun tickerFlow(interval: Long, timeUnit: TimeUnit): Flow = flow { while (true) { emit(SystemClock.elapsedRealtime()) delay(timeUnit.toMillis(interval)) } } fun Flow.withTicker(interval: Long, timeUnit: TimeUnit) = channelFlow { onCompletion { cause -> close(cause) }.combine(tickerFlow(interval, timeUnit)) { x, _ -> x } .transformWhile { trySend(it).isSuccess } .collect() } @Suppress("UNCHECKED_CAST") fun combine( flow: Flow, flow2: Flow, flow3: Flow, flow4: Flow, flow5: Flow, flow6: Flow, transform: suspend (T1, T2, T3, T4, T5, T6) -> R, ): Flow = combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> -> transform( args[0] as T1, args[1] as T2, args[2] as T3, args[3] as T4, args[4] as T5, args[5] as T6, ) } @Suppress("UNCHECKED_CAST") fun combine( flow: Flow, flow2: Flow, flow3: Flow, flow4: Flow, flow5: Flow, flow6: Flow, flow7: Flow, transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R, ): Flow = combine(flow, flow2, flow3, flow4, flow5, flow6, flow7) { args: Array<*> -> transform( args[0] as T1, args[1] as T2, args[2] as T3, args[3] as T4, args[4] as T5, args[5] as T6, args[6] as T7, ) } suspend fun Flow.firstNotNull(): T = checkNotNull(first { x -> x != null }) suspend fun Flow.firstNotNullOrNull(): T? = firstOrNull { x -> x != null } fun Flow>.flattenLatest() = flatMapLatest { it } fun SuspendLazy.asFlow() = flow { emit(runCatchingCancellable { get() }) } suspend fun SendChannel.sendNotNull(item: T?) { if (item != null) { send(item) } } fun MutableStateFlow>.append(item: T) { update { list -> list + item } } fun Flow.concat(other: Flow) = flow { emitAll(this@concat) emitAll(other) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/FlowObserver.kt ================================================ package org.koitharu.kotatsu.core.util.ext import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import org.koitharu.kotatsu.core.util.Event fun Flow.observe(owner: LifecycleOwner, collector: FlowCollector) { val start = if (this is StateFlow) CoroutineStart.UNDISPATCHED else CoroutineStart.DEFAULT owner.lifecycleScope.launch(start = start) { collect(collector) } } fun Flow.observe(owner: LifecycleOwner, minState: Lifecycle.State, collector: FlowCollector) { owner.lifecycleScope.launch { owner.lifecycle.repeatOnLifecycle(minState) { collect(collector) } } } fun Flow?>.observeEvent(owner: LifecycleOwner, collector: FlowCollector) { observeEvent(owner, Lifecycle.State.STARTED, collector) } fun Flow?>.observeEvent(owner: LifecycleOwner, minState: Lifecycle.State, collector: FlowCollector) { owner.lifecycleScope.launch { owner.repeatOnLifecycle(minState) { collect { it?.consume(collector) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Fragment.kt ================================================ package org.koitharu.kotatsu.core.util.ext import android.os.Bundle import androidx.core.view.MenuProvider import androidx.core.view.ancestors import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentContainerView import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope inline fun T.withArgs(size: Int, block: Bundle.() -> Unit): T { val b = Bundle(size) b.block() this.arguments = b return this } val Fragment.viewLifecycleScope inline get() = viewLifecycleOwner.lifecycle.coroutineScope fun Fragment.addMenuProvider(provider: MenuProvider) { requireActivity().addMenuProvider(provider, viewLifecycleOwner, Lifecycle.State.RESUMED) } @Suppress("UNCHECKED_CAST") tailrec fun Fragment.findParentCallback(cls: Class): T? { val parent = parentFragment return when { parent == null -> cls.castOrNull(activity) cls.isInstance(parent) -> parent as T else -> parent.findParentCallback(cls) } } val Fragment.container: FragmentContainerView? get() = view?.ancestors?.firstNotNullOfOrNull { it as? FragmentContainerView // TODO check if direct parent } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Graphics.kt ================================================ package org.koitharu.kotatsu.core.util.ext import android.content.res.ColorStateList import android.graphics.Bitmap import android.graphics.Rect import kotlin.math.roundToInt fun Rect.scale(factor: Double) { val newWidth = (width() * factor).roundToInt() val newHeight = (height() * factor).roundToInt() inset( (width() - newWidth) / 2, (height() - newHeight) / 2, ) } inline fun Bitmap.use(block: (Bitmap) -> R) = try { block(this) } finally { recycle() } fun ColorStateList.hasFocusStateSpecified(): Boolean { return getColorForState(intArrayOf(android.R.attr.state_focused), defaultColor) != defaultColor } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Http.kt ================================================ package org.koitharu.kotatsu.core.util.ext import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import okhttp3.internal.closeQuietly import okio.IOException import org.json.JSONObject import org.jsoup.HttpStatusException import java.net.HttpURLConnection private val TYPE_JSON = "application/json".toMediaType() fun JSONObject.toRequestBody() = toString().toRequestBody(TYPE_JSON) fun Response.parseJsonOrNull(): JSONObject? { return try { when { !isSuccessful -> throw IOException(body?.string()) code == HttpURLConnection.HTTP_NO_CONTENT -> null else -> JSONObject(body?.string() ?: return null) } } finally { closeQuietly() } } fun Response.ensureSuccess() = apply { if (!isSuccessful || code == HttpURLConnection.HTTP_NO_CONTENT) { closeQuietly() throw HttpStatusException(message, code, request.url.toString()) } } fun String.sanitizeHeaderValue(): String { return if (all(Char::isValidForHeaderValue)) { this // fast path } else { filter(Char::isValidForHeaderValue) } } private fun Char.isValidForHeaderValue(): Boolean { // from okhttp3.Headers$Companion.checkValue return this == '\t' || this in '\u0020'..'\u007e' } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/IO.kt ================================================ package org.koitharu.kotatsu.core.util.ext import android.content.ContentResolver import android.net.Uri import androidx.annotation.CheckResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.withContext import okhttp3.ResponseBody import okio.BufferedSink import okio.BufferedSource import okio.FileSystem import okio.IOException import okio.Path import okio.Source import okio.source import org.koitharu.kotatsu.core.util.CancellableSource import org.koitharu.kotatsu.core.util.progress.ProgressResponseBody import java.io.ByteArrayOutputStream import java.io.InputStream import java.nio.ByteBuffer fun ResponseBody.withProgress(progressState: MutableStateFlow): ResponseBody { return ProgressResponseBody(this, progressState) } suspend fun Source.cancellable(): Source { val job = currentCoroutineContext()[Job] return CancellableSource(job, this) } suspend fun BufferedSink.writeAllCancellable(source: Source) = withContext(Dispatchers.IO) { writeAll(source.cancellable()) } fun BufferedSource.readByteBuffer(): ByteBuffer { val bytes = readByteArray() return ByteBuffer.allocateDirect(bytes.size) .put(bytes) .rewind() as ByteBuffer } @Deprecated("") fun InputStream.toByteBuffer(): ByteBuffer { val outStream = ByteArrayOutputStream(available()) copyTo(outStream) val bytes = outStream.toByteArray() return ByteBuffer.allocateDirect(bytes.size).put(bytes).position(0) as ByteBuffer } fun FileSystem.isDirectory(path: Path) = try { metadataOrNull(path)?.isDirectory == true } catch (_: IOException) { false } fun FileSystem.isRegularFile(path: Path) = try { metadataOrNull(path)?.isRegularFile == true } catch (_: IOException) { false } @CheckResult fun ContentResolver.openSource(uri: Uri): Source = checkNotNull(openInputStream(uri)) { "Cannot open input stream from $uri" }.source() ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Insets.kt ================================================ package org.koitharu.kotatsu.core.util.ext import android.view.View import androidx.core.graphics.Insets import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat.Type.InsetsType fun Insets.end(view: View): Int { return if (view.isRtl) left else right } fun Insets.start(view: View): Int { return if (view.isRtl) right else left } @Deprecated("") val WindowInsetsCompat.systemBarsInsets: Insets get() = getInsets(WindowInsetsCompat.Type.systemBars()) @Deprecated("") fun WindowInsetsCompat.consumeSystemBarsInsets( left: Boolean = false, top: Boolean = false, right: Boolean = false, bottom: Boolean = false, ): WindowInsetsCompat { val barsInsets = systemBarsInsets val insets = Insets.of( if (left) 0 else barsInsets.left, if (top) 0 else barsInsets.top, if (right) 0 else barsInsets.right, if (bottom) 0 else barsInsets.bottom, ) return WindowInsetsCompat.Builder(this) .setInsets(WindowInsetsCompat.Type.systemBars(), insets) .build() } fun WindowInsetsCompat.consume( v: View, @InsetsType typeMask: Int, start: Boolean = false, top: Boolean = false, end: Boolean = false, bottom: Boolean = false, ): WindowInsetsCompat { val insets = getInsets(typeMask) val newInsets = Insets.of( /* left = */ if (if (v.isRtl) end else start) 0 else insets.left, /* top = */ if (top) 0 else insets.top, /* right = */ if (if (v.isRtl) start else end) 0 else insets.right, /* bottom = */ if (bottom) 0 else insets.bottom, ) return WindowInsetsCompat.Builder(this) .setInsets(typeMask, newInsets) .build() } fun WindowInsetsCompat.consumeAll( @InsetsType typeMask: Int, ): WindowInsetsCompat = WindowInsetsCompat.Builder(this) .setInsets(typeMask, Insets.NONE) .build() @Deprecated("") fun WindowInsetsCompat.consumeSystemBarsInsets( view: View, start: Boolean = false, top: Boolean = false, end: Boolean = false, bottom: Boolean = false, ): WindowInsetsCompat = consume(view, WindowInsetsCompat.Type.systemBars(), start, top, end, bottom) @Deprecated("") fun WindowInsetsCompat.consumeAllSystemBarsInsets() = consumeAll(WindowInsetsCompat.Type.systemBars()) @Deprecated("") fun Insets.consume( view: View, start: Boolean = false, top: Boolean = false, end: Boolean = false, bottom: Boolean = false, ): Insets = Insets.of( /* left = */ if (if (view.isRtl) end else start) 0 else this.left, /* top = */ if (top) 0 else this.top, /* right = */ if (if (view.isRtl) start else end) 0 else this.right, /* bottom = */ if (bottom) 0 else this.bottom, ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/LocaleList.kt ================================================ package org.koitharu.kotatsu.core.util.ext import android.content.Context import androidx.core.os.LocaleListCompat import org.koitharu.kotatsu.R import org.koitharu.kotatsu.parsers.util.Set import org.koitharu.kotatsu.parsers.util.toTitleCase import java.util.Locale operator fun LocaleListCompat.iterator(): ListIterator = LocaleListCompatIterator(this) fun LocaleListCompat.toList(): List = List(size()) { i -> getOrThrow(i) } inline fun LocaleListCompat.map(block: (Locale) -> T): List { return List(size()) { i -> block(getOrThrow(i)) } } inline fun LocaleListCompat.mapToSet(block: (Locale) -> T): Set { return Set(size()) { i -> block(getOrThrow(i)) } } fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementException() fun String.toLocale(): Locale = Locale.forLanguageTag(this) fun String.toLocaleOrNull() = if (isEmpty()) { null } else { toLocale().takeUnless { it.displayName == this } } fun Locale?.getDisplayName(context: Context): String = when (this) { null -> context.getString(R.string.all_languages) Locale.ROOT -> context.getString(R.string.various_languages) else -> getDisplayLanguage(this).toTitleCase(this) } private class LocaleListCompatIterator(private val list: LocaleListCompat) : ListIterator { private var index = 0 override fun hasNext() = index < list.size() override fun hasPrevious() = index > 0 override fun next() = list.get(index++) ?: throw NoSuchElementException() override fun nextIndex() = index override fun previous() = list.get(--index) ?: throw NoSuchElementException() override fun previousIndex() = index - 1 } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/MimeType.kt ================================================ package org.koitharu.kotatsu.core.util.ext import okhttp3.MediaType private const val TYPE_IMAGE = "image" private val REGEX_MIME = Regex("^\\w+/([-+.\\w]+|\\*)$", RegexOption.IGNORE_CASE) @JvmInline value class MimeType(private val value: String) { val type: String? get() = value.substringBefore('/', "").takeIfSpecified() val subtype: String? get() = value.substringAfterLast('/', "").takeIfSpecified() private fun String.takeIfSpecified(): String? = takeUnless { it.isEmpty() || it == "*" } override fun toString(): String = value } fun MediaType.toMimeType(): MimeType = MimeType("$type/$subtype") fun String.toMimeTypeOrNull(): MimeType? = if (REGEX_MIME.matches(this)) { MimeType(lowercase()) } else { null } val MimeType.isImage: Boolean get() = type == TYPE_IMAGE ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Other.kt ================================================ package org.koitharu.kotatsu.core.util.ext import org.koitharu.kotatsu.core.io.NullOutputStream import java.io.ObjectOutputStream @Suppress("UNCHECKED_CAST") fun Class.castOrNull(obj: Any?): T? { if (obj == null || !isInstance(obj)) { return null } return obj as T } fun Any.isSerializable() = runCatching { val oos = ObjectOutputStream(NullOutputStream()) oos.writeObject(this) oos.flush() }.isSuccess ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Preferences.kt ================================================ package org.koitharu.kotatsu.core.util.ext import android.content.SharedPreferences import androidx.collection.ArraySet import androidx.preference.ListPreference import androidx.preference.MultiSelectListPreference import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow import org.json.JSONArray fun ListPreference.setDefaultValueCompat(defaultValue: String) { if (value == null) { value = defaultValue } } fun MultiSelectListPreference.setDefaultValueCompat(defaultValue: Set) { setDefaultValue(defaultValue) // FIXME not working } fun > SharedPreferences.getEnumValue(key: String, enumClass: Class): E? { val stringValue = getString(key, null) ?: return null return enumClass.enumConstants?.find { it.name == stringValue } } fun > SharedPreferences.getEnumValue(key: String, defaultValue: E): E { return getEnumValue(key, defaultValue.javaClass) ?: defaultValue } fun > SharedPreferences.Editor.putEnumValue(key: String, value: E?) { putString(key, value?.name) } fun SharedPreferences.observeChanges(): Flow = callbackFlow { val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> trySendBlocking(key) } registerOnSharedPreferenceChangeListener(listener) awaitClose { unregisterOnSharedPreferenceChangeListener(listener) } } fun SharedPreferences.observe(key: String, valueProducer: suspend () -> T): Flow = flow { emit(valueProducer()) observeChanges().collect { upstreamKey -> if (upstreamKey == key) { emit(valueProducer()) } } }.distinctUntilChanged() fun SharedPreferences.Editor.putAll(values: Map) { values.forEach { e -> when (val v = e.value) { is Boolean -> putBoolean(e.key, v) is Int -> putInt(e.key, v) is Long -> putLong(e.key, v) is Float -> putFloat(e.key, v) is String -> putString(e.key, v) is JSONArray -> putStringSet(e.key, v.toStringSet()) } } } private fun JSONArray.toStringSet(): Set { val len = length() val result = ArraySet(len) for (i in 0 until len) { result.add(getString(i)) } return result } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Primitive.kt ================================================ package org.koitharu.kotatsu.core.util.ext ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/RecyclerView.kt ================================================ package org.koitharu.kotatsu.core.util.ext import android.util.DisplayMetrics import androidx.core.view.doOnNextLayout import androidx.core.view.isEmpty import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.StaggeredGridLayoutManager import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewHolder fun RecyclerView.clearItemDecorations() { suppressLayout(true) while (itemDecorationCount > 0) { removeItemDecorationAt(0) } suppressLayout(false) } fun RecyclerView.removeItemDecoration(cls: Class) { repeat(itemDecorationCount) { i -> if (cls.isInstance(getItemDecorationAt(i))) { removeItemDecorationAt(i) return } } } var RecyclerView.firstVisibleItemPosition: Int get() = (layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition() ?: RecyclerView.NO_POSITION set(value) { if (value != RecyclerView.NO_POSITION) { (layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset(value, 0) } } val RecyclerView.visibleItemCount: Int get() = (layoutManager as? LinearLayoutManager)?.run { findLastVisibleItemPosition() - findFirstVisibleItemPosition() } ?: 0 fun RecyclerView.ViewHolder.getItem(clazz: Class): T? { val rawItem = when (this) { is AdapterDelegateViewBindingViewHolder<*, *> -> item is AdapterDelegateViewHolder<*> -> item else -> null } ?: return null return if (clazz.isAssignableFrom(rawItem.javaClass)) { clazz.cast(rawItem) } else { null } } val RecyclerView.isScrolledToTop: Boolean get() { if (isEmpty()) { return true } val holder = findViewHolderForAdapterPosition(0) return holder != null && holder.itemView.top >= 0 } val RecyclerView.LayoutManager?.firstVisibleItemPosition get() = when (this) { is LinearLayoutManager -> findFirstVisibleItemPosition() is StaggeredGridLayoutManager -> findFirstVisibleItemPositions(null)[0] else -> 0 } val RecyclerView.LayoutManager?.isLayoutReversed get() = when (this) { is LinearLayoutManager -> reverseLayout is StaggeredGridLayoutManager -> reverseLayout else -> false } // https://medium.com/flat-pack-tech/quickly-scroll-to-the-top-of-a-recyclerview-da15b717f3c4 fun RecyclerView.smoothScrollToTop() { val layoutManager = layoutManager as? LinearLayoutManager ?: return if (!context.isAnimationsEnabled) { layoutManager.scrollToPositionWithOffset(0, 0) return } val smoothScroller = object : LinearSmoothScroller(context) { init { targetPosition = 0 } override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics?) = super.calculateSpeedPerPixel(displayMetrics) / DEFAULT_SPEED_FACTOR } val jumpBeforeScroll = layoutManager.findFirstVisibleItemPosition() > DEFAULT_JUMP_THRESHOLD if (jumpBeforeScroll) { layoutManager.scrollToPositionWithOffset(DEFAULT_JUMP_THRESHOLD, 0) doOnNextLayout { layoutManager.startSmoothScroll(smoothScroller) } } else { layoutManager.startSmoothScroll(smoothScroller) } } private const val DEFAULT_JUMP_THRESHOLD = 30 private const val DEFAULT_SPEED_FACTOR = 1f ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Resources.kt ================================================ package org.koitharu.kotatsu.core.util.ext import android.annotation.SuppressLint import android.content.Context import android.content.res.Resources import android.os.Build import androidx.annotation.PluralsRes import androidx.annotation.Px import androidx.core.util.TypedValueCompat import coil3.size.Size import kotlin.math.roundToInt import androidx.core.R as androidxR @Px fun Resources.resolveDp(dp: Int) = resolveDp(dp.toFloat()).roundToInt() @Px fun Resources.resolveDp(dp: Float) = TypedValueCompat.dpToPx(dp, displayMetrics) @Px fun Resources.resolveSp(sp: Float) = TypedValueCompat.spToPx(sp, displayMetrics) @SuppressLint("DiscouragedApi") fun Context.getSystemBoolean(resName: String, fallback: Boolean): Boolean { val id = Resources.getSystem().getIdentifier(resName, "bool", "android") return if (id != 0) { createPackageContext("android", 0).resources.getBoolean(id) } else { fallback } } fun Resources.getQuantityStringSafe(@PluralsRes resId: Int, quantity: Int, vararg formatArgs: Any): String = try { getQuantityString(resId, quantity, *formatArgs) } catch (e: Resources.NotFoundException) { if (Build.VERSION.SDK_INT == Build.VERSION_CODES.VANILLA_ICE_CREAM) { // known issue e.printStackTraceDebug() formatArgs.firstOrNull()?.toString() ?: quantity.toString() } else { throw e } } fun Resources.getNotificationIconSize() = Size( getDimensionPixelSize(androidxR.dimen.compat_notification_large_icon_max_width), getDimensionPixelSize(androidxR.dimen.compat_notification_large_icon_max_height), ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt ================================================ package org.koitharu.kotatsu.core.util.ext import android.content.Context import androidx.collection.arraySetOf import org.koitharu.kotatsu.R import org.koitharu.kotatsu.parsers.util.ellipsize import org.koitharu.kotatsu.parsers.util.nullIfEmpty import java.util.UUID fun String.toUUIDOrNull(): UUID? = try { UUID.fromString(this) } catch (e: IllegalArgumentException) { e.printStackTraceDebug() null } fun String.transliterate(skipMissing: Boolean): String { val cyr = charArrayOf( 'а', 'б', 'в', 'г', 'д', 'е', 'ж', 'з', 'и', 'й', 'к', 'л', 'м', 'н', 'о', 'п', 'р', 'с', 'т', 'у', 'ф', 'х', 'ц', 'ч', 'ш', 'щ', 'ъ', 'ы', 'ь', 'э', 'ю', 'я', 'ё', 'ў', ) val lat = arrayOf( "a", "b", "v", "g", "d", "e", "zh", "z", "i", "y", "k", "l", "m", "n", "o", "p", "r", "s", "t", "u", "f", "h", "ts", "ch", "sh", "sch", "", "i", "", "e", "ju", "ja", "jo", "w", ) return buildString(length + 5) { for (c in this@transliterate) { val p = cyr.binarySearch(c.lowercaseChar()) if (p in lat.indices) { if (c.isUpperCase()) { append(lat[p].uppercase()) } else { append(lat[p]) } } else if (!skipMissing) { append(c) } } } } fun String.toFileNameSafe(): String = this.transliterate(false) .replace(Regex("[^a-z0-9_\\-]", arraySetOf(RegexOption.IGNORE_CASE)), " ") .replace(Regex("\\s+"), "_") fun CharSequence.sanitize(): CharSequence { return filterNot { c -> c.isReplacement() } } fun Char.isReplacement() = this in '\uFFF0'..'\uFFFF' fun Collection.joinToStringWithLimit(context: Context, limit: Int, transform: ((T) -> String)): String { if (size == 1) { return transform(first()).ellipsize(limit) } return buildString(limit + 6) { for ((i, item) in this@joinToStringWithLimit.withIndex()) { val str = transform(item) when { i == 0 -> append(str.ellipsize(limit - 4)) length + str.length > limit -> { append(", ") append(context.getString(R.string.list_ellipsize_pattern, this@joinToStringWithLimit.size - i)) break } else -> append(", ").append(str) } } } } fun String.isHttpUrl() = startsWith("https://", ignoreCase = true) || startsWith("http://", ignoreCase = true) fun concatStrings(context: Context, a: String?, b: String?): String? = when { a.isNullOrEmpty() && b.isNullOrEmpty() -> null a.isNullOrEmpty() -> b?.nullIfEmpty() b.isNullOrEmpty() -> a.nullIfEmpty() else -> context.getString(R.string.download_summary_pattern, a, b) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/TextView.kt ================================================ package org.koitharu.kotatsu.core.util.ext import android.graphics.Typeface import android.graphics.drawable.Drawable import android.widget.TextView import androidx.annotation.AttrRes import androidx.annotation.StringRes import androidx.annotation.StyleRes import androidx.core.content.res.use import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.widget.TextViewCompat var TextView.textAndVisible: CharSequence? get() = text?.takeIf { isVisible } set(value) { text = value isGone = value.isNullOrEmpty() } var TextView.drawableStart: Drawable? inline get() = compoundDrawablesRelative[0] set(value) { val dr = compoundDrawablesRelative setCompoundDrawablesRelativeWithIntrinsicBounds(value, dr[1], dr[2], dr[3]) } var TextView.drawableEnd: Drawable? inline get() = compoundDrawablesRelative[2] set(value) { val dr = compoundDrawablesRelative setCompoundDrawablesRelativeWithIntrinsicBounds(dr[0], dr[1], value, dr[3]) } var TextView.drawableTop: Drawable? inline get() = compoundDrawablesRelative[1] set(value) { val dr = compoundDrawablesRelative setCompoundDrawablesRelativeWithIntrinsicBounds(dr[0], value, dr[2], dr[3]) } fun TextView.setTextAndVisible(@StringRes textResId: Int) { if (textResId == 0) { text = null isGone = true } else { setText(textResId) isGone = text.isNullOrEmpty() } } fun TextView.setTextColorAttr(@AttrRes attrResId: Int) { setTextColor(context.getThemeColorStateList(attrResId)) } var TextView.isBold: Boolean get() = typeface.isBold set(value) { var style = typeface.style style = if (value) { style or Typeface.BOLD } else { style and Typeface.BOLD.inv() } setTypeface(typeface, style) } fun TextView.setThemeTextAppearance(@AttrRes resId: Int, @StyleRes fallback: Int) { context.obtainStyledAttributes(intArrayOf(resId)).use { TextViewCompat.setTextAppearance(this, it.getResourceId(0, fallback)) } } val TextView.isTextTruncated: Boolean get() { val l = layout ?: return false if (maxLines in 0 until l.lineCount) { return true } val layoutLines = l.lineCount return layoutLines > 0 && l.getEllipsisCount(layoutLines - 1) > 0 } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Theme.kt ================================================ package org.koitharu.kotatsu.core.util.ext import android.content.Context import android.content.res.Configuration import android.content.res.Resources import android.content.res.TypedArray import android.graphics.Color import android.graphics.drawable.Drawable import androidx.annotation.AttrRes import androidx.annotation.ColorInt import androidx.annotation.FloatRange import androidx.annotation.Px import androidx.core.content.ContextCompat import androidx.core.content.res.use import androidx.core.graphics.ColorUtils val Resources.isNightMode: Boolean get() = configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES fun Context.getThemeDrawable( @AttrRes resId: Int, ) = obtainStyledAttributes(intArrayOf(resId)).use { it.getDrawable(0) } @ColorInt fun Context.getThemeColor( @AttrRes resId: Int, @ColorInt fallback: Int = Color.TRANSPARENT, ) = obtainStyledAttributes(intArrayOf(resId)).use { it.getColor(0, fallback) } @Px fun Context.getThemeDimensionPixelSize( @AttrRes resId: Int, @Px fallback: Int = 0, ) = obtainStyledAttributes(intArrayOf(resId)).use { it.getDimensionPixelSize(0, fallback) } @Px fun Context.getThemeDimensionPixelOffset( @AttrRes resId: Int, @Px fallback: Int = 0, ) = obtainStyledAttributes(intArrayOf(resId)).use { it.getDimensionPixelOffset(0, fallback) } @ColorInt fun Context.getThemeColor( @AttrRes resId: Int, @FloatRange(from = 0.0, to = 1.0) alphaFactor: Float, @ColorInt fallback: Int = Color.TRANSPARENT, ): Int { if (alphaFactor <= 0f) { return Color.TRANSPARENT } val color = getThemeColor(resId, fallback) if (alphaFactor >= 1f) { return color } return ColorUtils.setAlphaComponent(color, (0xFF * alphaFactor).toInt()) } fun Context.getThemeColorStateList( @AttrRes resId: Int, ) = obtainStyledAttributes(intArrayOf(resId)).use { it.getColorStateList(0) } fun Context.getThemeResId( @AttrRes resId: Int, fallback: Int ): Int = obtainStyledAttributes(intArrayOf(resId)).use { it.getResourceId(0, fallback) } @Deprecated("") fun TypedArray.getDrawableCompat(context: Context, index: Int): Drawable? { val resId = getResourceId(index, 0) return if (resId != 0) ContextCompat.getDrawable(context, resId) else null } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt ================================================ package org.koitharu.kotatsu.core.util.ext import android.content.ActivityNotFoundException import android.content.res.Resources import android.database.sqlite.SQLiteFullException import androidx.annotation.DrawableRes import coil3.network.HttpException import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException import kotlinx.coroutines.CancellationException import okhttp3.Response import okhttp3.internal.http2.StreamResetException import okio.FileNotFoundException import okio.IOException import okio.ProtocolException import org.acra.ktx.sendSilentlyWithAcra import org.acra.ktx.sendWithAcra import org.jsoup.HttpStatusException import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException import org.koitharu.kotatsu.core.exceptions.CaughtException import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException import org.koitharu.kotatsu.core.exceptions.EmptyMangaException import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException import org.koitharu.kotatsu.core.exceptions.NonFileUriException import org.koitharu.kotatsu.core.exceptions.ProxyConfigException import org.koitharu.kotatsu.core.exceptions.SyncApiException import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException import org.koitharu.kotatsu.core.exceptions.WrapperIOException import org.koitharu.kotatsu.core.exceptions.WrongPasswordException import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_MULTIPLE_GENRES_NOT_SUPPORTED import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_MULTIPLE_STATES_NOT_SUPPORTED import org.koitharu.kotatsu.parsers.ErrorMessages.SEARCH_NOT_SUPPORTED import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.ContentUnavailableException import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException import java.io.File import java.net.ConnectException import java.net.HttpURLConnection import java.net.NoRouteToHostException import java.net.SocketException import java.net.SocketTimeoutException import java.net.UnknownHostException import java.util.Locale import java.util.zip.ZipException private const val MSG_NO_SPACE_LEFT = "No space left on device" private const val MSG_CONNECTION_RESET = "Connection reset" private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported" private val FNFE_MESSAGE_REGEX = Regex("^(/[^\\s:]+)?.+?\\s([A-Z]{2,6})?\\s.+$") fun Throwable.getDisplayMessage(resources: Resources): String = getDisplayMessageOrNull(resources) ?: resources.getString(R.string.error_occurred) private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = when (this) { is CancellationException -> cause?.getDisplayMessageOrNull(resources) ?: message is CaughtException -> cause.getDisplayMessageOrNull(resources) is WrapperIOException -> cause.getDisplayMessageOrNull(resources) is ScrobblerAuthRequiredException -> resources.getString( R.string.scrobbler_auth_required, resources.getString(scrobbler.titleResId), ) is AuthRequiredException -> resources.getString(R.string.auth_required) is InteractiveActionRequiredException -> resources.getString(R.string.additional_action_required) is CloudFlareProtectedException -> resources.getString(R.string.captcha_required_message) is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message) is ActivityNotFoundException, is UnsupportedOperationException, -> resources.getString(R.string.operation_not_supported) is TooManyRequestExceptions -> { val delay = getRetryDelay() val formattedTime = if (delay > 0L && delay < Long.MAX_VALUE) { resources.formatDurationShort(delay) } else { null } if (formattedTime != null) { resources.getString(R.string.too_many_requests_message_retry, formattedTime) } else { resources.getString(R.string.too_many_requests_message) } } is ZipException -> resources.getString(R.string.error_corrupted_zip, this.message.orEmpty()) is SQLiteFullException -> resources.getString(R.string.error_no_space_left) is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported) is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message) is FileNotFoundException -> parseMessage(resources) ?: message is AccessDeniedException -> resources.getString(R.string.no_access_to_file) is NonFileUriException -> resources.getString(R.string.error_non_file_uri) is EmptyHistoryException -> resources.getString(R.string.history_is_empty) is EmptyMangaException -> reason?.let { resources.getString(it.msgResId) } ?: cause?.getDisplayMessage(resources) is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration) is SyncApiException, is ContentUnavailableException -> message is ParseException -> shortMessage is ConnectException, is UnknownHostException, is NoRouteToHostException, is SocketTimeoutException -> resources.getString(R.string.network_error) is ImageDecodeException -> { val type = format?.substringBefore('/') val formatString = format.ifNullOrEmpty { resources.getString(R.string.unknown).lowercase(Locale.getDefault()) } if (type.isNullOrEmpty() || type == "image") { resources.getString(R.string.error_image_format, formatString) } else { resources.getString(R.string.error_not_image, formatString) } } is NoDataReceivedException -> resources.getString(R.string.error_no_data_received) is IncompatiblePluginException -> { cause?.getDisplayMessageOrNull(resources)?.let { resources.getString(R.string.plugin_incompatible_with_cause, it) } ?: resources.getString(R.string.plugin_incompatible) } is WrongPasswordException -> resources.getString(R.string.wrong_password) is NotFoundException -> resources.getString(R.string.not_found_404) is UnsupportedSourceException -> resources.getString(R.string.unsupported_source) is HttpException -> getHttpDisplayMessage(response.code, resources) is HttpStatusException -> getHttpDisplayMessage(statusCode, resources) else -> mapDisplayMessage(message, resources) ?: message }.takeUnless { it.isNullOrBlank() } @DrawableRes fun Throwable.getDisplayIcon(): Int = when (this) { is AuthRequiredException -> R.drawable.ic_auth_key_large is CloudFlareProtectedException -> R.drawable.ic_bot_large is UnknownHostException, is SocketTimeoutException, is ConnectException, is NoRouteToHostException, is ProtocolException -> R.drawable.ic_plug_large is CloudFlareBlockedException -> R.drawable.ic_denied_large is InteractiveActionRequiredException -> R.drawable.ic_interaction_large else -> R.drawable.ic_error_large } fun Throwable.getCauseUrl(): String? = when (this) { is ParseException -> url is NotFoundException -> url is TooManyRequestExceptions -> url is CaughtException -> cause.getCauseUrl() is WrapperIOException -> cause.getCauseUrl() is NoDataReceivedException -> url is CloudFlareBlockedException -> url is CloudFlareProtectedException -> url is InteractiveActionRequiredException -> url is HttpStatusException -> url is UnsupportedSourceException -> manga?.publicUrl?.takeIf { it.isHttpUrl() } is EmptyMangaException -> manga.publicUrl.takeIf { it.isHttpUrl() } is HttpException -> (response.delegate as? Response)?.request?.url?.toString() else -> null } private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String? = when (statusCode) { HttpURLConnection.HTTP_NOT_FOUND -> resources.getString(R.string.not_found_404) HttpURLConnection.HTTP_FORBIDDEN -> resources.getString(R.string.access_denied_403) HttpURLConnection.HTTP_GATEWAY_TIMEOUT -> resources.getString(R.string.network_unavailable) in 500..599 -> resources.getString(R.string.server_error, statusCode) else -> null } private fun mapDisplayMessage(msg: String?, resources: Resources): String? = when { msg.isNullOrEmpty() -> null msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left) msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file) msg == MSG_CONNECTION_RESET -> resources.getString(R.string.error_connection_reset) msg == FILTER_MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported) msg == FILTER_MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported) msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported) msg == FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_locale_genre_not_supported) msg == FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_states_genre_not_supported) else -> null } fun Throwable.isReportable(): Boolean { if (this is Error) { return true } if (this is CaughtException) { return cause.isReportable() } if (this is WrapperIOException) { return cause.isReportable() } if (ExceptionResolver.canResolve(this)) { return false } if (this is ParseException || this.isNetworkError() || this is CloudFlareBlockedException || this is CloudFlareProtectedException || this is BadBackupFormatException || this is WrongPasswordException || this is TooManyRequestExceptions || this is HttpStatusException ) { return false } return true } fun Throwable.isNetworkError(): Boolean { return this is UnknownHostException || this is SocketTimeoutException || this is StreamResetException || this is SocketException || this is HttpException && response.code == HttpURLConnection.HTTP_GATEWAY_TIMEOUT } fun Throwable.report(silent: Boolean = false) { val exception = CaughtException(this) if (!silent) { exception.sendWithAcra() } else if (!BuildConfig.DEBUG) { exception.sendSilentlyWithAcra() } } fun Throwable.isWebViewUnavailable(): Boolean { val trace = stackTraceToString() return trace.contains("android.webkit.WebView.") } @Suppress("FunctionName") fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT) fun FileNotFoundException.getFile(): File? { val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null return groups.getOrNull(1)?.let { File(it) } } fun FileNotFoundException.parseMessage(resources: Resources): String? { /* Examples: /storage/0000-0000/Android/media/d1f08350-0c25-460b-8f50-008e49de3873.jpg.tmp: open failed: EROFS (Read-only file system) /storage/emulated/0/Android/data/org.koitharu.kotatsu/cache/pages/fe06e192fa371e55918980f7a24c91ea.jpg: open failed: ENOENT (No such file or directory) /storage/0000-0000/Android/data/org.koitharu.kotatsu/files/manga/e57d3af4-216e-48b2-8432-1541d58eea1e.tmp (I/O error) */ val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null val path = groups.getOrNull(1) val error = groups.getOrNull(2) val baseMessageIs = when (error) { "EROFS" -> R.string.no_write_permission_to_file "ENOENT" -> R.string.file_not_found else -> return null } return if (path.isNullOrEmpty()) { resources.getString(baseMessageIs) } else { resources.getString( R.string.inline_preference_pattern, resources.getString(baseMessageIs), path, ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Toolbar.kt ================================================ package org.koitharu.kotatsu.core.util.ext import androidx.annotation.DrawableRes import androidx.appcompat.widget.Toolbar fun Toolbar.setNavigationIconSafe(@DrawableRes iconRes: Int, retry: Boolean = true) { try { setNavigationIcon(iconRes) } catch (e: IllegalStateException) { if (retry) { post { setNavigationIconSafe(iconRes, retry = false) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Uri.kt ================================================ package org.koitharu.kotatsu.core.util.ext import android.net.Uri import androidx.core.net.toUri import okio.Path import java.io.File const val URI_SCHEME_ZIP = "file+zip" private const val URI_SCHEME_FILE = "file" private const val URI_SCHEME_HTTP = "http" private const val URI_SCHEME_HTTPS = "https" private const val URI_SCHEME_LEGACY_CBZ = "cbz" private const val URI_SCHEME_LEGACY_ZIP = "zip" fun Uri.isZipUri() = scheme.let { it == URI_SCHEME_ZIP || it == URI_SCHEME_LEGACY_CBZ || it == URI_SCHEME_LEGACY_ZIP } fun Uri.isFileUri() = scheme == URI_SCHEME_FILE fun Uri.isNetworkUri() = scheme.let { it == URI_SCHEME_HTTP || it == URI_SCHEME_HTTPS } fun File.toZipUri(entryPath: String): Uri = "$URI_SCHEME_ZIP://$absolutePath#$entryPath".toUri() fun File.toZipUri(entryPath: Path?): Uri = toZipUri(entryPath?.toString()?.removePrefix(Path.DIRECTORY_SEPARATOR).orEmpty()) fun String.toUriOrNull() = if (isEmpty()) null else this.toUri() fun File.toUri(fragment: String?): Uri = toUri().run { if (fragment != null) { buildUpon().fragment(fragment).build() } else { this } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt ================================================ package org.koitharu.kotatsu.core.util.ext import android.content.Context import android.graphics.Point import android.graphics.Rect import android.os.Build import android.view.View import android.view.View.MeasureSpec import android.view.ViewGroup import android.view.WindowManager import android.widget.Checkable import androidx.annotation.StringRes import androidx.appcompat.widget.ActionMenuView import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.TooltipCompat import androidx.core.view.children import androidx.core.view.descendants import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.swiperefreshlayout.widget.CircularProgressDrawable import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.button.MaterialButton import com.google.android.material.chip.Chip import com.google.android.material.progressindicator.BaseProgressIndicator import com.google.android.material.slider.RangeSlider import com.google.android.material.slider.Slider import com.google.android.material.tabs.TabLayout import kotlin.math.roundToInt fun View.hasGlobalPoint(x: Int, y: Int): Boolean { if (visibility != View.VISIBLE) { return false } val rect = Rect() getGlobalVisibleRect(rect) return rect.contains(x, y) } val ViewGroup.hasVisibleChildren: Boolean get() = children.any { it.isVisible } fun View.measureHeight(): Int { val vh = height return if (vh == 0) { measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED) measuredHeight } else vh } fun View.measureWidth(): Int { val vw = width return if (vw == 0) { measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED) measuredWidth } else vw } inline fun ViewPager2.doOnPageChanged(crossinline callback: (Int) -> Unit) { registerOnPageChangeCallback( object : ViewPager2.OnPageChangeCallback() { override fun onPageSelected(position: Int) { super.onPageSelected(position) callback(position) } }, ) } val ViewPager2.recyclerView: RecyclerView? get() = children.firstNotNullOfOrNull { it as? RecyclerView } fun ViewPager2.findCurrentViewHolder(): ViewHolder? { return recyclerView?.findViewHolderForAdapterPosition(currentItem) } fun FragmentManager.findCurrentPagerFragment(pager: ViewPager2): Fragment? { val currentId = pager.adapter?.getItemId(pager.currentItem) ?: pager.currentItem return findFragmentByTag("f$currentId") } fun View.resetTransformations() { alpha = 1f translationX = 0f translationY = 0f translationZ = 0f scaleX = 1f scaleY = 1f rotation = 0f rotationX = 0f rotationY = 0f } fun Slider.setValueRounded(newValue: Float) { val step = stepSize val roundedValue = if (step <= 0f) { newValue } else { (newValue / step).roundToInt() * step } value = roundedValue.coerceIn(valueFrom, valueTo) } fun RangeSlider.setValuesRounded(vararg newValues: Float) { val step = stepSize values = newValues.map { newValue -> if (step <= 0f) { newValue } else { (newValue / step).roundToInt() * step }.coerceIn(valueFrom, valueTo) } } fun RecyclerView.invalidateNestedItemDecorations() { descendants.filterIsInstance().forEach { it.invalidateItemDecorations() } } val View.parentView: ViewGroup? get() = parent as? ViewGroup @Suppress("UnusedReceiverParameter") fun View.measureDimension(desiredSize: Int, measureSpec: Int): Int { var result: Int val specMode = MeasureSpec.getMode(measureSpec) val specSize = MeasureSpec.getSize(measureSpec) if (specMode == MeasureSpec.EXACTLY) { result = specSize } else { result = desiredSize if (specMode == MeasureSpec.AT_MOST) { result = result.coerceAtMost(specSize) } } return result } fun V.setChecked(checked: Boolean, animate: Boolean) where V : View, V : Checkable { val skipAnimation = !animate && checked != isChecked isChecked = checked if (skipAnimation) { jumpDrawablesToCurrentState() } } var View.isRtl: Boolean get() = layoutDirection == View.LAYOUT_DIRECTION_RTL set(value) { layoutDirection = if (value) View.LAYOUT_DIRECTION_RTL else View.LAYOUT_DIRECTION_LTR } fun TabLayout.setTabsEnabled(enabled: Boolean) { for (i in 0 until tabCount) { getTabAt(i)?.view?.isEnabled = enabled } } fun BaseProgressIndicator<*>.showOrHide(value: Boolean) { if (value) { show() } else { hide() } } fun View.setTooltipCompat(tooltip: CharSequence?) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { tooltipText = tooltip } else if (!isLongClickable) { // don't use TooltipCompat if has a LongClickListener TooltipCompat.setTooltipText(this, tooltip) } } fun View.setTooltipCompat(@StringRes tooltipResId: Int) = setTooltipCompat(context.getString(tooltipResId)) val Toolbar.menuView: ActionMenuView? get() { menu // to call ensureMenu() return children.firstNotNullOfOrNull { it as? ActionMenuView } } fun MaterialButton.setProgressIcon() { val progressDrawable = CircularProgressDrawable(context) progressDrawable.strokeWidth = resources.resolveDp(2f) progressDrawable.setColorSchemeColors(currentTextColor) progressDrawable.setTintList(textColors) icon = progressDrawable progressDrawable.start() } fun Chip.setProgressIcon() { val progressDrawable = CircularProgressDrawable(context) progressDrawable.strokeWidth = resources.resolveDp(2f) progressDrawable.setColorSchemeColors(currentTextColor) chipIcon = progressDrawable progressDrawable.start() } fun View.setContentDescriptionAndTooltip(@StringRes resId: Int) { val text = resources.getString(resId) contentDescription = text setTooltipCompat(text) } fun View.getWindowBounds(): Rect { val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { wm.currentWindowMetrics.bounds } else { val size = Point() @Suppress("DEPRECATION") display.getSize(size) Rect(0, 0, size.x, size.y) } } fun View.isOnScreen(): Boolean { if (!isShown) { return false } val actualPosition = Rect() getGlobalVisibleRect(actualPosition) return actualPosition.intersect(getWindowBounds()) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/ViewModel.kt ================================================ package org.koitharu.kotatsu.core.util.ext import androidx.annotation.MainThread import androidx.fragment.app.Fragment import androidx.fragment.app.createViewModelLazy import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.CreationExtras @MainThread inline fun Fragment.parentFragmentViewModels( noinline extrasProducer: (() -> CreationExtras)? = null, noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null, ): Lazy = createViewModelLazy( viewModelClass = VM::class, storeProducer = { requireParentFragment().viewModelStore }, extrasProducer = { extrasProducer?.invoke() ?: requireParentFragment().defaultViewModelCreationExtras }, factoryProducer = factoryProducer ?: { requireParentFragment().defaultViewModelProviderFactory }, ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/WorkManager.kt ================================================ package org.koitharu.kotatsu.core.util.ext import android.annotation.SuppressLint import androidx.work.Data import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.WorkQuery import androidx.work.WorkRequest import androidx.work.impl.WorkManagerImpl import androidx.work.impl.model.WorkSpec import kotlinx.coroutines.guava.await import java.util.UUID import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine @SuppressLint("RestrictedApi") suspend fun WorkManager.deleteWork(id: UUID) = suspendCoroutine { cont -> workManagerImpl.workTaskExecutor.executeOnTaskThread { try { workManagerImpl.workDatabase.workSpecDao().delete(id.toString()) cont.resume(Unit) } catch (e: Exception) { cont.resumeWithException(e) } } } @SuppressLint("RestrictedApi") suspend fun WorkManager.deleteWorks(ids: Collection) = suspendCoroutine { cont -> workManagerImpl.workTaskExecutor.executeOnTaskThread { try { val db = workManagerImpl.workDatabase db.runInTransaction { for (id in ids) { db.workSpecDao().delete(id.toString()) } } cont.resume(Unit) } catch (e: Exception) { cont.resumeWithException(e) } } } @SuppressLint("RestrictedApi") suspend fun WorkManager.awaitWorkInfosByTag(tag: String): List { return getWorkInfosByTag(tag).await() } @SuppressLint("RestrictedApi") suspend fun WorkManager.awaitFinishedWorkInfosByTag(tag: String): List { val query = WorkQuery.Builder.fromTags(listOf(tag)) .addStates(listOf(WorkInfo.State.SUCCEEDED, WorkInfo.State.CANCELLED, WorkInfo.State.FAILED)) .build() return getWorkInfos(query).await() } @SuppressLint("RestrictedApi") suspend fun WorkManager.awaitWorkInfoById(id: UUID): WorkInfo? { return getWorkInfoById(id).await() } @SuppressLint("RestrictedApi") suspend fun WorkManager.awaitUniqueWorkInfoByName(name: String): List { return getWorkInfosForUniqueWork(name).await() } @SuppressLint("RestrictedApi") suspend fun WorkManager.awaitUpdateWork(request: WorkRequest): WorkManager.UpdateResult { return updateWork(request).await() } @SuppressLint("RestrictedApi") suspend fun WorkManager.getWorkSpec(id: UUID): WorkSpec? = suspendCoroutine { cont -> workManagerImpl.workTaskExecutor.executeOnTaskThread { try { val spec = workManagerImpl.workDatabase.workSpecDao().getWorkSpec(id.toString()) cont.resume(spec) } catch (e: Exception) { cont.resumeWithException(e) } } } @SuppressLint("RestrictedApi") suspend fun WorkManager.getWorkInputData(id: UUID): Data? = getWorkSpec(id)?.input val Data.isEmpty: Boolean get() = this == Data.EMPTY private val WorkManager.workManagerImpl @SuppressLint("RestrictedApi") inline get() = this as WorkManagerImpl ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/iterator/MappingIterator.kt ================================================ package org.koitharu.kotatsu.core.util.iterator class MappingIterator( private val upstream: Iterator, private val mapper: (T) -> R, ) : Iterator { override fun hasNext(): Boolean = upstream.hasNext() override fun next(): R = mapper(upstream.next()) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/ImageRequestIndicatorListener.kt ================================================ package org.koitharu.kotatsu.core.util.progress import coil3.request.ErrorResult import coil3.request.ImageRequest import coil3.request.SuccessResult import com.google.android.material.progressindicator.BaseProgressIndicator class ImageRequestIndicatorListener( private val indicators: Collection>, ) : ImageRequest.Listener { override fun onCancel(request: ImageRequest) = hide() override fun onError(request: ImageRequest, result: ErrorResult) = hide() override fun onStart(request: ImageRequest) = show() override fun onSuccess(request: ImageRequest, result: SuccessResult) = hide() private fun hide() { indicators.forEach { it.hide() } } private fun show() { indicators.forEach { it.show() } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/IntPercentLabelFormatter.kt ================================================ package org.koitharu.kotatsu.core.util.progress import android.content.Context import com.google.android.material.slider.LabelFormatter import org.koitharu.kotatsu.R class IntPercentLabelFormatter(context: Context) : LabelFormatter { private val pattern = context.getString(R.string.percent_string_pattern) override fun getFormattedValue(value: Float) = pattern.format(value.toInt().toString()) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/Progress.kt ================================================ package org.koitharu.kotatsu.core.util.progress data class Progress( val progress: Int, val total: Int, ) : Comparable { val percent: Float get() = if (total == 0) 0f else progress / total.toFloat() val isEmpty: Boolean get() = progress == 0 val isFull: Boolean get() = progress == total val isIndeterminate: Boolean get() = total < 0 override fun compareTo(other: Progress): Int = if (total == other.total) { progress.compareTo(other.progress) } else { percent.compareTo(other.percent) } operator fun inc() = if (isFull) { this } else { copy( progress = progress + 1, total = total, ) } operator fun dec() = if (isEmpty) { this } else { copy( progress = progress - 1, total = total, ) } operator fun plus(child: Progress) = Progress( progress = progress * child.total + child.progress, total = total * child.total, ) fun percentSting() = (percent * 100f).toInt().toString() companion object { val INDETERMINATE = Progress(0, -1) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/ProgressDeferred.kt ================================================ package org.koitharu.kotatsu.core.util.progress import kotlinx.coroutines.Deferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow class ProgressDeferred( private val deferred: Deferred, private val progress: StateFlow

, ) : Deferred by deferred { val progressValue: P get() = progress.value fun progressAsFlow(): Flow

= progress } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/ProgressResponseBody.kt ================================================ package org.koitharu.kotatsu.core.util.progress import kotlinx.coroutines.flow.MutableStateFlow import okhttp3.MediaType import okhttp3.ResponseBody import okio.Buffer import okio.BufferedSource import okio.ForwardingSource import okio.Source import okio.buffer class ProgressResponseBody( private val delegate: ResponseBody, private val progressState: MutableStateFlow, ) : ResponseBody() { private var bufferedSource: BufferedSource? = null override fun close() { super.close() delegate.close() } override fun contentLength(): Long = delegate.contentLength() override fun contentType(): MediaType? = delegate.contentType() override fun source(): BufferedSource { return bufferedSource ?: synchronized(this) { bufferedSource ?: ProgressSource(delegate.source(), contentLength(), progressState).buffer().also { bufferedSource = it } } } private class ProgressSource( delegate: Source, private val contentLength: Long, private val progressState: MutableStateFlow, ) : ForwardingSource(delegate) { private var totalBytesRead = 0L override fun read(sink: Buffer, byteCount: Long): Long { val bytesRead = super.read(sink, byteCount) if (contentLength > 0) { totalBytesRead += if (bytesRead != -1L) bytesRead else 0 progressState.value = (totalBytesRead.toDouble() / contentLength.toDouble()).toFloat() } return bytesRead } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/RealtimeEtaEstimator.kt ================================================ package org.koitharu.kotatsu.core.util.progress import android.os.SystemClock import androidx.annotation.AnyThread import androidx.collection.CircularArray import java.util.concurrent.TimeUnit import kotlin.math.roundToLong class RealtimeEtaEstimator { private val ticks = CircularArray(MAX_TICKS) @Volatile private var lastChange = 0L @AnyThread fun onProgressChanged(value: Int, total: Int) { if (total <= 0 || value > total) { reset() return } val tick = Tick(value, total, SystemClock.elapsedRealtime()) synchronized(this) { if (!ticks.isEmpty()) { val last = ticks.last if (last.value == tick.value && last.total == tick.total) { ticks.popLast() } else { lastChange = tick.timestamp } } else { lastChange = tick.timestamp } ticks.addLast(tick) } } @AnyThread fun reset() = synchronized(this) { ticks.clear() lastChange = 0L } @AnyThread fun getEta(): Long { val etl = getEstimatedTimeLeft() return if (etl == NO_TIME || etl > MAX_TIME) NO_TIME else System.currentTimeMillis() + etl } @AnyThread fun isStuck(): Boolean = synchronized(this) { return ticks.size() >= MIN_ESTIMATE_TICKS && (SystemClock.elapsedRealtime() - lastChange) > STUCK_DELAY } private fun getEstimatedTimeLeft(): Long = synchronized(this) { val ticksCount = ticks.size() if (ticksCount < MIN_ESTIMATE_TICKS) { return NO_TIME } val percentDiff = ticks.last.percent - ticks.first.percent val timeDiff = ticks.last.timestamp - ticks.first.timestamp if (percentDiff <= 0 || timeDiff <= 0) { return NO_TIME } val averageTime = timeDiff / percentDiff val percentLeft = 1.0 - ticks.last.percent return (percentLeft * averageTime).roundToLong() } private class Tick( @JvmField val value: Int, @JvmField val total: Int, @JvmField val timestamp: Long, ) { init { require(total > 0) { "total = $total" } require(value >= 0) { "value = $value" } require(value <= total) { "total = $total, value = $value" } } @JvmField val percent = value.toDouble() / total.toDouble() } private companion object { const val MAX_TICKS = 20 const val MIN_ESTIMATE_TICKS = 4 const val NO_TIME = -1L const val STUCK_DELAY = 10_000L val MAX_TIME = TimeUnit.DAYS.toMillis(1) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/core/zip/ZipOutput.kt ================================================ package org.koitharu.kotatsu.core.zip import androidx.annotation.WorkerThread import androidx.collection.ArraySet import okio.Closeable import org.jetbrains.annotations.Blocking import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.withChildren import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.util.zip.Deflater import java.util.zip.ZipEntry import java.util.zip.ZipFile import java.util.zip.ZipOutputStream class ZipOutput( val file: File, private val compressionLevel: Int = Deflater.DEFAULT_COMPRESSION, ) : Closeable { private val entryNames = ArraySet() private var cachedOutput: ZipOutputStream? = null private var append: Boolean = false @Blocking fun put(name: String, file: File): Boolean = withOutput { output -> output.appendFile(file, name) } @Blocking fun put(name: String, content: String): Boolean = withOutput { output -> output.appendText(content, name) } @Blocking fun addDirectory(name: String): Boolean { val entry = if (name.endsWith("/")) { ZipEntry(name) } else { ZipEntry("$name/") } return if (entryNames.add(entry.name)) { withOutput { output -> output.putNextEntry(entry) output.closeEntry() } true } else { false } } @Blocking fun copyEntryFrom(other: ZipFile, entry: ZipEntry): Boolean { return if (entryNames.add(entry.name)) { val zipEntry = ZipEntry(entry.name) withOutput { output -> output.putNextEntry(zipEntry) try { other.getInputStream(entry).use { input -> input.copyTo(output) } } finally { output.closeEntry() } } true } else { false } } @Blocking fun finish() = withOutput { output -> output.finish() } @Synchronized override fun close() { cachedOutput?.closeSafe() cachedOutput = null } @WorkerThread private fun ZipOutputStream.appendFile(fileToZip: File, name: String): Boolean { if (fileToZip.isDirectory) { val entry = if (name.endsWith("/")) { ZipEntry(name) } else { ZipEntry("$name/") } if (!entryNames.add(entry.name)) { return false } putNextEntry(entry) closeEntry() fileToZip.withChildren { children -> children.forEach { childFile -> appendFile(childFile, "$name/${childFile.name}") } } } else { FileInputStream(fileToZip).use { fis -> if (!entryNames.add(name)) { return false } val zipEntry = ZipEntry(name) putNextEntry(zipEntry) try { fis.copyTo(this) } finally { closeEntry() } } } return true } @WorkerThread private fun ZipOutputStream.appendText(content: String, name: String): Boolean { if (!entryNames.add(name)) { return false } val zipEntry = ZipEntry(name) putNextEntry(zipEntry) try { content.byteInputStream().copyTo(this) } finally { closeEntry() } return true } @Synchronized private fun withOutput(block: (ZipOutputStream) -> T): T { return try { (cachedOutput ?: newOutput(append)).withOutputImpl(block).also { append = true // after 1st success write } } catch (e: NullPointerException) { // probably NullPointerException: Deflater has been closed e.printStackTraceDebug() newOutput(append).withOutputImpl(block) } } private fun ZipOutputStream.withOutputImpl(block: (ZipOutputStream) -> T): T { val res = block(this) flush() return res } private fun newOutput(append: Boolean) = ZipOutputStream(FileOutputStream(file, append)).also { it.setLevel(compressionLevel) cachedOutput?.closeSafe() cachedOutput = it } private fun Closeable.closeSafe() { try { close() } catch (e: NullPointerException) { // Don't throw the "Deflater has been closed" exception e.printStackTraceDebug() } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/data/MangaDetails.kt ================================================ package org.koitharu.kotatsu.details.data import org.koitharu.kotatsu.core.model.getLocale import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.withOverride import org.koitharu.kotatsu.core.ui.model.MangaOverride import org.koitharu.kotatsu.local.domain.model.LocalManga 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.util.ifNullOrEmpty import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.reader.data.filterChapters import java.util.Locale data class MangaDetails( private val manga: Manga, private val localManga: LocalManga?, private val override: MangaOverride?, val description: CharSequence?, val isLoaded: Boolean, ) { constructor(manga: Manga) : this( manga = manga, localManga = null, override = null, description = null, isLoaded = false, ) val id: Long get() = manga.id val allChapters: List by lazy { mergeChapters() } val chapters: Map> by lazy { allChapters.groupBy { it.branch } } val isLocal get() = manga.isLocal val local: LocalManga? get() = localManga ?: if (manga.isLocal) LocalManga(manga) else null val coverUrl: String? get() = override?.coverUrl .ifNullOrEmpty { manga.largeCoverUrl } .ifNullOrEmpty { manga.coverUrl } .ifNullOrEmpty { localManga?.manga?.coverUrl } ?.nullIfEmpty() val isRestricted: Boolean get() = manga.state == MangaState.RESTRICTED private val mergedManga by lazy { if (localManga == null) { // fast path manga.withOverride(override) } else { manga.copy( title = override?.title.ifNullOrEmpty { manga.title }, coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl }, largeCoverUrl = override?.coverUrl.ifNullOrEmpty { manga.largeCoverUrl }, contentRating = override?.contentRating ?: manga.contentRating, chapters = allChapters, ) } } fun toManga() = mergedManga fun getLocale(): Locale? { findAppropriateLocale(chapters.keys.singleOrNull())?.let { return it } return manga.source.getLocale() } fun filterChapters(branch: String?) = copy( manga = manga.filterChapters(branch), localManga = localManga?.run { copy(manga = manga.filterChapters(branch)) }, ) private fun mergeChapters(): List { val chapters = manga.chapters val localChapters = local?.manga?.chapters.orEmpty() if (chapters.isNullOrEmpty()) { return localChapters } val localMap = if (localChapters.isNotEmpty()) { localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id } } else { null } val result = ArrayList(chapters.size) for (chapter in chapters) { val local = localMap?.remove(chapter.id) result += local ?: chapter } if (!localMap.isNullOrEmpty()) { result.addAll(localMap.values) } return result } private fun findAppropriateLocale(name: String?): Locale? { if (name.isNullOrEmpty()) { return null } return Locale.getAvailableLocales().find { lc -> name.contains(lc.getDisplayName(lc), ignoreCase = true) || name.contains(lc.getDisplayName(Locale.ENGLISH), ignoreCase = true) || name.contains(lc.getDisplayLanguage(lc), ignoreCase = true) || name.contains(lc.getDisplayLanguage(Locale.ENGLISH), ignoreCase = true) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/data/ReadingTime.kt ================================================ package org.koitharu.kotatsu.details.data import android.content.res.Resources import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe data class ReadingTime( val minutes: Int, val hours: Int, val isContinue: Boolean, ) { fun format(resources: Resources): String = when { hours == 0 && minutes == 0 -> resources.getString(R.string.less_than_minute) hours == 0 -> resources.getQuantityStringSafe(R.plurals.minutes, minutes, minutes) minutes == 0 -> resources.getQuantityStringSafe(R.plurals.hours, hours, hours) else -> resources.getString( R.string.remaining_time_pattern, resources.getQuantityStringSafe(R.plurals.hours, hours, hours), resources.getQuantityStringSafe(R.plurals.minutes, minutes, minutes), ) } fun formatShort(resources: Resources): String? = when { hours == 0 && minutes == 0 -> null hours == 0 -> resources.getString(R.string.minutes_short, minutes) minutes == 0 -> resources.getString(R.string.hours_short, hours) else -> resources.getString(R.string.hours_minutes_short, hours, minutes) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/domain/BranchComparator.kt ================================================ package org.koitharu.kotatsu.details.domain import org.koitharu.kotatsu.core.util.LocaleStringComparator import org.koitharu.kotatsu.details.ui.model.MangaBranch class BranchComparator : Comparator { private val delegate = LocaleStringComparator() override fun compare(o1: MangaBranch, o2: MangaBranch): Int = delegate.compare(o1.name, o2.name) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/domain/DetailsInteractor.kt ================================================ package org.koitharu.kotatsu.details.domain import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.TriStateOption import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.tracker.domain.TrackingRepository import javax.inject.Inject /* TODO: remove */ class DetailsInteractor @Inject constructor( private val historyRepository: HistoryRepository, private val favouritesRepository: FavouritesRepository, private val localMangaRepository: LocalMangaRepository, private val trackingRepository: TrackingRepository, private val settings: AppSettings, private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>, ) { fun observeFavourite(mangaId: Long): Flow> { return favouritesRepository.observeCategories(mangaId) } fun observeNewChapters(mangaId: Long): Flow { return settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled } .flatMapLatest { isEnabled -> if (isEnabled) { trackingRepository.observeNewChaptersCount(mangaId) } else { flowOf(0) } } } fun observeScrobblingInfo(mangaId: Long): Flow> { return combine( scrobblers.map { it.observeScrobblingInfo(mangaId) }, ) { scrobblingInfo -> scrobblingInfo.filterNotNull() } } fun observeIncognitoMode(mangaFlow: Flow): Flow { return mangaFlow .filterNotNull() .distinctUntilChangedBy { it.isNsfw() } .combine(observeIncognitoMode()) { manga, globalIncognito -> when { globalIncognito -> TriStateOption.ENABLED manga.isNsfw() -> settings.incognitoModeForNsfw else -> TriStateOption.DISABLED } } } suspend fun updateLocal(subject: MangaDetails?, localManga: LocalManga): MangaDetails? { subject ?: return null return if (subject.id == localManga.manga.id) { if (subject.isLocal) { subject.copy( manga = localManga.manga, ) } else { subject.copy( localManga = runCatchingCancellable { localManga.copy( manga = localMangaRepository.getDetails(localManga.manga), ) }.getOrNull() ?: subject.local, ) } } else { subject } } suspend fun findRemote(seed: Manga) = localMangaRepository.getRemoteManga(seed) private fun observeIncognitoMode() = settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/domain/DetailsLoadUseCase.kt ================================================ package org.koitharu.kotatsu.details.domain import android.text.Html import android.text.SpannableString import android.text.Spanned import android.text.style.ForegroundColorSpan import androidx.core.text.getSpans import androidx.core.text.parseAsHtml import coil3.request.CachePolicy import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.runInterruptible import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.nav.MangaIntent import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.parser.CachingMangaRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.ui.model.MangaOverride import org.koitharu.kotatsu.core.util.ext.sanitize import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.explore.domain.RecoverMangaUseCase import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.parsers.util.recoverNotNull import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import javax.inject.Inject class DetailsLoadUseCase @Inject constructor( private val mangaDataRepository: MangaDataRepository, private val localMangaRepository: LocalMangaRepository, private val mangaRepositoryFactory: MangaRepository.Factory, private val recoverUseCase: RecoverMangaUseCase, private val imageGetter: Html.ImageGetter, private val networkState: NetworkState, ) { operator fun invoke(intent: MangaIntent, force: Boolean): Flow = flow { val manga = requireNotNull(mangaDataRepository.resolveIntent(intent, withChapters = true)) { "Cannot resolve intent $intent" } val override = mangaDataRepository.getOverride(manga.id) emit( MangaDetails( manga = manga, localManga = null, override = override, description = manga.description?.parseAsHtml(withImages = false), isLoaded = false, ), ) if (manga.isLocal) { loadLocal(manga, override, force) } else { loadRemote(manga, override, force) } }.distinctUntilChanged() .flowOn(Dispatchers.Default) /** * Load local manga + try to load the linked remote one if network is not restricted * Suppress any network errors */ private suspend fun FlowCollector.loadLocal(manga: Manga, override: MangaOverride?, force: Boolean) { val skipNetworkLoad = !force && networkState.isOfflineOrRestricted() val localDetails = localMangaRepository.getDetails(manga) emit( MangaDetails( manga = localDetails, localManga = null, override = override, description = localDetails.description?.parseAsHtml(withImages = false), isLoaded = skipNetworkLoad, ), ) if (skipNetworkLoad) { return } val remoteManga = localMangaRepository.getRemoteManga(manga) if (remoteManga == null) { emit( MangaDetails( manga = localDetails, localManga = null, override = override, description = localDetails.description?.parseAsHtml(withImages = true), isLoaded = true, ), ) } else { val remoteDetails = getDetails(remoteManga, force).getOrNull() emit( MangaDetails( manga = remoteDetails ?: remoteManga, localManga = LocalManga(localDetails), override = override, description = (remoteDetails ?: localDetails).description?.parseAsHtml(withImages = true), isLoaded = true, ), ) if (remoteDetails != null) { mangaDataRepository.updateChapters(remoteDetails) } } } /** * Load remote manga + saved one if available * Throw network errors after loading local manga only */ private suspend fun FlowCollector.loadRemote( manga: Manga, override: MangaOverride?, force: Boolean ) = coroutineScope { val remoteDeferred = async { getDetails(manga, force) } val localManga = localMangaRepository.findSavedManga(manga, withDetails = true) if (localManga != null) { emit( MangaDetails( manga = manga, localManga = localManga, override = override, description = localManga.manga.description?.parseAsHtml(withImages = true), isLoaded = false, ), ) } val remoteDetails = remoteDeferred.await().getOrThrow() emit( MangaDetails( manga = remoteDetails, localManga = localManga, override = override, description = (remoteDetails.description ?: localManga?.manga?.description)?.parseAsHtml(withImages = true), isLoaded = true, ), ) mangaDataRepository.updateChapters(remoteDetails) } private suspend fun getDetails(seed: Manga, force: Boolean) = runCatchingCancellable { val repository = mangaRepositoryFactory.create(seed.source) if (repository is CachingMangaRepository) { repository.getDetails(seed, if (force) CachePolicy.WRITE_ONLY else CachePolicy.ENABLED) } else { repository.getDetails(seed) } }.recoverNotNull { e -> if (e is NotFoundException) { recoverUseCase(seed) } else { null } } private suspend fun String.parseAsHtml(withImages: Boolean): CharSequence? = if (withImages) { runInterruptible(Dispatchers.IO) { parseAsHtml(imageGetter = imageGetter) }.filterSpans() } else { runInterruptible(Dispatchers.Default) { parseAsHtml() }.filterSpans().sanitize() }.trim().nullIfEmpty() private fun Spanned.filterSpans(): Spanned { val spannable = SpannableString.valueOf(this) val spans = spannable.getSpans() for (span in spans) { spannable.removeSpan(span) } return spannable } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/domain/ProgressUpdateUseCase.kt ================================================ package org.koitharu.kotatsu.details.domain import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.parsers.model.Manga import javax.inject.Inject class ProgressUpdateUseCase @Inject constructor( private val mangaRepositoryFactory: MangaRepository.Factory, private val database: MangaDatabase, private val localMangaRepository: LocalMangaRepository, private val networkState: NetworkState, ) { suspend operator fun invoke(manga: Manga): Float { val history = database.getHistoryDao().find(manga.id) ?: return PROGRESS_NONE val seed = if (manga.isLocal) { localMangaRepository.getRemoteManga(manga) ?: manga } else { manga } if (!seed.isLocal && !networkState.value) { return PROGRESS_NONE } val repo = mangaRepositoryFactory.create(seed.source) val details = if (manga.source != seed.source || seed.chapters.isNullOrEmpty()) { repo.getDetails(seed) } else { seed } val chapter = details.findChapterById(history.chapterId) ?: return PROGRESS_NONE val chapters = details.getChapters(chapter.branch) val chapterRepo = if (repo.source == chapter.source) { repo } else { mangaRepositoryFactory.create(chapter.source) } val chaptersCount = chapters.size if (chaptersCount == 0) { return PROGRESS_NONE } val chapterIndex = chapters.indexOfFirst { x -> x.id == history.chapterId } val pagesCount = chapterRepo.getPages(chapter).size if (pagesCount == 0) { return PROGRESS_NONE } val pagePercent = (history.page + 1) / pagesCount.toFloat() val ppc = 1f / chaptersCount val result = ppc * chapterIndex + ppc * pagePercent if (result != history.percent) { database.getHistoryDao().update( history.copy( chapterId = chapter.id, percent = result, ), ) } return result } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/domain/ReadingTimeUseCase.kt ================================================ package org.koitharu.kotatsu.details.domain import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.details.data.ReadingTime import org.koitharu.kotatsu.parsers.util.findById import org.koitharu.kotatsu.stats.data.StatsRepository import java.util.concurrent.TimeUnit import javax.inject.Inject import kotlin.math.roundToInt class ReadingTimeUseCase @Inject constructor( private val settings: AppSettings, private val statsRepository: StatsRepository, ) { suspend operator fun invoke(manga: MangaDetails?, branch: String?, history: MangaHistory?): ReadingTime? { if (!settings.isReadingTimeEstimationEnabled) { return null } val chapters = manga?.chapters?.get(branch) if (chapters.isNullOrEmpty()) { return null } val isOnHistoryBranch = history != null && chapters.findById(history.chapterId) != null // Impossible task, I guess. Good luck on this. var averageTimeSec: Int = 20 /* pages */ * getSecondsPerPage(manga.id) * chapters.size if (isOnHistoryBranch) { averageTimeSec = (averageTimeSec * (1f - history.percent)).roundToInt() } if (averageTimeSec < 60) { return null } return ReadingTime( minutes = (averageTimeSec / 60) % 60, hours = averageTimeSec / 3600, isContinue = isOnHistoryBranch, ) } private suspend fun getSecondsPerPage(mangaId: Long): Int { var time = if (settings.isStatsEnabled) { TimeUnit.MILLISECONDS.toSeconds(statsRepository.getTimePerPage(mangaId)).toInt() } else { 0 } if (time == 0) { time = 10 // default } return time } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/domain/RelatedMangaUseCase.kt ================================================ package org.koitharu.kotatsu.details.domain import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import javax.inject.Inject class RelatedMangaUseCase @Inject constructor( private val mangaRepositoryFactory: MangaRepository.Factory, ) { suspend operator fun invoke(seed: Manga) = runCatchingCancellable { mangaRepositoryFactory.create(seed.source).getRelated(seed) }.onFailure { it.printStackTraceDebug() }.getOrNull() } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt ================================================ package org.koitharu.kotatsu.details.service import android.content.Context import android.content.Intent import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.EntryPointAccessors import org.koitharu.kotatsu.core.cache.MemoryContentCache import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.parcelable.ParcelableChapter import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.ui.CoroutineIntentService import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.findById import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import javax.inject.Inject @AndroidEntryPoint class MangaPrefetchService : CoroutineIntentService() { @Inject lateinit var mangaRepositoryFactory: MangaRepository.Factory @Inject lateinit var cache: MemoryContentCache @Inject lateinit var historyRepository: HistoryRepository override suspend fun IntentJobContext.processIntent(intent: Intent) { when (intent.action) { ACTION_PREFETCH_DETAILS -> prefetchDetails( manga = intent.getParcelableExtraCompat(EXTRA_MANGA)?.manga ?: return, ) ACTION_PREFETCH_PAGES -> prefetchPages( chapter = intent.getParcelableExtraCompat(EXTRA_CHAPTER)?.chapter ?: return, ) ACTION_PREFETCH_LAST -> prefetchLast() } } override fun IntentJobContext.onError(error: Throwable) = Unit private suspend fun prefetchDetails(manga: Manga) { val source = mangaRepositoryFactory.create(manga.source) runCatchingCancellable { source.getDetails(manga) } } private suspend fun prefetchPages(chapter: MangaChapter) { val source = mangaRepositoryFactory.create(chapter.source) runCatchingCancellable { source.getPages(chapter) } } private suspend fun prefetchLast() { val last = historyRepository.getLastOrNull() ?: return if (last.isLocal) return val repo = mangaRepositoryFactory.create(last.source) val details = runCatchingCancellable { repo.getDetails(last) }.getOrNull() ?: return val chapters = details.chapters if (chapters.isNullOrEmpty()) { return } val history = historyRepository.getOne(last) val chapter = if (history == null) { chapters.firstOrNull() } else { chapters.findById(history.chapterId) ?: chapters.firstOrNull() } ?: return runCatchingCancellable { repo.getPages(chapter) } } companion object { private const val EXTRA_MANGA = "manga" private const val EXTRA_CHAPTER = "manga" private const val ACTION_PREFETCH_DETAILS = "details" private const val ACTION_PREFETCH_PAGES = "pages" private const val ACTION_PREFETCH_LAST = "last" fun prefetchDetails(context: Context, manga: Manga) { if (!isPrefetchAvailable(context, manga.source)) return val intent = Intent(context, MangaPrefetchService::class.java) intent.action = ACTION_PREFETCH_DETAILS intent.putExtra(EXTRA_MANGA, ParcelableManga(manga)) tryStart(context, intent) } fun prefetchPages(context: Context, chapter: MangaChapter) { if (!isPrefetchAvailable(context, chapter.source)) return val intent = Intent(context, MangaPrefetchService::class.java) intent.action = ACTION_PREFETCH_PAGES intent.putExtra(EXTRA_CHAPTER, ParcelableChapter(chapter)) tryStart(context, intent) } fun prefetchLast(context: Context) { if (!isPrefetchAvailable(context, null)) return val intent = Intent(context, MangaPrefetchService::class.java) intent.action = ACTION_PREFETCH_LAST tryStart(context, intent) } private fun isPrefetchAvailable(context: Context, source: MangaSource?): Boolean { if (source == LocalMangaSource || context.isPowerSaveMode()) { return false } val entryPoint = EntryPointAccessors.fromApplication( context, PrefetchCompanionEntryPoint::class.java, ) return entryPoint.settings.isContentPrefetchEnabled } private fun tryStart(context: Context, intent: Intent) { try { context.startService(intent) } catch (e: IllegalStateException) { // probably app is in background e.printStackTraceDebug() } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/service/PrefetchCompanionEntryPoint.kt ================================================ package org.koitharu.kotatsu.details.service import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import org.koitharu.kotatsu.core.prefs.AppSettings @EntryPoint @InstallIn(SingletonComponent::class) interface PrefetchCompanionEntryPoint { val settings: AppSettings } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/AuthorSpan.kt ================================================ package org.koitharu.kotatsu.details.ui import android.text.Spannable import android.text.TextPaint import android.text.style.ClickableSpan import android.view.View import android.widget.TextView class AuthorSpan(private val listener: OnAuthorClickListener) : ClickableSpan() { override fun onClick(widget: View) { val text = (widget as? TextView)?.text as? Spannable ?: return val start = text.getSpanStart(this) val end = text.getSpanEnd(this) val selected = text.substring(start, end).trim() if (selected.isNotEmpty()) { listener.onAuthorClick(selected) } } override fun updateDrawState(ds: TextPaint) { ds.setColor(ds.linkColor) } fun interface OnAuthorClickListener { fun onAuthorClick(author: String) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersMapper.kt ================================================ package org.koitharu.kotatsu.details.ui import android.content.Context import org.koitharu.kotatsu.R import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.toListItem import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.util.mapToSet fun MangaDetails.mapChapters( currentChapterId: Long, newCount: Int, branch: String?, bookmarks: List, isGrid: Boolean, isDownloadedOnly: Boolean, ): List { val remoteChapters = chapters[branch].orEmpty() val localChapters = local?.manga?.getChapters(branch).orEmpty() if (remoteChapters.isEmpty() && localChapters.isEmpty()) { return emptyList() } val bookmarked = bookmarks.mapToSet { it.chapterId } val newFrom = if (newCount == 0 || remoteChapters.isEmpty()) Int.MAX_VALUE else remoteChapters.size - newCount val ids = buildSet(maxOf(remoteChapters.size, localChapters.size)) { remoteChapters.mapTo(this) { it.id } localChapters.mapTo(this) { it.id } } val result = ArrayList(ids.size) val localMap = if (localChapters.isNotEmpty()) { localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id } } else { null } var isUnread = currentChapterId !in ids if (!isDownloadedOnly || local?.manga?.chapters == null) { for (chapter in remoteChapters) { val local = localMap?.remove(chapter.id) if (chapter.id == currentChapterId) { isUnread = true } result += (local ?: chapter).toListItem( isCurrent = chapter.id == currentChapterId, isUnread = isUnread, isNew = isUnread && result.size >= newFrom, isDownloaded = local != null, isBookmarked = chapter.id in bookmarked, isGrid = isGrid, ) } } if (!localMap.isNullOrEmpty()) { for (chapter in localMap.values) { if (chapter.id == currentChapterId) { isUnread = true } result += chapter.toListItem( isCurrent = chapter.id == currentChapterId, isUnread = isUnread, isNew = false, isDownloaded = !isLocal, isBookmarked = chapter.id in bookmarked, isGrid = isGrid, ) } } return result } fun List.withVolumeHeaders(context: Context): MutableList { var prevVolume = 0 val result = ArrayList((size * 1.4).toInt()) for (item in this) { val chapter = item.chapter if (chapter.volume != prevVolume) { val text = if (chapter.volume == 0) { context.getString(R.string.volume_unknown) } else { context.getString(R.string.volume_, chapter.volume) } result.add(ListHeader(text)) prevVolume = chapter.volume } result.add(item) } return result } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt ================================================ package org.koitharu.kotatsu.details.ui import android.app.assist.AssistContent import android.content.Context import android.os.Bundle import android.text.SpannedString import android.view.Gravity import android.view.View import android.view.ViewGroup import android.view.ViewTreeObserver import android.widget.Toast import androidx.activity.viewModels import androidx.core.text.buildSpannedString import androidx.core.text.inSpans import androidx.core.text.method.LinkMovementMethodCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.core.view.updatePaddingRelative import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.transition.TransitionManager import coil3.ImageLoader import coil3.request.ImageRequest import coil3.request.allowRgb565 import coil3.request.crossfade import coil3.request.lifecycle import coil3.request.transformations import coil3.size.Precision import coil3.transform.RoundedCornersTransformation import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.chip.Chip import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import org.koitharu.kotatsu.R import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.core.image.CoilMemoryCacheKey import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.UnknownMangaSource import org.koitharu.kotatsu.core.model.getSummary import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.model.titleResId import org.koitharu.kotatsu.core.nav.ReaderIntent import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.prefs.AppSettings 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.image.FaviconDrawable import org.koitharu.kotatsu.core.ui.image.TextDrawable import org.koitharu.kotatsu.core.ui.image.TextViewTarget import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.sheet.BottomSheetCollapseCallback import org.koitharu.kotatsu.core.ui.util.MenuInvalidator import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.util.FileSize import org.koitharu.kotatsu.core.util.LocaleUtils import org.koitharu.kotatsu.core.util.ext.consume import org.koitharu.kotatsu.core.util.ext.copyToClipboard import org.koitharu.kotatsu.core.util.ext.drawableStart import org.koitharu.kotatsu.core.util.ext.end import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.isTextTruncated import org.koitharu.kotatsu.core.util.ext.joinToStringWithLimit import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.parentView import org.koitharu.kotatsu.core.util.ext.setTooltipCompat import org.koitharu.kotatsu.core.util.ext.start import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.toUriOrNull import org.koitharu.kotatsu.databinding.ActivityDetailsBinding import org.koitharu.kotatsu.databinding.LayoutDetailsTableBinding import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.details.data.ReadingTime import org.koitharu.kotatsu.details.service.MangaPrefetchService import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.HistoryInfo import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver import org.koitharu.kotatsu.list.domain.ReadingProgress import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver import org.koitharu.kotatsu.main.ui.owners.BottomSheetOwner import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import javax.inject.Inject import kotlin.math.roundToInt import com.google.android.material.R as materialR @AndroidEntryPoint class DetailsActivity : BaseActivity(), View.OnClickListener, View.OnLayoutChangeListener, ViewTreeObserver.OnDrawListener, ChipsView.OnChipClickListener, OnListItemClickListener, SwipeRefreshLayout.OnRefreshListener, AuthorSpan.OnAuthorClickListener, BottomSheetOwner { @Inject lateinit var shortcutManager: AppShortcutManager @Inject lateinit var coil: ImageLoader @Inject lateinit var settings: AppSettings private val viewModel: DetailsViewModel by viewModels() private lateinit var menuProvider: DetailsMenuProvider private lateinit var infoBinding: LayoutDetailsTableBinding override val bottomSheet: View? get() = viewBinding.containerBottomSheet override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityDetailsBinding.inflate(layoutInflater)) infoBinding = LayoutDetailsTableBinding.bind(viewBinding.root) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) supportActionBar?.setDisplayShowTitleEnabled(false) viewBinding.chipFavorite.setOnClickListener(this) infoBinding.textViewLocal.setOnClickListener(this) infoBinding.textViewSource.setOnClickListener(this) viewBinding.imageViewCover.setOnClickListener(this) viewBinding.textViewTitle.setOnClickListener(this) viewBinding.buttonDescriptionMore.setOnClickListener(this) viewBinding.buttonScrobblingMore.setOnClickListener(this) viewBinding.buttonRelatedMore.setOnClickListener(this) viewBinding.textViewDescription.addOnLayoutChangeListener(this) viewBinding.swipeRefreshLayout.setOnRefreshListener(this) viewBinding.textViewDescription.viewTreeObserver.addOnDrawListener(this) infoBinding.textViewAuthor.movementMethod = LinkMovementMethodCompat.getInstance() viewBinding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance() viewBinding.chipsTags.onChipClickListener = this TitleScrollCoordinator(viewBinding.textViewTitle).attach(viewBinding.scrollView) if (settings.isDescriptionExpanded) { viewBinding.textViewDescription.maxLines = Int.MAX_VALUE - 1 } viewBinding.containerBottomSheet?.let { sheet -> sheet.setOnClickListener(this) sheet.addOnLayoutChangeListener(this) onBackPressedDispatcher.addCallback(BottomSheetCollapseCallback(sheet)) BottomSheetBehavior.from(sheet).addBottomSheetCallback( DetailsBottomSheetCallback(viewBinding.swipeRefreshLayout, checkNotNull(viewBinding.navbarDim)), ) } val appRouter = router viewModel.mangaDetails.filterNotNull().observe(this, ::onMangaUpdated) viewModel.coverUrl.observe(this, ::loadCover) viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved) viewModel.onError .filterNot { appRouter.isChapterPagesSheetShown() } .observeEvent(this, DetailsErrorObserver(this, viewModel, exceptionResolver)) viewModel.onActionDone .filterNot { appRouter.isChapterPagesSheetShown() } .observeEvent(this, ReversibleActionObserver(viewBinding.scrollView)) combine(viewModel.historyInfo, viewModel.isLoading, ::Pair).observe(this) { onHistoryChanged(it.first, it.second) } viewModel.isLoading.observe(this, ::onLoadingStateChanged) viewModel.scrobblingInfo.observe(this, ::onScrobblingInfoChanged) viewModel.localSize.observe(this, ::onLocalSizeChanged) viewModel.relatedManga.observe(this, ::onRelatedMangaChanged) viewModel.favouriteCategories.observe(this, ::onFavoritesChanged) val menuInvalidator = MenuInvalidator(this) viewModel.isStatsAvailable.observe(this, menuInvalidator) viewModel.remoteManga.observe(this, menuInvalidator) viewModel.tags.observe(this, ::onTagsChanged) viewModel.chapters.observe(this, PrefetchObserver(this)) viewModel.onDownloadStarted .filterNot { appRouter.isChapterPagesSheetShown() } .observeEvent(this, DownloadStartedObserver(viewBinding.scrollView)) menuProvider = DetailsMenuProvider( activity = this, viewModel = viewModel, snackbarHost = viewBinding.scrollView, appShortcutManager = shortcutManager, ) addMenuProvider(menuProvider) } override fun onProvideAssistContent(outContent: AssistContent) { super.onProvideAssistContent(outContent) viewModel.getMangaOrNull()?.publicUrl?.toUriOrNull()?.let { outContent.webUri = it } } override fun isNsfwContent(): Flow = viewModel.manga.map { it?.contentRating == ContentRating.ADULT } override fun onClick(v: View) { when (v.id) { R.id.textView_source -> { val manga = viewModel.getMangaOrNull() ?: return router.openList(manga.source, null, null) } R.id.textView_local -> { val manga = viewModel.getMangaOrNull() ?: return router.showLocalInfoDialog(manga) } R.id.chip_favorite -> { val manga = viewModel.getMangaOrNull() ?: return router.showFavoriteDialog(manga) } R.id.imageView_cover -> { val manga = viewModel.getMangaOrNull() ?: return router.openImage( url = viewModel.coverUrl.value ?: return, source = manga.source, preview = CoilMemoryCacheKey.from(viewBinding.imageViewCover), anchor = v, ) } R.id.button_description_more -> { val tv = viewBinding.textViewDescription if (tv.context.isAnimationsEnabled) { tv.parentView?.let { TransitionManager.beginDelayedTransition(it) } } if (tv.maxLines in 1 until Integer.MAX_VALUE) { tv.maxLines = Integer.MAX_VALUE } else { tv.maxLines = resources.getInteger(R.integer.details_description_lines) } } R.id.button_scrobbling_more -> { router.showScrobblingSelectorSheet( manga = viewModel.getMangaOrNull() ?: return, scrobblerService = viewModel.scrobblingInfo.value.firstOrNull()?.scrobbler, ) } R.id.button_related_more -> { val manga = viewModel.getMangaOrNull() ?: return router.openRelated(manga) } R.id.textView_title -> { val title = viewModel.getMangaOrNull()?.title?.nullIfEmpty() ?: return buildAlertDialog(this) { setMessage(title) setNegativeButton(R.string.close, null) setPositiveButton(androidx.preference.R.string.copy) { _, _ -> copyToClipboard(getString(R.string.content_type_manga), title) } }.show() } } } override fun onAuthorClick(author: String) { router.showAuthorDialog(author, viewModel.getMangaOrNull()?.source ?: return) } override fun onChipClick(chip: Chip, data: Any?) { val tag = data as? MangaTag ?: return router.showTagDialog(tag) } override fun onItemClick(item: Bookmark, view: View) { router.openReader(ReaderIntent.Builder(view.context).bookmark(item).incognito().build()) Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show() } override fun onRefresh() { viewModel.reload() } override fun onDraw() { viewBinding.run { buttonDescriptionMore.isVisible = textViewDescription.maxLines == Int.MAX_VALUE || textViewDescription.isTextTruncated } } override fun onLayoutChange( v: View?, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int ) { with(viewBinding) { containerBottomSheet?.let { sheet -> val peekHeight = BottomSheetBehavior.from(sheet).peekHeight if (scrollView.paddingBottom != peekHeight) { scrollView.updatePadding(bottom = peekHeight) } } } } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val typeMask = WindowInsetsCompat.Type.systemBars() val barsInsets = insets.getInsets(typeMask) if (viewBinding.cardChapters != null) { // landscape viewBinding.cardChapters?.updateLayoutParams { topMargin = barsInsets.top + resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer) marginEnd = barsInsets.end(v) + resources.getDimensionPixelOffset(R.dimen.side_card_offset) bottomMargin = barsInsets.bottom + resources.getDimensionPixelOffset(R.dimen.side_card_offset) } viewBinding.scrollView.updatePaddingRelative( bottom = barsInsets.bottom, start = barsInsets.start(v), ) viewBinding.appbar.updatePaddingRelative( start = barsInsets.start(v), ) return insets.consume(v, typeMask, bottom = true, end = true) } else { viewBinding.navbarDim?.updateLayoutParams { height = barsInsets.bottom } return insets } } private fun onFavoritesChanged(categories: Set) { val chip = viewBinding.chipFavorite chip.setChipIconResource(if (categories.isEmpty()) R.drawable.ic_heart_outline else R.drawable.ic_heart) chip.text = if (categories.isEmpty()) { getString(R.string.add_to_favourites) } else { categories.joinToStringWithLimit(this, FAV_LABEL_LIMIT) { it.title } } } private fun onLocalSizeChanged(size: Long) { if (size == 0L) { infoBinding.textViewLocal.isVisible = false infoBinding.textViewLocalLabel.isVisible = false } else { infoBinding.textViewLocal.text = FileSize.BYTES.format(this, size) infoBinding.textViewLocal.isVisible = true infoBinding.textViewLocalLabel.isVisible = true } } private fun onRelatedMangaChanged(related: List) { if (related.isEmpty()) { viewBinding.groupRelated.isVisible = false return } val rv = viewBinding.recyclerViewRelated @Suppress("UNCHECKED_CAST") val adapter = (rv.adapter as? BaseListAdapter) ?: BaseListAdapter() .addDelegate( ListItemType.MANGA_GRID, mangaGridItemAD( sizeResolver = StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width)), ) { item, view -> router.openDetails(item.toMangaWithOverride()) }, ).also { rv.adapter = it } adapter.items = related viewBinding.groupRelated.isVisible = true } private fun onLoadingStateChanged(isLoading: Boolean) { viewBinding.swipeRefreshLayout.isRefreshing = isLoading } private fun onScrobblingInfoChanged(scrobblings: List) { var adapter = viewBinding.recyclerViewScrobbling.adapter as? ScrollingInfoAdapter viewBinding.groupScrobbling.isGone = scrobblings.isEmpty() if (adapter != null) { adapter.items = scrobblings } else { adapter = ScrollingInfoAdapter(router) adapter.items = scrobblings viewBinding.recyclerViewScrobbling.adapter = adapter viewBinding.recyclerViewScrobbling.addItemDecoration(ScrobblingItemDecoration()) } } private fun onMangaUpdated(details: MangaDetails) { val manga = details.toManga() with(viewBinding) { textViewTitle.text = manga.title textViewSubtitle.textAndVisible = manga.altTitles.joinToString("\n") textViewNsfw16.isVisible = manga.contentRating == ContentRating.SUGGESTIVE textViewNsfw18.isVisible = manga.contentRating == ContentRating.ADULT textViewDescription.text = details.description.ifNullOrEmpty { getString(R.string.no_description) } } with(infoBinding) { val translation = details.getLocale() infoBinding.textViewTranslation.textAndVisible = translation?.getDisplayLanguage(translation) ?.toTitleCase(translation) infoBinding.textViewTranslation.drawableStart = translation?.let { LocaleUtils.getEmojiFlag(it) }?.let { TextDrawable.compound(infoBinding.textViewTranslation, it) } infoBinding.textViewTranslationLabel.isVisible = infoBinding.textViewTranslation.isVisible textViewAuthor.textAndVisible = manga.getAuthorsString() textViewAuthorLabel.isVisible = textViewAuthor.isVisible if (manga.hasRating) { ratingBarRating.rating = manga.rating * ratingBarRating.numStars ratingBarRating.isVisible = true textViewRatingLabel.isVisible = true } else { ratingBarRating.isVisible = false textViewRatingLabel.isVisible = false } manga.state?.let { state -> textViewState.textAndVisible = resources.getString(state.titleResId) textViewStateLabel.isVisible = textViewState.isVisible } ?: run { textViewState.isVisible = false textViewStateLabel.isVisible = false } if (manga.source == LocalMangaSource || manga.source == UnknownMangaSource) { textViewSource.isVisible = false textViewSourceLabel.isVisible = false } else { textViewSource.textAndVisible = manga.source.getTitle(this@DetailsActivity) textViewSource.setTooltipCompat(manga.source.getSummary(this@DetailsActivity)) textViewSourceLabel.isVisible = textViewSource.isVisible == true } val faviconPlaceholderFactory = FaviconDrawable.Factory(R.style.FaviconDrawable_Chip) ImageRequest.Builder(this@DetailsActivity) .data(manga.source.faviconUri()) .lifecycle(this@DetailsActivity) .crossfade(false) .precision(Precision.EXACT) .size(resources.getDimensionPixelSize(materialR.dimen.m3_chip_icon_size)) .target(TextViewTarget(textViewSource, Gravity.START)) .placeholder(faviconPlaceholderFactory) .error(faviconPlaceholderFactory) .fallback(faviconPlaceholderFactory) .mangaSourceExtra(manga.source) .transformations(RoundedCornersTransformation(resources.getDimension(R.dimen.chip_icon_corner))) .allowRgb565(true) .enqueueWith(coil) } title = manga.title invalidateOptionsMenu() } private fun onMangaRemoved(manga: Manga) { Toast.makeText( this, getString(R.string._s_deleted_from_local_storage, manga.title), Toast.LENGTH_SHORT, ).show() finishAfterTransition() } private fun onHistoryChanged(info: HistoryInfo, isLoading: Boolean) = with(infoBinding) { textViewChapters.text = when { isLoading -> getString(R.string.loading_) info.currentChapter >= 0 -> getString( R.string.chapter_d_of_d, info.currentChapter + 1, info.totalChapters, ).withEstimatedTime(info.estimatedTime) info.totalChapters == 0 -> getString(R.string.no_chapters) info.totalChapters == -1 -> getString(R.string.error_occurred) else -> resources.getQuantityStringSafe(R.plurals.chapters, info.totalChapters, info.totalChapters) .withEstimatedTime(info.estimatedTime) } textViewProgress.textAndVisible = if (info.percent <= 0f) { null } else { val displayPercent = if (ReadingProgress.isCompleted(info.percent)) 100 else (info.percent * 100f).toInt() getString(R.string.percent_string_pattern, displayPercent.toString()) } progress.setProgressCompat( (progress.max * info.percent.coerceIn(0f, 1f)).roundToInt(), true, ) textViewProgressLabel.isVisible = info.history != null textViewProgress.isVisible = info.history != null progress.isVisible = info.history != null } private fun onTagsChanged(tags: Collection) { viewBinding.chipsTags.isVisible = tags.isNotEmpty() viewBinding.chipsTags.setChips(tags) } private fun loadCover(imageUrl: String?) { viewBinding.imageViewCover.setImageAsync(imageUrl, viewModel.getMangaOrNull()) } private fun String.withEstimatedTime(time: ReadingTime?): String { if (time == null) { return this } val timeFormatted = time.formatShort(resources) return getString(R.string.chapters_time_pattern, this, timeFormatted) } private fun Manga.getAuthorsString(): SpannedString? { if (authors.isEmpty()) { return null } return buildSpannedString { authors.forEach { a -> if (a.isNotEmpty()) { if (isNotEmpty()) { append(", ") } inSpans(AuthorSpan(this@DetailsActivity)) { append(a) } } } }.nullIfEmpty() } private class PrefetchObserver( private val context: Context, ) : FlowCollector?> { private var isCalled = false override suspend fun emit(value: List?) { if (value.isNullOrEmpty()) { return } if (!isCalled) { isCalled = true val item = value.find { it.isCurrent } ?: value.first() MangaPrefetchService.prefetchPages(context, item.chapter) } } } companion object { private const val FAV_LABEL_LIMIT = 16 } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsBottomSheetCallback.kt ================================================ package org.koitharu.kotatsu.details.ui import android.view.View import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import com.google.android.material.bottomsheet.BottomSheetBehavior class DetailsBottomSheetCallback( private val swipeRefreshLayout: SwipeRefreshLayout, private val navbarDimView: View, ) : BottomSheetBehavior.BottomSheetCallback() { override fun onStateChanged(bottomSheet: View, newState: Int) { swipeRefreshLayout.isEnabled = newState == BottomSheetBehavior.STATE_COLLAPSED } override fun onSlide(bottomSheet: View, slideOffset: Float) { navbarDimView.alpha = 1f - slideOffset.coerceAtLeast(0f) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsErrorObserver.kt ================================================ package org.koitharu.kotatsu.details.ui import com.google.android.material.snackbar.Snackbar import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException import org.koitharu.kotatsu.core.exceptions.resolve.ErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.isNetworkError import org.koitharu.kotatsu.core.util.ext.isSerializable import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.ParseException class DetailsErrorObserver( override val activity: DetailsActivity, private val viewModel: DetailsViewModel, resolver: ExceptionResolver?, ) : ErrorObserver( activity.viewBinding.scrollView, null, resolver, { isResolved -> if (isResolved) { viewModel.reload() } }, ) { override suspend fun emit(value: Throwable) { val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT) snackbar.setAnchorView(activity.viewBinding.containerBottomSheet) if (value is NotFoundException || value is UnsupportedSourceException) { snackbar.duration = Snackbar.LENGTH_INDEFINITE } when { canResolve(value) -> { snackbar.setAction(ExceptionResolver.getResolveStringId(value)) { resolve(value) } } value is ParseException -> { val router = router() if (router != null && value.isSerializable()) { snackbar.setAction(R.string.details) { router.showErrorDialog(value) } } } value.isNetworkError() -> { snackbar.setAction(R.string.try_again) { viewModel.reload() } } } snackbar.show() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt ================================================ package org.koitharu.kotatsu.details.ui import android.app.Activity import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.view.MenuProvider import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.launch import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.util.ext.isHttpUrl class DetailsMenuProvider( private val activity: FragmentActivity, private val viewModel: DetailsViewModel, private val snackbarHost: View, private val appShortcutManager: AppShortcutManager, ) : MenuProvider, ActivityResultCallback { private val activityForResultLauncher = activity.registerForActivityResult( ActivityResultContracts.StartActivityForResult(), this, ) private val router: AppRouter get() = activity.router override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.opt_details, menu) } override fun onPrepareMenu(menu: Menu) { val manga = viewModel.manga.value menu.findItem(R.id.action_share).isVisible = manga != null && AppRouter.isShareSupported(manga) menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != LocalMangaSource menu.findItem(R.id.action_delete).isVisible = manga?.source == LocalMangaSource menu.findItem(R.id.action_browser).isVisible = manga?.publicUrl?.isHttpUrl() == true menu.findItem(R.id.action_alternatives).isVisible = manga?.source != LocalMangaSource menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity) menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable menu.findItem(R.id.action_online).isVisible = viewModel.remoteManga.value != null menu.findItem(R.id.action_stats).isVisible = viewModel.isStatsAvailable.value } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { val manga = viewModel.getMangaOrNull() ?: return false when (menuItem.itemId) { R.id.action_share -> { router.showShareDialog(manga) } R.id.action_delete -> { buildAlertDialog(activity) { setTitle(R.string.delete_manga) setMessage(activity.getString(R.string.text_delete_local_manga, manga.title)) setPositiveButton(R.string.delete) { _, _ -> viewModel.deleteLocal() } setNegativeButton(android.R.string.cancel, null) }.show() } R.id.action_save -> { router.showDownloadDialog(manga, snackbarHost) } R.id.action_browser -> { router.openBrowser(url = manga.publicUrl, source = manga.source, title = manga.title) } R.id.action_online -> { router.openDetails(viewModel.remoteManga.value ?: return false) } R.id.action_related -> { router.openSearch(manga.title) } R.id.action_alternatives -> { router.openAlternatives(manga) } R.id.action_stats -> { router.showStatisticSheet(manga) } R.id.action_scrobbling -> { router.showScrobblingSelectorSheet(manga, null) } R.id.action_shortcut -> { activity.lifecycleScope.launch { if (!appShortcutManager.requestPinShortcut(manga)) { Snackbar.make(snackbarHost, R.string.operation_not_supported, Snackbar.LENGTH_SHORT) .show() } } } R.id.action_edit_override -> { val intent = AppRouter.overrideEditIntent(activity, manga) activityForResultLauncher.launch(intent) } else -> return false } return true } override fun onActivityResult(result: ActivityResult) { if (result.resultCode == Activity.RESULT_OK) { viewModel.reload() } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt ================================================ package org.koitharu.kotatsu.details.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.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.core.model.getPreferredBranch import org.koitharu.kotatsu.core.nav.MangaIntent import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.TriStateOption import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.computeSize import org.koitharu.kotatsu.core.util.ext.onEachWhile import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.details.domain.BranchComparator import org.koitharu.kotatsu.details.domain.DetailsInteractor import org.koitharu.kotatsu.details.domain.DetailsLoadUseCase import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase import org.koitharu.kotatsu.details.domain.ReadingTimeUseCase import org.koitharu.kotatsu.details.domain.RelatedMangaUseCase import org.koitharu.kotatsu.details.ui.model.HistoryInfo import org.koitharu.kotatsu.details.ui.model.MangaBranch import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.findById import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus import org.koitharu.kotatsu.stats.data.StatsRepository import javax.inject.Inject @HiltViewModel class DetailsViewModel @Inject constructor( private val historyRepository: HistoryRepository, bookmarksRepository: BookmarksRepository, settings: AppSettings, private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>, @LocalStorageChanges localStorageChanges: SharedFlow, downloadScheduler: DownloadWorker.Scheduler, interactor: DetailsInteractor, savedStateHandle: SavedStateHandle, deleteLocalMangaUseCase: DeleteLocalMangaUseCase, private val relatedMangaUseCase: RelatedMangaUseCase, private val mangaListMapper: MangaListMapper, private val detailsLoadUseCase: DetailsLoadUseCase, private val progressUpdateUseCase: ProgressUpdateUseCase, private val readingTimeUseCase: ReadingTimeUseCase, statsRepository: StatsRepository, ) : ChaptersPagesViewModel( settings = settings, interactor = interactor, bookmarksRepository = bookmarksRepository, historyRepository = historyRepository, downloadScheduler = downloadScheduler, deleteLocalMangaUseCase = deleteLocalMangaUseCase, localStorageChanges = localStorageChanges, ) { private val intent = MangaIntent(savedStateHandle) private var loadingJob: Job val mangaId = intent.mangaId init { mangaDetails.value = intent.manga?.let { MangaDetails(it) } } val history = historyRepository.observeOne(mangaId) .onEach { h -> readingState.value = h?.let(::ReaderState) }.withErrorHandling() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) val favouriteCategories = interactor.observeFavourite(mangaId) .withErrorHandling() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptySet()) val isStatsAvailable = statsRepository.observeHasStats(mangaId) .withErrorHandling() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) val remoteManga = MutableStateFlow(null) val historyInfo: StateFlow = combine( mangaDetails, selectedBranch, history, interactor.observeIncognitoMode(manga), ) { m, b, h, im -> val estimatedTime = readingTimeUseCase.invoke(m, b, h) HistoryInfo(m, b, h, im == TriStateOption.ENABLED, estimatedTime) }.withErrorHandling() .stateIn( scope = viewModelScope + Dispatchers.Default, started = SharingStarted.Eagerly, initialValue = HistoryInfo(null, null, null, false, null), ) val localSize = mangaDetails .map { it?.local } .distinctUntilChanged() .combine(localStorageChanges.onStart { emit(null) }) { x, _ -> x } .map { local -> if (local != null) { runCatchingCancellable { local.file.computeSize() }.getOrDefault(0L) } else { 0L } }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), 0L) val isScrobblingAvailable: Boolean get() = scrobblers.any { it.isEnabled } val scrobblingInfo: StateFlow> = interactor.observeScrobblingInfo(mangaId) .withErrorHandling() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) val relatedManga: StateFlow> = manga.mapLatest { if (it != null && settings.isRelatedMangaEnabled) { mangaListMapper.toListModelList( manga = relatedMangaUseCase(it).orEmpty(), mode = ListMode.GRID, ) } else { emptyList() } }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList()) val tags = manga.mapLatest { mangaListMapper.mapTags(it?.tags.orEmpty()) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) val branches: StateFlow> = combine( mangaDetails, selectedBranch, history, ) { m, b, h -> val c = m?.chapters if (c.isNullOrEmpty()) { return@combine emptyList() } val currentBranch = h?.let { m.allChapters.findById(it.chapterId) }?.branch c.map { x -> MangaBranch( name = x.key, count = x.value.size, isSelected = x.key == b, isCurrent = h != null && x.key == currentBranch, ) }.sortedWith(BranchComparator()) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) val selectedBranchValue: String? get() = selectedBranch.value init { loadingJob = doLoad(force = false) launchJob(Dispatchers.Default + SkipErrors) { val manga = mangaDetails.firstOrNull { !it?.chapters.isNullOrEmpty() } ?: return@launchJob val h = history.firstOrNull() if (h != null) { progressUpdateUseCase(manga.toManga()) } } launchJob(Dispatchers.Default) { val manga = mangaDetails.firstOrNull { it != null && it.isLocal } ?: return@launchJob remoteManga.value = interactor.findRemote(manga.toManga()) } } fun reload() { loadingJob.cancel() loadingJob = doLoad(force = true) } fun updateScrobbling(index: Int, rating: Float, status: ScrobblingStatus?) { val scrobbler = getScrobbler(index) ?: return launchJob(Dispatchers.Default) { scrobbler.updateScrobblingInfo( mangaId = mangaId, rating = rating, status = status, comment = null, ) } } fun unregisterScrobbling(index: Int) { val scrobbler = getScrobbler(index) ?: return launchJob(Dispatchers.Default) { scrobbler.unregisterScrobbling( mangaId = mangaId, ) } } fun removeFromHistory() { launchJob(Dispatchers.Default) { val handle = historyRepository.delete(setOf(mangaId)) onActionDone.call(ReversibleAction(R.string.removed_from_history, handle)) } } private fun doLoad(force: Boolean) = launchLoadingJob(Dispatchers.Default) { detailsLoadUseCase.invoke(intent, force) .onEachWhile { if (it.allChapters.isNotEmpty()) { val manga = it.toManga() // find default branch val hist = historyRepository.getOne(manga) selectedBranch.value = manga.getPreferredBranch(hist) true } else { false } }.collect { mangaDetails.value = it } } private fun getScrobbler(index: Int): Scrobbler? { val info = scrobblingInfo.value.getOrNull(index) val scrobbler = if (info != null) { scrobblers.find { it.scrobblerService == info.scrobbler && it.isEnabled } } else { null } if (scrobbler == null) { errorEvent.call(IllegalStateException("Scrobbler [$index] is not available")) } return scrobbler } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ReadButtonDelegate.kt ================================================ package org.koitharu.kotatsu.details.ui import android.content.Context import android.graphics.Color import android.text.style.DynamicDrawableSpan import android.text.style.ForegroundColorSpan import android.text.style.ImageSpan import android.text.style.RelativeSizeSpan import android.view.Menu import android.view.MenuItem import android.view.View import android.widget.Toast import androidx.appcompat.widget.PopupMenu import androidx.core.text.buildSpannedString import androidx.core.text.inSpans import androidx.core.view.MenuCompat import androidx.core.view.get import androidx.lifecycle.LifecycleOwner import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialSplitButton import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.flow.combine import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.ReaderIntent import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.details.ui.model.HistoryInfo class ReadButtonDelegate( private val splitButton: MaterialSplitButton, private val viewModel: DetailsViewModel, private val router: AppRouter, ) : View.OnClickListener, PopupMenu.OnMenuItemClickListener, PopupMenu.OnDismissListener { private val buttonRead = splitButton[0] as MaterialButton private val buttonMenu = splitButton[1] as MaterialButton private val context: Context get() = buttonRead.context override fun onClick(v: View) { when (v.id) { R.id.button_read -> openReader(isIncognitoMode = false) R.id.button_read_menu -> showMenu() } } override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { R.id.action_incognito -> openReader(isIncognitoMode = true) R.id.action_forget -> viewModel.removeFromHistory() R.id.action_download -> { router.showDownloadDialog( manga = setOf(viewModel.getMangaOrNull() ?: return false), snackbarHost = splitButton, ) } Menu.NONE -> { val branch = viewModel.branches.value.getOrNull(item.order) ?: return false viewModel.setSelectedBranch(branch.name) } else -> return false } return true } override fun onDismiss(menu: PopupMenu?) { buttonMenu.isChecked = false } fun attach(lifecycleOwner: LifecycleOwner) { buttonRead.setOnClickListener(this) buttonMenu.setOnClickListener(this) combine(viewModel.isLoading, viewModel.historyInfo, ::Pair) .observe(lifecycleOwner) { (isLoading, historyInfo) -> onHistoryChanged(isLoading, historyInfo) } } private fun showMenu() { val menu = PopupMenu(context, buttonMenu) menu.inflate(R.menu.popup_read) prepareMenu(menu.menu) menu.setOnMenuItemClickListener(this) menu.setForceShowIcon(true) menu.setOnDismissListener(this) if (menu.menu.hasVisibleItems()) { buttonMenu.isChecked = true menu.show() } else { buttonMenu.isChecked = false } } private fun prepareMenu(menu: Menu) { MenuCompat.setGroupDividerEnabled(menu, true) menu.populateBranchList() val history = viewModel.historyInfo.value menu.findItem(R.id.action_incognito)?.isVisible = !history.isIncognitoMode menu.findItem(R.id.action_forget)?.isVisible = history.history != null menu.findItem(R.id.action_download)?.isVisible = viewModel.getMangaOrNull()?.isLocal == false } private fun openReader(isIncognitoMode: Boolean) { val manga = viewModel.getMangaOrNull() ?: return if (viewModel.historyInfo.value.isChapterMissing) { Snackbar.make(buttonRead, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT) .show() // TODO } else { val intentBuilder = ReaderIntent.Builder(context) .manga(manga) .branch(viewModel.selectedBranchValue) if (isIncognitoMode) { intentBuilder.incognito() } router.openReader(intentBuilder.build()) if (isIncognitoMode) { Toast.makeText(context, R.string.incognito_mode, Toast.LENGTH_SHORT).show() } } } private fun onHistoryChanged(isLoading: Boolean, info: HistoryInfo) { val isChaptersLoading = isLoading && (info.totalChapters <= 0 || info.isChapterMissing) buttonRead.setText( when { isChaptersLoading -> R.string.loading_ info.isIncognitoMode -> R.string.incognito info.canContinue -> R.string._continue else -> R.string.read }, ) splitButton.isEnabled = !isChaptersLoading && info.isValid } private fun Menu.populateBranchList() { val branches = viewModel.branches.value if (branches.size <= 1) { return } for ((i, branch) in branches.withIndex()) { val title = buildSpannedString { if (branch.isCurrent) { inSpans( ImageSpan( context, R.drawable.ic_current_chapter, DynamicDrawableSpan.ALIGN_BASELINE, ), ) { append(' ') } append(' ') } append(branch.name ?: context.getString(R.string.system_default)) append(' ') append(' ') inSpans( ForegroundColorSpan( context.getThemeColor( android.R.attr.textColorSecondary, Color.LTGRAY, ), ), RelativeSizeSpan(0.74f), ) { append(branch.count.toString()) } } val item = add(R.id.group_branches, Menu.NONE, i, title) item.isCheckable = true item.isChecked = branch.isSelected } setGroupCheckable(R.id.group_branches, true, true) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/TitleScrollCoordinator.kt ================================================ package org.koitharu.kotatsu.details.ui import android.content.Context import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import androidx.core.view.doOnLayout import androidx.core.widget.NestedScrollView import org.koitharu.kotatsu.core.util.ext.findActivity import java.lang.ref.WeakReference class TitleScrollCoordinator( private val titleView: TextView, ) : NestedScrollView.OnScrollChangeListener { private val location = IntArray(2) private var activityRef: WeakReference? = null override fun onScrollChange(v: NestedScrollView, scrollX: Int, scrollY: Int, oldScrollX: Int, oldScrollY: Int) { val actionBar = getActivity(v.context)?.supportActionBar ?: return titleView.getLocationOnScreen(location) var top = location[1] + titleView.height v.getLocationOnScreen(location) top -= location[1] actionBar.setDisplayShowTitleEnabled(top < 0) } fun attach(scrollView: NestedScrollView) { scrollView.setOnScrollChangeListener(this) scrollView.doOnLayout { onScrollChange(scrollView, 0, 0, 0, 0) } } private fun getActivity(context: Context): AppCompatActivity? { activityRef?.get()?.let { if (!it.isDestroyed) return it } val activity = context.findActivity() as? AppCompatActivity if (activity == null || activity.isDestroyed) { return null } activityRef = WeakReference(activity) return activity } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterGridItemAD.kt ================================================ package org.koitharu.kotatsu.details.ui.adapter import android.graphics.Typeface import androidx.core.view.isVisible import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList import org.koitharu.kotatsu.core.util.ext.setTooltipCompat import org.koitharu.kotatsu.databinding.ItemChapterGridBinding import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.list.ui.model.ListModel fun chapterGridItemAD( clickListener: OnListItemClickListener, ) = adapterDelegateViewBinding( viewBinding = { inflater, parent -> ItemChapterGridBinding.inflate(inflater, parent, false) }, on = { item, _, _ -> item is ChapterListItem && item.isGrid }, ) { AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView) bind { payloads -> if (payloads.isEmpty()) { binding.textViewTitle.text = item.chapter.numberString() ?: "?" itemView.setTooltipCompat(item.chapter.title) } binding.imageViewNew.isVisible = item.isNew binding.imageViewCurrent.isVisible = item.isCurrent binding.imageViewBookmarked.isVisible = item.isBookmarked binding.imageViewDownloaded.isVisible = item.isDownloaded when { item.isCurrent -> { binding.textViewTitle.setTextColor(context.getThemeColorStateList(android.R.attr.textColorPrimary)) binding.textViewTitle.typeface = Typeface.DEFAULT_BOLD } item.isUnread -> { binding.textViewTitle.setTextColor(context.getThemeColorStateList(android.R.attr.textColorPrimary)) binding.textViewTitle.typeface = Typeface.DEFAULT } else -> { binding.textViewTitle.setTextColor(context.getThemeColorStateList(android.R.attr.textColorHint)) binding.textViewTitle.typeface = Typeface.DEFAULT } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt ================================================ package org.koitharu.kotatsu.details.ui.adapter import android.graphics.Typeface import androidx.core.content.ContextCompat import androidx.core.view.isVisible import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.drawableStart import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ItemChapterBinding import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.list.ui.model.ListModel import com.google.android.material.R as materialR fun chapterListItemAD( clickListener: OnListItemClickListener, ) = adapterDelegateViewBinding( viewBinding = { inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) }, on = { item, _, _ -> item is ChapterListItem && !item.isGrid }, ) { AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView) bind { binding.textViewTitle.text = item.getTitle(context.resources) binding.textViewDescription.textAndVisible = item.description when { item.isCurrent -> { binding.textViewTitle.drawableStart = ContextCompat.getDrawable(context, R.drawable.ic_current_chapter) binding.textViewTitle.setTextColor(context.getThemeColorStateList(android.R.attr.textColorPrimary)) binding.textViewDescription.setTextColor(context.getThemeColorStateList(android.R.attr.textColorPrimary)) binding.textViewTitle.typeface = Typeface.DEFAULT_BOLD binding.textViewDescription.typeface = Typeface.DEFAULT_BOLD } item.isUnread -> { binding.textViewTitle.drawableStart = if (item.isNew) { ContextCompat.getDrawable(context, R.drawable.ic_new) } else { null } binding.textViewTitle.setTextColor(context.getThemeColorStateList(android.R.attr.textColorPrimary)) binding.textViewDescription.setTextColor(context.getThemeColorStateList(materialR.attr.colorOutline)) binding.textViewTitle.typeface = Typeface.DEFAULT binding.textViewDescription.typeface = Typeface.DEFAULT } else -> { binding.textViewTitle.drawableStart = null binding.textViewTitle.setTextColor(context.getThemeColorStateList(android.R.attr.textColorHint)) binding.textViewDescription.setTextColor(context.getThemeColorStateList(android.R.attr.textColorHint)) binding.textViewTitle.typeface = Typeface.DEFAULT binding.textViewDescription.typeface = Typeface.DEFAULT } } binding.imageViewBookmarked.isVisible = item.isBookmarked binding.imageViewDownloaded.isVisible = item.isDownloaded } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt ================================================ package org.koitharu.kotatsu.details.ui.adapter import android.content.Context 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.details.ui.model.ChapterListItem import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListModel class ChaptersAdapter( onItemClickListener: OnListItemClickListener, ) : BaseListAdapter(), FastScroller.SectionIndexer { private var hasVolumes = false init { addDelegate(ListItemType.HEADER, listHeaderAD(null)) addDelegate(ListItemType.CHAPTER_LIST, chapterListItemAD(onItemClickListener)) addDelegate(ListItemType.CHAPTER_GRID, chapterGridItemAD(onItemClickListener)) } override suspend fun emit(value: List?) { super.emit(value) hasVolumes = value != null && value.any { it is ListHeader } } override fun getSectionText(context: Context, position: Int): CharSequence? { return if (hasVolumes) { findHeader(position)?.getText(context) } else { val chapter = (items.getOrNull(position) as? ChapterListItem)?.chapter ?: return null chapter.numberString() } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChaptersSelectionDecoration.kt ================================================ package org.koitharu.kotatsu.details.ui.adapter import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.RectF import android.view.View import androidx.cardview.widget.CardView import androidx.core.content.ContextCompat import androidx.core.graphics.ColorUtils import androidx.recyclerview.widget.RecyclerView import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration import org.koitharu.kotatsu.core.util.ext.getItem import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.details.ui.model.ChapterListItem import androidx.appcompat.R as appcompatR import com.google.android.material.R as materialR class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() { private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val radius = context.resources.getDimension(appcompatR.dimen.abc_control_corner_material) private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle) private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.chapter_check_offset) private val iconSize = context.resources.getDimensionPixelOffset(R.dimen.chapter_check_size) private val strokeColor = context.getThemeColor(appcompatR.attr.colorPrimary, Color.RED) private val fillColor = ColorUtils.setAlphaComponent( ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f), 0x74, ) init { paint.color = ColorUtils.setAlphaComponent( context.getThemeColor(appcompatR.attr.colorPrimary, Color.DKGRAY), 98, ) paint.style = Paint.Style.FILL hasBackground = true hasForeground = true isIncludeDecorAndMargins = false paint.strokeWidth = context.resources.getDimension(R.dimen.selection_stroke_width) checkIcon?.setTint(strokeColor) } override fun getItemId(parent: RecyclerView, child: View): Long { val holder = parent.getChildViewHolder(child) ?: return RecyclerView.NO_ID val item = holder.getItem(ChapterListItem::class.java) ?: return RecyclerView.NO_ID return item.chapter.id } override fun onDrawBackground( canvas: Canvas, parent: RecyclerView, child: View, bounds: RectF, state: RecyclerView.State, ) { if (child is CardView) { return } canvas.drawRoundRect(bounds, radius, radius, paint) } override fun onDrawForeground( canvas: Canvas, parent: RecyclerView, child: View, bounds: RectF, state: RecyclerView.State ) { if (child !is CardView) { return } val radius = child.radius paint.color = fillColor paint.style = Paint.Style.FILL canvas.drawRoundRect(bounds, radius, radius, paint) paint.color = strokeColor paint.style = Paint.Style.STROKE canvas.drawRoundRect(bounds, radius, radius, paint) checkIcon?.run { setBounds( (bounds.right - iconSize - iconOffset).toInt(), (bounds.top + iconOffset).toInt(), (bounds.right - iconOffset).toInt(), (bounds.top + iconOffset + iconSize).toInt(), ) draw(canvas) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt ================================================ package org.koitharu.kotatsu.details.ui.model import android.content.res.Resources import android.text.format.DateUtils import org.jsoup.internal.StringUtil.StringJoiner import org.koitharu.kotatsu.core.model.getLocalizedTitle import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.MangaChapter import kotlin.experimental.and data class ChapterListItem( val chapter: MangaChapter, val flags: Byte, ) : ListModel { private var cachedTitle: String? = null var description: String? = null private set get() { if (field != null) return field field = buildDescription() return field } var uploadDate: CharSequence? = null private set get() { if (field != null) return field if (chapter.uploadDate == 0L) return null field = DateUtils.getRelativeTimeSpanString( chapter.uploadDate, System.currentTimeMillis(), DateUtils.DAY_IN_MILLIS, ) return field } val isCurrent: Boolean get() = hasFlag(FLAG_CURRENT) val isUnread: Boolean get() = hasFlag(FLAG_UNREAD) val isDownloaded: Boolean get() = hasFlag(FLAG_DOWNLOADED) val isBookmarked: Boolean get() = hasFlag(FLAG_BOOKMARKED) val isNew: Boolean get() = hasFlag(FLAG_NEW) val isGrid: Boolean get() = hasFlag(FLAG_GRID) operator fun contains(query: String): Boolean = with(chapter) { title?.contains(query, ignoreCase = true) == true || numberString()?.contains(query) == true || volumeString()?.contains(query) == true } fun getTitle(resources: Resources): String { cachedTitle?.let { return it } return chapter.getLocalizedTitle(resources).also { cachedTitle = it } } private fun buildDescription(): String { val joiner = StringJoiner(" • ") chapter.numberString()?.let { joiner.add("#").append(it) } uploadDate?.let { date -> joiner.add(date.toString()) } chapter.scanlator?.let { scanlator -> if (scanlator.isNotBlank()) { joiner.add(scanlator) } } return joiner.complete() } private fun hasFlag(flag: Byte): Boolean { return (flags and flag) == flag } override fun areItemsTheSame(other: ListModel): Boolean { return other is ChapterListItem && chapter.id == other.chapter.id } override fun getChangePayload(previousState: ListModel): Any? { if (previousState !is ChapterListItem) { return super.getChangePayload(previousState) } return if (chapter == previousState.chapter && flags != previousState.flags) { flags } else { super.getChangePayload(previousState) } } companion object { const val FLAG_UNREAD: Byte = 2 const val FLAG_CURRENT: Byte = 4 const val FLAG_NEW: Byte = 8 const val FLAG_BOOKMARKED: Byte = 16 const val FLAG_DOWNLOADED: Byte = 32 const val FLAG_GRID: Byte = 64 } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/HistoryInfo.kt ================================================ package org.koitharu.kotatsu.details.ui.model import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.details.data.ReadingTime data class HistoryInfo( val totalChapters: Int, val currentChapter: Int, val history: MangaHistory?, val isIncognitoMode: Boolean, val isChapterMissing: Boolean, val canDownload: Boolean, val estimatedTime: ReadingTime?, ) { val isValid: Boolean get() = totalChapters >= 0 val canContinue get() = currentChapter >= 0 val percent: Float get() = if (history != null && (canContinue || isChapterMissing)) { history.percent } else { 0f } } fun HistoryInfo( manga: MangaDetails?, branch: String?, history: MangaHistory?, isIncognitoMode: Boolean, estimatedTime: ReadingTime?, ): HistoryInfo { val chapters = if (manga?.chapters?.isEmpty() == true) { emptyList() } else { manga?.chapters?.get(branch) } val currentChapter = if (history != null && !chapters.isNullOrEmpty()) { chapters.indexOfFirst { it.id == history.chapterId } } else { -2 } return HistoryInfo( totalChapters = chapters?.size ?: -1, currentChapter = currentChapter, history = history, isIncognitoMode = isIncognitoMode, isChapterMissing = history != null && manga?.isLoaded == true && manga.allChapters.none { it.id == history.chapterId }, canDownload = manga?.isLocal == false, estimatedTime = estimatedTime, ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt ================================================ package org.koitharu.kotatsu.details.ui.model import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_BOOKMARKED import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_CURRENT import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_DOWNLOADED import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_GRID import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_NEW import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_UNREAD import org.koitharu.kotatsu.parsers.model.MangaChapter import kotlin.experimental.or fun MangaChapter.toListItem( isCurrent: Boolean, isUnread: Boolean, isNew: Boolean, isDownloaded: Boolean, isBookmarked: Boolean, isGrid: Boolean, ): ChapterListItem { var flags: Byte = 0 if (isCurrent) flags = flags or FLAG_CURRENT if (isUnread) flags = flags or FLAG_UNREAD if (isNew) flags = flags or FLAG_NEW if (isBookmarked) flags = flags or FLAG_BOOKMARKED if (isDownloaded) flags = flags or FLAG_DOWNLOADED if (isGrid) flags = flags or FLAG_GRID return ChapterListItem( chapter = this, flags = flags, ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/MangaBranch.kt ================================================ package org.koitharu.kotatsu.details.ui.model import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.model.ListModel data class MangaBranch( val name: String?, val count: Int, val isSelected: Boolean, val isCurrent: Boolean, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is MangaBranch && other.name == name } override fun getChangePayload(previousState: ListModel): Any? { return if (previousState is MangaBranch && previousState.isSelected != isSelected) { ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED } else { super.getChangePayload(previousState) } } override fun toString(): String { return "$name: $count" } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChapterPagesMenuProvider.kt ================================================ package org.koitharu.kotatsu.details.ui.pager import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.activity.OnBackPressedCallback import androidx.appcompat.widget.SearchView import androidx.core.view.MenuProvider import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.slider.LabelFormatter import com.google.android.material.slider.Slider import com.google.android.material.slider.TickVisibilityMode import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.util.ext.setValueRounded import org.koitharu.kotatsu.core.util.progress.IntPercentLabelFormatter import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet.Companion.TAB_BOOKMARKS import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet.Companion.TAB_CHAPTERS import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet.Companion.TAB_PAGES import java.lang.ref.WeakReference class ChapterPagesMenuProvider( private val viewModel: ChaptersPagesViewModel, private val sheet: BaseAdaptiveSheet<*>, private val pager: ViewPager2, private val settings: AppSettings, ) : OnBackPressedCallback(false), MenuProvider, SearchView.OnQueryTextListener, MenuItem.OnActionExpandListener, Slider.OnChangeListener { private var expandedItemRef: WeakReference? = null override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { val tab = getCurrentTab() when (tab) { TAB_CHAPTERS -> { menuInflater.inflate(R.menu.opt_chapters, menu) menu.findItem(R.id.action_search)?.run { setOnActionExpandListener(this@ChapterPagesMenuProvider) (actionView as? SearchView)?.setupChaptersSearchView() } menu.findItem(R.id.action_search)?.isVisible = viewModel.emptyReason.value == null menu.findItem(R.id.action_reversed)?.isChecked = viewModel.isChaptersReversed.value == true menu.findItem(R.id.action_grid_view)?.isChecked = viewModel.isChaptersInGridView.value == true menu.findItem(R.id.action_downloaded)?.let { menuItem -> menuItem.isVisible = viewModel.mangaDetails.value?.local != null menuItem.isChecked = viewModel.isDownloadedOnly.value == true } } TAB_PAGES, TAB_BOOKMARKS -> { menuInflater.inflate(R.menu.opt_pages, menu) menu.findItem(R.id.action_grid_size)?.run { setOnActionExpandListener(this@ChapterPagesMenuProvider) (actionView as? Slider)?.setupPagesSizeSlider() } } } } override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { R.id.action_reversed -> { viewModel.setChaptersReversed(!menuItem.isChecked) true } R.id.action_grid_view -> { viewModel.setChaptersInGridView(!menuItem.isChecked) true } R.id.action_downloaded -> { viewModel.isDownloadedOnly.value = !menuItem.isChecked true } else -> false } override fun handleOnBackPressed() { expandedItemRef?.get()?.collapseActionView() } override fun onMenuItemActionExpand(item: MenuItem): Boolean { expandedItemRef = WeakReference(item) sheet.expandAndLock() isEnabled = true return true } override fun onMenuItemActionCollapse(item: MenuItem): Boolean { expandedItemRef = null isEnabled = false (item.actionView as? SearchView)?.setQuery("", false) viewModel.performChapterSearch(null) sheet.unlock() return true } override fun onQueryTextSubmit(query: String?): Boolean = false override fun onQueryTextChange(newText: String?): Boolean { viewModel.performChapterSearch(newText) return true } override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { if (fromUser) { settings.gridSizePages = value.toInt() } } private fun SearchView.setupChaptersSearchView() { setOnQueryTextListener(this@ChapterPagesMenuProvider) setIconifiedByDefault(false) queryHint = context.getString(R.string.search_chapters) } private fun Slider.setupPagesSizeSlider() { valueFrom = 50f valueTo = 150f stepSize = 5f tickVisibilityMode = TickVisibilityMode.TICK_VISIBILITY_HIDDEN labelBehavior = LabelFormatter.LABEL_FLOATING setLabelFormatter(IntPercentLabelFormatter(context)) setValueRounded(settings.gridSizePages.toFloat()) addOnChangeListener(this@ChapterPagesMenuProvider) } private fun getCurrentTab(): Int { var page = pager.currentItem if (page > 0 && pager.adapter?.itemCount == 2) { // no Pages page page++ // shift } return page } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesAdapter.kt ================================================ package org.koitharu.kotatsu.details.ui.pager import androidx.fragment.app.Fragment import androidx.viewpager2.adapter.FragmentStateAdapter import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import org.koitharu.kotatsu.R import org.koitharu.kotatsu.details.ui.pager.bookmarks.BookmarksFragment import org.koitharu.kotatsu.details.ui.pager.chapters.ChaptersFragment import org.koitharu.kotatsu.details.ui.pager.pages.PagesFragment class ChaptersPagesAdapter( fragment: Fragment, val isPagesTabEnabled: Boolean, ) : FragmentStateAdapter(fragment), TabLayoutMediator.TabConfigurationStrategy { override fun getItemCount(): Int = if (isPagesTabEnabled) 3 else 2 override fun createFragment(position: Int): Fragment = when (position) { 0 -> ChaptersFragment() 1 -> if (isPagesTabEnabled) PagesFragment() else BookmarksFragment() 2 -> BookmarksFragment() else -> throw IllegalArgumentException("Invalid position $position") } override fun onConfigureTab(tab: TabLayout.Tab, position: Int) { tab.setIcon( when (position) { 0 -> R.drawable.ic_list 1 -> if (isPagesTabEnabled) R.drawable.ic_grid else R.drawable.ic_bookmark 2 -> R.drawable.ic_bookmark else -> 0 }, ) // tab.setText( // when (position) { // 0 -> R.string.chapters // 1 -> if (isPagesTabEnabled) R.string.pages else R.string.bookmarks // 2 -> R.string.bookmarks // else -> 0 // }, // ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesSheet.kt ================================================ package org.koitharu.kotatsu.details.ui.pager import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.appcompat.view.ActionMode import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver 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.sheet.AdaptiveSheetBehavior.Companion.STATE_COLLAPSED import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior.Companion.STATE_DRAGGING import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior.Companion.STATE_EXPANDED import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior.Companion.STATE_SETTLING import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.ui.util.ActionModeListener import org.koitharu.kotatsu.core.ui.util.MenuInvalidator import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.util.ext.doOnPageChanged import org.koitharu.kotatsu.core.util.ext.findCurrentPagerFragment import org.koitharu.kotatsu.core.util.ext.menuView import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.recyclerView import org.koitharu.kotatsu.core.util.ext.smoothScrollToTop import org.koitharu.kotatsu.databinding.SheetChaptersPagesBinding import org.koitharu.kotatsu.details.ui.DetailsViewModel import org.koitharu.kotatsu.details.ui.ReadButtonDelegate import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver import javax.inject.Inject @AndroidEntryPoint class ChaptersPagesSheet : BaseAdaptiveSheet(), TabLayout.OnTabSelectedListener, ActionModeListener, AdaptiveSheetCallback { @Inject lateinit var settings: AppSettings private val viewModel by ChaptersPagesViewModel.ActivityVMLazy(this) override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetChaptersPagesBinding { return SheetChaptersPagesBinding.inflate(inflater, container, false) } override fun onViewBindingCreated(binding: SheetChaptersPagesBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) disableFitToContents() val args = arguments ?: Bundle.EMPTY var defaultTab = args.getInt(AppRouter.KEY_TAB, settings.defaultDetailsTab) val adapter = ChaptersPagesAdapter(this, settings.isPagesTabEnabled) if (!adapter.isPagesTabEnabled) { defaultTab = (defaultTab - 1).coerceAtLeast(TAB_CHAPTERS) } (viewModel as? DetailsViewModel)?.let { dvm -> ReadButtonDelegate(binding.splitButtonRead, dvm, router).attach(viewLifecycleOwner) } binding.pager.offscreenPageLimit = adapter.itemCount binding.pager.recyclerView?.isNestedScrollingEnabled = false binding.pager.adapter = adapter binding.pager.doOnPageChanged(::onPageChanged) TabLayoutMediator(binding.tabs, binding.pager, adapter).attach() binding.tabs.addOnTabSelectedListener(this) binding.pager.setCurrentItem(defaultTab, false) binding.tabs.isVisible = adapter.itemCount > 1 val menuProvider = ChapterPagesMenuProvider(viewModel, this, binding.pager, settings) onBackPressedDispatcher.addCallback(viewLifecycleOwner, menuProvider) binding.toolbar.addMenuProvider(menuProvider) val menuInvalidator = MenuInvalidator(binding.toolbar) viewModel.isChaptersReversed.observe(viewLifecycleOwner, menuInvalidator) viewModel.isChaptersInGridView.observe(viewLifecycleOwner, menuInvalidator) viewModel.isDownloadedOnly.observe(viewLifecycleOwner, menuInvalidator) actionModeDelegate?.addListener(this, viewLifecycleOwner) addSheetCallback(this, viewLifecycleOwner) viewModel.newChaptersCount.observe(viewLifecycleOwner, ::onNewChaptersChanged) if (dialog != null) { viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.pager, this)) viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.pager)) viewModel.onDownloadStarted.observeEvent(viewLifecycleOwner, DownloadStartedObserver(binding.pager)) } else { PeekHeightController(arrayOf(binding.headerBar, binding.toolbar)).attach() } } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = insets override fun onStateChanged(sheet: View, newState: Int) { val binding = viewBinding ?: return binding.layoutTouchBlock.isTouchEventsAllowed = dialog != null || newState != STATE_COLLAPSED if (newState == STATE_DRAGGING || newState == STATE_SETTLING) { return } val isActionModeStarted = actionModeDelegate?.isActionModeStarted == true binding.toolbar.menuView?.isVisible = newState == STATE_EXPANDED && !isActionModeStarted binding.splitButtonRead.isVisible = newState != STATE_EXPANDED && !isActionModeStarted && viewModel is DetailsViewModel } override fun onActionModeStarted(mode: ActionMode) { viewBinding?.toolbar?.menuView?.isVisible = false view?.post(::expandAndLock) } override fun onActionModeFinished(mode: ActionMode) { unlock() val state = behavior?.state ?: STATE_EXPANDED viewBinding?.toolbar?.menuView?.isVisible = state != STATE_COLLAPSED } override fun onTabSelected(tab: TabLayout.Tab?) = Unit override fun onTabUnselected(tab: TabLayout.Tab?) = Unit override fun onTabReselected(tab: TabLayout.Tab?) { val f = childFragmentManager.findCurrentPagerFragment( viewBinding?.pager ?: return, ) as? RecyclerViewOwner ?: return f.recyclerView?.smoothScrollToTop() } override fun expandAndLock() { super.expandAndLock() adjustLockState() } override fun unlock() { super.unlock() adjustLockState() } private fun adjustLockState() { viewBinding?.run { pager.isUserInputEnabled = !isLocked tabs.visibility = when { (pager.adapter?.itemCount ?: 0) <= 1 -> View.GONE isLocked -> View.INVISIBLE else -> View.VISIBLE } } } private fun onPageChanged(position: Int) { viewBinding?.toolbar?.invalidateMenu() settings.lastDetailsTab = position } private fun onNewChaptersChanged(counter: Int) { val tab = viewBinding?.tabs?.getTabAt(0) ?: return if (counter == 0) { tab.removeBadge() } else { val badge = tab.orCreateBadge badge.number = counter } } companion object { const val TAB_CHAPTERS = 0 const val TAB_PAGES = 1 const val TAB_BOOKMARKS = 2 } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesViewModel.kt ================================================ package org.koitharu.kotatsu.details.ui.pager import android.app.Activity import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.plus import okio.FileNotFoundException import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.core.model.toChipModel import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.util.LocaleStringComparator import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.combine import org.koitharu.kotatsu.core.util.ext.requireValue import org.koitharu.kotatsu.core.util.ext.sortedWithSafe import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.details.domain.DetailsInteractor import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsViewModel import org.koitharu.kotatsu.details.ui.mapChapters import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.download.ui.worker.DownloadTask import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderViewModel abstract class ChaptersPagesViewModel( @JvmField protected val settings: AppSettings, @JvmField protected val interactor: DetailsInteractor, private val bookmarksRepository: BookmarksRepository, private val historyRepository: HistoryRepository, private val downloadScheduler: DownloadWorker.Scheduler, private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase, private val localStorageChanges: SharedFlow, ) : BaseViewModel() { val mangaDetails = MutableStateFlow(null) val readingState = MutableStateFlow(null) val onActionDone = MutableEventFlow() val onDownloadStarted = MutableEventFlow() val onMangaRemoved = MutableEventFlow() private val chaptersQuery = MutableStateFlow("") val selectedBranch = MutableStateFlow(null) val manga = mangaDetails.map { x -> x?.toManga() } .withErrorHandling() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) val coverUrl = mangaDetails.map { x -> x?.coverUrl } .withErrorHandling() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) val isChaptersReversed = settings.observeAsStateFlow( scope = viewModelScope + Dispatchers.Default, key = AppSettings.KEY_REVERSE_CHAPTERS, valueProducer = { isChaptersReverse }, ) val isChaptersInGridView = settings.observeAsStateFlow( scope = viewModelScope + Dispatchers.Default, key = AppSettings.KEY_GRID_VIEW_CHAPTERS, valueProducer = { isChaptersGridView }, ) val isDownloadedOnly = MutableStateFlow(false) val newChaptersCount = mangaDetails.flatMapLatest { d -> if (d?.isLocal == false) { interactor.observeNewChapters(d.id) } else { flowOf(0) } }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0) val emptyReason: StateFlow = combine( mangaDetails, isLoading, onError.onStart { emit(null) }, ) { details, loading, error -> when { details == null || loading -> null details.chapters.isNotEmpty() -> null details.toManga().state == MangaState.RESTRICTED -> EmptyMangaReason.RESTRICTED error != null -> EmptyMangaReason.LOADING_ERROR else -> EmptyMangaReason.NO_CHAPTERS } }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(), null) val bookmarks = mangaDetails.flatMapLatest { if (it != null) { bookmarksRepository.observeBookmarks(it.toManga()).withErrorHandling() } else { flowOf(emptyList()) } }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList()) val chapters = combine( combine( mangaDetails, readingState.map { it?.chapterId ?: 0L }.distinctUntilChanged(), selectedBranch, newChaptersCount, bookmarks, isChaptersInGridView, isDownloadedOnly, ) { manga, currentChapterId, branch, news, bookmarks, grid, downloadedOnly -> manga?.mapChapters( currentChapterId = currentChapterId, newCount = news, branch = branch, bookmarks = bookmarks, isGrid = grid, isDownloadedOnly = downloadedOnly, ).orEmpty() }, isChaptersReversed, chaptersQuery, ) { list, reversed, query -> (if (reversed) list.asReversed() else list).filterSearch(query) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) val quickFilter = combine( mangaDetails, selectedBranch, ) { details, branch -> val branches = details?.chapters?.toList()?.sortedWithSafe( compareBy(LocaleStringComparator()) { it.first }, ).orEmpty() if (branches.size > 1) { branches.map { val option = ListFilterOption.Branch(titleText = it.first, chaptersCount = it.second.size) option.toChipModel(isChecked = it.first == branch) } } else { emptyList() } } init { launchJob(Dispatchers.Default) { localStorageChanges .collect { onDownloadComplete(it) } } } fun setChaptersReversed(newValue: Boolean) { settings.isChaptersReverse = newValue } fun setChaptersInGridView(newValue: Boolean) { settings.isChaptersGridView = newValue } fun setSelectedBranch(branch: String?) { selectedBranch.value = branch } fun performChapterSearch(query: String?) { chaptersQuery.value = query?.trim().orEmpty() } fun getMangaOrNull(): Manga? = mangaDetails.value?.toManga() fun requireManga() = mangaDetails.requireValue().toManga() fun markChapterAsCurrent(chapterId: Long) { launchJob(Dispatchers.Default) { val manga = mangaDetails.requireValue() val chapters = checkNotNull(manga.chapters[selectedBranch.value]) val chapterIndex = chapters.indexOfFirst { it.id == chapterId } check(chapterIndex in chapters.indices) { "Chapter not found" } val percent = chapterIndex / chapters.size.toFloat() historyRepository.addOrUpdate( manga = manga.toManga(), chapterId = chapterId, page = 0, scroll = 0, percent = percent, force = true, ) } } fun download(chaptersIds: Set?, allowMeteredNetwork: Boolean) { launchJob(Dispatchers.Default) { val manga = requireManga() val task = DownloadTask( mangaId = manga.id, isPaused = false, isSilent = false, chaptersIds = chaptersIds?.toLongArray(), destination = null, format = null, allowMeteredNetwork = allowMeteredNetwork, ) downloadScheduler.schedule(setOf(manga to task)) onDownloadStarted.call(Unit) } } fun deleteLocal() { val m = mangaDetails.value?.local?.manga if (m == null) { errorEvent.call(FileNotFoundException()) return } launchLoadingJob(Dispatchers.Default) { deleteLocalMangaUseCase(m) onMangaRemoved.call(m) } } private fun List.filterSearch(query: String): List { if (query.isEmpty() || this.isEmpty()) { return this } return filter { it.contains(query) } } private suspend fun onDownloadComplete(downloadedManga: LocalManga?) { downloadedManga ?: return mangaDetails.update { interactor.updateLocal(it, downloadedManga) } } class ActivityVMLazy( private val fragment: Fragment, ) : Lazy { private var cached: ChaptersPagesViewModel? = null override val value: ChaptersPagesViewModel get() { val viewModel = cached return if (viewModel == null) { val activity = fragment.requireActivity() val vmClass = getViewModelClass(activity) ViewModelProvider.create( store = activity.viewModelStore, factory = activity.defaultViewModelProviderFactory, extras = activity.defaultViewModelCreationExtras, )[vmClass].also { cached = it } } else { viewModel } } override fun isInitialized(): Boolean = cached != null private fun getViewModelClass(activity: Activity) = when (activity) { is ReaderActivity -> ReaderViewModel::class.java is DetailsActivity -> DetailsViewModel::class.java else -> error("Wrong activity ${activity.javaClass.simpleName} for ${ChaptersPagesViewModel::class.java.simpleName}") } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/EmptyMangaReason.kt ================================================ package org.koitharu.kotatsu.details.ui.pager import androidx.annotation.StringRes import org.koitharu.kotatsu.R enum class EmptyMangaReason( @StringRes val msgResId: Int, ) { NO_CHAPTERS(R.string.no_chapters_in_manga), LOADING_ERROR(R.string.chapters_load_failed), RESTRICTED(R.string.manga_restricted_description), } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/PeekHeightController.kt ================================================ package org.koitharu.kotatsu.details.ui.pager import android.view.View import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.ancestors import com.google.android.material.bottomsheet.BottomSheetBehavior class PeekHeightController( private val views: Array, ) : View.OnLayoutChangeListener, OnApplyWindowInsetsListener { private var behavior: BottomSheetBehavior<*>? = null fun attach() { behavior = findBehavior() ?: return views.forEach { v -> v.addOnLayoutChangeListener(this) } ViewCompat.setOnApplyWindowInsetsListener(views.first(), this) } override fun onLayoutChange( v: View?, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int ) { if (top != oldTop || bottom != oldBottom) { updatePeekHeight() } } override fun onApplyWindowInsets( v: View, insets: WindowInsetsCompat ): WindowInsetsCompat { updatePeekHeight() return insets } private fun updatePeekHeight() { behavior?.peekHeight = views.sumOf { it.height } + getBottomInset() } private fun getBottomInset(): Int = ViewCompat.getRootWindowInsets(views.first()) ?.getInsets(WindowInsetsCompat.Type.navigationBars()) ?.bottom ?: 0 private fun findBehavior(): BottomSheetBehavior<*>? { return views.first().ancestors.firstNotNullOfOrNull { ((it as? View)?.layoutParams as? CoordinatorLayout.LayoutParams)?.behavior as? BottomSheetBehavior<*> } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/bookmarks/BookmarksFragment.kt ================================================ package org.koitharu.kotatsu.details.ui.pager.bookmarks 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 androidx.appcompat.view.ActionMode import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.viewModels import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.ui.BookmarksSelectionDecoration 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.dismissParentDialog 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.util.PagerNestedScrollHelper import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner 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.findParentCallback 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.FragmentMangaBookmarksBinding import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel import org.koitharu.kotatsu.list.ui.GridSpanResolver import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.reader.ui.PageSaveHelper import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback import javax.inject.Inject @AndroidEntryPoint class BookmarksFragment : BaseFragment(), OnListItemClickListener, RecyclerViewOwner, ListSelectionController.Callback { private val activityViewModel by ChaptersPagesViewModel.ActivityVMLazy(this) private val viewModel by viewModels() @Inject lateinit var settings: AppSettings @Inject lateinit var pageSaveHelperFactory: PageSaveHelper.Factory override val recyclerView: RecyclerView? get() = viewBinding?.recyclerView private lateinit var pageSaveHelper: PageSaveHelper private var bookmarksAdapter: BookmarksAdapter? = null private var spanResolver: GridSpanResolver? = null private var selectionController: ListSelectionController? = null private val spanSizeLookup = SpanSizeLookup() private val listCommitCallback = Runnable { spanSizeLookup.invalidateCache() } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) pageSaveHelper = pageSaveHelperFactory.create(this) activityViewModel.mangaDetails.observe(this, viewModel) } override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentMangaBookmarksBinding { return FragmentMangaBookmarksBinding.inflate(inflater, container, false) } override fun onViewBindingCreated(binding: FragmentMangaBookmarksBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) spanResolver = GridSpanResolver(binding.root.resources) selectionController = ListSelectionController( appCompatDelegate = checkNotNull(findAppCompatDelegate()), decoration = BookmarksSelectionDecoration(binding.root.context), registryOwner = this, callback = this, ) bookmarksAdapter = BookmarksAdapter( clickListener = this@BookmarksFragment, headerClickListener = null, ) viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) // before rv initialization with(binding.recyclerView) { addItemDecoration(TypedListSpacingDecoration(context, false)) setHasFixedSize(true) PagerNestedScrollHelper(this).bind(viewLifecycleOwner) adapter = bookmarksAdapter addOnLayoutChangeListener(spanResolver) (layoutManager as GridLayoutManager).let { it.spanSizeLookup = spanSizeLookup it.spanCount = checkNotNull(spanResolver).spanCount } selectionController?.attachToRecyclerView(this) } viewModel.content.observe(viewLifecycleOwner) { bookmarksAdapter?.setItems(it, listCommitCallback) } 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 viewBinding?.recyclerView?.setPadding( barsInsets.left, barsInsets.top, barsInsets.right, barsInsets.bottom, ) return insets.consumeAllSystemBarsInsets() } override fun onDestroyView() { spanResolver = null bookmarksAdapter = null selectionController = null spanSizeLookup.invalidateCache() super.onDestroyView() } override fun onItemClick(item: Bookmark, view: View) { if (selectionController?.onItemClick(item.pageId) == true) { return } val listener = findParentCallback(ReaderNavigationCallback::class.java) if (listener != null && listener.onBookmarkSelected(item)) { dismissParentDialog() } else { val intent = ReaderIntent.Builder(view.context) .manga(activityViewModel.getMangaOrNull() ?: return) .bookmark(item) .incognito() .build() router.openReader(intent) } } 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 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 fun onGridScaleChanged(scale: Float) { spanSizeLookup.invalidateCache() spanResolver?.setGridSize(scale, requireViewBinding().recyclerView) } private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() { 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 } } fun invalidateCache() { invalidateSpanGroupIndexCache() invalidateSpanIndexCache() } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/bookmarks/BookmarksViewModel.kt ================================================ package org.koitharu.kotatsu.details.ui.pager.bookmarks import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest 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.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsStateFlow 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.requireValue import org.koitharu.kotatsu.details.data.MangaDetails 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.parsers.model.Manga import org.koitharu.kotatsu.reader.ui.PageSaveHelper import javax.inject.Inject @HiltViewModel class BookmarksViewModel @Inject constructor( private val bookmarksRepository: BookmarksRepository, settings: AppSettings, ) : BaseViewModel(), FlowCollector { private val manga = MutableStateFlow(null) val onActionDone = MutableEventFlow() val gridScale = settings.observeAsStateFlow( scope = viewModelScope + Dispatchers.Default, key = AppSettings.KEY_GRID_SIZE_PAGES, valueProducer = { gridSizePages / 100f }, ) val content: StateFlow> = manga.filterNotNull().flatMapLatest { m -> bookmarksRepository.observeBookmarks(m) .map { mapList(m, it) } }.withErrorHandling() .filterNotNull() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState)) override suspend fun emit(value: MangaDetails?) { manga.value = value?.toManga() } fun removeBookmarks(ids: Set) { launchJob(Dispatchers.Default) { val handle = bookmarksRepository.removeBookmarks(ids) onActionDone.call(ReversibleAction(R.string.bookmarks_removed, handle)) } } fun savePages(pageSaveHelper: PageSaveHelper, ids: Set) { launchLoadingJob(Dispatchers.Default) { val m = manga.requireValue() val tasks = content.value.mapNotNull { if (it !is Bookmark || it.pageId !in ids) return@mapNotNull null PageSaveHelper.Task( manga = m, 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(manga: Manga, bookmarks: List): List? { val chapters = manga.chapters ?: return null val bookmarksMap = bookmarks.groupBy { it.chapterId } val result = ArrayList(bookmarks.size + bookmarksMap.size) for (chapter in chapters) { val b = bookmarksMap[chapter.id] if (b.isNullOrEmpty()) { continue } result += ListHeader(chapter) result.addAll(b) } if (result.isEmpty()) { result.add( EmptyState( icon = 0, textPrimary = R.string.no_bookmarks_yet, textSecondary = R.string.no_bookmarks_summary, actionStringRes = 0, ), ) } return result } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChapterGridSpanHelper.kt ================================================ package org.koitharu.kotatsu.details.ui.pager.chapters import android.view.View import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import org.koitharu.kotatsu.R import org.koitharu.kotatsu.list.ui.adapter.ListItemType import kotlin.math.roundToInt class ChapterGridSpanHelper private constructor() : View.OnLayoutChangeListener { override fun onLayoutChange( v: View?, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int ) { val rv = v as? RecyclerView ?: return if (rv.width > 0) { apply(rv) } } private fun apply(rv: RecyclerView) { (rv.layoutManager as? GridLayoutManager)?.spanCount = getSpanCount(rv) } class SpanSizeLookup( private val recyclerView: RecyclerView ) : GridLayoutManager.SpanSizeLookup() { override fun getSpanSize(position: Int): Int { return when (recyclerView.adapter?.getItemViewType(position)) { ListItemType.CHAPTER_LIST.ordinal, // for smooth transition ListItemType.HEADER.ordinal -> getTotalSpans() else -> 1 } } private fun getTotalSpans() = (recyclerView.layoutManager as? GridLayoutManager)?.spanCount ?: 1 } companion object { fun attach(view: RecyclerView) { val helper = ChapterGridSpanHelper() view.addOnLayoutChangeListener(helper) helper.apply(view) } fun getSpanCount(view: RecyclerView): Int { val cellWidth = view.resources.getDimension(R.dimen.chapter_grid_width) val estimatedCount = (view.width / cellWidth).roundToInt() return estimatedCount.coerceAtLeast(2) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt ================================================ package org.koitharu.kotatsu.details.ui.pager.chapters import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.WindowInsetsCompat import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.updatePadding import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.chip.Chip import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.ReaderIntent import org.koitharu.kotatsu.core.nav.dismissParentDialog import org.koitharu.kotatsu.core.nav.router 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.util.PagerNestedScrollHelper import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate import org.koitharu.kotatsu.core.util.ext.findParentCallback import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.databinding.FragmentChaptersBinding import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel import org.koitharu.kotatsu.details.ui.withVolumeHeaders import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback import org.koitharu.kotatsu.reader.ui.ReaderState import kotlin.math.roundToInt @AndroidEntryPoint class ChaptersFragment : BaseFragment(), OnListItemClickListener, RecyclerViewOwner, ChipsView.OnChipClickListener { private val viewModel by ChaptersPagesViewModel.ActivityVMLazy(this) private var chaptersAdapter: ChaptersAdapter? = null private var selectionController: ListSelectionController? = null override val recyclerView: RecyclerView? get() = viewBinding?.recyclerViewChapters override fun onCreateViewBinding( inflater: LayoutInflater, container: ViewGroup?, ) = FragmentChaptersBinding.inflate(inflater, container, false) override fun onViewBindingCreated(binding: FragmentChaptersBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) chaptersAdapter = ChaptersAdapter(this) selectionController = ListSelectionController( appCompatDelegate = checkNotNull(findAppCompatDelegate()), decoration = ChaptersSelectionDecoration(binding.root.context), registryOwner = this, callback = ChaptersSelectionCallback(viewModel, router, binding.recyclerViewChapters), ) viewModel.isChaptersInGridView.observe(viewLifecycleOwner) { chaptersInGridView -> binding.recyclerViewChapters.layoutManager = if (chaptersInGridView) { GridLayoutManager(context, ChapterGridSpanHelper.getSpanCount(binding.recyclerViewChapters)).apply { spanSizeLookup = ChapterGridSpanHelper.SpanSizeLookup(binding.recyclerViewChapters) } } else { LinearLayoutManager(context) } } with(binding.recyclerViewChapters) { addItemDecoration(TypedListSpacingDecoration(context, true)) checkNotNull(selectionController).attachToRecyclerView(this) setHasFixedSize(true) PagerNestedScrollHelper(this).bind(viewLifecycleOwner) adapter = chaptersAdapter ChapterGridSpanHelper.attach(this) } binding.chipsFilter.onChipClickListener = this viewModel.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged) viewModel.chapters .map { it.withVolumeHeaders(requireContext()) } .flowOn(Dispatchers.Default) .observe(viewLifecycleOwner, this::onChaptersChanged) viewModel.quickFilter.observe(viewLifecycleOwner, this::onFilterChanged) viewModel.emptyReason.observe(viewLifecycleOwner) { binding.textViewHolder.setTextAndVisible(it?.msgResId ?: 0) } } override fun onDestroyView() { chaptersAdapter = null selectionController = null super.onDestroyView() } override fun onItemClick(item: ChapterListItem, view: View) { if (selectionController?.onItemClick(item.chapter.id) == true) { return } val listener = findParentCallback(ReaderNavigationCallback::class.java) if (listener != null && listener.onChapterSelected(item.chapter)) { dismissParentDialog() } else { router.openReader( ReaderIntent.Builder(view.context) .manga(viewModel.getMangaOrNull() ?: return) .state(ReaderState(item.chapter.id, 0, 0)) .build(), ) } } override fun onItemLongClick(item: ChapterListItem, view: View): Boolean { return selectionController?.onItemLongClick(view, item.chapter.id) == true } override fun onItemContextClick(item: ChapterListItem, view: View): Boolean { return selectionController?.onItemContextClick(view, item.chapter.id) == true } override fun onChipClick(chip: Chip, data: Any?) { if (data !is ListFilterOption.Branch) return viewModel.setSelectedBranch(data.titleText) } override fun onApplyWindowInsets( v: View, insets: WindowInsetsCompat ): WindowInsetsCompat { viewBinding?.run { val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) recyclerViewChapters.updatePadding( left = bars.left, right = bars.right, bottom = bars.bottom, ) chipsFilter.updatePadding( left = bars.left, right = bars.right, ) } return WindowInsetsCompat.CONSUMED } private fun onChaptersChanged(list: List) { val adapter = chaptersAdapter ?: return if (adapter.itemCount == 0) { val position = list.indexOfFirst { it is ChapterListItem && it.isCurrent } - 1 if (position > 0) { val offset = (resources.getDimensionPixelSize(R.dimen.chapter_list_item_height) * 0.6).roundToInt() adapter.setItems( list, RecyclerViewScrollCallback(requireViewBinding().recyclerViewChapters, position, offset), ) } else { adapter.items = list } } else { adapter.items = list } } private fun onFilterChanged(list: List) { viewBinding?.chipsFilter?.run { setChips(list) isGone = list.isEmpty() } } private fun onLoadingStateChanged(isLoading: Boolean) { requireViewBinding().progressBar.isVisible = isLoading } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersSelectionCallback.kt ================================================ package org.koitharu.kotatsu.details.ui.pager.chapters import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.widget.Toast import androidx.appcompat.view.ActionMode import androidx.recyclerview.widget.RecyclerView import com.google.android.material.snackbar.Snackbar import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.list.BaseListSelectionCallback import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.toCollection import org.koitharu.kotatsu.core.util.ext.toSet import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService class ChaptersSelectionCallback( private val viewModel: ChaptersPagesViewModel, private val router: AppRouter, recyclerView: RecyclerView, ) : BaseListSelectionCallback(recyclerView) { override fun onCreateActionMode( controller: ListSelectionController, menuInflater: MenuInflater, menu: Menu ): Boolean { menuInflater.inflate(R.menu.mode_chapters, menu) return true } override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode?, menu: Menu): Boolean { val selectedIds = controller.peekCheckedIds() val allItems = viewModel.chapters.value val items = allItems.withIndex().filter { it.value.chapter.id in selectedIds } var canSave = true var canDelete = true items.forEach { (_, x) -> val isLocal = x.isDownloaded || x.chapter.source == LocalMangaSource if (isLocal) canSave = false else canDelete = false } menu.findItem(R.id.action_save).isVisible = canSave menu.findItem(R.id.action_delete).isVisible = canDelete menu.findItem(R.id.action_select_all).isVisible = items.size < allItems.size menu.findItem(R.id.action_mark_current).isVisible = items.size == 1 mode?.title = items.size.toString() var hasGap = false for (i in 0 until items.size - 1) { if (items[i].index + 1 != items[i + 1].index) { hasGap = true break } } menu.findItem(R.id.action_select_range).isVisible = hasGap return true } override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean { return when (item.itemId) { R.id.action_save -> { val snapshot = controller.snapshot() mode?.finish() if (snapshot.isNotEmpty()) { router.askForDownloadOverMeteredNetwork { viewModel.download(snapshot, it) } } true } R.id.action_delete -> { val ids = controller.peekCheckedIds() val manga = viewModel.getMangaOrNull() when { ids.isEmpty() || manga == null -> Unit ids.size == manga.chapters?.size -> viewModel.deleteLocal() else -> { LocalChaptersRemoveService.start(recyclerView.context, manga, ids.toSet()) try { Snackbar.make( recyclerView, R.string.chapters_will_removed_background, Snackbar.LENGTH_LONG, ).show() } catch (e: IllegalArgumentException) { e.printStackTraceDebug() Toast.makeText( recyclerView.context, R.string.chapters_will_removed_background, Toast.LENGTH_SHORT, ).show() } } } mode?.finish() true } R.id.action_select_range -> { val items = viewModel.chapters.value val ids = controller.peekCheckedIds().toCollection(HashSet()) val buffer = HashSet() var isAdding = false for (x in items) { if (x.chapter.id in ids) { isAdding = true if (buffer.isNotEmpty()) { ids.addAll(buffer) buffer.clear() } } else if (isAdding) { buffer.add(x.chapter.id) } } controller.addAll(ids) true } R.id.action_select_all -> { val ids = viewModel.chapters.value.map { it.chapter.id } controller.addAll(ids) true } R.id.action_mark_current -> { val ids = controller.peekCheckedIds() if (ids.size == 1) { viewModel.markChapterAsCurrent(ids.first()) } else { return false } mode?.finish() true } else -> false } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageFetcher.kt ================================================ package org.koitharu.kotatsu.details.ui.pager.pages import androidx.core.net.toUri import coil3.ImageLoader import coil3.decode.DataSource import coil3.decode.ImageSource import coil3.fetch.FetchResult import coil3.fetch.Fetcher import coil3.fetch.SourceFetchResult import coil3.network.HttpException import coil3.network.NetworkHeaders import coil3.network.NetworkResponse import coil3.network.NetworkResponseBody import coil3.request.Options import okhttp3.Headers import okhttp3.OkHttpClient import okhttp3.Response import okio.FileSystem import okio.Path.Companion.toOkioPath import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.ext.fetch import org.koitharu.kotatsu.core.util.ext.isNetworkUri import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull import org.koitharu.kotatsu.local.data.LocalStorageCache import org.koitharu.kotatsu.local.data.PageCache import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.util.mimeType import org.koitharu.kotatsu.parsers.util.requireBody import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.reader.domain.PageLoader import javax.inject.Inject class MangaPageFetcher( private val okHttpClient: OkHttpClient, private val pagesCache: LocalStorageCache, private val options: Options, private val page: MangaPage, private val mangaRepositoryFactory: MangaRepository.Factory, private val imageProxyInterceptor: ImageProxyInterceptor, private val imageLoader: ImageLoader, ) : Fetcher { override suspend fun fetch(): FetchResult? { if (!page.preview.isNullOrEmpty()) { runCatchingCancellable { imageLoader.fetch(checkNotNull(page.preview), options) }.onSuccess { return it } } val repo = mangaRepositoryFactory.create(page.source) val pageUrl = repo.getPageUrl(page) if (options.diskCachePolicy.readEnabled) { pagesCache[pageUrl]?.let { file -> return SourceFetchResult( source = ImageSource(file.toOkioPath(), options.fileSystem), mimeType = MimeTypes.getMimeTypeFromExtension(file.name)?.toString(), dataSource = DataSource.DISK, ) } } return loadPage(pageUrl) } private suspend fun loadPage(pageUrl: String): FetchResult? = if (pageUrl.toUri().isNetworkUri()) { fetchPage(pageUrl) } else { imageLoader.fetch(pageUrl, options) } private suspend fun fetchPage(pageUrl: String): FetchResult { val request = PageLoader.createPageRequest(pageUrl, page.source) return imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { response -> if (!response.isSuccessful) { throw HttpException(response.toNetworkResponse()) } val mimeType = response.mimeType?.toMimeTypeOrNull() val file = response.requireBody().use { pagesCache.set(pageUrl, it.source(), mimeType) } SourceFetchResult( source = ImageSource(file.toOkioPath(), FileSystem.SYSTEM), mimeType = mimeType?.toString(), dataSource = DataSource.NETWORK, ) } } private fun Response.toNetworkResponse() = NetworkResponse( code = code, requestMillis = sentRequestAtMillis, responseMillis = receivedResponseAtMillis, headers = headers.toNetworkHeaders(), body = body?.source()?.let(::NetworkResponseBody), delegate = this, ) private fun Headers.toNetworkHeaders(): NetworkHeaders { val headers = NetworkHeaders.Builder() for ((key, values) in this) { headers.add(key, values) } return headers.build() } class Factory @Inject constructor( @MangaHttpClient private val okHttpClient: OkHttpClient, @PageCache private val pagesCache: LocalStorageCache, private val mangaRepositoryFactory: MangaRepository.Factory, private val imageProxyInterceptor: ImageProxyInterceptor, ) : Fetcher.Factory { override fun create(data: MangaPage, options: Options, imageLoader: ImageLoader) = MangaPageFetcher( okHttpClient = okHttpClient, pagesCache = pagesCache, options = options, page = data, mangaRepositoryFactory = mangaRepositoryFactory, imageProxyInterceptor = imageProxyInterceptor, imageLoader = imageLoader, ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageKeyer.kt ================================================ package org.koitharu.kotatsu.details.ui.pager.pages import coil3.key.Keyer import coil3.request.Options import org.koitharu.kotatsu.parsers.model.MangaPage class MangaPageKeyer : Keyer { override fun key(data: MangaPage, options: Options) = data.url } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PageThumbnail.kt ================================================ package org.koitharu.kotatsu.details.ui.pager.pages import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.reader.ui.pager.ReaderPage data class PageThumbnail( val isCurrent: Boolean, val page: ReaderPage, ) : ListModel { val number get() = page.index + 1 override fun areItemsTheSame(other: ListModel): Boolean { return other is PageThumbnail && page == other.page } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PageThumbnailAD.kt ================================================ package org.koitharu.kotatsu.details.ui.pager.pages import coil3.size.Size import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.setTextColorAttr import org.koitharu.kotatsu.databinding.ItemPageThumbBinding import org.koitharu.kotatsu.list.ui.model.ListModel import com.google.android.material.R as materialR fun pageThumbnailAD( clickListener: OnListItemClickListener, ) = adapterDelegateViewBinding( { inflater, parent -> ItemPageThumbBinding.inflate(inflater, parent, false) }, ) { val gridWidth = itemView.context.resources.getDimensionPixelSize(R.dimen.preferred_grid_width) binding.imageViewThumb.exactImageSize = Size( width = gridWidth, height = (gridWidth / 13f * 18f).toInt(), ) AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView) bind { binding.imageViewThumb.setImageAsync(item.page) with(binding.textViewNumber) { setBackgroundResource(if (item.isCurrent) R.drawable.bg_badge_accent else R.drawable.bg_badge_empty) setTextColorAttr(if (item.isCurrent) materialR.attr.colorOnTertiary else android.R.attr.textColorPrimary) text = item.number.toString() } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PageThumbnailAdapter.kt ================================================ package org.koitharu.kotatsu.details.ui.pager.pages import android.content.Context 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.ListItemType import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD import org.koitharu.kotatsu.list.ui.model.ListModel class PageThumbnailAdapter( clickListener: OnListItemClickListener, ) : BaseListAdapter(), FastScroller.SectionIndexer { init { addDelegate(ListItemType.PAGE_THUMB, pageThumbnailAD(clickListener)) addDelegate(ListItemType.HEADER, listHeaderAD(null)) } override fun getSectionText(context: Context, position: Int): CharSequence? { return findHeader(position)?.getText(context) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesFragment.kt ================================================ package org.koitharu.kotatsu.details.ui.pager.pages 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 androidx.appcompat.view.ActionMode import androidx.collection.ArraySet import androidx.core.view.WindowInsetsCompat import androidx.core.view.isInvisible import androidx.fragment.app.viewModels import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.nav.ReaderIntent import org.koitharu.kotatsu.core.nav.dismissParentDialog 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.BoundsScrollListener import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback import org.koitharu.kotatsu.core.util.ext.consumeAll import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate import org.koitharu.kotatsu.core.util.ext.findParentCallback import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.core.util.ext.showOrHide import org.koitharu.kotatsu.databinding.FragmentPagesBinding import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason import org.koitharu.kotatsu.list.ui.GridSpanResolver import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.reader.ui.PageSaveHelper import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import javax.inject.Inject import kotlin.math.roundToInt @AndroidEntryPoint class PagesFragment : BaseFragment(), OnListItemClickListener, RecyclerViewOwner, ListSelectionController.Callback { @Inject lateinit var settings: AppSettings @Inject lateinit var pageSaveHelperFactory: PageSaveHelper.Factory private val parentViewModel by ChaptersPagesViewModel.ActivityVMLazy(this) private val viewModel by viewModels() private lateinit var pageSaveHelper: PageSaveHelper private var thumbnailsAdapter: PageThumbnailAdapter? = null private var spanResolver: GridSpanResolver? = null private var scrollListener: ScrollListener? = null private var selectionController: ListSelectionController? = null private val spanSizeLookup = SpanSizeLookup() override val recyclerView: RecyclerView? get() = viewBinding?.recyclerView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) pageSaveHelper = pageSaveHelperFactory.create(this) combine( parentViewModel.mangaDetails, parentViewModel.readingState, parentViewModel.selectedBranch, ) { details, readingState, branch -> if (details != null && (details.isLoaded || details.chapters.isNotEmpty())) { PagesViewModel.State(details.filterChapters(branch), readingState, branch) } else { null } }.flowOn(Dispatchers.Default) .observe(this, viewModel::updateState) } override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentPagesBinding { return FragmentPagesBinding.inflate(inflater, container, false) } override fun onViewBindingCreated(binding: FragmentPagesBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) spanResolver = GridSpanResolver(binding.root.resources) selectionController = ListSelectionController( appCompatDelegate = checkNotNull(findAppCompatDelegate()), decoration = PagesSelectionDecoration(binding.root.context), registryOwner = this, callback = this, ) thumbnailsAdapter = PageThumbnailAdapter( clickListener = this@PagesFragment, ) viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) // before rv initialization with(binding.recyclerView) { addItemDecoration(TypedListSpacingDecoration(context, false)) checkNotNull(selectionController).attachToRecyclerView(this) adapter = thumbnailsAdapter setHasFixedSize(true) PagerNestedScrollHelper(this).bind(viewLifecycleOwner) addOnLayoutChangeListener(spanResolver) addOnScrollListener(ScrollListener().also { scrollListener = it }) (layoutManager as GridLayoutManager).let { it.spanSizeLookup = spanSizeLookup it.spanCount = checkNotNull(spanResolver).spanCount } } parentViewModel.emptyReason.observe(viewLifecycleOwner, ::onNoChaptersChanged) viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged) viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(binding.recyclerView)) viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) combine( viewModel.isLoading, viewModel.thumbnails, ) { loading, content -> loading && content.isEmpty() }.observe(viewLifecycleOwner) { binding.progressBar.showOrHide(it) } viewModel.isLoadingUp.observe(viewLifecycleOwner) { binding.progressBarTop.showOrHide(it) } viewModel.isLoadingDown.observe(viewLifecycleOwner) { binding.progressBarBottom.showOrHide(it) } } override fun onDestroyView() { spanResolver = null scrollListener = null thumbnailsAdapter = null selectionController = null spanSizeLookup.invalidateCache() super.onDestroyView() } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val typeBask = WindowInsetsCompat.Type.systemBars() val barsInsets = insets.getInsets(typeBask) viewBinding?.recyclerView?.setPadding( barsInsets.left, barsInsets.top, barsInsets.right, barsInsets.bottom, ) return insets.consumeAll(typeBask) } override fun onItemClick(item: PageThumbnail, view: View) { if (selectionController?.onItemClick(item.page.id) == true) { return } val listener = findParentCallback(ReaderNavigationCallback::class.java) if (listener != null && listener.onPageSelected(item.page)) { dismissParentDialog() } else { router.openReader( ReaderIntent.Builder(view.context) .manga(parentViewModel.getMangaOrNull() ?: return) .state(ReaderState(item.page.chapterId, item.page.index, 0)) .build(), ) } } override fun onItemLongClick(item: PageThumbnail, view: View): Boolean { return selectionController?.onItemLongClick(view, item.page.id) == true } override fun onItemContextClick(item: PageThumbnail, view: View): Boolean { return selectionController?.onItemContextClick(view, item.page.id) == true } override fun onSelectionChanged(controller: ListSelectionController, count: Int) { viewBinding?.recyclerView?.invalidateItemDecorations() } override fun onCreateActionMode( controller: ListSelectionController, menuInflater: MenuInflater, menu: Menu, ): Boolean { menuInflater.inflate(R.menu.mode_pages, menu) return true } override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean { return when (item.itemId) { R.id.action_save -> { viewModel.savePages(pageSaveHelper, collectSelectedPages()) mode?.finish() true } else -> false } } private suspend fun onThumbnailsChanged(list: List) { val adapter = thumbnailsAdapter ?: return if (adapter.itemCount == 0) { var position = list.indexOfFirst { it is PageThumbnail && it.isCurrent } if (position > 0) { val spanCount = spanResolver?.spanCount ?: 0 val offset = if (position > spanCount + 1) { (resources.getDimensionPixelSize(R.dimen.manga_list_details_item_height) * 0.6).roundToInt() } else { position = 0 0 } val scrollCallback = RecyclerViewScrollCallback(requireViewBinding().recyclerView, position, offset) adapter.emit(list) scrollCallback.run() } else { adapter.emit(list) } } else { adapter.emit(list) } spanSizeLookup.invalidateCache() viewBinding?.recyclerView?.let { scrollListener?.postInvalidate(it) } } private fun onGridScaleChanged(scale: Float) { spanSizeLookup.invalidateCache() spanResolver?.setGridSize(scale, requireViewBinding().recyclerView) } private fun onNoChaptersChanged(reason: EmptyMangaReason?) { with(viewBinding ?: return) { textViewHolder.setTextAndVisible(reason?.msgResId ?: 0) recyclerView.isInvisible = reason != null } } private fun collectSelectedPages(): Set { val checkedIds = selectionController?.peekCheckedIds() ?: return emptySet() val items = thumbnailsAdapter?.items ?: return emptySet() val result = ArraySet(checkedIds.size) for (item in items) { if (item is PageThumbnail && item.page.id in checkedIds) { result.add(item.page) } } return result } private inner class ScrollListener : BoundsScrollListener(3, 3) { override fun onScrolledToStart(recyclerView: RecyclerView) { viewModel.loadPrevChapter() } override fun onScrolledToEnd(recyclerView: RecyclerView) { viewModel.loadNextChapter() } } private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() { init { isSpanIndexCacheEnabled = true isSpanGroupIndexCacheEnabled = true } override fun getSpanSize(position: Int): Int { val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1 return when (thumbnailsAdapter?.getItemViewType(position)) { ListItemType.PAGE_THUMB.ordinal -> 1 else -> total } } fun invalidateCache() { invalidateSpanGroupIndexCache() invalidateSpanIndexCache() } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesSavedObserver.kt ================================================ package org.koitharu.kotatsu.details.ui.pager.pages import android.net.Uri import android.view.View import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.flow.FlowCollector import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ShareHelper class PagesSavedObserver( private val snackbarHost: View, ) : FlowCollector> { override suspend fun emit(value: Collection) { val msg = when (value.size) { 0 -> R.string.nothing_found 1 -> R.string.page_saved else -> R.string.pages_saved } val snackbar = Snackbar.make(snackbarHost, msg, Snackbar.LENGTH_LONG) value.singleOrNull()?.let { uri -> snackbar.setAction(R.string.share) { ShareHelper(snackbarHost.context).shareImage(uri) } } snackbar.show() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesSelectionDecoration.kt ================================================ package org.koitharu.kotatsu.details.ui.pager.pages import android.content.Context import android.view.View import androidx.recyclerview.widget.RecyclerView import org.koitharu.kotatsu.core.util.ext.getItem import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration class PagesSelectionDecoration(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(PageThumbnail::class.java) ?: return RecyclerView.NO_ID return item.page.id } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesViewModel.kt ================================================ package org.koitharu.kotatsu.details.ui.pager.pages import android.net.Uri 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.filterNotNull import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsStateFlow 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.firstNotNull import org.koitharu.kotatsu.core.util.ext.requireValue import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.reader.domain.ChaptersLoader import org.koitharu.kotatsu.reader.ui.PageSaveHelper import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import javax.inject.Inject @HiltViewModel class PagesViewModel @Inject constructor( private val chaptersLoader: ChaptersLoader, settings: AppSettings, ) : BaseViewModel() { private var loadingJob: Job? = null private var loadingPrevJob: Job? = null private var loadingNextJob: Job? = null private val state = MutableStateFlow(null) val thumbnails = MutableStateFlow>(emptyList()) val isLoadingUp = MutableStateFlow(false) val isLoadingDown = MutableStateFlow(false) val onPageSaved = MutableEventFlow>() val gridScale = settings.observeAsStateFlow( scope = viewModelScope + Dispatchers.Default, key = AppSettings.KEY_GRID_SIZE_PAGES, valueProducer = { gridSizePages / 100f }, ) init { launchJob(Dispatchers.Default) { state.filterNotNull() .collect { val prevJob = loadingJob loadingJob = launchLoadingJob(Dispatchers.Default) { prevJob?.cancelAndJoin() doInit(it) } } } } fun updateState(newState: State?) { if (newState != null) { state.value = newState } } fun loadPrevChapter() { if (loadingJob?.isActive == true || loadingPrevJob?.isActive == true) { return } loadingPrevJob = loadPrevNextChapter(isNext = false) } fun loadNextChapter() { if (loadingJob?.isActive == true || loadingNextJob?.isActive == true) { return } loadingNextJob = loadPrevNextChapter(isNext = true) } fun savePages( pageSaveHelper: PageSaveHelper, pages: Set, ) { launchLoadingJob(Dispatchers.Default) { val manga = state.requireValue().details.toManga() val tasks = pages.map { PageSaveHelper.Task( manga = manga, chapterId = it.chapterId, pageNumber = it.index + 1, page = it.toMangaPage(), ) } val dest = pageSaveHelper.save(tasks) onPageSaved.call(dest) } } private suspend fun doInit(state: State) { chaptersLoader.init(state.details) val initialChapterId = state.readerState?.chapterId?.takeIf { chaptersLoader.peekChapter(it) != null } ?: state.details.allChapters.firstOrNull()?.id ?: return if (!chaptersLoader.hasPages(initialChapterId)) { var hasPages = chaptersLoader.loadSingleChapter(initialChapterId) while (!hasPages) { if (chaptersLoader.loadPrevNextChapter(state.details, initialChapterId, isNext = true)) { hasPages = chaptersLoader.snapshot().isNotEmpty() } else { break } } } updateList(state.readerState) } private fun loadPrevNextChapter(isNext: Boolean): Job = launchJob(Dispatchers.Default) { val indicator = if (isNext) isLoadingDown else isLoadingUp indicator.value = true try { val currentState = state.firstNotNull() val currentId = (if (isNext) chaptersLoader.last() else chaptersLoader.first()).chapterId chaptersLoader.loadPrevNextChapter(currentState.details, currentId, isNext) updateList(currentState.readerState) } finally { indicator.value = false } } private fun updateList(readerState: ReaderState?) { val snapshot = chaptersLoader.snapshot() val pages = buildList(snapshot.size + chaptersLoader.size + 2) { var previousChapterId = 0L for (page in snapshot) { if (page.chapterId != previousChapterId) { chaptersLoader.peekChapter(page.chapterId)?.let { add(ListHeader(it)) } previousChapterId = page.chapterId } this += PageThumbnail( isCurrent = readerState?.let { page.chapterId == it.chapterId && page.index == it.page } == true, page = page, ) } } thumbnails.value = pages } data class State( val details: MangaDetails, val readerState: ReaderState?, val branch: String? ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedListFragment.kt ================================================ package org.koitharu.kotatsu.details.ui.related import android.view.Menu import android.view.MenuInflater import androidx.fragment.app.viewModels import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.list.ui.MangaListFragment @AndroidEntryPoint class RelatedListFragment : MangaListFragment() { override val viewModel by viewModels() override val isSwipeRefreshEnabled = false override fun onScrolledToEnd() = Unit override fun onCreateActionMode( controller: ListSelectionController, menuInflater: MenuInflater, menu: Menu ): Boolean { menuInflater.inflate(R.menu.mode_remote, menu) return super.onCreateActionMode(controller, menuInflater, menu) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedListViewModel.kt ================================================ package org.koitharu.kotatsu.details.ui.related import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga import javax.inject.Inject @HiltViewModel class RelatedListViewModel @Inject constructor( savedStateHandle: SavedStateHandle, mangaRepositoryFactory: MangaRepository.Factory, settings: AppSettings, private val mangaListMapper: MangaListMapper, mangaDataRepository: MangaDataRepository, @LocalStorageChanges localStorageChanges: SharedFlow, ) : MangaListViewModel(settings, mangaDataRepository, localStorageChanges) { private val seed = savedStateHandle.require(AppRouter.KEY_MANGA).manga private val repository = mangaRepositoryFactory.create(seed.source) private val mangaList = MutableStateFlow?>(null) private val listError = MutableStateFlow(null) private var loadingJob: Job? = null override val content = combine( mangaList, observeListModeWithTriggers(), listError, ) { list, mode, error -> when { list.isNullOrEmpty() && error != null -> listOf(error.toErrorState(canRetry = true)) list == null -> listOf(LoadingState) list.isEmpty() -> listOf(createEmptyState()) else -> mangaListMapper.toListModelList(list, mode) } }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) init { loadList() } override fun onRefresh() { loadList() } override fun onRetry() { loadList() } private fun loadList(): Job { loadingJob?.let { if (it.isActive) return it } return launchLoadingJob(Dispatchers.Default) { try { listError.value = null mangaList.value = repository.getRelated(seed) } catch (e: CancellationException) { throw e } catch (e: Throwable) { e.printStackTraceDebug() listError.value = e if (!mangaList.value.isNullOrEmpty()) { errorEvent.call(e) } } }.also { loadingJob = it } } private fun createEmptyState() = EmptyState( icon = R.drawable.ic_empty_common, textPrimary = R.string.nothing_found, textSecondary = 0, actionStringRes = 0, ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedMangaActivity.kt ================================================ package org.koitharu.kotatsu.details.ui.related import org.koitharu.kotatsu.core.ui.FragmentContainerActivity class RelatedMangaActivity : FragmentContainerActivity(RelatedListFragment::class.java) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoAD.kt ================================================ package org.koitharu.kotatsu.details.ui.scrobbling import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.databinding.ItemScrobblingInfoBinding import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo fun scrobblingInfoAD( router: AppRouter, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemScrobblingInfoBinding.inflate(layoutInflater, parent, false) }, ) { binding.root.setOnClickListener { router.showScrobblingInfoSheet(bindingAdapterPosition) } bind { binding.imageViewCover.setImageAsync(item.coverUrl) binding.textViewTitle.setText(item.scrobbler.titleResId) binding.imageViewIcon.setImageResource(item.scrobbler.iconResId) binding.ratingBar.rating = item.rating * binding.ratingBar.numStars binding.textViewStatus.text = item.status?.let { context.resources.getStringArray(R.array.scrobbling_statuses).getOrNull(it.ordinal) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoSheet.kt ================================================ package org.koitharu.kotatsu.details.ui.scrobbling import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.AdapterView import android.widget.RatingBar import android.widget.Toast import androidx.appcompat.widget.PopupMenu import androidx.core.text.method.LinkMovementMethodCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import androidx.fragment.app.activityViewModels import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.util.ext.consume 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.sanitize import org.koitharu.kotatsu.databinding.SheetScrobblingBinding import org.koitharu.kotatsu.details.ui.DetailsViewModel import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus @AndroidEntryPoint class ScrobblingInfoSheet : BaseAdaptiveSheet(), AdapterView.OnItemSelectedListener, RatingBar.OnRatingBarChangeListener, View.OnClickListener, PopupMenu.OnMenuItemClickListener { private val viewModel by activityViewModels() private var scrobblerIndex: Int = -1 private var menu: PopupMenu? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) scrobblerIndex = requireArguments().getInt(AppRouter.KEY_INDEX, scrobblerIndex) } override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetScrobblingBinding { return SheetScrobblingBinding.inflate(inflater, container, false) } override fun onViewBindingCreated(binding: SheetScrobblingBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged) viewModel.onError.observeEvent(viewLifecycleOwner) { Toast.makeText(binding.root.context, it.getDisplayMessage(binding.root.resources), Toast.LENGTH_SHORT) .show() } binding.spinnerStatus.onItemSelectedListener = this binding.ratingBar.onRatingBarChangeListener = this binding.buttonMenu.setOnClickListener(this) binding.imageViewCover.setOnClickListener(this) binding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance() menu = PopupMenu(binding.root.context, binding.buttonMenu).apply { inflate(R.menu.opt_scrobbling) setOnMenuItemClickListener(this@ScrobblingInfoSheet) } } override fun onDestroyView() { super.onDestroyView() menu = null } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val typeMask = WindowInsetsCompat.Type.systemBars() viewBinding?.root?.updatePadding( bottom = insets.getInsets(typeMask).bottom, ) return insets.consume(v, typeMask, bottom = true) } override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { viewModel.updateScrobbling( index = scrobblerIndex, rating = requireViewBinding().ratingBar.rating / requireViewBinding().ratingBar.numStars, status = ScrobblingStatus.entries.getOrNull(position), ) } override fun onNothingSelected(parent: AdapterView<*>?) = Unit override fun onRatingChanged(ratingBar: RatingBar, rating: Float, fromUser: Boolean) { if (fromUser) { viewModel.updateScrobbling( index = scrobblerIndex, rating = rating / ratingBar.numStars, status = ScrobblingStatus.entries.getOrNull(requireViewBinding().spinnerStatus.selectedItemPosition), ) } } override fun onClick(v: View) { when (v.id) { R.id.button_menu -> menu?.show() R.id.imageView_cover -> router.openImage( url = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.coverUrl ?: return, source = null, anchor = v, ) } } private fun onScrobblingInfoChanged(scrobblings: List) { val scrobbling = scrobblings.getOrNull(scrobblerIndex) if (scrobbling == null) { dismissAllowingStateLoss() return } val binding = viewBinding ?: return binding.textViewTitle.text = scrobbling.title binding.ratingBar.rating = scrobbling.rating * binding.ratingBar.numStars binding.textViewDescription.text = scrobbling.description?.sanitize() binding.spinnerStatus.setSelection(scrobbling.status?.ordinal ?: -1) binding.imageViewLogo.contentDescription = getString(scrobbling.scrobbler.titleResId) binding.imageViewLogo.setImageResource(scrobbling.scrobbler.iconResId) binding.imageViewCover.setImageAsync(scrobbling.coverUrl) } override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { R.id.action_browser -> { val url = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.externalUrl ?: return false if (!router.openExternalBrowser(url, getString(R.string.open_in_browser))) { Snackbar.make( viewBinding?.textViewDescription ?: return false, R.string.operation_not_supported, Snackbar.LENGTH_SHORT, ).show() } } R.id.action_unregister -> { viewModel.unregisterScrobbling(scrobblerIndex) dismiss() } R.id.action_edit -> { val manga = viewModel.manga.value ?: return false val scrobblerService = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.scrobbler activity?.router?.showScrobblingSelectorSheet(manga, scrobblerService) dismiss() } } return true } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingItemDecoration.kt ================================================ package org.koitharu.kotatsu.details.ui.scrobbling import android.graphics.Rect import android.view.View import androidx.recyclerview.widget.RecyclerView import org.koitharu.kotatsu.R class ScrobblingItemDecoration : RecyclerView.ItemDecoration() { private var spacing: Int = -1 override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { if (spacing == -1) { spacing = parent.context.resources.getDimensionPixelOffset(R.dimen.scrobbling_list_spacing) } outRect.set(0, spacing, 0, 0) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrollingInfoAdapter.kt ================================================ package org.koitharu.kotatsu.details.ui.scrobbling import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.list.ui.model.ListModel class ScrollingInfoAdapter( router: AppRouter, ) : BaseListAdapter() { init { delegatesManager.addDelegate(scrobblingInfoAD(router)) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/download/domain/DownloadProgress.kt ================================================ package org.koitharu.kotatsu.download.domain data class DownloadProgress( val totalChapters: Int, val currentChapter: Int, val totalPages: Int, val currentPage: Int, ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/download/domain/DownloadState.kt ================================================ package org.koitharu.kotatsu.download.domain import androidx.work.Data import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga import java.time.Instant data class DownloadState( val manga: Manga, val isIndeterminate: Boolean, val isPaused: Boolean = false, val isStopped: Boolean = false, val error: Throwable? = null, val errorMessage: String? = null, val totalChapters: Int = 0, val currentChapter: Int = 0, val totalPages: Int = 0, val currentPage: Int = 0, val eta: Long = -1L, val isStuck: Boolean = false, val localManga: LocalManga? = null, val downloadedChapters: Int = 0, val timestamp: Long = System.currentTimeMillis(), ) { val max: Int = totalChapters * totalPages val progress: Int = totalPages * currentChapter + currentPage + 1 val percent: Float = if (max > 0) progress.toFloat() / max else PROGRESS_NONE val isFinalState: Boolean get() = localManga != null || (error != null && !isPaused) val isParticularProgress: Boolean get() = localManga == null && error == null && !isPaused && !isStopped && max > 0 && !isIndeterminate fun toWorkData() = Data.Builder() .putLong(DATA_MANGA_ID, manga.id) .putInt(DATA_MAX, max) .putInt(DATA_PROGRESS, progress) .putLong(DATA_ETA, eta) .putBoolean(DATA_STUCK, isStuck) .putLong(DATA_TIMESTAMP, timestamp) .putString(DATA_ERROR, errorMessage) .putInt(DATA_CHAPTERS, downloadedChapters) .putBoolean(DATA_INDETERMINATE, isIndeterminate) .putBoolean(DATA_PAUSED, isPaused) .build() companion object { private const val DATA_MANGA_ID = "manga_id" private const val DATA_MAX = "max" private const val DATA_PROGRESS = "progress" private const val DATA_CHAPTERS = "chapter_cnt" private const val DATA_ETA = "eta" private const val DATA_STUCK = "stuck" const val DATA_TIMESTAMP = "timestamp" private const val DATA_ERROR = "error" private const val DATA_INDETERMINATE = "indeterminate" private const val DATA_PAUSED = "paused" fun getMangaId(data: Data): Long = data.getLong(DATA_MANGA_ID, 0L) fun isIndeterminate(data: Data): Boolean = data.getBoolean(DATA_INDETERMINATE, false) fun isPaused(data: Data): Boolean = data.getBoolean(DATA_PAUSED, false) fun getMax(data: Data): Int = data.getInt(DATA_MAX, 0) fun getError(data: Data): String? = data.getString(DATA_ERROR) fun getProgress(data: Data): Int = data.getInt(DATA_PROGRESS, 0) fun getEta(data: Data): Long = data.getLong(DATA_ETA, -1L) fun isStuck(data: Data): Boolean = data.getBoolean(DATA_STUCK, false) fun getTimestamp(data: Data): Instant = Instant.ofEpochMilli(data.getLong(DATA_TIMESTAMP, 0L)) fun getDownloadedChapters(data: Data): Int = data.getInt(DATA_CHAPTERS, 0) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/ChapterSelectOptions.kt ================================================ package org.koitharu.kotatsu.download.ui.dialog data class ChapterSelectOptions( val wholeManga: ChaptersSelectMacro.WholeManga, val wholeBranch: ChaptersSelectMacro.WholeBranch?, val firstChapters: ChaptersSelectMacro.FirstChapters?, val unreadChapters: ChaptersSelectMacro.UnreadChapters?, ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/ChaptersSelectMacro.kt ================================================ package org.koitharu.kotatsu.download.ui.dialog import androidx.collection.ArraySet import androidx.collection.LongLongMap import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.util.mapNotNullToSet interface ChaptersSelectMacro { fun getChaptersIds(mangaId: Long, chapters: List): Set? class WholeManga( val chaptersCount: Int, ) : ChaptersSelectMacro { override fun getChaptersIds(mangaId: Long, chapters: List): Set? = null } class WholeBranch( val branches: Map, val selectedBranch: String?, ) : ChaptersSelectMacro { val chaptersCount: Int = branches[selectedBranch] ?: 0 override fun getChaptersIds( mangaId: Long, chapters: List ): Set = chapters.mapNotNullToSet { c -> if (c.branch == selectedBranch) { c.id } else { null } } fun copy(branch: String?) = WholeBranch(branches, branch) } class FirstChapters( val chaptersCount: Int, val maxAvailableCount: Int, val branch: String?, ) : ChaptersSelectMacro { override fun getChaptersIds(mangaId: Long, chapters: List): Set { val result = ArraySet(minOf(chaptersCount, chapters.size)) for (c in chapters) { if (c.branch == branch) { result.add(c.id) if (result.size >= chaptersCount) { break } } } return result } fun copy(count: Int) = FirstChapters(count, maxAvailableCount, branch) } class UnreadChapters( val chaptersCount: Int, val maxAvailableCount: Int, private val currentChaptersIds: LongLongMap, ) : ChaptersSelectMacro { override fun getChaptersIds(mangaId: Long, chapters: List): Set? { if (chapters.isEmpty()) { return null } val currentChapterId = currentChaptersIds.getOrDefault(mangaId, chapters.first().id) var branch: String? = null var isAdding = false val result = ArraySet(minOf(chaptersCount, chapters.size)) for (c in chapters) { if (!isAdding) { if (c.id == currentChapterId) { branch = c.branch isAdding = true } } if (isAdding) { if (c.branch == branch) { result.add(c.id) if (result.size >= chaptersCount) { break } } } } return result } fun copy(count: Int) = UnreadChapters(count, maxAvailableCount, currentChaptersIds) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DestinationsAdapter.kt ================================================ package org.koitharu.kotatsu.download.ui.dialog import android.content.Context import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter import android.widget.TextView import androidx.core.view.isVisible import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ItemStorageConfigBinding import org.koitharu.kotatsu.settings.storage.DirectoryModel class DestinationsAdapter(context: Context, dataset: List) : ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, android.R.id.text1, dataset) { init { setDropDownViewResource(R.layout.item_storage_config) } override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { val view = convertView ?: LayoutInflater.from(parent.context) .inflate(android.R.layout.simple_spinner_dropdown_item, parent, false) val item = getItem(position) ?: return view view.findViewById(android.R.id.text1).text = item.title ?: view.context.getString(item.titleRes) return view } override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { val view = convertView ?: LayoutInflater.from(parent.context) .inflate(R.layout.item_storage_config, parent, false) val item = getItem(position) ?: return view val binding = view.tag as? ItemStorageConfigBinding ?: ItemStorageConfigBinding.bind(view).also { view.tag = it } binding.buttonRemove.isVisible = false binding.textViewTitle.text = item.title ?: view.context.getString(item.titleRes) binding.textViewSubtitle.textAndVisible = item.file?.path return view } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogFragment.kt ================================================ package org.koitharu.kotatsu.download.ui.dialog import android.os.Bundle import android.view.LayoutInflater import android.view.Menu import android.view.View import android.view.ViewGroup import android.widget.Spinner import androidx.appcompat.widget.PopupMenu import androidx.core.view.isVisible import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentResultListener import androidx.fragment.app.setFragmentResult import androidx.fragment.app.viewModels import androidx.lifecycle.LifecycleOwner import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.DownloadFormat import org.koitharu.kotatsu.core.ui.AlertDialogFragment import org.koitharu.kotatsu.core.ui.widgets.TwoLinesItemView import org.koitharu.kotatsu.core.util.ext.findActivity import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.joinToStringWithLimit import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.parentView import org.koitharu.kotatsu.core.util.ext.showOrHide import org.koitharu.kotatsu.databinding.DialogDownloadBinding import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.settings.storage.DirectoryModel @AndroidEntryPoint class DownloadDialogFragment : AlertDialogFragment(), View.OnClickListener { private val viewModel by viewModels() private var optionViews: Array? = null override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?) = DialogDownloadBinding.inflate(inflater, container, false) override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { return super.onBuildDialog(builder) .setTitle(R.string.save_manga) .setCancelable(true) } override fun onViewBindingCreated(binding: DialogDownloadBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) optionViews = arrayOf( binding.optionWholeManga, binding.optionWholeBranch, binding.optionFirstChapters, binding.optionUnreadChapters, ).onEach { it.setOnClickListener(this) it.setOnButtonClickListener(this) } binding.buttonCancel.setOnClickListener(this) binding.buttonConfirm.setOnClickListener(this) binding.textViewMore.setOnClickListener(this) binding.textViewTip.isVisible = viewModel.manga.size == 1 binding.textViewSummary.text = viewModel.manga.joinToStringWithLimit(binding.root.context, 120) { it.title } viewModel.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged) viewModel.onScheduled.observeEvent(viewLifecycleOwner, this::onDownloadScheduled) viewModel.onError.observeEvent(viewLifecycleOwner, this::onError) viewModel.defaultFormat.observe(viewLifecycleOwner, this::onDefaultFormatChanged) viewModel.availableDestinations.observe(viewLifecycleOwner, this::onDestinationsChanged) viewModel.chaptersSelectOptions.observe(viewLifecycleOwner, this::onChapterSelectOptionsChanged) viewModel.isOptionsLoading.observe(viewLifecycleOwner, binding.progressBar::showOrHide) } override fun onViewStateRestored(savedInstanceState: Bundle?) { super.onViewStateRestored(savedInstanceState) showMoreOptions(requireViewBinding().textViewMore.isChecked) setCheckedOption( savedInstanceState?.getInt(KEY_CHECKED_OPTION, R.id.option_whole_manga) ?: R.id.option_whole_manga, ) } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) optionViews?.find { it.isChecked }?.let { outState.putInt(KEY_CHECKED_OPTION, it.id) } } override fun onDestroyView() { super.onDestroyView() optionViews = null } override fun onClick(v: View) { when (v.id) { R.id.button_cancel -> dialog?.cancel() R.id.button_confirm -> router.askForDownloadOverMeteredNetwork(::schedule) R.id.textView_more -> { val binding = viewBinding ?: return binding.textViewMore.toggle() showMoreOptions(binding.textViewMore.isChecked) } R.id.button -> when (v.parentView?.id ?: return) { R.id.option_whole_branch -> showBranchSelection(v) R.id.option_first_chapters -> showFirstChaptersCountSelection(v) R.id.option_unread_chapters -> showUnreadChaptersCountSelection(v) } else -> if (v is TwoLinesItemView) { setCheckedOption(v.id) } } } private fun schedule(allowMeteredNetwork: Boolean) { viewBinding?.run { val options = viewModel.chaptersSelectOptions.value viewModel.confirm( startNow = switchStart.isChecked, chaptersMacro = when { optionWholeManga.isChecked -> options.wholeManga optionWholeBranch.isChecked -> options.wholeBranch ?: return@run optionFirstChapters.isChecked -> options.firstChapters ?: return@run optionUnreadChapters.isChecked -> options.unreadChapters ?: return@run else -> return@run }, format = DownloadFormat.entries.getOrNull(spinnerFormat.selectedItemPosition), destination = viewModel.availableDestinations.value.getOrNull(spinnerDestination.selectedItemPosition), allowMetered = allowMeteredNetwork, ) } } 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 onLoadingStateChanged(value: Boolean) { with(requireViewBinding()) { buttonConfirm.isEnabled = !value } } private fun onDefaultFormatChanged(format: DownloadFormat?) { val spinner = viewBinding?.spinnerFormat ?: return spinner.setSelection(format?.ordinal ?: Spinner.INVALID_POSITION) } private fun onDestinationsChanged(directories: List) { viewBinding?.spinnerDestination?.run { adapter = DestinationsAdapter(context, directories) setSelection(directories.indexOfFirst { it.isChecked }) } } private fun onChapterSelectOptionsChanged(options: ChapterSelectOptions) { with(viewBinding ?: return) { // Whole manga optionWholeManga.subtitle = if (options.wholeManga.chaptersCount > 0) { resources.getQuantityStringSafe( R.plurals.chapters, options.wholeManga.chaptersCount, options.wholeManga.chaptersCount, ) } else { null } // All chapters for branch optionWholeBranch.isVisible = options.wholeBranch != null options.wholeBranch?.let { optionWholeBranch.title = resources.getString( R.string.download_option_all_chapters, it.selectedBranch, ) optionWholeBranch.subtitle = if (it.chaptersCount > 0) { resources.getQuantityStringSafe( R.plurals.chapters, it.chaptersCount, it.chaptersCount, ) } else { null } } // First N chapters optionFirstChapters.isVisible = options.firstChapters != null options.firstChapters?.let { optionFirstChapters.title = resources.getString( R.string.download_option_first_n_chapters, resources.getQuantityStringSafe( R.plurals.chapters, it.chaptersCount, it.chaptersCount, ), ) optionFirstChapters.subtitle = it.branch } // Next N unread chapters optionUnreadChapters.isVisible = options.unreadChapters != null options.unreadChapters?.let { optionUnreadChapters.title = if (it.chaptersCount == Int.MAX_VALUE) { resources.getString(R.string.download_option_all_unread) } else { resources.getString( R.string.download_option_next_unread_n_chapters, resources.getQuantityStringSafe( R.plurals.chapters, it.chaptersCount, it.chaptersCount, ), ) } } } } private fun onDownloadScheduled(isStarted: Boolean) { val bundle = Bundle(1) bundle.putBoolean(ARG_STARTED, isStarted) setFragmentResult(RESULT_KEY, bundle) dismiss() } private fun showMoreOptions(isVisible: Boolean) = viewBinding?.apply { cardFormat.isVisible = isVisible textViewFormat.isVisible = isVisible cardDestination.isVisible = isVisible textViewDestination.isVisible = isVisible } private fun setCheckedOption(id: Int) { for (optionView in optionViews ?: return) { optionView.isChecked = id == optionView.id optionView.isButtonEnabled = optionView.isChecked } } private fun showBranchSelection(v: View) { val option = viewModel.chaptersSelectOptions.value.wholeBranch ?: return val branches = option.branches.keys.toList() if (branches.size <= 1) { return } val menu = PopupMenu(v.context, v) for ((i, branch) in branches.withIndex()) { menu.menu.add(Menu.NONE, Menu.NONE, i, branch ?: getString(R.string.unknown)) } menu.setOnMenuItemClickListener { viewModel.setSelectedBranch(branches.getOrNull(it.order)) true } menu.show() } private fun showFirstChaptersCountSelection(v: View) { val option = viewModel.chaptersSelectOptions.value.firstChapters ?: return val menu = PopupMenu(v.context, v) chaptersCount(option.maxAvailableCount).forEach { i -> menu.menu.add(i.format()) } menu.setOnMenuItemClickListener { viewModel.setFirstChaptersCount( it.title?.toString()?.toIntOrNull() ?: return@setOnMenuItemClickListener false, ) true } menu.show() } private fun showUnreadChaptersCountSelection(v: View) { val option = viewModel.chaptersSelectOptions.value.unreadChapters ?: return val menu = PopupMenu(v.context, v) chaptersCount(option.maxAvailableCount).forEach { i -> menu.menu.add(i.format()) } menu.menu.add(getString(R.string.chapters_all)) menu.setOnMenuItemClickListener { viewModel.setUnreadChaptersCount(it.title?.toString()?.toIntOrNull() ?: Int.MAX_VALUE) true } menu.show() } private fun chaptersCount(max: Int) = sequence { yield(1) var seed = 5 var step = 5 while (seed + step <= max) { yield(seed) step = when { seed < 20 -> 5 seed < 60 -> 10 else -> 20 } seed += step } if (seed < max) { yield(max) } } private class SnackbarResultListener( private val host: View, ) : FragmentResultListener { override fun onFragmentResult(requestKey: String, result: Bundle) { val isStarted = result.getBoolean(ARG_STARTED, true) val snackbar = Snackbar.make( host, if (isStarted) R.string.download_started else R.string.download_added, Snackbar.LENGTH_LONG, ) (host.context.findActivity() as? BottomNavOwner)?.let { snackbar.anchorView = it.bottomNav } val router = AppRouter.from(host) if (router != null) { snackbar.setAction(R.string.details) { router.openDownloads() } } snackbar.show() } } companion object { private const val RESULT_KEY = "DOWNLOAD_STARTED" private const val ARG_STARTED = "started" private const val KEY_CHECKED_OPTION = "checked_opt" fun registerCallback( fm: FragmentManager, lifecycleOwner: LifecycleOwner, snackbarHost: View ) = fm.setFragmentResultListener(RESULT_KEY, lifecycleOwner, SnackbarResultListener(snackbarHost)) fun unregisterCallback(fm: FragmentManager) = fm.clearFragmentResultListener(RESULT_KEY) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogViewModel.kt ================================================ package org.koitharu.kotatsu.download.ui.dialog import androidx.collection.ArrayMap import androidx.collection.ArraySet import androidx.collection.MutableLongLongMap import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.getPreferredBranch 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.AppSettings import org.koitharu.kotatsu.core.prefs.DownloadFormat 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.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.download.ui.worker.DownloadTask import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.sizeOrZero import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy import org.koitharu.kotatsu.settings.storage.DirectoryModel import javax.inject.Inject @HiltViewModel class DownloadDialogViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val scheduler: DownloadWorker.Scheduler, private val localStorageManager: LocalStorageManager, private val localMangaRepository: LocalMangaRepository, private val mangaRepositoryFactory: MangaRepository.Factory, private val historyRepository: HistoryRepository, private val settings: AppSettings, ) : BaseViewModel() { val manga = savedStateHandle.require>(AppRouter.KEY_MANGA).map { it.manga } private val mangaDetails = suspendLazy { coroutineScope { manga.map { m -> async { m.getDetails() } }.awaitAll() } } val onScheduled = MutableEventFlow() val defaultFormat = MutableStateFlow(null) val availableDestinations = MutableStateFlow(listOf(defaultDestination())) val chaptersSelectOptions = MutableStateFlow( ChapterSelectOptions( wholeManga = ChaptersSelectMacro.WholeManga(0), wholeBranch = null, firstChapters = null, unreadChapters = null, ), ) val isOptionsLoading = MutableStateFlow(true) init { launchJob(Dispatchers.Default) { defaultFormat.value = settings.preferredDownloadFormat } launchJob(Dispatchers.Default) { try { loadAvailableOptions() } finally { isOptionsLoading.value = false } } loadAvailableDestinations() } fun confirm( startNow: Boolean, chaptersMacro: ChaptersSelectMacro, format: DownloadFormat?, destination: DirectoryModel?, allowMetered: Boolean, ) { launchLoadingJob(Dispatchers.Default) { val tasks = mangaDetails.get().map { m -> val chapters = checkNotNull(m.chapters) { "Manga \"${m.title}\" cannot be loaded" } m to DownloadTask( mangaId = m.id, isPaused = !startNow, isSilent = false, chaptersIds = chaptersMacro.getChaptersIds(m.id, chapters)?.toLongArray(), destination = destination?.file, format = format, allowMeteredNetwork = allowMetered, ) } scheduler.schedule(tasks) onScheduled.call(startNow) } } fun setSelectedBranch(branch: String?) { val snapshot = chaptersSelectOptions.value chaptersSelectOptions.value = snapshot.copy( wholeBranch = snapshot.wholeBranch?.copy(branch), ) } fun setFirstChaptersCount(count: Int) { val snapshot = chaptersSelectOptions.value chaptersSelectOptions.value = snapshot.copy( firstChapters = snapshot.firstChapters?.copy(count), ) } fun setUnreadChaptersCount(count: Int) { val snapshot = chaptersSelectOptions.value chaptersSelectOptions.value = snapshot.copy( unreadChapters = snapshot.unreadChapters?.copy(count), ) } private fun defaultDestination() = DirectoryModel( title = null, titleRes = R.string.system_default, file = null, isRemovable = false, isChecked = true, isAvailable = true, ) private suspend fun loadAvailableOptions() { val details = mangaDetails.get() var totalChapters = 0 val branches = ArrayMap() var maxChapters = 0 var maxUnreadChapters = 0 val preferredBranches = ArraySet(details.size) val currentChaptersIds = MutableLongLongMap(details.size) details.forEach { m -> val history = historyRepository.getOne(m) if (history != null) { currentChaptersIds[m.id] = history.chapterId val unreadChaptersCount = m.chapters?.dropWhile { it.id != history.chapterId }.sizeOrZero() maxUnreadChapters = maxOf(maxUnreadChapters, unreadChaptersCount) } else { maxUnreadChapters = maxOf(maxUnreadChapters, m.chapters.sizeOrZero()) } maxChapters = maxOf(maxChapters, m.chapters.sizeOrZero()) preferredBranches.add(m.getPreferredBranch(history)) m.chapters?.forEach { c -> totalChapters++ branches.increment(c.branch) } } val defaultBranch = preferredBranches.firstOrNull() chaptersSelectOptions.value = ChapterSelectOptions( wholeManga = ChaptersSelectMacro.WholeManga(totalChapters), wholeBranch = if (branches.size > 1) { ChaptersSelectMacro.WholeBranch( branches = branches, selectedBranch = defaultBranch, ) } else { null }, firstChapters = if (maxChapters > 0) { ChaptersSelectMacro.FirstChapters( chaptersCount = minOf(5, maxChapters), maxAvailableCount = maxChapters, branch = defaultBranch, ) } else { null }, unreadChapters = if (currentChaptersIds.isNotEmpty()) { ChaptersSelectMacro.UnreadChapters( chaptersCount = minOf(5, maxUnreadChapters), maxAvailableCount = maxUnreadChapters, currentChaptersIds = currentChaptersIds, ) } else { null }, ) } private fun loadAvailableDestinations() = launchJob(Dispatchers.Default) { val defaultDir = manga.mapToSet { localMangaRepository.getOutputDir(it, null) }.singleOrNull() val dirs = localStorageManager.getWriteableDirs() availableDestinations.value = buildList(dirs.size + 1) { if (defaultDir == null) { add(defaultDestination()) } else if (defaultDir !in dirs) { add( DirectoryModel( title = localStorageManager.getDirectoryDisplayName(defaultDir, isFullPath = false), titleRes = 0, file = defaultDir, isChecked = true, isAvailable = true, isRemovable = false, ), ) } dirs.mapTo(this) { dir -> DirectoryModel( title = localStorageManager.getDirectoryDisplayName(dir, isFullPath = false), titleRes = 0, file = dir, isChecked = dir == defaultDir, isAvailable = true, isRemovable = false, ) } } } private suspend fun Manga.getDetails(): Manga = runCatchingCancellable { mangaRepositoryFactory.create(source).getDetails(this) }.onFailure { e -> e.printStackTraceDebug() }.getOrDefault(this) private fun MutableMap.increment(key: T) { put(key, getOrDefault(key, 0) + 1) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt ================================================ package org.koitharu.kotatsu.download.ui.list import android.view.View import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.work.WorkInfo import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.setContentDescriptionAndTooltip import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ItemDownloadBinding import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter import org.koitharu.kotatsu.download.ui.list.chapters.downloadChapterAD import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.util.format fun downloadItemAD( lifecycleOwner: LifecycleOwner, listener: DownloadItemListener, ) = adapterDelegateViewBinding( { inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) }, ) { val percentPattern = context.resources.getString(R.string.percent_string_pattern) var chaptersJob: Job? = null val clickListener = object : View.OnClickListener, View.OnLongClickListener { override fun onClick(v: View) { when (v.id) { R.id.button_cancel -> listener.onCancelClick(item) R.id.button_resume -> listener.onResumeClick(item) R.id.button_skip -> listener.onSkipClick(item) R.id.button_skip_all -> listener.onSkipAllClick(item) R.id.button_pause -> listener.onPauseClick(item) R.id.button_expand -> listener.onExpandClick(item) else -> listener.onItemClick(item, v) } } override fun onLongClick(v: View): Boolean { return listener.onItemLongClick(item, v) } } val chaptersAdapter = BaseListAdapter() .addDelegate(ListItemType.CHAPTER_LIST, downloadChapterAD()) binding.recyclerViewChapters.adapter = chaptersAdapter binding.buttonCancel.setOnClickListener(clickListener) binding.buttonPause.setOnClickListener(clickListener) binding.buttonResume.setOnClickListener(clickListener) binding.buttonSkip.setOnClickListener(clickListener) binding.buttonSkipAll.setOnClickListener(clickListener) binding.buttonExpand.setOnClickListener(clickListener) itemView.setOnClickListener(clickListener) itemView.setOnLongClickListener(clickListener) fun scrollToCurrentChapter() { val rv = binding.recyclerViewChapters if (!rv.isVisible) { return } val chapters = chaptersAdapter.items if (chapters.isEmpty()) { return } val targetPos = item.chaptersDownloaded.coerceIn(chapters.indices) (rv.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(targetPos, rv.height / 3) } bind { payloads -> binding.textViewTitle.text = item.manga?.title ?: getString(R.string.unknown) binding.imageViewCover.setImageAsync(item.manga?.coverUrl, item.manga) if (chaptersJob == null || payloads.isEmpty()) { chaptersJob?.cancel() chaptersJob = lifecycleOwner.lifecycleScope.launch(start = CoroutineStart.UNDISPATCHED) { item.chapters.collect { chapters -> binding.buttonExpand.isGone = chapters.isNullOrEmpty() chaptersAdapter.emit(chapters) scrollToCurrentChapter() } } } else if (ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads) { binding.recyclerViewChapters.post { scrollToCurrentChapter() } } binding.buttonExpand.isChecked = item.isExpanded binding.buttonExpand.setContentDescriptionAndTooltip(if (item.isExpanded) R.string.collapse else R.string.expand) binding.recyclerViewChapters.isVisible = item.isExpanded when (item.workState) { WorkInfo.State.ENQUEUED, WorkInfo.State.BLOCKED -> { binding.textViewStatus.setText(R.string.queued) binding.progressBar.isIndeterminate = false binding.progressBar.isVisible = false binding.progressBar.isEnabled = true binding.textViewPercent.isVisible = false binding.textViewDetails.isVisible = false binding.buttonCancel.isVisible = true binding.buttonResume.isVisible = false binding.buttonSkip.isVisible = false binding.buttonSkipAll.isVisible = false binding.buttonPause.isVisible = false } WorkInfo.State.RUNNING -> { binding.textViewStatus.setText( if (item.isPaused) R.string.paused else R.string.manga_downloading_, ) binding.progressBar.isIndeterminate = item.isIndeterminate binding.progressBar.isVisible = true binding.progressBar.max = item.max binding.progressBar.isEnabled = !item.isPaused binding.progressBar.setProgressCompat(item.progress, payloads.isNotEmpty()) binding.textViewPercent.text = percentPattern.format((item.percent * 100f).format(1)) binding.textViewPercent.isVisible = true binding.textViewDetails.textAndVisible = when { item.isPaused -> item.getErrorMessage(context) item.isStuck -> context.getString(R.string.stuck) else -> item.getEtaString() } binding.buttonCancel.isVisible = true binding.buttonResume.isVisible = item.isPaused binding.buttonResume.setText(if (item.error == null) R.string.resume else R.string.retry) binding.buttonSkip.isVisible = item.isPaused && item.error != null binding.buttonSkipAll.isVisible = item.isPaused && item.error != null binding.buttonPause.isVisible = item.canPause } WorkInfo.State.SUCCEEDED -> { binding.textViewStatus.setText(R.string.download_complete) binding.progressBar.isIndeterminate = false binding.progressBar.isVisible = false binding.progressBar.isEnabled = true binding.textViewPercent.isVisible = false if (item.chaptersDownloaded > 0) { binding.textViewDetails.text = context.resources.getQuantityStringSafe( R.plurals.chapters, item.chaptersDownloaded, item.chaptersDownloaded, ) binding.textViewDetails.isVisible = true } else { binding.textViewDetails.isVisible = false } binding.buttonCancel.isVisible = false binding.buttonResume.isVisible = false binding.buttonSkip.isVisible = false binding.buttonSkipAll.isVisible = false binding.buttonPause.isVisible = false } WorkInfo.State.FAILED -> { binding.textViewStatus.setText(R.string.error_occurred) binding.progressBar.isIndeterminate = false binding.progressBar.isVisible = false binding.progressBar.isEnabled = true binding.textViewPercent.isVisible = false binding.textViewDetails.textAndVisible = item.getErrorMessage(context) binding.buttonCancel.isVisible = false binding.buttonResume.isVisible = false binding.buttonSkip.isVisible = false binding.buttonSkipAll.isVisible = false binding.buttonPause.isVisible = false } WorkInfo.State.CANCELLED -> { binding.textViewStatus.setText(R.string.canceled) binding.progressBar.isIndeterminate = false binding.progressBar.isVisible = false binding.progressBar.isEnabled = true binding.textViewPercent.isVisible = false binding.textViewDetails.isVisible = false binding.buttonCancel.isVisible = false binding.buttonResume.isVisible = false binding.buttonSkip.isVisible = false binding.buttonSkipAll.isVisible = false binding.buttonPause.isVisible = false } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemListener.kt ================================================ package org.koitharu.kotatsu.download.ui.list import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener interface DownloadItemListener : OnListItemClickListener { fun onCancelClick(item: DownloadItemModel) fun onPauseClick(item: DownloadItemModel) fun onResumeClick(item: DownloadItemModel) fun onSkipClick(item: DownloadItemModel) fun onSkipAllClick(item: DownloadItemModel) fun onExpandClick(item: DownloadItemModel) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemModel.kt ================================================ package org.koitharu.kotatsu.download.ui.list import android.content.Context import android.graphics.Color import android.text.format.DateUtils import androidx.core.text.bold import androidx.core.text.buildSpannedString import androidx.core.text.color import androidx.work.WorkInfo import kotlinx.coroutines.flow.StateFlow import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.Manga import java.time.Instant import java.util.UUID import androidx.appcompat.R as appcompatR data class DownloadItemModel( val id: UUID, val workState: WorkInfo.State, val isIndeterminate: Boolean, val isPaused: Boolean, val manga: Manga?, val error: String?, val max: Int, val progress: Int, val eta: Long, val isStuck: Boolean, val timestamp: Instant, val chaptersDownloaded: Int, val isExpanded: Boolean, val chapters: StateFlow?>, ) : ListModel, Comparable { val percent: Float get() = if (max > 0) progress / max.toFloat() else 0f val hasEta: Boolean get() = workState == WorkInfo.State.RUNNING && !isPaused && eta > 0L val canPause: Boolean get() = workState == WorkInfo.State.RUNNING && !isPaused && error == null val canResume: Boolean get() = workState == WorkInfo.State.RUNNING && isPaused fun getEtaString(): CharSequence? = if (hasEta) { DateUtils.getRelativeTimeSpanString( eta, System.currentTimeMillis(), DateUtils.SECOND_IN_MILLIS, ) } else { null } fun getErrorMessage(context: Context): CharSequence? = if (error != null) { buildSpannedString { bold { color(context.getThemeColor(appcompatR.attr.colorError, Color.RED)) { append(error) } } } } else { null } override fun compareTo(other: DownloadItemModel): Int { return timestamp compareTo other.timestamp } override fun areItemsTheSame(other: ListModel): Boolean { return other is DownloadItemModel && other.id == id } override fun getChangePayload(previousState: ListModel): Any? = when { previousState !is DownloadItemModel -> super.getChangePayload(previousState) workState != previousState.workState -> null isExpanded != previousState.isExpanded -> ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED else -> ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt ================================================ package org.koitharu.kotatsu.download.ui.list import android.os.Bundle import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.activity.viewModels import androidx.appcompat.view.ActionMode import androidx.core.graphics.Insets 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.nav.router import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper import org.koitharu.kotatsu.core.ui.util.MenuInvalidator import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import javax.inject.Inject @AndroidEntryPoint class DownloadsActivity : BaseActivity(), DownloadItemListener, ListSelectionController.Callback { @Inject lateinit var coil: ImageLoader @Inject lateinit var scheduler: DownloadWorker.Scheduler private val viewModel by viewModels() private lateinit var selectionController: ListSelectionController override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityDownloadsBinding.inflate(layoutInflater)) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) val downloadsAdapter = DownloadsAdapter(this, this) val decoration = TypedListSpacingDecoration(this, false) selectionController = ListSelectionController( appCompatDelegate = delegate, decoration = DownloadsSelectionDecoration(this), registryOwner = this, callback = this, ) with(viewBinding.recyclerView) { setHasFixedSize(true) addItemDecoration(decoration) adapter = downloadsAdapter selectionController.attachToRecyclerView(this) RecyclerScrollKeeper(this).attach() } addMenuProvider(DownloadsMenuProvider(this, viewModel)) viewModel.items.observe(this, downloadsAdapter) viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.recyclerView)) val menuInvalidator = MenuInvalidator(this) viewModel.hasActiveWorks.observe(this, menuInvalidator) viewModel.hasPausedWorks.observe(this, menuInvalidator) viewModel.hasCancellableWorks.observe(this, menuInvalidator) } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) viewBinding.recyclerView.updatePadding( left = bars.left, right = bars.right, bottom = bars.bottom, ) viewBinding.appbar.updatePadding( left = bars.left, right = bars.right, top = bars.top, ) return WindowInsetsCompat.Builder(insets) .setInsets(WindowInsetsCompat.Type.systemBars(), Insets.NONE) .build() } override fun onItemClick(item: DownloadItemModel, view: View) { if (selectionController.onItemClick(item.id.mostSignificantBits)) { return } router.openDetails(item.manga ?: return) } override fun onItemLongClick(item: DownloadItemModel, view: View): Boolean { return selectionController.onItemLongClick(view, item.id.mostSignificantBits) } override fun onItemContextClick(item: DownloadItemModel, view: View): Boolean { return selectionController.onItemContextClick(view, item.id.mostSignificantBits) } override fun onExpandClick(item: DownloadItemModel) { if (!selectionController.onItemClick(item.id.mostSignificantBits)) { viewModel.expandCollapse(item) } } override fun onCancelClick(item: DownloadItemModel) { viewModel.cancel(item.id) } override fun onPauseClick(item: DownloadItemModel) { scheduler.pause(item.id) } override fun onResumeClick(item: DownloadItemModel) { scheduler.resume(item.id) } override fun onSkipClick(item: DownloadItemModel) { scheduler.skip(item.id) } override fun onSkipAllClick(item: DownloadItemModel) { scheduler.skipAll(item.id) } override fun onSelectionChanged(controller: ListSelectionController, count: Int) { viewBinding.recyclerView.invalidateItemDecorations() } override fun onCreateActionMode( controller: ListSelectionController, menuInflater: MenuInflater, menu: Menu ): Boolean { menuInflater.inflate(R.menu.mode_downloads, menu) return true } override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean { return when (item.itemId) { R.id.action_resume -> { viewModel.resume(controller.snapshot()) mode?.finish() true } R.id.action_pause -> { viewModel.pause(controller.snapshot()) mode?.finish() true } R.id.action_cancel -> { viewModel.cancel(controller.snapshot()) mode?.finish() true } R.id.action_remove -> { viewModel.remove(controller.snapshot()) mode?.finish() true } R.id.action_select_all -> { controller.addAll(viewModel.allIds()) true } else -> false } } override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode?, menu: Menu): Boolean { val snapshot = viewModel.snapshot(controller.peekCheckedIds()) var canPause = true var canResume = true var canCancel = true var canRemove = true for (item in snapshot) { canPause = canPause and item.canPause canResume = canResume and item.canResume canCancel = canCancel and !item.workState.isFinished canRemove = canRemove and item.workState.isFinished } menu.findItem(R.id.action_pause)?.isVisible = canPause menu.findItem(R.id.action_resume)?.isVisible = canResume menu.findItem(R.id.action_cancel)?.isVisible = canCancel menu.findItem(R.id.action_remove)?.isVisible = canRemove return super.onPrepareActionMode(controller, mode, menu) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsAdapter.kt ================================================ package org.koitharu.kotatsu.download.ui.list import androidx.lifecycle.LifecycleOwner import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.model.ListModel class DownloadsAdapter( lifecycleOwner: LifecycleOwner, listener: DownloadItemListener, ) : BaseListAdapter() { init { addDelegate(ListItemType.DOWNLOAD, downloadItemAD(lifecycleOwner, listener)) addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(null)) addDelegate(ListItemType.HEADER, listHeaderAD(null)) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsMenuProvider.kt ================================================ package org.koitharu.kotatsu.download.ui.list import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.core.view.MenuProvider import androidx.fragment.app.FragmentActivity import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog class DownloadsMenuProvider( private val activity: FragmentActivity, private val viewModel: DownloadsViewModel, ) : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.opt_downloads, menu) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { when (menuItem.itemId) { R.id.action_pause -> viewModel.pauseAll() R.id.action_resume -> viewModel.resumeAll() R.id.action_cancel_all -> confirmCancelAll() R.id.action_remove_completed -> confirmRemoveCompleted() R.id.action_settings -> activity.router.openDownloadsSetting() else -> return false } return true } override fun onPrepareMenu(menu: Menu) { super.onPrepareMenu(menu) menu.findItem(R.id.action_pause)?.isVisible = viewModel.hasActiveWorks.value == true menu.findItem(R.id.action_resume)?.isVisible = viewModel.hasPausedWorks.value == true menu.findItem(R.id.action_cancel_all)?.isVisible = viewModel.hasCancellableWorks.value == true } private fun confirmCancelAll() { buildAlertDialog(activity, isCentered = true) { setTitle(R.string.cancel_all) setMessage(R.string.cancel_all_downloads_confirm) setIcon(R.drawable.ic_cancel_multiple) setNegativeButton(android.R.string.cancel, null) setPositiveButton(R.string.confirm) { _, _ -> viewModel.cancelAll() } }.show() } private fun confirmRemoveCompleted() { buildAlertDialog(activity, isCentered = true) { setTitle(R.string.remove_completed) setMessage(R.string.remove_completed_downloads_confirm) setIcon(R.drawable.ic_clear_all) setNegativeButton(android.R.string.cancel, null) setPositiveButton(R.string.clear) { _, _ -> viewModel.removeCompleted() } }.show() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsSelectionDecoration.kt ================================================ package org.koitharu.kotatsu.download.ui.list import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.RectF import android.view.View import androidx.cardview.widget.CardView import androidx.core.content.ContextCompat import androidx.core.graphics.ColorUtils import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.NO_ID import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration import org.koitharu.kotatsu.core.util.ext.getItem import org.koitharu.kotatsu.core.util.ext.getThemeColor import androidx.appcompat.R as appcompatR import com.google.android.material.R as materialR class DownloadsSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() { private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle) private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.card_indicator_offset) private val iconSize = context.resources.getDimensionPixelOffset(R.dimen.card_indicator_size) private val strokeColor = context.getThemeColor(appcompatR.attr.colorPrimary, Color.RED) private val fillColor = ColorUtils.setAlphaComponent( ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f), 0x74, ) private val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner) init { hasBackground = false hasForeground = true isIncludeDecorAndMargins = false paint.strokeWidth = context.resources.getDimension(R.dimen.selection_stroke_width) checkIcon?.setTint(strokeColor) } override fun getItemId(parent: RecyclerView, child: View): Long { val holder = parent.getChildViewHolder(child) ?: return NO_ID val item = holder.getItem(DownloadItemModel::class.java) ?: return NO_ID return item.id.mostSignificantBits } override fun onDrawForeground( canvas: Canvas, parent: RecyclerView, child: View, bounds: RectF, state: RecyclerView.State, ) { val isCard = child is CardView val radius = (child as? CardView)?.radius ?: defaultRadius paint.color = fillColor paint.style = Paint.Style.FILL canvas.drawRoundRect(bounds, radius, radius, paint) paint.color = strokeColor paint.style = Paint.Style.STROKE canvas.drawRoundRect(bounds, radius, radius, paint) if (isCard) { checkIcon?.run { setBounds( (bounds.right - iconSize - iconOffset).toInt(), (bounds.top + iconOffset).toInt(), (bounds.right - iconOffset).toInt(), (bounds.top + iconOffset + iconSize).toInt(), ) draw(canvas) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt ================================================ package org.koitharu.kotatsu.download.ui.list import androidx.collection.ArrayMap import androidx.collection.LongSet import androidx.collection.LongSparseArray import androidx.collection.getOrElse import androidx.collection.set import androidx.lifecycle.viewModelScope import androidx.work.WorkInfo import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.plus import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.model.DateTimeAgo import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.isEmpty import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter import org.koitharu.kotatsu.download.ui.worker.DownloadWorker 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.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.util.LinkedList import java.util.UUID import javax.inject.Inject @HiltViewModel class DownloadsViewModel @Inject constructor( private val workScheduler: DownloadWorker.Scheduler, private val mangaDataRepository: MangaDataRepository, private val mangaRepositoryFactory: MangaRepository.Factory, @LocalStorageChanges private val localStorageChanges: MutableSharedFlow, private val localMangaRepository: LocalMangaRepository, ) : BaseViewModel() { private val mangaCache = LongSparseArray() private val cacheMutex = Mutex() private val expanded = MutableStateFlow(emptySet()) private val chaptersCache = ArrayMap?>>() private val works = combine( workScheduler.observeWorks(), expanded, ) { list, exp -> list.toDownloadsList(exp) }.withErrorHandling() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) val onActionDone = MutableEventFlow() val items = works.map { it?.toUiList() ?: listOf(LoadingState) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) val hasPausedWorks = works.map { it?.any { x -> x.canResume } == true }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), false) val hasActiveWorks = works.map { it?.any { x -> x.canPause } == true }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), false) val hasCancellableWorks = works.map { it?.any { x -> !x.workState.isFinished } == true }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), false) fun cancel(id: UUID) { launchJob(Dispatchers.Default) { workScheduler.cancel(id) } } fun cancel(ids: Set) { launchJob(Dispatchers.Default) { val snapshot = works.value ?: return@launchJob for (work in snapshot) { if (work.id.mostSignificantBits in ids) { workScheduler.cancel(work.id) } } onActionDone.call(ReversibleAction(R.string.downloads_cancelled, null)) } } fun cancelAll() { launchJob(Dispatchers.Default) { workScheduler.cancelAll() onActionDone.call(ReversibleAction(R.string.downloads_cancelled, null)) } } fun pause(ids: Set) { val snapshot = works.value ?: return for (work in snapshot) { if (work.id.mostSignificantBits in ids) { workScheduler.pause(work.id) } } onActionDone.call(ReversibleAction(R.string.downloads_paused, null)) } fun pauseAll() { val snapshot = works.value ?: return var isPaused = false for (work in snapshot) { if (work.canPause) { workScheduler.pause(work.id) isPaused = true } } if (isPaused) { onActionDone.call(ReversibleAction(R.string.downloads_paused, null)) } } fun resumeAll() { val snapshot = works.value ?: return var isResumed = false for (work in snapshot) { if (work.workState == WorkInfo.State.RUNNING && work.isPaused) { workScheduler.resume(work.id) isResumed = true } } if (isResumed) { onActionDone.call(ReversibleAction(R.string.downloads_resumed, null)) } } fun resume(ids: Set) { val snapshot = works.value ?: return for (work in snapshot) { if (work.id.mostSignificantBits in ids) { workScheduler.resume(work.id) } } onActionDone.call(ReversibleAction(R.string.downloads_resumed, null)) } fun remove(ids: Set) { launchJob(Dispatchers.Default) { val snapshot = works.value ?: return@launchJob val uuids = HashSet(ids.size) for (work in snapshot) { if (work.id.mostSignificantBits in ids) { uuids.add(work.id) } } workScheduler.delete(uuids) onActionDone.call(ReversibleAction(R.string.downloads_removed, null)) } } fun removeCompleted() { launchJob(Dispatchers.Default) { workScheduler.removeCompleted() onActionDone.call(ReversibleAction(R.string.downloads_removed, null)) } } fun snapshot(ids: LongSet): Collection { return works.value?.filterTo(ArrayList(ids.size)) { x -> x.id.mostSignificantBits in ids }.orEmpty() } fun allIds(): Set = works.value?.mapToSet { it.id.mostSignificantBits } ?: emptySet() fun expandCollapse(item: DownloadItemModel) { expanded.update { if (item.id in it) { it - item.id } else { it + item.id } } } private suspend fun List.toDownloadsList(exp: Set): List { if (isEmpty()) { return emptyList() } val list = mapNotNullTo(ArrayList(size)) { it.toUiModel(it.id in exp) } list.sortByDescending { it.timestamp } return list } private fun List.toUiList(): List { if (isEmpty()) { return emptyStateList() } val queued = LinkedList() val running = LinkedList() val destination = ArrayDeque((size * 1.4).toInt()) var prevDate: DateTimeAgo? = null for (item in this) { when (item.workState) { WorkInfo.State.RUNNING -> running += item WorkInfo.State.BLOCKED, WorkInfo.State.ENQUEUED -> queued += item else -> { val date = calculateTimeAgo(item.timestamp) if (prevDate != date) { destination += if (date != null) { ListHeader(date) } else { ListHeader(R.string.unknown) } } prevDate = date destination += item } } } if (running.isNotEmpty()) { running.addFirst(ListHeader(R.string.in_progress)) } destination.addAll(0, running) if (queued.isNotEmpty()) { queued.addFirst(ListHeader(R.string.queued)) } destination.addAll(0, queued) return destination } private suspend fun WorkInfo.toUiModel(isExpanded: Boolean): DownloadItemModel? { val workData = outputData.takeUnless { it.isEmpty } ?: progress.takeUnless { it.isEmpty } ?: workScheduler.getInputData(id) ?: return null val mangaId = DownloadState.getMangaId(workData) if (mangaId == 0L) return null val manga = getManga(mangaId) ?: return null val chapters = synchronized(chaptersCache) { chaptersCache.getOrPut(id) { observeChapters(manga, id) } } return DownloadItemModel( id = id, workState = state, manga = manga, error = DownloadState.getError(workData), isIndeterminate = DownloadState.isIndeterminate(workData), isPaused = DownloadState.isPaused(workData), max = DownloadState.getMax(workData), progress = DownloadState.getProgress(workData), eta = DownloadState.getEta(workData), isStuck = DownloadState.isStuck(workData), timestamp = DownloadState.getTimestamp(workData), chaptersDownloaded = DownloadState.getDownloadedChapters(workData), isExpanded = isExpanded, chapters = chapters, ) } private fun emptyStateList() = listOf( EmptyState( icon = R.drawable.ic_empty_common, textPrimary = R.string.text_downloads_list_holder, textSecondary = 0, actionStringRes = 0, ), ) private suspend fun getManga(mangaId: Long): Manga? { mangaCache[mangaId]?.let { return it } return cacheMutex.withLock { mangaCache.getOrElse(mangaId) { mangaDataRepository.findMangaById(mangaId, withChapters = true)?.also { mangaCache[mangaId] = it } ?: return null } } } private fun observeChapters(manga: Manga, workId: UUID): StateFlow?> = flow { val chapterIds = workScheduler.getTask(workId)?.chaptersIds val chapters = (tryLoad(manga) ?: manga).chapters ?: return@flow suspend fun mapChapters(): List { val size = chapterIds?.size ?: chapters.size val localChapters = localMangaRepository.findSavedManga(manga)?.manga?.chapters?.mapToSet { it.id }.orEmpty() return chapters.mapNotNullTo(ArrayList(size)) { if (chapterIds == null || it.id in chapterIds) { DownloadChapter( number = it.numberString(), name = it.name, isDownloaded = it.id in localChapters, ) } else { null } } } emit(mapChapters()) localStorageChanges.collect { if (it?.manga?.id == manga.id) { emit(mapChapters()) } } }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) private suspend fun tryLoad(manga: Manga) = runCatchingCancellable { mangaRepositoryFactory.create(manga.source).getDetails(manga) }.getOrNull() } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/chapters/DownloadChapter.kt ================================================ package org.koitharu.kotatsu.download.ui.list.chapters import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.model.ListModel data class DownloadChapter( val number: String?, val name: String, val isDownloaded: Boolean, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is DownloadChapter && other.name == name } override fun getChangePayload(previousState: ListModel): Any? { return if (previousState is DownloadChapter && previousState.name == name && previousState.number == number) { ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED } else { super.getChangePayload(previousState) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/chapters/DownloadChapterAD.kt ================================================ package org.koitharu.kotatsu.download.ui.list.chapters import androidx.core.content.ContextCompat import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.drawableEnd import org.koitharu.kotatsu.databinding.ItemChapterDownloadBinding fun downloadChapterAD() = adapterDelegateViewBinding( { layoutInflater, parent -> ItemChapterDownloadBinding.inflate(layoutInflater, parent, false) }, ) { val iconDone = ContextCompat.getDrawable(context, R.drawable.ic_check) bind { binding.textViewNumber.text = item.number binding.textViewTitle.text = item.name binding.textViewTitle.drawableEnd = if (item.isDownloaded) iconDone else null } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt ================================================ package org.koitharu.kotatsu.download.ui.worker import android.app.Notification import android.app.PendingIntent import android.content.Context import android.content.Intent import android.graphics.drawable.Drawable import android.text.format.DateUtils import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.PendingIntentCompat import androidx.core.graphics.drawable.toBitmap import androidx.work.WorkManager import coil3.ImageLoader import coil3.request.ImageRequest import coil3.request.allowHardware import coil3.size.Scale import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ErrorReporterReceiver import org.koitharu.kotatsu.core.LocalizedAppContext import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow import org.koitharu.kotatsu.core.util.ext.getNotificationIconSize import org.koitharu.kotatsu.core.util.ext.isReportable import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.ui.list.DownloadsActivity import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.util.UUID import androidx.appcompat.R as appcompatR private const val CHANNEL_ID_DEFAULT = "download" private const val CHANNEL_ID_SILENT = "download_bg" private const val GROUP_ID = "downloads" class DownloadNotificationFactory @AssistedInject constructor( @LocalizedAppContext private val context: Context, private val workManager: WorkManager, private val coil: ImageLoader, @Assisted private val uuid: UUID, @Assisted val isSilent: Boolean, ) { private val covers = HashMap() // TODO cache private val builder = NotificationCompat.Builder(context, if (isSilent) CHANNEL_ID_SILENT else CHANNEL_ID_DEFAULT) private val mutex = Mutex() private val queueIntent = PendingIntentCompat.getActivity( context, 0, Intent(context, DownloadsActivity::class.java), 0, false, ) private val actionCancel by lazy { NotificationCompat.Action( appcompatR.drawable.abc_ic_clear_material, context.getString(android.R.string.cancel), workManager.createCancelPendingIntent(uuid), ) } private val actionPause by lazy { NotificationCompat.Action( R.drawable.ic_action_pause, context.getString(R.string.pause), PausingReceiver.createPausePendingIntent(context, uuid), ) } private val actionResume by lazy { NotificationCompat.Action( R.drawable.ic_action_resume, context.getString(R.string.resume), PausingReceiver.createResumePendingIntent(context, uuid), ) } private val actionRetry by lazy { NotificationCompat.Action( R.drawable.ic_retry, context.getString(R.string.retry), actionResume.actionIntent, ) } private val actionSkip by lazy { NotificationCompat.Action( R.drawable.ic_action_skip, context.getString(R.string.skip), PausingReceiver.createSkipPendingIntent(context, uuid), ) } init { createChannels() builder.setOnlyAlertOnce(true) builder.setDefaults(0) builder.foregroundServiceBehavior = if (isSilent) { NotificationCompat.FOREGROUND_SERVICE_DEFERRED } else { NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE } builder.setSilent(true) builder.setGroup(GROUP_ID) builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) builder.priority = if (isSilent) NotificationCompat.PRIORITY_MIN else NotificationCompat.PRIORITY_DEFAULT } suspend fun create(state: DownloadState?): Notification = mutex.withLock { if (state == null) { builder.setContentTitle(context.getString(R.string.manga_downloading_)) builder.setContentText(context.getString(R.string.preparing_)) } else { builder.setContentTitle(state.manga.title) builder.setContentText(context.getString(R.string.manga_downloading_)) } builder.setProgress(1, 0, true) builder.setSmallIcon(android.R.drawable.stat_sys_download) builder.setContentIntent(queueIntent) builder.setStyle(null) builder.setLargeIcon(if (state != null) getCover(state.manga)?.toBitmap() else null) builder.clearActions() builder.setSubText(null) builder.setShowWhen(false) builder.setVisibility( if (state != null && state.manga.isNsfw()) { NotificationCompat.VISIBILITY_SECRET } else { NotificationCompat.VISIBILITY_PRIVATE }, ) when { state == null -> Unit state.localManga != null -> { // downloaded, final state builder.setProgress(0, 0, false) builder.setContentText(context.getString(R.string.download_complete)) builder.setContentIntent(createMangaIntent(context, state.localManga.manga)) builder.setAutoCancel(true) builder.setSmallIcon(android.R.drawable.stat_sys_download_done) builder.setCategory(null) builder.setStyle(null) builder.setOngoing(false) builder.setShowWhen(true) builder.setWhen(System.currentTimeMillis()) } state.isStopped -> { builder.setProgress(0, 0, false) builder.setContentText(context.getString(R.string.queued)) builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) builder.setStyle(null) builder.setOngoing(true) builder.setSmallIcon(R.drawable.ic_stat_paused) builder.addAction(actionCancel) } state.isPaused -> { // paused (with error or manually) builder.setProgress(state.max, state.progress, false) val percent = if (state.percent >= 0) { context.getString(R.string.percent_string_pattern, (state.percent * 100).format()) } else { null } if (state.errorMessage != null) { builder.setContentText( context.getString( R.string.download_summary_pattern, percent, state.errorMessage, ), ) } else { builder.setContentText(percent) } builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) builder.setStyle(null) builder.setOngoing(true) builder.setSmallIcon(R.drawable.ic_stat_paused) builder.addAction(actionCancel) if (state.errorMessage != null) { builder.addAction(actionRetry) builder.addAction(actionSkip) } else { builder.addAction(actionResume) } } state.error != null -> { // error, final state builder.setProgress(0, 0, false) builder.setSmallIcon(android.R.drawable.stat_notify_error) builder.setSubText(context.getString(R.string.error)) builder.setContentText(state.errorMessage) builder.setAutoCancel(true) builder.setOngoing(false) builder.setCategory(NotificationCompat.CATEGORY_ERROR) builder.setShowWhen(true) builder.setWhen(System.currentTimeMillis()) builder.setStyle(NotificationCompat.BigTextStyle().bigText(state.errorMessage)) if (state.error.isReportable()) { ErrorReporterReceiver.getPendingIntent(context, state.error)?.let { reportIntent -> builder.addAction( NotificationCompat.Action( 0, context.getString(R.string.report), reportIntent, ), ) } } } else -> { builder.setProgress(state.max, state.progress, false) builder.setContentText(getProgressString(state.percent, state.eta, state.isStuck)) builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) builder.setStyle(null) builder.setOngoing(true) builder.addAction(actionCancel) builder.addAction(actionPause) } } return builder.build() } private fun getProgressString(percent: Float, eta: Long, isStuck: Boolean): CharSequence? { val percentString = if (percent >= 0f) { context.getString(R.string.percent_string_pattern, (percent * 100).format()) } else { null } val etaString = when { eta <= 0L -> null isStuck -> context.getString(R.string.stuck) else -> DateUtils.getRelativeTimeSpanString( eta, System.currentTimeMillis(), DateUtils.SECOND_IN_MILLIS, ) } return when { percentString == null && etaString == null -> null percentString != null && etaString == null -> percentString percentString == null && etaString != null -> etaString else -> context.getString(R.string.download_summary_pattern, percentString, etaString) } } private fun createMangaIntent(context: Context, manga: Manga?) = PendingIntentCompat.getActivity( context, manga.hashCode(), if (manga != null) { AppRouter.detailsIntent(context, manga) } else { AppRouter.listIntent(context, LocalMangaSource, null, null) }, PendingIntent.FLAG_CANCEL_CURRENT, false, ) private suspend fun getCover(manga: Manga) = covers[manga] ?: run { runCatchingCancellable { coil.execute( ImageRequest.Builder(context) .data(manga.coverUrl) .allowHardware(false) .mangaSourceExtra(manga.source) .size(context.resources.getNotificationIconSize()) .scale(Scale.FILL) .build(), ).getDrawableOrThrow() }.onSuccess { covers[manga] = it }.onFailure { it.printStackTraceDebug() }.getOrNull() } private fun createChannels() { val manager = NotificationManagerCompat.from(context) manager.createNotificationChannel( NotificationChannelCompat.Builder(CHANNEL_ID_DEFAULT, NotificationManagerCompat.IMPORTANCE_LOW) .setName(context.getString(R.string.downloads)) .setVibrationEnabled(false) .setLightsEnabled(false) .setSound(null, null) .build(), ) manager.createNotificationChannel( NotificationChannelCompat.Builder(CHANNEL_ID_SILENT, NotificationManagerCompat.IMPORTANCE_MIN) .setName(context.getString(R.string.downloads_background)) .setVibrationEnabled(false) .setLightsEnabled(false) .setSound(null, null) .setShowBadge(false) .build(), ) } @AssistedFactory interface Factory { fun create(uuid: UUID, isSilent: Boolean): DownloadNotificationFactory } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadSlowdownDispatcher.kt ================================================ package org.koitharu.kotatsu.download.ui.worker import android.os.SystemClock import androidx.collection.MutableObjectLongMap import kotlinx.coroutines.delay import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.parsers.model.MangaSource import javax.inject.Inject import javax.inject.Singleton @Singleton class DownloadSlowdownDispatcher @Inject constructor( private val mangaRepositoryFactory: MangaRepository.Factory, ) { private val timeMap = MutableObjectLongMap() private val defaultDelay = 1_600L suspend fun delay(source: MangaSource) { val repo = mangaRepositoryFactory.create(source) as? ParserMangaRepository ?: return if (!repo.isSlowdownEnabled()) { return } val lastRequest = synchronized(timeMap) { val res = timeMap.getOrDefault(source, 0L) timeMap[source] = SystemClock.elapsedRealtime() res } if (lastRequest != 0L) { delay(lastRequest + defaultDelay - SystemClock.elapsedRealtime()) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadStartedObserver.kt ================================================ package org.koitharu.kotatsu.download.ui.worker import android.view.View import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.flow.FlowCollector import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.util.ext.findActivity import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner class DownloadStartedObserver( private val snackbarHost: View, ) : FlowCollector { override suspend fun emit(value: Unit) { val snackbar = Snackbar.make(snackbarHost, R.string.download_started, Snackbar.LENGTH_LONG) (snackbarHost.context.findActivity() as? BottomNavOwner)?.let { snackbar.anchorView = it.bottomNav } val router = AppRouter.from(snackbarHost) if (router != null) { snackbar.setAction(R.string.details) { router.openDownloads() } } snackbar.show() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadTask.kt ================================================ package org.koitharu.kotatsu.download.ui.worker import android.os.Parcelable import androidx.work.Data import kotlinx.parcelize.Parcelize import org.koitharu.kotatsu.core.prefs.DownloadFormat import org.koitharu.kotatsu.parsers.util.find import java.io.File @Parcelize class DownloadTask( val mangaId: Long, val isPaused: Boolean, val isSilent: Boolean, val chaptersIds: LongArray?, val destination: File?, val format: DownloadFormat?, val allowMeteredNetwork: Boolean, ) : Parcelable { constructor(data: Data) : this( mangaId = data.getLong(MANGA_ID, 0L), isPaused = data.getBoolean(START_PAUSED, false), isSilent = data.getBoolean(IS_SILENT, false), chaptersIds = data.getLongArray(CHAPTERS)?.takeUnless(LongArray::isEmpty), destination = data.getString(DESTINATION)?.let { File(it) }, format = data.getString(FORMAT)?.let { DownloadFormat.entries.find(it) }, allowMeteredNetwork = data.getBoolean(ALLOW_METERED, true), ) fun toData(): Data = Data.Builder() .putLong(MANGA_ID, mangaId) .putBoolean(START_PAUSED, isPaused) .putBoolean(IS_SILENT, isSilent) .putLongArray(CHAPTERS, chaptersIds ?: LongArray(0)) .putString(DESTINATION, destination?.path) .putString(FORMAT, format?.name) .build() override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as DownloadTask if (mangaId != other.mangaId) return false if (isPaused != other.isPaused) return false if (isSilent != other.isSilent) return false if (!(chaptersIds contentEquals other.chaptersIds)) return false if (destination != other.destination) return false if (format != other.format) return false if (allowMeteredNetwork != other.allowMeteredNetwork) return false return true } override fun hashCode(): Int { var result = mangaId.hashCode() result = 31 * result + isPaused.hashCode() result = 31 * result + isSilent.hashCode() result = 31 * result + (chaptersIds?.contentHashCode() ?: 0) result = 31 * result + (destination?.hashCode() ?: 0) result = 31 * result + (format?.hashCode() ?: 0) result = 31 * result + allowMeteredNetwork.hashCode() return result } private companion object { const val MANGA_ID = "manga_id" const val IS_SILENT = "silent" const val START_PAUSED = "paused" const val CHAPTERS = "chapters" const val DESTINATION = "dest" const val FORMAT = "format" const val ALLOW_METERED = "metered" } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt ================================================ package org.koitharu.kotatsu.download.ui.worker import android.annotation.SuppressLint import android.app.NotificationManager import android.content.Context import android.content.pm.ServiceInfo import android.os.Build import androidx.core.content.ContextCompat import androidx.core.net.toUri import androidx.hilt.work.HiltWorker import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.CoroutineWorker import androidx.work.Data import androidx.work.ForegroundInfo import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OutOfQuotaPolicy import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.await import dagger.Reusable import dagger.assisted.Assisted import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.internal.closeQuietly import okio.IOException import okio.buffer import okio.sink import okio.use import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.image.BitmapDecoderCompat import org.koitharu.kotatsu.core.model.ids import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.Throttler import org.koitharu.kotatsu.core.util.ext.MimeType import org.koitharu.kotatsu.core.util.ext.awaitFinishedWorkInfosByTag import org.koitharu.kotatsu.core.util.ext.awaitUpdateWork import org.koitharu.kotatsu.core.util.ext.awaitWorkInfosByTag import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.deleteWork import org.koitharu.kotatsu.core.util.ext.deleteWorks import org.koitharu.kotatsu.core.util.ext.ensureSuccess import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getWorkInputData import org.koitharu.kotatsu.core.util.ext.getWorkSpec import org.koitharu.kotatsu.core.util.ext.openSource import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.toFileOrNull import org.koitharu.kotatsu.core.util.ext.toMimeType import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull import org.koitharu.kotatsu.core.util.ext.withTicker import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.core.util.progress.RealtimeEtaEstimator import org.koitharu.kotatsu.download.domain.DownloadProgress import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.local.data.LocalMangaRepository 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.data.TempFileFilter import org.koitharu.kotatsu.local.data.input.LocalMangaParser import org.koitharu.kotatsu.local.data.output.LocalMangaOutput import org.koitharu.kotatsu.local.domain.MangaLock import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.requireBody import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.reader.domain.PageLoader import java.io.File import java.util.UUID import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject @HiltWorker class DownloadWorker @AssistedInject constructor( @Assisted appContext: Context, @Assisted params: WorkerParameters, @MangaHttpClient private val okHttp: OkHttpClient, @PageCache private val cache: LocalStorageCache, private val localMangaRepository: LocalMangaRepository, private val mangaLock: MangaLock, private val mangaDataRepository: MangaDataRepository, private val mangaRepositoryFactory: MangaRepository.Factory, private val settings: AppSettings, @LocalStorageChanges private val localStorageChanges: MutableSharedFlow, private val slowdownDispatcher: DownloadSlowdownDispatcher, private val imageProxyInterceptor: ImageProxyInterceptor, notificationFactoryFactory: DownloadNotificationFactory.Factory, ) : CoroutineWorker(appContext, params) { private val task = DownloadTask(params.inputData) private val notificationFactory = notificationFactoryFactory.create(uuid = params.id, isSilent = task.isSilent) private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @Volatile private var lastPublishedState: DownloadState? = null private val currentState: DownloadState get() = checkNotNull(lastPublishedState) private val etaEstimator = RealtimeEtaEstimator() private val notificationThrottler = Throttler(400) override suspend fun doWork(): Result { setForeground(getForegroundInfo()) val manga = mangaDataRepository.findMangaById(task.mangaId, withChapters = true) ?: return Result.failure() publishState(DownloadState(manga = manga, isIndeterminate = true).also { lastPublishedState = it }) val downloadedIds = getDoneChapters(manga) return try { val pausingHandle = PausingHandle() if (task.isPaused) { pausingHandle.pause() } withContext(pausingHandle) { downloadMangaImpl(manga, task, downloadedIds) } Result.success(currentState.toWorkData()) } catch (_: CancellationException) { withContext(NonCancellable) { val notification = notificationFactory.create(currentState.copy(isStopped = true)) notificationManager.notify(id.hashCode(), notification) } Result.failure( currentState.copy(eta = -1L, isStuck = false).toWorkData(), ) } catch (e: Exception) { e.printStackTraceDebug() Result.failure( currentState.copy( error = e, errorMessage = e.getDisplayMessage(applicationContext.resources), eta = -1L, isStuck = false, ).toWorkData(), ) } finally { notificationManager.cancel(id.hashCode()) } } override suspend fun getForegroundInfo() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { ForegroundInfo( id.hashCode(), notificationFactory.create(lastPublishedState), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, ) } else { ForegroundInfo( id.hashCode(), notificationFactory.create(lastPublishedState), ) } private suspend fun downloadMangaImpl( subject: Manga, task: DownloadTask, excludedIds: Set, ) { var manga = subject val chaptersToSkip = excludedIds.toMutableSet() val pausingReceiver = PausingReceiver(id, PausingHandle.current()) mangaLock.withLock(manga) { ContextCompat.registerReceiver( applicationContext, pausingReceiver, PausingReceiver.createIntentFilter(id), ContextCompat.RECEIVER_NOT_EXPORTED, ) val destination = localMangaRepository.getOutputDir(manga, task.destination) checkNotNull(destination) { applicationContext.getString(R.string.cannot_find_available_storage) } var output: LocalMangaOutput? = null try { if (manga.isLocal) { manga = localMangaRepository.getRemoteManga(manga) ?: error("Cannot obtain remote manga instance") } val repo = mangaRepositoryFactory.create(manga.source) val mangaDetails = if (manga.chapters.isNullOrEmpty() || manga.description.isNullOrEmpty()) repo.getDetails(manga) else manga output = LocalMangaOutput.getOrCreate( root = destination, manga = mangaDetails, format = task.format ?: settings.preferredDownloadFormat, ) val coverUrl = mangaDetails.largeCoverUrl.ifNullOrEmpty { mangaDetails.coverUrl } if (!coverUrl.isNullOrEmpty()) { downloadFile(coverUrl, destination, repo.source).let { file -> output.addCover(file, getMediaType(coverUrl, file)) file.deleteAwait() } } val chapters = getChapters(mangaDetails, task) for ((chapterIndex, chapter) in chapters.withIndex()) { checkIsPaused() if (chaptersToSkip.remove(chapter.value.id)) { publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1)) continue } val pages = runFailsafe { repo.getPages(chapter.value) } ?: continue val pageCounter = AtomicInteger(0) channelFlow { val semaphore = Semaphore(MAX_PAGES_PARALLELISM) for ((pageIndex, page) in pages.withIndex()) { checkIsPaused() launch { semaphore.withPermit { runFailsafe { val url = repo.getPageUrl(page) val file = cache[url] ?: downloadFile(url, destination, repo.source) output.addPage( chapter = chapter, file = file, pageNumber = pageIndex, type = getMediaType(url, file), ) if (file.extension == "tmp") { file.deleteAwait() } } send(pageIndex) } } } }.map { DownloadProgress( totalChapters = chapters.size, currentChapter = chapterIndex, totalPages = pages.size, currentPage = pageCounter.getAndIncrement(), ) }.withTicker(2L, TimeUnit.SECONDS).collect { progress -> publishState( currentState.copy( totalChapters = progress.totalChapters, currentChapter = progress.currentChapter, totalPages = progress.totalPages, currentPage = progress.currentPage, isIndeterminate = false, eta = etaEstimator.getEta(), isStuck = etaEstimator.isStuck(), ), ) } if (output.flushChapter(chapter.value)) { runCatchingCancellable { localStorageChanges.emit(LocalMangaParser(output.rootFile).getManga(withDetails = false)) }.onFailure(Throwable::printStackTraceDebug) } publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1)) } publishState(currentState.copy(isIndeterminate = true, eta = -1L, isStuck = false)) output.mergeWithExisting() output.finish() val localManga = LocalMangaParser(output.rootFile).getManga(withDetails = false) localStorageChanges.emit(localManga) publishState(currentState.copy(localManga = localManga, eta = -1L, isStuck = false)) } catch (e: Exception) { if (e !is CancellationException) { publishState( currentState.copy( error = e, errorMessage = e.getDisplayMessage(applicationContext.resources), ), ) } throw e } finally { withContext(NonCancellable) { applicationContext.unregisterReceiver(pausingReceiver) output?.closeQuietly() output?.cleanup() destination.listFiles(TempFileFilter())?.forEach { it.deleteAwait() } } } } } private suspend fun runFailsafe( block: suspend () -> R, ): R? { checkIsPaused() var countDown = MAX_FAILSAFE_ATTEMPTS failsafe@ while (true) { try { return block() } catch (e: IOException) { val retryDelay = if (e is TooManyRequestExceptions) { e.getRetryDelay() } else { DOWNLOAD_ERROR_DELAY } if (countDown <= 0 || retryDelay < 0 || retryDelay > MAX_RETRY_DELAY) { val pausingHandle = PausingHandle.current() if (pausingHandle.skipAllErrors()) { return null } publishState( currentState.copy( isPaused = true, error = e, errorMessage = e.getDisplayMessage(applicationContext.resources), eta = -1L, isStuck = false, ), ) countDown = MAX_FAILSAFE_ATTEMPTS pausingHandle.pause() try { pausingHandle.awaitResumed() if (pausingHandle.skipCurrentError()) { return null } } finally { publishState(currentState.copy(isPaused = false, error = null, errorMessage = null)) } } else { countDown-- delay(retryDelay) } } } } private suspend fun checkIsPaused() { val pausingHandle = PausingHandle.current() if (pausingHandle.isPaused) { publishState(currentState.copy(isPaused = true, eta = -1L, isStuck = false)) try { pausingHandle.awaitResumed() } finally { publishState(currentState.copy(isPaused = false)) } } } private suspend fun getMediaType(url: String, file: File): MimeType? = runInterruptible(Dispatchers.IO) { BitmapDecoderCompat.probeMimeType(file)?.let { return@runInterruptible it } MimeTypes.getMimeTypeFromUrl(url) } private suspend fun downloadFile( url: String, destination: File, source: MangaSource, ): File { if (url.startsWith("content:", ignoreCase = true) || url.startsWith("file:", ignoreCase = true)) { val uri = url.toUri() val cr = applicationContext.contentResolver val ext = uri.toFileOrNull()?.let { MimeTypes.getNormalizedExtension(it.name) } ?: cr.getType(uri)?.toMimeTypeOrNull()?.let { MimeTypes.getExtension(it) } val file = destination.createTempFile(ext) try { cr.openSource(uri).use { input -> file.sink(append = false).buffer().use { it.writeAllCancellable(input) } } } catch (e: Exception) { file.delete() throw e } return file } val request = PageLoader.createPageRequest(url, source) slowdownDispatcher.delay(source) return imageProxyInterceptor.interceptPageRequest(request, okHttp) .ensureSuccess() .use { response -> var file: File? = null try { response.requireBody().use { body -> file = destination.createTempFile( ext = MimeTypes.getExtension(body.contentType()?.toMimeType()) ) file.sink(append = false).buffer().use { it.writeAllCancellable(body.source()) } } } catch (e: Exception) { file?.delete() throw e } checkNotNull(file) } } private fun File.createTempFile(ext: String?) = File( this, buildString { append(UUID.randomUUID().toString()) if (!ext.isNullOrEmpty()) { append('.') append(ext) } append(".tmp") }, ) private suspend fun publishState(state: DownloadState) { val previousState = currentState lastPublishedState = state if (previousState.isParticularProgress && state.isParticularProgress) { etaEstimator.onProgressChanged(state.progress, state.max) } else { etaEstimator.reset() notificationThrottler.reset() } val notification = notificationFactory.create(state) if (state.isFinalState) { if (!notificationFactory.isSilent) { notificationManager.notify(id.toString(), id.hashCode(), notification) } } else if (notificationThrottler.throttle()) { notificationManager.notify(id.hashCode(), notification) } else { return } setProgress(state.toWorkData()) } private suspend fun getDoneChapters(manga: Manga) = runCatchingCancellable { localMangaRepository.getDetails(manga).chapters?.ids() }.getOrNull().orEmpty() private fun getChapters( manga: Manga, task: DownloadTask, ): List> { val chapters = checkNotNull(manga.chapters) { "Chapters list must not be null" } val chaptersIdsSet = task.chaptersIds?.toMutableSet() val result = ArrayList>((chaptersIdsSet ?: chapters).size) val counters = HashMap() for (chapter in chapters) { val index = counters[chapter.branch] ?: 0 counters[chapter.branch] = index + 1 if (chaptersIdsSet != null && !chaptersIdsSet.remove(chapter.id)) { continue } result.add(IndexedValue(index, chapter)) } if (chaptersIdsSet != null) { check(chaptersIdsSet.isEmpty()) { "${chaptersIdsSet.size} of ${task.chaptersIds.size} requested chapters not found in manga" } } check(result.isNotEmpty()) { "Chapters list must not be empty" } return result } @Reusable class Scheduler @Inject constructor( @ApplicationContext private val context: Context, private val mangaDataRepository: MangaDataRepository, private val workManager: WorkManager, ) { fun observeWorks(): Flow> = workManager .getWorkInfosByTagFlow(TAG) @SuppressLint("RestrictedApi") suspend fun getInputData(id: UUID): Data? { val spec = workManager.getWorkSpec(id) ?: return null return Data.Builder() .putAll(spec.input) .putLong(DownloadState.DATA_TIMESTAMP, spec.scheduleRequestedAt) .build() } suspend fun getTask(workId: UUID): DownloadTask? { return workManager.getWorkInputData(workId)?.let { DownloadTask(it) } } suspend fun cancel(id: UUID) { workManager.cancelWorkById(id).await() } suspend fun cancelAll() { workManager.cancelAllWorkByTag(TAG).await() } fun pause(id: UUID) = context.sendBroadcast( PausingReceiver.getPauseIntent(context, id), ) fun resume(id: UUID) = context.sendBroadcast( PausingReceiver.getResumeIntent(context, id), ) fun skip(id: UUID) = context.sendBroadcast( PausingReceiver.getSkipIntent(context, id), ) fun skipAll(id: UUID) = context.sendBroadcast( PausingReceiver.getSkipAllIntent(context, id), ) suspend fun delete(id: UUID) { workManager.deleteWork(id) } suspend fun delete(ids: Collection) { val wm = workManager ids.forEach { id -> wm.cancelWorkById(id).await() } workManager.deleteWorks(ids) } suspend fun removeCompleted() { val finishedWorks = workManager.awaitFinishedWorkInfosByTag(TAG) workManager.deleteWorks(finishedWorks.mapToSet { it.id }) } suspend fun updateConstraints(allowMeteredNetwork: Boolean) { val constraints = createConstraints(allowMeteredNetwork) val works = workManager.awaitWorkInfosByTag(TAG) for (work in works) { if (work.state.isFinished) { continue } val request = OneTimeWorkRequestBuilder() .setConstraints(constraints) .addTag(TAG) .setId(work.id) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build() workManager.awaitUpdateWork(request) } } suspend fun schedule(tasks: Collection>) { if (tasks.isEmpty()) { return } val requests = tasks.map { (manga, task) -> mangaDataRepository.storeManga(manga, replaceExisting = true) OneTimeWorkRequestBuilder() .setConstraints(createConstraints(task.allowMeteredNetwork)) .addTag(TAG) .keepResultsForAtLeast(30, TimeUnit.DAYS) .setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.SECONDS) .setInputData(task.toData()) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build() } workManager.enqueue(requests).await() } private fun createConstraints(allowMeteredNetwork: Boolean) = Constraints.Builder() .setRequiredNetworkType(if (allowMeteredNetwork) NetworkType.CONNECTED else NetworkType.UNMETERED) .build() } private companion object { const val MAX_FAILSAFE_ATTEMPTS = 2 const val MAX_PAGES_PARALLELISM = 4 const val DOWNLOAD_ERROR_DELAY = 2_000L const val MAX_RETRY_DELAY = 7_200_000L // 2 hours const val TAG = "download" } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingHandle.kt ================================================ package org.koitharu.kotatsu.download.ui.worker import androidx.annotation.AnyThread import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.CoroutineContext class PausingHandle : AbstractCoroutineContextElement(PausingHandle) { private val paused = MutableStateFlow(false) private val skipError = MutableStateFlow(false) @Volatile private var skipAllErrors = false @get:AnyThread val isPaused: Boolean get() = paused.value @AnyThread suspend fun awaitResumed() { paused.first { !it } } @AnyThread fun pause() { paused.value = true } @AnyThread fun resume() { skipError.value = false paused.value = false } @AnyThread fun skip() { skipError.value = true paused.value = false } @AnyThread fun skipAll() { skipAllErrors = true skip() } suspend fun yield() { if (paused.value) { paused.first { !it } } } fun skipAllErrors(): Boolean = skipAllErrors fun skipCurrentError(): Boolean = skipError.compareAndSet(expect = true, update = false) companion object : CoroutineContext.Key { suspend fun current() = checkNotNull(currentCoroutineContext()[this]) { "PausingHandle not found in current context" } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingReceiver.kt ================================================ package org.koitharu.kotatsu.download.ui.worker import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.PatternMatcher import androidx.core.app.PendingIntentCompat import androidx.core.net.toUri import org.koitharu.kotatsu.core.util.ext.toUUIDOrNull import java.util.UUID class PausingReceiver( private val id: UUID, private val pausingHandle: PausingHandle, ) : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent?) { val uuid = intent?.getStringExtra(EXTRA_UUID)?.toUUIDOrNull() if (uuid != id) { return } when (intent.action) { ACTION_RESUME -> pausingHandle.resume() ACTION_SKIP -> pausingHandle.skip() ACTION_SKIP_ALL -> pausingHandle.skipAll() ACTION_PAUSE -> pausingHandle.pause() } } companion object { private const val ACTION_PAUSE = "org.koitharu.kotatsu.download.PAUSE" private const val ACTION_RESUME = "org.koitharu.kotatsu.download.RESUME" private const val ACTION_SKIP = "org.koitharu.kotatsu.download.SKIP" private const val ACTION_SKIP_ALL = "org.koitharu.kotatsu.download.SKIP_ALL" private const val EXTRA_UUID = "uuid" private const val SCHEME = "workuid" fun createIntentFilter(id: UUID) = IntentFilter().apply { addAction(ACTION_PAUSE) addAction(ACTION_RESUME) addAction(ACTION_SKIP) addAction(ACTION_SKIP_ALL) addDataScheme(SCHEME) addDataPath(id.toString(), PatternMatcher.PATTERN_LITERAL) } fun getPauseIntent(context: Context, id: UUID) = createIntent(context, id, ACTION_PAUSE) fun getResumeIntent(context: Context, id: UUID) = createIntent(context, id, ACTION_RESUME) fun getSkipIntent(context: Context, id: UUID) = createIntent(context, id, ACTION_SKIP) fun getSkipAllIntent(context: Context, id: UUID) = createIntent(context, id, ACTION_SKIP_ALL) fun createPausePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast( context, 0, getPauseIntent(context, id), 0, false, ) fun createResumePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast( context, 0, getResumeIntent(context, id), 0, false, ) fun createSkipPendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast( context, 0, getSkipIntent(context, id), 0, false, ) private fun createIntent(context: Context, id: UUID, action: String) = Intent(action) .setData("$SCHEME://$id".toUri()) .setPackage(context.packageName) .putExtra(EXTRA_UUID, id.toString()) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt ================================================ package org.koitharu.kotatsu.explore.data import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import androidx.core.content.ContextCompat import androidx.room.withTransaction import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.LocalizedAppContext import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity import org.koitharu.kotatsu.core.model.MangaSourceInfo import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.ui.util.ReversibleHandle import org.koitharu.kotatsu.core.util.ext.flattenLatest 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.network.CloudFlareHelper import org.koitharu.kotatsu.parsers.util.mapNotNullToSet import org.koitharu.kotatsu.parsers.util.mapToSet import java.util.Collections import java.util.EnumSet import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject import javax.inject.Singleton @Singleton class MangaSourcesRepository @Inject constructor( @LocalizedAppContext private val context: Context, private val db: MangaDatabase, private val settings: AppSettings, ) { private val isNewSourcesAssimilated = AtomicBoolean(false) private val dao: MangaSourcesDao get() = db.getSourcesDao() val allMangaSources: Set = Collections.unmodifiableSet( EnumSet.noneOf(MangaParserSource::class.java).also { MangaParserSource.entries.filterNotTo(it, MangaParserSource::isBroken) } ) suspend fun getEnabledSources(): List { assimilateNewSources() val order = settings.sourcesSortOrder return dao.findAll(!settings.isAllSourcesEnabled, order).toSources(settings.isNsfwContentDisabled, order) .let { enabled -> val external = getExternalSources() val list = ArrayList(enabled.size + external.size) external.mapTo(list) { MangaSourceInfo(it, isEnabled = true, isPinned = true) } list.addAll(enabled) list } } suspend fun getPinnedSources(): Set { assimilateNewSources() val skipNsfw = settings.isNsfwContentDisabled return dao.findAllPinned().mapNotNullToSet { it.source.toMangaSourceOrNull()?.takeUnless { x -> skipNsfw && x.isNsfw() } } } suspend fun getTopSources(limit: Int): List { assimilateNewSources() return dao.findLastUsed(limit).toSources(settings.isNsfwContentDisabled, null) } suspend fun getDisabledSources(): Set { assimilateNewSources() if (settings.isAllSourcesEnabled) { return emptySet() } val result = EnumSet.copyOf(allMangaSources) val enabled = dao.findAllEnabledNames() for (name in enabled) { val source = name.toMangaSourceOrNull() ?: continue result.remove(source) } return result } suspend fun queryParserSources( isDisabledOnly: Boolean, isNewOnly: Boolean, excludeBroken: Boolean, types: Set, query: String?, locale: String?, sortOrder: SourcesSortOrder?, ): List { assimilateNewSources() val entities = dao.findAll().toMutableList() if (isDisabledOnly && !settings.isAllSourcesEnabled) { entities.removeAll { it.isEnabled } } if (isNewOnly) { entities.retainAll { it.addedIn == BuildConfig.VERSION_CODE } } val sources = entities.toSources( skipNsfwSources = settings.isNsfwContentDisabled, sortOrder = sortOrder, ).run { mapNotNullTo(ArrayList(size)) { it.mangaSource as? MangaParserSource } } if (locale != null) { sources.retainAll { it.locale == locale } } if (excludeBroken) { sources.removeAll { it.isBroken } } if (types.isNotEmpty()) { sources.retainAll { it.contentType in types } } if (!query.isNullOrEmpty()) { sources.retainAll { it.getTitle(context).contains(query, ignoreCase = true) || it.name.contains(query, ignoreCase = true) } } return sources } fun observeIsEnabled(source: MangaSource): Flow { return dao.observeIsEnabled(source.name).onStart { assimilateNewSources() } } fun observeEnabledSourcesCount(): Flow { return combine( observeIsNsfwDisabled(), observeAllEnabled().flatMapLatest { isAllSourcesEnabled -> dao.observeAll(!isAllSourcesEnabled, SourcesSortOrder.MANUAL) }, ) { skipNsfw, sources -> sources.count { it.source.toMangaSourceOrNull()?.let { s -> !skipNsfw || !s.isNsfw() } == true } }.distinctUntilChanged().onStart { assimilateNewSources() } } fun observeAvailableSourcesCount(): Flow { return combine( observeIsNsfwDisabled(), observeAllEnabled().flatMapLatest { isAllSourcesEnabled -> dao.observeAll(!isAllSourcesEnabled, SourcesSortOrder.MANUAL) }, ) { skipNsfw, enabledSources -> val enabled = enabledSources.mapToSet { it.source } allMangaSources.count { x -> x.name !in enabled && (!skipNsfw || !x.isNsfw()) } }.distinctUntilChanged().onStart { assimilateNewSources() } } fun observeEnabledSources(): Flow> = combine( observeIsNsfwDisabled(), observeAllEnabled(), observeSortOrder(), ) { skipNsfw, allEnabled, order -> dao.observeAll(!allEnabled, order).map { it.toSources(skipNsfw, order) } }.flattenLatest() .onStart { assimilateNewSources() } .combine(observeExternalSources()) { enabled, external -> val list = ArrayList(enabled.size + external.size) external.mapTo(list) { MangaSourceInfo(it, isEnabled = true, isPinned = true) } list.addAll(enabled) list } fun observeAll(): Flow>> = dao.observeAll().map { entities -> val result = ArrayList>(entities.size) for (entity in entities) { val source = entity.source.toMangaSourceOrNull() ?: continue if (source in allMangaSources) { result.add(source to entity.isEnabled) } } result }.onStart { assimilateNewSources() } suspend fun setSourcesEnabled(sources: Collection, isEnabled: Boolean): ReversibleHandle { setSourcesEnabledImpl(sources, isEnabled) return ReversibleHandle { setSourcesEnabledImpl(sources, !isEnabled) } } suspend fun setSourcesEnabledExclusive(sources: Set) { db.withTransaction { assimilateNewSources() for (s in allMangaSources) { dao.setEnabled(s.name, s in sources) } } } suspend fun disableAllSources() { db.withTransaction { assimilateNewSources() dao.disableAllSources() } } suspend fun setPositions(sources: List) { db.withTransaction { for ((index, item) in sources.withIndex()) { dao.setSortKey(item.name, index) } } } fun observeHasNewSources(): Flow = observeIsNsfwDisabled().map { skipNsfw -> val sources = dao.findAllFromVersion(BuildConfig.VERSION_CODE).toSources(skipNsfw, null) sources.isNotEmpty() && sources.size != allMangaSources.size }.onStart { assimilateNewSources() } fun observeHasNewSourcesForBadge(): Flow = combine( settings.observeAsFlow(AppSettings.KEY_SOURCES_VERSION) { sourcesVersion }, observeIsNsfwDisabled(), ) { version, skipNsfw -> if (version < BuildConfig.VERSION_CODE) { val sources = dao.findAllFromVersion(version).toSources(skipNsfw, null) sources.isNotEmpty() } else { false } }.onStart { assimilateNewSources() } fun clearNewSourcesBadge() { settings.sourcesVersion = BuildConfig.VERSION_CODE } private suspend fun assimilateNewSources(): Boolean { if (isNewSourcesAssimilated.getAndSet(true)) { return false } val new = getNewSources() if (new.isEmpty()) { return false } var maxSortKey = dao.getMaxSortKey() val isAllEnabled = settings.isAllSourcesEnabled val entities = new.map { x -> MangaSourceEntity( source = x.name, isEnabled = isAllEnabled, sortKey = ++maxSortKey, addedIn = BuildConfig.VERSION_CODE, lastUsedAt = 0, isPinned = false, cfState = CloudFlareHelper.PROTECTION_NOT_DETECTED, ) } dao.insertIfAbsent(entities) return true } suspend fun isSetupRequired(): Boolean { return settings.sourcesVersion == 0 && dao.findAllEnabledNames().isEmpty() } suspend fun setIsPinned(sources: Collection, isPinned: Boolean): ReversibleHandle { setSourcesPinnedImpl(sources, isPinned) return ReversibleHandle { setSourcesEnabledImpl(sources, !isPinned) } } suspend fun trackUsage(source: MangaSource) { if (!settings.isIncognitoModeEnabled(source.isNsfw())) { dao.setLastUsed(source.name, System.currentTimeMillis()) } } private suspend fun setSourcesEnabledImpl(sources: Collection, isEnabled: Boolean) { if (sources.size == 1) { // fast path dao.setEnabled(sources.first().name, isEnabled) return } db.withTransaction { for (source in sources) { dao.setEnabled(source.name, isEnabled) } } } private suspend fun getNewSources(): MutableSet { val entities = dao.findAll() val result = EnumSet.copyOf(allMangaSources) for (e in entities) { result.remove(e.source.toMangaSourceOrNull() ?: continue) } return result } private suspend fun setSourcesPinnedImpl(sources: Collection, isPinned: Boolean) { if (sources.size == 1) { // fast path dao.setPinned(sources.first().name, isPinned) return } db.withTransaction { for (source in sources) { dao.setPinned(source.name, isPinned) } } } private fun observeExternalSources(): Flow> { return callbackFlow { val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { trySendBlocking(intent) } } ContextCompat.registerReceiver( context, receiver, IntentFilter().apply { addAction(Intent.ACTION_PACKAGE_ADDED) addAction(Intent.ACTION_PACKAGE_VERIFIED) addAction(Intent.ACTION_PACKAGE_REPLACED) addAction(Intent.ACTION_PACKAGE_REMOVED) addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED) addDataScheme("package") }, ContextCompat.RECEIVER_EXPORTED, ) awaitClose { context.unregisterReceiver(receiver) } }.onStart { emit(null) }.map { getExternalSources() }.distinctUntilChanged() .conflate() } fun getExternalSources(): List = context.packageManager.queryIntentContentProviders( Intent("app.kotatsu.parser.PROVIDE_MANGA"), 0, ).map { resolveInfo -> ExternalMangaSource( packageName = resolveInfo.providerInfo.packageName, authority = resolveInfo.providerInfo.authority, ) } private fun List.toSources( skipNsfwSources: Boolean, sortOrder: SourcesSortOrder?, ): MutableList { val isAllEnabled = settings.isAllSourcesEnabled val result = ArrayList(size) for (entity in this) { val source = entity.source.toMangaSourceOrNull() ?: continue if (skipNsfwSources && source.isNsfw()) { continue } if (source in allMangaSources) { result.add( MangaSourceInfo( mangaSource = source, isEnabled = entity.isEnabled || isAllEnabled, isPinned = entity.isPinned, ), ) } } if (sortOrder == SourcesSortOrder.ALPHABETIC) { result.sortWith(compareBy { !it.isPinned }.thenBy { it.getTitle(context) }) } return result } private fun observeIsNsfwDisabled() = settings.observeAsFlow(AppSettings.KEY_DISABLE_NSFW) { isNsfwContentDisabled } private fun observeSortOrder() = settings.observeAsFlow(AppSettings.KEY_SOURCES_ORDER) { sourcesSortOrder } private fun observeAllEnabled() = settings.observeAsFlow(AppSettings.KEY_SOURCES_ENABLED_ALL) { isAllSourcesEnabled } private fun String.toMangaSourceOrNull(): MangaParserSource? = MangaParserSource.entries.find { it.name == this } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/explore/data/SourcesSortOrder.kt ================================================ package org.koitharu.kotatsu.explore.data import androidx.annotation.StringRes import org.koitharu.kotatsu.R enum class SourcesSortOrder( @StringRes val titleResId: Int, ) { ALPHABETIC(R.string.by_name), POPULARITY(R.string.popular), MANUAL(R.string.manual), LAST_USED(R.string.last_used), } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt ================================================ package org.koitharu.kotatsu.explore.domain import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.parser.MangaRepository 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.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.almostEquals import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist import javax.inject.Inject class ExploreRepository @Inject constructor( private val settings: AppSettings, private val sourcesRepository: MangaSourcesRepository, private val historyRepository: HistoryRepository, private val mangaRepositoryFactory: MangaRepository.Factory, ) { suspend fun findRandomManga(tagsLimit: Int): Manga { val tagsBlacklist = TagsBlacklist(settings.suggestionsTagsBlacklist, 0.4f) val tags = historyRepository.getPopularTags(tagsLimit).mapNotNull { if (it in tagsBlacklist) null else it.title } val sources = sourcesRepository.getEnabledSources() check(sources.isNotEmpty()) { "No sources available" } for (i in 0..4) { val list = getList(sources.random(), tags, tagsBlacklist) val manga = list.randomOrNull() ?: continue val details = runCatchingCancellable { mangaRepositoryFactory.create(manga.source).getDetails(manga) }.getOrNull() ?: continue if ((settings.isSuggestionsExcludeNsfw && details.isNsfw()) || details in tagsBlacklist) { continue } return details } throw NoSuchElementException() } suspend fun findRandomManga(source: MangaSource, tagsLimit: Int): Manga { val tagsBlacklist = TagsBlacklist(settings.suggestionsTagsBlacklist, 0.4f) val skipNsfw = settings.isSuggestionsExcludeNsfw && !source.isNsfw() val tags = historyRepository.getPopularTags(tagsLimit).mapNotNull { if (it in tagsBlacklist) null else it.title } for (i in 0..4) { val list = getList(source, tags, tagsBlacklist) val manga = list.randomOrNull() ?: continue val details = runCatchingCancellable { mangaRepositoryFactory.create(manga.source).getDetails(manga) }.getOrNull() ?: continue if ((skipNsfw && details.isNsfw()) || details in tagsBlacklist) { continue } return details } throw NoSuchElementException() } private suspend fun getList( source: MangaSource, tags: List, blacklist: TagsBlacklist, ): List = runCatchingCancellable { val repository = mangaRepositoryFactory.create(source) val order = repository.sortOrders.random() val availableTags = repository.getFilterOptions().availableTags val tag = tags.firstNotNullOfOrNull { title -> availableTags.find { x -> x.title.almostEquals(title, 0.4f) } } val list = repository.getList( offset = 0, order = order, filter = MangaListFilter(tags = setOfNotNull(tag)), ).asArrayList() if (settings.isSuggestionsExcludeNsfw) { list.removeAll { it.isNsfw() } } if (blacklist.isNotEmpty()) { list.removeAll { manga -> manga in blacklist } } list.shuffle() list }.onFailure { it.printStackTraceDebug() }.getOrDefault(emptyList()) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/RecoverMangaUseCase.kt ================================================ package org.koitharu.kotatsu.explore.domain import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import javax.inject.Inject class RecoverMangaUseCase @Inject constructor( private val mangaDataRepository: MangaDataRepository, private val repositoryFactory: MangaRepository.Factory, ) { suspend operator fun invoke(manga: Manga): Manga? = runCatchingCancellable { if (manga.isLocal) { return@runCatchingCancellable null } val repository = repositoryFactory.create(manga.source) val list = repository.getList(offset = 0, null, MangaListFilter(query = manga.title)) val newManga = list.find { x -> x.title == manga.title }?.let { repository.getDetails(it) } ?: return@runCatchingCancellable null val merged = merge(manga, newManga) mangaDataRepository.storeManga(merged, replaceExisting = true) merged }.onFailure { it.printStackTraceDebug() }.getOrNull() private fun merge( broken: Manga, current: Manga, ) = Manga( id = broken.id, title = current.title, altTitles = current.altTitles, url = current.url, publicUrl = current.publicUrl, rating = current.rating, contentRating = current.contentRating, coverUrl = current.coverUrl, tags = current.tags, state = current.state, authors = current.authors, largeCoverUrl = current.largeCoverUrl, description = current.description, chapters = current.chapters, source = current.source, ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt ================================================ package org.koitharu.kotatsu.explore.ui import android.content.DialogInterface import android.content.Intent import android.net.Uri import android.os.Build 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 androidx.appcompat.view.ActionMode import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.viewModels import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.dialog.BigButtonsAlertDialog import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.ui.util.SpanSizeResolver import org.koitharu.kotatsu.core.util.ext.addMenuProvider 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.FragmentExploreBinding import org.koitharu.kotatsu.explore.ui.adapter.ExploreAdapter import org.koitharu.kotatsu.explore.ui.adapter.ExploreListEventListener import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaParserSource @AndroidEntryPoint class ExploreFragment : BaseFragment(), RecyclerViewOwner, ExploreListEventListener, OnListItemClickListener, ListSelectionController.Callback { private val viewModel by viewModels() private var exploreAdapter: ExploreAdapter? = null private var sourceSelectionController: ListSelectionController? = null override val recyclerView: RecyclerView? get() = viewBinding?.recyclerView override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentExploreBinding { return FragmentExploreBinding.inflate(inflater, container, false) } override fun onViewBindingCreated(binding: FragmentExploreBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) exploreAdapter = ExploreAdapter(this, this) { manga, view -> router.openDetails(manga) } sourceSelectionController = ListSelectionController( appCompatDelegate = checkNotNull(findAppCompatDelegate()), decoration = SourceSelectionDecoration(binding.root.context), registryOwner = this, callback = this, ) with(binding.recyclerView) { adapter = exploreAdapter setHasFixedSize(true) SpanSizeResolver(this, resources.getDimensionPixelSize(R.dimen.explore_grid_width)).attach() addItemDecoration(TypedListSpacingDecoration(context, false)) checkNotNull(sourceSelectionController).attachToRecyclerView(this) } addMenuProvider(ExploreMenuProvider(router)) viewModel.content.observe(viewLifecycleOwner, checkNotNull(exploreAdapter)) viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) viewModel.onOpenManga.observeEvent(viewLifecycleOwner, ::onOpenManga) viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) viewModel.isGrid.observe(viewLifecycleOwner, ::onGridModeChanged) viewModel.onShowSuggestionsTip.observeEvent(viewLifecycleOwner) { showSuggestionsTip() } } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val barsInsets = insets.systemBarsInsets val basePadding = v.resources.getDimensionPixelOffset(R.dimen.list_spacing_normal) viewBinding?.recyclerView?.setPadding( /* left = */ barsInsets.left + basePadding, /* top = */ basePadding, /* right = */ barsInsets.right + basePadding, /* bottom = */ barsInsets.bottom + basePadding, ) return insets.consumeAllSystemBarsInsets() } override fun onDestroyView() { super.onDestroyView() sourceSelectionController = null exploreAdapter = null } override fun onListHeaderClick(item: ListHeader, view: View) { if (item.payload == R.id.nav_suggestions) { router.openSuggestions() } else if (viewModel.isAllSourcesEnabled.value) { router.openManageSources() } else { router.openSourcesCatalog() } } override fun onClick(v: View) { when (v.id) { R.id.button_local -> router.openList(LocalMangaSource, null, null) R.id.button_bookmarks -> router.openBookmarks() R.id.button_more -> router.openSuggestions() R.id.button_downloads -> router.openDownloads() R.id.button_random -> viewModel.openRandom() } } override fun onItemClick(item: MangaSourceItem, view: View) { if (sourceSelectionController?.onItemClick(item.id) == true) { return } router.openList(item.source, null, null) } override fun onItemLongClick(item: MangaSourceItem, view: View): Boolean { return sourceSelectionController?.onItemLongClick(view, item.id) == true } override fun onItemContextClick(item: MangaSourceItem, view: View): Boolean { return sourceSelectionController?.onItemContextClick(view, item.id) == true } override fun onRetryClick(error: Throwable) = Unit override fun onEmptyActionClick() = router.openSourcesCatalog() override fun onSelectionChanged(controller: ListSelectionController, count: Int) { viewBinding?.recyclerView?.invalidateItemDecorations() } override fun onCreateActionMode( controller: ListSelectionController, menuInflater: MenuInflater, menu: Menu ): Boolean { menuInflater.inflate(R.menu.mode_source, menu) return true } override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode?, menu: Menu): Boolean { val selectedSources = viewModel.sourcesSnapshot(controller.peekCheckedIds()) val isSingleSelection = selectedSources.size == 1 menu.findItem(R.id.action_settings).isVisible = isSingleSelection menu.findItem(R.id.action_shortcut).isVisible = isSingleSelection menu.findItem(R.id.action_pin).isVisible = selectedSources.all { !it.isPinned } menu.findItem(R.id.action_unpin).isVisible = selectedSources.all { it.isPinned } menu.findItem(R.id.action_disable)?.isVisible = !viewModel.isAllSourcesEnabled.value && selectedSources.all { it.mangaSource is MangaParserSource } menu.findItem(R.id.action_delete)?.isVisible = selectedSources.all { it.mangaSource is ExternalMangaSource } return super.onPrepareActionMode(controller, mode, menu) } override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean { val selectedSources = viewModel.sourcesSnapshot(controller.peekCheckedIds()) if (selectedSources.isEmpty()) { return false } when (item.itemId) { R.id.action_settings -> { val source = selectedSources.singleOrNull() ?: return false router.openSourceSettings(source) mode?.finish() } R.id.action_disable -> { viewModel.disableSources(selectedSources) mode?.finish() } R.id.action_delete -> { selectedSources.forEach { (it.mangaSource as? ExternalMangaSource)?.let { uninstallExternalSource(it) } } mode?.finish() } R.id.action_shortcut -> { val source = selectedSources.singleOrNull() ?: return false viewModel.requestPinShortcut(source) mode?.finish() } R.id.action_pin -> { viewModel.setSourcesPinned(selectedSources, isPinned = true) mode?.finish() } R.id.action_unpin -> { viewModel.setSourcesPinned(selectedSources, isPinned = false) mode?.finish() } else -> return false } return true } private fun onOpenManga(manga: Manga) { router.openDetails(manga) } private fun onGridModeChanged(isGrid: Boolean) { requireViewBinding().recyclerView.layoutManager = if (isGrid) { GridLayoutManager(requireContext(), 4).also { lm -> lm.spanSizeLookup = ExploreGridSpanSizeLookup(checkNotNull(exploreAdapter), lm) } } else { LinearLayoutManager(requireContext()) } } private fun showSuggestionsTip() { val listener = DialogInterface.OnClickListener { _, which -> viewModel.respondSuggestionTip(which == DialogInterface.BUTTON_POSITIVE) } BigButtonsAlertDialog.Builder(requireContext()) .setIcon(R.drawable.ic_suggestion) .setTitle(R.string.suggestions_enable_prompt) .setPositiveButton(R.string.enable, listener) .setNegativeButton(R.string.no_thanks, listener) .create() .show() } private fun uninstallExternalSource(source: ExternalMangaSource) { val uri = Uri.fromParts("package", source.packageName, null) val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { Intent.ACTION_DELETE } else { @Suppress("DEPRECATION") Intent.ACTION_UNINSTALL_PACKAGE } context?.startActivity(Intent(action, uri)) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreGridSpanSizeLookup.kt ================================================ package org.koitharu.kotatsu.explore.ui import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup import org.koitharu.kotatsu.explore.ui.adapter.ExploreAdapter import org.koitharu.kotatsu.list.ui.adapter.ListItemType class ExploreGridSpanSizeLookup( private val adapter: ExploreAdapter, private val layoutManager: GridLayoutManager, ) : SpanSizeLookup() { override fun getSpanSize(position: Int): Int { val itemType = adapter.getItemViewType(position) return if (itemType == ListItemType.EXPLORE_SOURCE_GRID.ordinal) 1 else layoutManager.spanCount } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreMenuProvider.kt ================================================ package org.koitharu.kotatsu.explore.ui import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.core.view.MenuProvider import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.AppRouter class ExploreMenuProvider( private val router: AppRouter, ) : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.opt_explore, menu) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return when (menuItem.itemId) { R.id.action_manage -> { router.openSourcesSettings() true } else -> false } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt ================================================ package org.koitharu.kotatsu.explore.ui import androidx.collection.LongSet import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.MangaSourceInfo import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsStateFlow 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.combine import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.explore.ui.model.ExploreButtons import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem import org.koitharu.kotatsu.explore.ui.model.RecommendationsItem import org.koitharu.kotatsu.list.ui.model.EmptyHint 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.MangaCompactListModel import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository import javax.inject.Inject @HiltViewModel class ExploreViewModel @Inject constructor( private val settings: AppSettings, private val suggestionRepository: SuggestionRepository, private val exploreRepository: ExploreRepository, private val sourcesRepository: MangaSourcesRepository, private val shortcutManager: AppShortcutManager, ) : BaseViewModel() { val isGrid = settings.observeAsStateFlow( key = AppSettings.KEY_SOURCES_GRID, scope = viewModelScope + Dispatchers.IO, valueProducer = { isSourcesGridMode }, ) val isAllSourcesEnabled = settings.observeAsStateFlow( scope = viewModelScope + Dispatchers.IO, key = AppSettings.KEY_SOURCES_ENABLED_ALL, valueProducer = { isAllSourcesEnabled }, ) private val isSuggestionsEnabled = settings.observeAsFlow( key = AppSettings.KEY_SUGGESTIONS, valueProducer = { isSuggestionsEnabled }, ) val onOpenManga = MutableEventFlow() val onActionDone = MutableEventFlow() val onShowSuggestionsTip = MutableEventFlow() private val isRandomLoading = MutableStateFlow(false) val content: StateFlow> = isLoading.flatMapLatest { loading -> if (loading) { flowOf(getLoadingStateList()) } else { createContentFlow() } }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, getLoadingStateList()) init { launchJob(Dispatchers.Default) { if (!settings.isSuggestionsEnabled && settings.isTipEnabled(TIP_SUGGESTIONS)) { onShowSuggestionsTip.call(Unit) } } } fun openRandom() { if (isRandomLoading.value) { return } launchJob(Dispatchers.Default) { isRandomLoading.value = true try { val manga = exploreRepository.findRandomManga(tagsLimit = 8) onOpenManga.call(manga) } finally { isRandomLoading.value = false } } } fun disableSources(sources: Collection) { launchJob(Dispatchers.Default) { val rollback = sourcesRepository.setSourcesEnabled(sources, isEnabled = false) val message = if (sources.size == 1) R.string.source_disabled else R.string.sources_disabled onActionDone.call(ReversibleAction(message, rollback)) } } fun requestPinShortcut(source: MangaSource) { launchLoadingJob(Dispatchers.Default) { shortcutManager.requestPinShortcut(source) } } fun setSourcesPinned(sources: Collection, isPinned: Boolean) { launchJob(Dispatchers.Default) { sourcesRepository.setIsPinned(sources, isPinned) val message = if (sources.size == 1) { if (isPinned) R.string.source_pinned else R.string.source_unpinned } else { if (isPinned) R.string.sources_pinned else R.string.sources_unpinned } onActionDone.call(ReversibleAction(message, null)) } } fun respondSuggestionTip(isAccepted: Boolean) { settings.isSuggestionsEnabled = isAccepted settings.closeTip(TIP_SUGGESTIONS) } fun sourcesSnapshot(ids: LongSet): List { return content.value.mapNotNull { (it as? MangaSourceItem)?.takeIf { x -> x.id in ids }?.source } } private fun createContentFlow() = combine( sourcesRepository.observeEnabledSources(), getSuggestionFlow(), isGrid, isRandomLoading, isAllSourcesEnabled, sourcesRepository.observeHasNewSourcesForBadge(), ) { content, suggestions, grid, randomLoading, allSourcesEnabled, newSources -> buildList(content, suggestions, grid, randomLoading, allSourcesEnabled, newSources) }.withErrorHandling() private fun buildList( sources: List, recommendation: List, isGrid: Boolean, randomLoading: Boolean, allSourcesEnabled: Boolean, hasNewSources: Boolean, ): List { val result = ArrayList(sources.size + 3) result += ExploreButtons(randomLoading) if (recommendation.isNotEmpty()) { result += ListHeader(R.string.suggestions, R.string.more, R.id.nav_suggestions) result += RecommendationsItem(recommendation.toRecommendationList()) } if (sources.isNotEmpty()) { result += ListHeader( textRes = R.string.remote_sources, buttonTextRes = if (allSourcesEnabled) R.string.manage else R.string.catalog, badge = if (!allSourcesEnabled && hasNewSources) "" else null, ) sources.mapTo(result) { MangaSourceItem(it, isGrid) } } else { result += EmptyHint( icon = R.drawable.ic_empty_common, textPrimary = R.string.no_manga_sources, textSecondary = R.string.no_manga_sources_text, actionStringRes = R.string.catalog, ) } return result } private fun getLoadingStateList() = listOf( ExploreButtons(isRandomLoading.value), LoadingState, ) private fun getSuggestionFlow() = isSuggestionsEnabled.mapLatest { isEnabled -> if (isEnabled) { runCatchingCancellable { suggestionRepository.getRandomList(SUGGESTIONS_COUNT) }.getOrDefault(emptyList()) } else { emptyList() } } private fun List.toRecommendationList() = map { manga -> MangaCompactListModel( manga = manga, override = null, subtitle = manga.tags.joinToString { it.title }, counter = 0, ) } companion object { private const val TIP_SUGGESTIONS = "suggestions" private const val SUGGESTIONS_COUNT = 8 } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/SourceSelectionDecoration.kt ================================================ package org.koitharu.kotatsu.explore.ui import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.RectF import android.view.View import androidx.core.graphics.ColorUtils import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.NO_ID import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration import org.koitharu.kotatsu.core.util.ext.getItem import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem import androidx.appcompat.R as appcompatR import com.google.android.material.R as materialR class SourceSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() { private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val strokeColor = context.getThemeColor(appcompatR.attr.colorPrimary, Color.RED) private val fillColor = ColorUtils.setAlphaComponent( ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f), 0x74, ) private val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner) init { hasBackground = false hasForeground = true isIncludeDecorAndMargins = false paint.strokeWidth = context.resources.getDimension(R.dimen.selection_stroke_width) } override fun getItemId(parent: RecyclerView, child: View): Long { val holder = parent.getChildViewHolder(child) ?: return NO_ID val item = holder.getItem(MangaSourceItem::class.java) ?: return NO_ID return item.id } override fun onDrawForeground( canvas: Canvas, parent: RecyclerView, child: View, bounds: RectF, state: RecyclerView.State, ) { paint.color = fillColor paint.style = Paint.Style.FILL canvas.drawRoundRect(bounds, defaultRadius, defaultRadius, paint) paint.color = strokeColor paint.style = Paint.Style.STROKE canvas.drawRoundRect(bounds, defaultRadius, defaultRadius, paint) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapter.kt ================================================ package org.koitharu.kotatsu.explore.ui.adapter import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.emptyHintAD import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.Manga class ExploreAdapter( listener: ExploreListEventListener, clickListener: OnListItemClickListener, mangaClickListener: OnListItemClickListener, ) : BaseListAdapter() { init { addDelegate(ListItemType.EXPLORE_BUTTONS, exploreButtonsAD(listener)) addDelegate( ListItemType.EXPLORE_SUGGESTION, exploreRecommendationItemAD(mangaClickListener), ) addDelegate(ListItemType.HEADER, listHeaderAD(listener)) addDelegate(ListItemType.EXPLORE_SOURCE_LIST, exploreSourceListItemAD(clickListener)) addDelegate(ListItemType.EXPLORE_SOURCE_GRID, exploreSourceGridItemAD(clickListener)) addDelegate(ListItemType.HINT_EMPTY, emptyHintAD(listener)) addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt ================================================ package org.koitharu.kotatsu.explore.ui.adapter import android.view.View import androidx.core.content.ContextCompat import androidx.core.text.bold import androidx.core.text.buildSpannedString import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.getSummary import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.drawableStart import org.koitharu.kotatsu.core.util.ext.recyclerView import org.koitharu.kotatsu.core.util.ext.setProgressIcon import org.koitharu.kotatsu.core.util.ext.setTooltipCompat import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding import org.koitharu.kotatsu.databinding.ItemExploreSourceGridBinding import org.koitharu.kotatsu.databinding.ItemExploreSourceListBinding import org.koitharu.kotatsu.databinding.ItemRecommendationBinding import org.koitharu.kotatsu.databinding.ItemRecommendationMangaBinding import org.koitharu.kotatsu.explore.ui.model.ExploreButtons import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem import org.koitharu.kotatsu.explore.ui.model.RecommendationsItem import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaCompactListModel import org.koitharu.kotatsu.parsers.model.Manga fun exploreButtonsAD( clickListener: View.OnClickListener, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemExploreButtonsBinding.inflate(layoutInflater, parent, false) }, ) { binding.buttonBookmarks.setOnClickListener(clickListener) binding.buttonDownloads.setOnClickListener(clickListener) binding.buttonLocal.setOnClickListener(clickListener) binding.buttonRandom.setOnClickListener(clickListener) bind { if (item.isRandomLoading) { binding.buttonRandom.setProgressIcon() } else { binding.buttonRandom.setIconResource(R.drawable.ic_dice) } binding.buttonRandom.isClickable = !item.isRandomLoading } } fun exploreRecommendationItemAD( itemClickListener: OnListItemClickListener, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemRecommendationBinding.inflate(layoutInflater, parent, false) }, ) { val adapter = BaseListAdapter() .addDelegate(ListItemType.MANGA_LIST, recommendationMangaItemAD(itemClickListener)) binding.pager.adapter = adapter binding.pager.recyclerView?.isNestedScrollingEnabled = false binding.dots.bindToViewPager(binding.pager) bind { adapter.items = item.manga } } fun recommendationMangaItemAD( itemClickListener: OnListItemClickListener, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemRecommendationMangaBinding.inflate(layoutInflater, parent, false) }, ) { binding.root.setOnClickListener { v -> itemClickListener.onItemClick(item.manga, v) } bind { binding.textViewTitle.text = item.manga.title binding.textViewSubtitle.textAndVisible = item.subtitle binding.imageViewCover.setImageAsync(item.manga.coverUrl, item.manga.source) } } fun exploreSourceListItemAD( listener: OnListItemClickListener, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemExploreSourceListBinding.inflate( layoutInflater, parent, false, ) }, on = { item, _, _ -> item is MangaSourceItem && !item.isGrid }, ) { AdapterDelegateClickListenerAdapter(this, listener).attach(itemView) val iconPinned = ContextCompat.getDrawable(context, R.drawable.ic_pin_small) bind { binding.textViewTitle.text = item.source.getTitle(context) binding.textViewTitle.drawableStart = if (item.source.isPinned) iconPinned else null binding.textViewSubtitle.text = item.source.getSummary(context) binding.imageViewIcon.setImageAsync(item.source) } } fun exploreSourceGridItemAD( listener: OnListItemClickListener, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemExploreSourceGridBinding.inflate( layoutInflater, parent, false, ) }, on = { item, _, _ -> item is MangaSourceItem && item.isGrid }, ) { AdapterDelegateClickListenerAdapter(this, listener).attach(itemView) val iconPinned = ContextCompat.getDrawable(context, R.drawable.ic_pin_small) bind { val title = item.source.getTitle(context) itemView.setTooltipCompat( buildSpannedString { bold { append(title) } appendLine() append(item.source.getSummary(context)) }, ) binding.textViewTitle.text = title binding.textViewTitle.drawableStart = if (item.source.isPinned) iconPinned else null binding.imageViewIcon.setImageAsync(item.source) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreListEventListener.kt ================================================ package org.koitharu.kotatsu.explore.ui.adapter import android.view.View import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener interface ExploreListEventListener : ListStateHolderListener, View.OnClickListener, ListHeaderClickListener ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/model/ExploreButtons.kt ================================================ package org.koitharu.kotatsu.explore.ui.model import org.koitharu.kotatsu.list.ui.model.ListModel data class ExploreButtons( val isRandomLoading: Boolean, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is ExploreButtons } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/model/MangaSourceItem.kt ================================================ package org.koitharu.kotatsu.explore.ui.model import org.koitharu.kotatsu.core.model.MangaSourceInfo import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.util.longHashCode data class MangaSourceItem( val source: MangaSourceInfo, val isGrid: Boolean, ) : ListModel { val id: Long = source.name.longHashCode() override fun areItemsTheSame(other: ListModel): Boolean { return other is MangaSourceItem && other.source == source } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/model/RecommendationsItem.kt ================================================ package org.koitharu.kotatsu.explore.ui.model import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaCompactListModel data class RecommendationsItem( val manga: List ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is RecommendationsItem } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/EntityMapping.kt ================================================ package org.koitharu.kotatsu.favourites.data import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.list.domain.ListSortOrder import java.time.Instant fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory( id = id, title = title, sortKey = sortKey, order = ListSortOrder(order, ListSortOrder.NEWEST), createdAt = Instant.ofEpochMilli(createdAt), isTrackingEnabled = track, isVisibleInLibrary = isVisibleInLibrary, ) fun FavouriteManga.toManga() = manga.toManga(tags.toMangaTags(), null) fun Collection.toMangaList() = map { it.toManga() } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt ================================================ package org.koitharu.kotatsu.favourites.data import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.RoomWarnings import androidx.room.Upsert import kotlinx.coroutines.flow.Flow @Dao abstract class FavouriteCategoriesDao { @Query("SELECT * FROM favourite_categories WHERE category_id = :id AND deleted_at = 0") abstract suspend fun find(id: Int): FavouriteCategoryEntity @Query("SELECT * FROM favourite_categories WHERE deleted_at = 0 ORDER BY sort_key") abstract suspend fun findAll(): List @Query("SELECT * FROM favourite_categories WHERE deleted_at = 0 ORDER BY sort_key") abstract fun observeAll(): Flow> @Query("SELECT * FROM favourite_categories WHERE deleted_at = 0 AND show_in_lib = 1 ORDER BY sort_key") abstract fun observeAllVisible(): Flow> @Query("SELECT * FROM favourite_categories WHERE category_id = :id AND deleted_at = 0") abstract fun observe(id: Long): Flow @Insert(onConflict = OnConflictStrategy.ABORT) abstract suspend fun insert(category: FavouriteCategoryEntity): Long suspend fun delete(id: Long) = setDeletedAt(id, System.currentTimeMillis()) @Query("UPDATE favourite_categories SET title = :title, `order` = :order, `track` = :tracker, `show_in_lib` = :onShelf WHERE category_id = :id") abstract suspend fun update(id: Long, title: String, order: String, tracker: Boolean, onShelf: Boolean) @Query("UPDATE favourite_categories SET `order` = :order WHERE category_id = :id") abstract suspend fun updateOrder(id: Long, order: String) @Query("UPDATE favourite_categories SET `track` = :isEnabled WHERE category_id = :id") abstract suspend fun updateTracking(id: Long, isEnabled: Boolean) @Query("UPDATE favourite_categories SET `show_in_lib` = :isEnabled WHERE category_id = :id") abstract suspend fun updateVisibility(id: Long, isEnabled: Boolean) @Query("UPDATE favourite_categories SET sort_key = :sortKey WHERE category_id = :id") abstract suspend fun updateSortKey(id: Long, sortKey: Int) @Query("DELETE FROM favourite_categories WHERE deleted_at != 0 AND deleted_at < :maxDeletionTime") abstract suspend fun gc(maxDeletionTime: Long) @Query("SELECT MAX(sort_key) FROM favourite_categories WHERE deleted_at = 0") protected abstract suspend fun getMaxSortKey(): Int? @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) // for the new_chapters column @Query("SELECT favourite_categories.*, (SELECT SUM(chapters_new) FROM tracks WHERE tracks.manga_id IN (SELECT manga_id FROM favourites WHERE favourites.category_id = favourite_categories.category_id)) AS new_chapters FROM favourite_categories WHERE track = 1 AND show_in_lib = 1 AND deleted_at = 0 AND new_chapters > 0 ORDER BY new_chapters DESC LIMIT :limit") abstract suspend fun getMostUpdatedCategories(limit: Int): List suspend fun getNextSortKey(): Int { return (getMaxSortKey() ?: 0) + 1 } @Upsert abstract suspend fun upsert(entity: FavouriteCategoryEntity) @Query("UPDATE favourite_categories SET deleted_at = :deletedAt WHERE category_id = :id") protected abstract suspend fun setDeletedAt(id: Long, deletedAt: Long) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt ================================================ package org.koitharu.kotatsu.favourites.data import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES @Entity(tableName = TABLE_FAVOURITE_CATEGORIES) data class FavouriteCategoryEntity( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "category_id") val categoryId: Int, @ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "sort_key") val sortKey: Int, @ColumnInfo(name = "title") val title: String, @ColumnInfo(name = "order") val order: String, @ColumnInfo(name = "track") val track: Boolean, @ColumnInfo(name = "show_in_lib") val isVisibleInLibrary: Boolean, @ColumnInfo(name = "deleted_at") val deletedAt: Long, ) { override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as FavouriteCategoryEntity if (categoryId != other.categoryId) return false if (createdAt != other.createdAt) return false if (sortKey != other.sortKey) return false if (title != other.title) return false if (order != other.order) return false if (track != other.track) return false return isVisibleInLibrary == other.isVisibleInLibrary } override fun hashCode(): Int { var result = categoryId result = 31 * result + createdAt.hashCode() result = 31 * result + sortKey result = 31 * result + title.hashCode() result = 31 * result + order.hashCode() result = 31 * result + track.hashCode() result = 31 * result + isVisibleInLibrary.hashCode() return result } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt ================================================ package org.koitharu.kotatsu.favourites.data import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES import org.koitharu.kotatsu.core.db.entity.MangaEntity @Entity( tableName = TABLE_FAVOURITES, primaryKeys = ["manga_id", "category_id"], foreignKeys = [ ForeignKey( entity = MangaEntity::class, parentColumns = ["manga_id"], childColumns = ["manga_id"], onDelete = ForeignKey.CASCADE ), ForeignKey( entity = FavouriteCategoryEntity::class, parentColumns = ["category_id"], childColumns = ["category_id"], onDelete = ForeignKey.CASCADE ) ] ) data class FavouriteEntity( @ColumnInfo(name = "manga_id", index = true) val mangaId: Long, @ColumnInfo(name = "category_id", index = true) val categoryId: Long, @ColumnInfo(name = "sort_key") val sortKey: Int, @ColumnInfo(name = "pinned") val isPinned: Boolean, @ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "deleted_at") val deletedAt: Long, ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouriteManga.kt ================================================ package org.koitharu.kotatsu.favourites.data import androidx.room.Embedded import androidx.room.Junction import androidx.room.Relation import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity import org.koitharu.kotatsu.core.db.entity.TagEntity class FavouriteManga( @Embedded val favourite: FavouriteEntity, @Relation( parentColumn = "manga_id", entityColumn = "manga_id" ) val manga: MangaEntity, @Relation( parentColumn = "category_id", entityColumn = "category_id" ) val categories: List, @Relation( parentColumn = "manga_id", entityColumn = "tag_id", associateBy = Junction(MangaTagsEntity::class) ) val tags: List ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt ================================================ package org.koitharu.kotatsu.favourites.data import android.database.DatabaseUtils.sqlEscapeString 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.intellij.lang.annotations.Language import org.koitharu.kotatsu.core.db.MangaQueryBuilder import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES import org.koitharu.kotatsu.core.db.entity.MangaWithTags import org.koitharu.kotatsu.favourites.domain.model.Cover import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_COMPLETED @Dao abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback { /** SELECT **/ @Transaction @Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC") abstract suspend fun findAll(): List @Transaction @Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit") abstract suspend fun findLast(limit: Int): List @Transaction @Query("SELECT manga.* FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id WHERE favourites.deleted_at = 0 AND (manga.title LIKE :query OR manga.alt_title LIKE :query) LIMIT :limit") abstract suspend fun searchByTitle(query: String, limit: Int): List @Transaction @Query("SELECT manga.* FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id WHERE favourites.deleted_at = 0 AND (manga.author LIKE :query) LIMIT :limit") abstract suspend fun searchByAuthor(query: String, limit: Int): List @Transaction @Query("SELECT manga.* FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id WHERE favourites.deleted_at = 0 AND EXISTS(SELECT 1 FROM tags LEFT JOIN manga_tags ON manga_tags.tag_id = tags.tag_id WHERE manga_tags.manga_id = manga.manga_id AND tags.title LIKE :query) LIMIT :limit") abstract suspend fun searchByTag(query: String, limit: Int): List fun observeAll( order: ListSortOrder, filterOptions: Set, limit: Int ): Flow> = observeAll(0L, order, filterOptions, limit) @Transaction @Query("SELECT * FROM favourites WHERE deleted_at = 0 ORDER BY created_at DESC LIMIT :limit OFFSET :offset") abstract suspend fun findAllRaw(offset: Int, limit: Int): List @Query("SELECT DISTINCT manga_id FROM favourites WHERE deleted_at = 0 AND category_id IN (SELECT category_id FROM favourite_categories WHERE track = 1 AND deleted_at = 0)") abstract suspend fun findIdsWithTrack(): LongArray @Transaction @Query( "SELECT * FROM favourites WHERE category_id = :categoryId AND deleted_at = 0 " + "GROUP BY manga_id ORDER BY created_at DESC", ) abstract suspend fun findAll(categoryId: Long): List fun observeAll( categoryId: Long, order: ListSortOrder, filterOptions: Set, limit: Int ): Flow> = observeAllImpl( MangaQueryBuilder(TABLE_FAVOURITES, this) .join("LEFT JOIN manga ON favourites.manga_id = manga.manga_id") .where("deleted_at = 0") .where( if (categoryId != 0L) { "category_id = $categoryId" } else { "(SELECT show_in_lib FROM favourite_categories WHERE favourite_categories.category_id = favourites.category_id) = 1" }, ) .filters(filterOptions) .groupBy("favourites.manga_id") .orderBy(getOrderBy(order)) .limit(limit) .build(), ) suspend fun findCovers(categoryId: Long, order: ListSortOrder): List { val orderBy = getOrderBy(order) @Language("RoomSql") val query = SimpleSQLiteQuery( "SELECT manga.cover_url AS url, manga.source AS source FROM favourites " + "LEFT JOIN manga ON favourites.manga_id = manga.manga_id " + "WHERE favourites.category_id = ? AND deleted_at = 0 ORDER BY $orderBy", arrayOf(categoryId), ) return findCoversImpl(query) } suspend fun findCovers(order: ListSortOrder, limit: Int): List { val orderBy = getOrderBy(order) @Language("RoomSql") val query = SimpleSQLiteQuery( "SELECT manga.cover_url AS url, manga.source AS source FROM favourites " + "LEFT JOIN manga ON favourites.manga_id = manga.manga_id " + "WHERE deleted_at = 0 AND " + "(SELECT show_in_lib FROM favourite_categories WHERE favourite_categories.category_id = favourites.category_id) = 1 " + "GROUP BY manga.manga_id ORDER BY $orderBy LIMIT ?", arrayOf(limit), ) return findCoversImpl(query) } @Query("SELECT COUNT(DISTINCT manga_id) FROM favourites WHERE deleted_at = 0") abstract fun observeMangaCount(): Flow @Query("SELECT * FROM favourites WHERE manga_id = :mangaId AND deleted_at = 0") abstract suspend fun findAllRaw(mangaId: Long): List @Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id = :id AND deleted_at = 0") abstract fun observeIds(id: Long): Flow> @Query("SELECT favourite_categories.* FROM favourites LEFT JOIN favourite_categories ON favourite_categories.category_id = favourites.category_id WHERE favourites.manga_id = :mangaId AND favourites.deleted_at = 0") abstract fun observeCategories(mangaId: Long): Flow> @Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id = :mangaId AND deleted_at = 0 ORDER BY favourites.created_at ASC") abstract suspend fun findCategoriesIds(mangaId: Long): List @Query("SELECT COUNT(category_id) FROM favourites WHERE manga_id = :mangaId AND deleted_at = 0") abstract suspend fun findCategoriesCount(mangaId: Long): Int @Query("SELECT manga.source AS count FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id GROUP BY manga.source ORDER BY COUNT(manga.source) DESC LIMIT :limit") abstract suspend fun findPopularSources(limit: Int): List @Query("SELECT manga.source AS count FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id WHERE favourites.category_id = :categoryId GROUP BY manga.source ORDER BY COUNT(manga.source) DESC LIMIT :limit") abstract suspend fun findPopularSources(categoryId: Long, limit: Int): List fun dump(): Flow = flow { val window = 10 var offset = 0 while (currentCoroutineContext().isActive) { val list = findAllRaw(offset, window) if (list.isEmpty()) { break } offset += window list.forEach { emit(it) } } } /** INSERT **/ @Insert(onConflict = OnConflictStrategy.REPLACE) abstract suspend fun insert(favourite: FavouriteEntity) /** DELETE **/ suspend fun delete(mangaId: Long) = setDeletedAt( mangaId = mangaId, deletedAt = System.currentTimeMillis(), ) suspend fun delete(mangaId: Long, categoryId: Long) = setDeletedAt( categoryId = categoryId, mangaId = mangaId, deletedAt = System.currentTimeMillis(), ) suspend fun deleteAll(categoryId: Long) = setDeletedAtAll( categoryId = categoryId, deletedAt = System.currentTimeMillis(), ) suspend fun recover(mangaId: Long) = setDeletedAt( mangaId = mangaId, deletedAt = 0L, ) suspend fun recover(categoryId: Long, mangaId: Long) = setDeletedAt( categoryId = categoryId, mangaId = mangaId, deletedAt = 0L, ) @Query("DELETE FROM favourites WHERE deleted_at != 0 AND deleted_at < :maxDeletionTime") abstract suspend fun gc(maxDeletionTime: Long) /** TOOLS **/ @Upsert abstract suspend fun upsert(entity: FavouriteEntity) @Transaction @RawQuery(observedEntities = [FavouriteEntity::class]) protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow> @RawQuery protected abstract suspend fun findCoversImpl(query: SupportSQLiteQuery): List @Query("UPDATE favourites SET deleted_at = :deletedAt WHERE manga_id = :mangaId") protected abstract suspend fun setDeletedAt(mangaId: Long, deletedAt: Long) @Query("UPDATE favourites SET deleted_at = :deletedAt WHERE manga_id = :mangaId AND category_id = :categoryId") protected abstract suspend fun setDeletedAt(categoryId: Long, mangaId: Long, deletedAt: Long) @Query("UPDATE favourites SET deleted_at = :deletedAt WHERE category_id = :categoryId AND deleted_at = 0") protected abstract suspend fun setDeletedAtAll(categoryId: Long, deletedAt: Long) private fun getOrderBy(sortOrder: ListSortOrder) = when (sortOrder) { ListSortOrder.RATING -> "manga.rating DESC" ListSortOrder.NEWEST -> "favourites.created_at DESC" ListSortOrder.OLDEST -> "favourites.created_at ASC" ListSortOrder.ALPHABETIC -> "manga.title ASC" ListSortOrder.ALPHABETIC_REVERSE -> "manga.title DESC" ListSortOrder.NEW_CHAPTERS -> "IFNULL((SELECT chapters_new FROM tracks WHERE tracks.manga_id = manga.manga_id), 0) DESC" ListSortOrder.PROGRESS -> "IFNULL((SELECT percent FROM history WHERE history.manga_id = manga.manga_id), 0) DESC" ListSortOrder.UNREAD -> "IFNULL((SELECT percent FROM history WHERE history.manga_id = manga.manga_id), 0) ASC" ListSortOrder.LAST_READ -> "IFNULL((SELECT updated_at FROM history WHERE history.manga_id = manga.manga_id), 0) DESC" ListSortOrder.LONG_AGO_READ -> "IFNULL((SELECT updated_at FROM history WHERE history.manga_id = manga.manga_id), 0) ASC" ListSortOrder.UPDATED -> "IFNULL((SELECT last_chapter_date FROM tracks WHERE tracks.manga_id = manga.manga_id), 0) DESC" else -> throw IllegalArgumentException("Sort order $sortOrder is not supported") } override fun getCondition(option: ListFilterOption): String? = when (option) { ListFilterOption.Macro.COMPLETED -> "EXISTS(SELECT * FROM history WHERE history.manga_id = favourites.manga_id AND history.percent >= $PROGRESS_COMPLETED)" ListFilterOption.Macro.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = favourites.manga_id) > 0" ListFilterOption.Macro.NSFW -> "manga.nsfw = 1" is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE favourites.manga_id = manga_tags.manga_id AND tag_id = ${option.tagId})" ListFilterOption.Downloaded -> "EXISTS(SELECT * FROM local_index WHERE local_index.manga_id = favourites.manga_id)" is ListFilterOption.Source -> "manga.source = ${sqlEscapeString(option.mangaSource.name)}" else -> null } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavoritesListQuickFilter.kt ================================================ package org.koitharu.kotatsu.favourites.domain import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.MangaListQuickFilter class FavoritesListQuickFilter @AssistedInject constructor( @Assisted private val categoryId: Long, private val settings: AppSettings, private val repository: FavouritesRepository, networkState: NetworkState, ) : MangaListQuickFilter(settings) { init { setFilterOption(ListFilterOption.Downloaded, !networkState.value) } override suspend fun getAvailableFilterOptions(): List = buildList { add(ListFilterOption.Downloaded) if (settings.isTrackerEnabled) { add(ListFilterOption.Macro.NEW_CHAPTERS) } add(ListFilterOption.Macro.COMPLETED) repository.findPopularSources(categoryId, 3).mapTo(this) { ListFilterOption.Source(it) } } @AssistedFactory interface Factory { fun create(categoryId: Long): FavoritesListQuickFilter } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt ================================================ package org.koitharu.kotatsu.favourites.domain import androidx.room.withTransaction import dagger.Reusable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES import org.koitharu.kotatsu.core.db.entity.toEntities import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toMangaList import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.toMangaSources import org.koitharu.kotatsu.core.ui.util.ReversibleHandle import org.koitharu.kotatsu.core.util.ext.mapItems import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.toFavouriteCategory import org.koitharu.kotatsu.favourites.data.toMangaList import org.koitharu.kotatsu.favourites.domain.model.Cover import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.levenshteinDistance import org.koitharu.kotatsu.search.domain.SearchKind import javax.inject.Inject @Reusable class FavouritesRepository @Inject constructor( private val db: MangaDatabase, private val localObserver: LocalFavoritesObserver, ) { suspend fun getAllManga(): List { val entities = db.getFavouritesDao().findAll() return entities.toMangaList() } suspend fun getLastManga(limit: Int): List { val entities = db.getFavouritesDao().findLast(limit) return entities.toMangaList() } suspend fun search(query: String, kind: SearchKind, limit: Int): List { val dao = db.getFavouritesDao() val q = "%$query%" val entities = when (kind) { SearchKind.SIMPLE, SearchKind.TITLE -> dao.searchByTitle(q, limit).sortedBy { it.manga.title.levenshteinDistance(query) } SearchKind.AUTHOR -> dao.searchByAuthor(q, limit) SearchKind.TAG -> dao.searchByTag(q, limit) } return entities.toMangaList() } fun observeAll(order: ListSortOrder, filterOptions: Set, limit: Int): Flow> { if (ListFilterOption.Downloaded in filterOptions) { return localObserver.observeAll(order, filterOptions, limit) } return db.getFavouritesDao().observeAll(order, filterOptions, limit) .map { it.toMangaList() } } suspend fun getManga(categoryId: Long): List { val entities = db.getFavouritesDao().findAll(categoryId) return entities.toMangaList() } fun observeAll( categoryId: Long, order: ListSortOrder, filterOptions: Set, limit: Int ): Flow> { if (ListFilterOption.Downloaded in filterOptions) { return localObserver.observeAll(categoryId, order, filterOptions, limit) } return db.getFavouritesDao().observeAll(categoryId, order, filterOptions, limit) .map { it.toMangaList() } } fun observeAll(categoryId: Long, filterOptions: Set, limit: Int): Flow> { return observeOrder(categoryId) .flatMapLatest { order -> observeAll(categoryId, order, filterOptions, limit) } } fun observeMangaCount(): Flow { return db.getFavouritesDao().observeMangaCount() .distinctUntilChanged() } fun observeCategories(): Flow> { return db.getFavouriteCategoriesDao().observeAll().mapItems { it.toFavouriteCategory() }.distinctUntilChanged() } fun observeCategoriesForLibrary(): Flow> { return db.getFavouriteCategoriesDao().observeAllVisible().mapItems { it.toFavouriteCategory() }.distinctUntilChanged() } fun observeCategoriesWithCovers(): Flow>> { return db.invalidationTracker.createFlow( TABLE_FAVOURITES, TABLE_FAVOURITE_CATEGORIES, emitInitialState = true, ).mapLatest { db.withTransaction { val categories = db.getFavouriteCategoriesDao().findAll() val res = LinkedHashMap>(categories.size) for (entity in categories) { val cat = entity.toFavouriteCategory() res[cat] = db.getFavouritesDao().findCovers( categoryId = cat.id, order = cat.order, ) } res } }.distinctUntilChanged() } suspend fun getAllFavoritesCovers(order: ListSortOrder, limit: Int): List { return db.getFavouritesDao().findCovers(order, limit) } fun observeCategory(id: Long): Flow { return db.getFavouriteCategoriesDao().observe(id) .map { it?.toFavouriteCategory() } } fun observeCategoriesIds(mangaId: Long): Flow> { return db.getFavouritesDao().observeIds(mangaId).map { it.toSet() } } fun observeCategories(mangaId: Long): Flow> { return db.getFavouritesDao().observeCategories(mangaId).map { it.mapTo(LinkedHashSet(it.size)) { x -> x.toFavouriteCategory() } } } suspend fun getCategory(id: Long): FavouriteCategory { return db.getFavouriteCategoriesDao().find(id.toInt()).toFavouriteCategory() } suspend fun isFavorite(mangaId: Long): Boolean { return db.getFavouritesDao().findCategoriesCount(mangaId) != 0 } suspend fun getCategoriesIds(mangaId: Long): Set { return db.getFavouritesDao().findCategoriesIds(mangaId).toSet() } suspend fun findPopularSources(categoryId: Long, limit: Int): List { return db.getFavouritesDao().run { if (categoryId == 0L) { findPopularSources(limit) } else { findPopularSources(categoryId, limit) } }.toMangaSources() } suspend fun createCategory( title: String, sortOrder: ListSortOrder, isTrackerEnabled: Boolean, isVisibleOnShelf: Boolean, ): FavouriteCategory { val entity = FavouriteCategoryEntity( title = title, createdAt = System.currentTimeMillis(), sortKey = db.getFavouriteCategoriesDao().getNextSortKey(), categoryId = 0, order = sortOrder.name, track = isTrackerEnabled, deletedAt = 0L, isVisibleInLibrary = isVisibleOnShelf, ) val id = db.getFavouriteCategoriesDao().insert(entity) val category = entity.toFavouriteCategory(id) return category } suspend fun updateCategory( id: Long, title: String, sortOrder: ListSortOrder, isTrackerEnabled: Boolean, isVisibleOnShelf: Boolean, ) { db.getFavouriteCategoriesDao().update(id, title, sortOrder.name, isTrackerEnabled, isVisibleOnShelf) } suspend fun updateCategory(id: Long, isVisibleInLibrary: Boolean) { db.getFavouriteCategoriesDao().updateVisibility(id, isVisibleInLibrary) } suspend fun updateCategoryTracking(id: Long, isTrackingEnabled: Boolean) { db.getFavouriteCategoriesDao().updateTracking(id, isTrackingEnabled) } suspend fun removeCategories(ids: Collection) { db.withTransaction { for (id in ids) { db.getFavouritesDao().deleteAll(id) db.getFavouriteCategoriesDao().delete(id) } db.getChaptersDao().gc() } } suspend fun setCategoryOrder(id: Long, order: ListSortOrder) { db.getFavouriteCategoriesDao().updateOrder(id, order.name) } suspend fun reorderCategories(orderedIds: List) { val dao = db.getFavouriteCategoriesDao() db.withTransaction { for ((i, id) in orderedIds.withIndex()) { dao.updateSortKey(id, i) } } } suspend fun addToCategory(categoryId: Long, mangas: Collection) { db.withTransaction { for (manga in mangas) { val tags = manga.tags.toEntities() db.getTagsDao().upsert(tags) db.getMangaDao().upsert(manga.toEntity(), tags) val entity = FavouriteEntity( mangaId = manga.id, categoryId = categoryId, createdAt = System.currentTimeMillis(), sortKey = 0, deletedAt = 0L, isPinned = false, ) db.getFavouritesDao().insert(entity) } } } suspend fun removeFromFavourites(ids: Collection): ReversibleHandle { db.withTransaction { for (id in ids) { db.getFavouritesDao().delete(mangaId = id) } db.getChaptersDao().gc() } return ReversibleHandle { recoverToFavourites(ids) } } suspend fun removeFromCategory(categoryId: Long, ids: Collection): ReversibleHandle { db.withTransaction { for (id in ids) { db.getFavouritesDao().delete(categoryId = categoryId, mangaId = id) } db.getChaptersDao().gc() } return ReversibleHandle { recoverToCategory(categoryId, ids) } } private fun observeOrder(categoryId: Long): Flow { return db.getFavouriteCategoriesDao().observe(categoryId) .filterNotNull() .map { x -> ListSortOrder(x.order, ListSortOrder.NEWEST) } .distinctUntilChanged() } suspend fun getMostUpdatedCategories(limit: Int): List { return db.getFavouriteCategoriesDao().getMostUpdatedCategories(limit).map { it.toFavouriteCategory() } } private suspend fun recoverToFavourites(ids: Collection) { db.withTransaction { for (id in ids) { db.getFavouritesDao().recover(mangaId = id) } } } private suspend fun recoverToCategory(categoryId: Long, ids: Collection) { db.withTransaction { for (id in ids) { db.getFavouritesDao().recover(mangaId = id, categoryId = categoryId) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/LocalFavoritesObserver.kt ================================================ package org.koitharu.kotatsu.favourites.domain import dagger.Reusable import kotlinx.coroutines.flow.Flow import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.favourites.data.FavouriteManga import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.local.data.index.LocalMangaIndex import org.koitharu.kotatsu.local.domain.LocalObserveMapper import org.koitharu.kotatsu.parsers.model.Manga import javax.inject.Inject @Reusable class LocalFavoritesObserver @Inject constructor( localMangaIndex: LocalMangaIndex, private val db: MangaDatabase, ) : LocalObserveMapper(localMangaIndex) { fun observeAll( order: ListSortOrder, filterOptions: Set, limit: Int ): Flow> = db.getFavouritesDao().observeAll(order, filterOptions, limit).mapToLocal() fun observeAll( categoryId: Long, order: ListSortOrder, filterOptions: Set, limit: Int ): Flow> = db.getFavouritesDao().observeAll(categoryId, order, filterOptions, limit).mapToLocal() override fun toManga(e: FavouriteManga) = e.manga.toManga(e.tags.toMangaTags(), null) override fun toResult(e: FavouriteManga, manga: Manga) = manga } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/model/Cover.kt ================================================ package org.koitharu.kotatsu.favourites.domain.model import org.koitharu.kotatsu.core.model.MangaSource data class Cover( val url: String?, val source: String, ) { val mangaSource by lazy { MangaSource(source) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/FavouritesActivity.kt ================================================ package org.koitharu.kotatsu.favourites.ui import android.os.Bundle import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.FragmentContainerActivity import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment class FavouritesActivity : FragmentContainerActivity(FavouritesListFragment::class.java) { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val categoryTitle = intent.getStringExtra(AppRouter.KEY_TITLE) if (categoryTitle != null) { title = categoryTitle } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/CategoriesSelectionCallback.kt ================================================ package org.koitharu.kotatsu.favourites.ui.categories import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.appcompat.view.ActionMode import androidx.recyclerview.widget.RecyclerView import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.ui.list.ListSelectionController class CategoriesSelectionCallback( private val recyclerView: RecyclerView, private val viewModel: FavouritesCategoriesViewModel, ) : ListSelectionController.Callback { override fun onSelectionChanged(controller: ListSelectionController, count: Int) { recyclerView.invalidateItemDecorations() } override fun onCreateActionMode( controller: ListSelectionController, menuInflater: MenuInflater, menu: Menu ): Boolean { menuInflater.inflate(R.menu.mode_category, menu) return true } override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode?, menu: Menu): Boolean { val categories = viewModel.getCategories(controller.peekCheckedIds()) var canShow = categories.isNotEmpty() var canHide = canShow for (cat in categories) { if (cat.isVisibleInLibrary) { canShow = false } else { canHide = false } } menu.findItem(R.id.action_show)?.isVisible = canShow menu.findItem(R.id.action_hide)?.isVisible = canHide mode?.title = controller.count.toString() return true } override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean { return when (item.itemId) { R.id.action_show -> { viewModel.setIsVisible(controller.snapshot(), true) mode?.finish() true } R.id.action_hide -> { viewModel.setIsVisible(controller.snapshot(), false) mode?.finish() true } R.id.action_remove -> { confirmDeleteCategories(controller.snapshot(), mode) true } else -> false } } private fun confirmDeleteCategories(ids: Set, mode: ActionMode?) { buildAlertDialog(recyclerView.context, isCentered = true) { setMessage(R.string.categories_delete_confirm) setTitle(R.string.remove_category) setIcon(R.drawable.ic_delete) setNegativeButton(android.R.string.cancel, null) setPositiveButton(R.string.remove) { _, _ -> viewModel.deleteCategories(ids) mode?.finish() } }.show() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/CategoriesSelectionDecoration.kt ================================================ package org.koitharu.kotatsu.favourites.ui.categories import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.RectF import android.view.View import androidx.core.graphics.ColorUtils import androidx.recyclerview.widget.RecyclerView import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration import org.koitharu.kotatsu.core.util.ext.getItem import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel import androidx.appcompat.R as appcompatR import com.google.android.material.R as materialR class CategoriesSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() { private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val radius = context.resources.getDimension(R.dimen.list_selector_corner) private val strokeColor = context.getThemeColor(appcompatR.attr.colorPrimary, Color.RED) private val fillColor = ColorUtils.setAlphaComponent( ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f), 0x74, ) private val padding = context.resources.getDimension(R.dimen.grid_spacing_outer) init { paint.strokeWidth = context.resources.getDimension(R.dimen.selection_stroke_width) hasForeground = true hasBackground = false isIncludeDecorAndMargins = false } override fun getItemId(parent: RecyclerView, child: View): Long { val holder = parent.getChildViewHolder(child) ?: return RecyclerView.NO_ID val item = holder.getItem(CategoryListModel::class.java) ?: return RecyclerView.NO_ID return item.category.id } override fun onDrawForeground( canvas: Canvas, parent: RecyclerView, child: View, bounds: RectF, state: RecyclerView.State, ) { bounds.inset(padding, padding) paint.color = fillColor paint.style = Paint.Style.FILL canvas.drawRoundRect(bounds, radius, radius, paint) paint.color = strokeColor paint.style = Paint.Style.STROKE canvas.drawRoundRect(bounds, radius, radius, paint) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt ================================================ package org.koitharu.kotatsu.favourites.ui.categories import android.os.Bundle import android.view.View import android.view.ViewGroup.MarginLayoutParams import androidx.activity.viewModels import androidx.appcompat.view.ActionMode import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView 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.FavouriteCategory import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.end 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.ActivityCategoriesBinding import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoriesAdapter import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.model.ListModel import javax.inject.Inject @AndroidEntryPoint class FavouriteCategoriesActivity : BaseActivity(), FavouriteCategoriesListListener, View.OnClickListener, ListStateHolderListener { @Inject lateinit var coil: ImageLoader private val viewModel by viewModels() private lateinit var adapter: CategoriesAdapter private lateinit var selectionController: ListSelectionController private lateinit var reorderHelper: ItemTouchHelper override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityCategoriesBinding.inflate(layoutInflater)) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) adapter = CategoriesAdapter(this, this) selectionController = ListSelectionController( appCompatDelegate = delegate, decoration = CategoriesSelectionDecoration(this), registryOwner = this, callback = CategoriesSelectionCallback(viewBinding.recyclerView, viewModel), ) selectionController.attachToRecyclerView(viewBinding.recyclerView) viewBinding.recyclerView.setHasFixedSize(true) viewBinding.recyclerView.adapter = adapter viewBinding.recyclerView.addItemDecoration(TypedListSpacingDecoration(this, false)) viewBinding.fabAdd.setOnClickListener(this) reorderHelper = ItemTouchHelper(ReorderHelperCallback()).apply { attachToRecyclerView(viewBinding.recyclerView) } viewModel.content.observe(this, ::onCategoriesChanged) viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) } 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, ) viewBinding.fabAdd.updateLayoutParams { marginEnd = topMargin + barsInsets.end(v) bottomMargin = topMargin + barsInsets.bottom } return insets.consumeAllSystemBarsInsets() } override fun onClick(v: View) { when (v.id) { R.id.fab_add -> router.openFavoriteCategoryCreate() } } override fun onItemClick(item: FavouriteCategory?, view: View) { if (item == null) { if (selectionController.count == 0) { router.openFavorites() } return } if (selectionController.onItemClick(item.id)) { return } router.openFavorites(item) } override fun onEditClick(item: FavouriteCategory, view: View) { if (selectionController.onItemClick(item.id)) { return } router.openFavoriteCategoryEdit(item.id) } override fun onItemLongClick(item: FavouriteCategory?, view: View): Boolean { return item != null && selectionController.onItemLongClick(view, item.id) } override fun onItemContextClick(item: FavouriteCategory?, view: View): Boolean { return item != null && selectionController.onItemContextClick(view, item.id) } override fun onSupportActionModeStarted(mode: ActionMode) { super.onSupportActionModeStarted(mode) viewBinding.fabAdd.hide() viewModel.setActionsEnabled(false) } override fun onSupportActionModeFinished(mode: ActionMode) { super.onSupportActionModeFinished(mode) viewBinding.fabAdd.show() viewModel.setActionsEnabled(true) } override fun onShowAllClick(isChecked: Boolean) { viewModel.setAllCategoriesVisible(isChecked) } override fun onDragHandleTouch(holder: RecyclerView.ViewHolder): Boolean { reorderHelper.startDrag(holder) return true } override fun onRetryClick(error: Throwable) = Unit override fun onEmptyActionClick() = Unit private suspend fun onCategoriesChanged(categories: List) { adapter.emit(categories) invalidateOptionsMenu() } private inner class ReorderHelperCallback : ItemTouchHelper.SimpleCallback( ItemTouchHelper.DOWN or ItemTouchHelper.UP, 0, ) { override fun getDragDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { return if (actionModeDelegate.isActionModeStarted) { 0 } else { super.getDragDirs(recyclerView, viewHolder) } } override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder, ): Boolean { if (viewHolder.itemViewType != target.itemViewType) { return false } val fromPos = viewHolder.bindingAdapterPosition val toPos = target.bindingAdapterPosition if (fromPos == toPos || fromPos == RecyclerView.NO_POSITION || toPos == RecyclerView.NO_POSITION) { return false } adapter.reorderItems(fromPos, toPos) return true } override fun canDropOver( recyclerView: RecyclerView, current: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder, ): Boolean = current.itemViewType == target.itemViewType override fun isLongPressDragEnabled(): Boolean = false override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { super.onSelectedChanged(viewHolder, actionState) viewBinding.recyclerView.isNestedScrollingEnabled = actionState == ItemTouchHelper.ACTION_STATE_IDLE } override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { super.clearView(recyclerView, viewHolder) viewModel.saveOrder(adapter.items ?: return) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesListListener.kt ================================================ package org.koitharu.kotatsu.favourites.ui.categories import android.view.View import androidx.recyclerview.widget.RecyclerView import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener interface FavouriteCategoriesListListener : OnListItemClickListener { fun onDragHandleTouch(holder: RecyclerView.ViewHolder): Boolean fun onEditClick(item: FavouriteCategory, view: View) fun onShowAllClick(isChecked: Boolean) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt ================================================ package org.koitharu.kotatsu.favourites.ui.categories import androidx.collection.LongSet 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.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.requireValue import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.model.Cover import org.koitharu.kotatsu.favourites.ui.categories.adapter.AllCategoriesListModel import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingState import javax.inject.Inject @HiltViewModel class FavouritesCategoriesViewModel @Inject constructor( private val repository: FavouritesRepository, private val settings: AppSettings, ) : BaseViewModel() { private var commitJob: Job? = null private val isActionsEnabled = MutableStateFlow(true) val content = combine( repository.observeCategoriesWithCovers(), observeAllCategories(), settings.observeAsFlow(AppSettings.KEY_ALL_FAVOURITES_VISIBLE) { isAllFavouritesVisible }, isActionsEnabled, ) { cats, all, showAll, hasActions -> cats.toUiList(all, showAll, hasActions) }.withErrorHandling() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) fun deleteCategories(ids: Set) { launchJob(Dispatchers.Default) { repository.removeCategories(ids) } } fun setAllCategoriesVisible(isVisible: Boolean) { settings.isAllFavouritesVisible = isVisible } fun isEmpty(): Boolean = content.value.none { it is CategoryListModel } fun saveOrder(snapshot: List) { val prevJob = commitJob commitJob = launchJob { prevJob?.cancelAndJoin() val ids = snapshot.mapNotNullTo(ArrayList(snapshot.size)) { (it as? CategoryListModel)?.category?.id } if (ids.isNotEmpty()) { repository.reorderCategories(ids) } } } fun setIsVisible(ids: Set, isVisible: Boolean) { launchJob(Dispatchers.Default) { for (id in ids) { repository.updateCategory(id, isVisible) } } } fun setActionsEnabled(value: Boolean) { isActionsEnabled.value = value } fun getCategories(ids: LongSet): ArrayList { val items = content.requireValue() return items.mapNotNullTo(ArrayList(ids.size)) { item -> (item as? CategoryListModel)?.category?.takeIf { it.id in ids } } } private fun Map>.toUiList( allFavorites: Pair>, showAll: Boolean, hasActions: Boolean, ): List { if (isEmpty()) { return listOf( EmptyState( icon = R.drawable.ic_empty_favourites, textPrimary = R.string.text_empty_holder_primary, textSecondary = R.string.empty_favourite_categories, actionStringRes = 0, ), ) } val result = ArrayList(size + 1) result.add( AllCategoriesListModel( mangaCount = allFavorites.first, covers = allFavorites.second, isVisible = showAll, isActionsEnabled = hasActions, ), ) mapTo(result) { (category, covers) -> CategoryListModel( mangaCount = covers.size, covers = covers.take(3), category = category, isActionsEnabled = hasActions, isTrackerEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources, ) } return result } private fun observeAllCategories(): Flow>> { return settings.observeAsFlow(AppSettings.KEY_FAVORITES_ORDER) { allFavoritesSortOrder }.mapLatest { order -> repository.getAllFavoritesCovers(order, limit = 3) }.combine(repository.observeMangaCount()) { covers, count -> count to covers } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/AllCategoriesListModel.kt ================================================ package org.koitharu.kotatsu.favourites.ui.categories.adapter import org.koitharu.kotatsu.favourites.domain.model.Cover import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.model.ListModel data class AllCategoriesListModel( val mangaCount: Int, val covers: List, val isVisible: Boolean, val isActionsEnabled: Boolean, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is AllCategoriesListModel } override fun getChangePayload(previousState: ListModel): Any? = when { previousState !is AllCategoriesListModel -> super.getChangePayload(previousState) previousState.isVisible != isVisible -> ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED previousState.isActionsEnabled != isActionsEnabled -> ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED else -> super.getChangePayload(previousState) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoriesAdapter.kt ================================================ package org.koitharu.kotatsu.favourites.ui.categories.adapter import org.koitharu.kotatsu.core.ui.ReorderableListAdapter import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.model.ListModel class CategoriesAdapter( onItemClickListener: FavouriteCategoriesListListener, listListener: ListStateHolderListener, ) : ReorderableListAdapter() { init { addDelegate(ListItemType.CATEGORY_LARGE, categoryAD(onItemClickListener)) addDelegate(ListItemType.NAV_ITEM, allCategoriesAD(onItemClickListener)) addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(listListener)) addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt ================================================ package org.koitharu.kotatsu.favourites.ui.categories.adapter import android.annotation.SuppressLint import android.view.MotionEvent import android.view.View import android.view.View.OnClickListener import android.view.View.OnLongClickListener import android.view.View.OnTouchListener import androidx.core.view.isGone import androidx.core.view.isVisible import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.setTooltipCompat import org.koitharu.kotatsu.databinding.ItemCategoriesAllBinding import org.koitharu.kotatsu.databinding.ItemCategoryBinding import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener import org.koitharu.kotatsu.list.ui.model.ListModel @SuppressLint("ClickableViewAccessibility") fun categoryAD( clickListener: FavouriteCategoriesListListener, ) = adapterDelegateViewBinding( { inflater, parent -> ItemCategoryBinding.inflate(inflater, parent, false) }, ) { val eventListener = object : OnClickListener, OnLongClickListener, OnTouchListener { override fun onClick(v: View) = if (v.id == R.id.imageView_edit) { clickListener.onEditClick(item.category, v) } else { clickListener.onItemClick(item.category, v) } override fun onLongClick(v: View) = clickListener.onItemLongClick(item.category, v) override fun onTouch(v: View?, event: MotionEvent): Boolean = event.actionMasked == MotionEvent.ACTION_DOWN && clickListener.onDragHandleTouch(this@adapterDelegateViewBinding) } itemView.setOnClickListener(eventListener) itemView.setOnLongClickListener(eventListener) binding.imageViewEdit.setOnClickListener(eventListener) binding.imageViewHandle.setOnTouchListener(eventListener) bind { binding.imageViewHandle.isVisible = item.isActionsEnabled binding.imageViewEdit.isVisible = item.isActionsEnabled binding.textViewTitle.text = item.category.title binding.textViewSubtitle.text = if (item.mangaCount == 0) { getString(R.string.empty) } else { context.resources.getQuantityStringSafe( R.plurals.items, item.mangaCount, item.mangaCount, ) } binding.imageViewTracker.isVisible = item.category.isTrackingEnabled binding.imageViewHidden.isGone = item.category.isVisibleInLibrary binding.coversView.setCoversAsync(item.covers) } } fun allCategoriesAD( clickListener: FavouriteCategoriesListListener, ) = adapterDelegateViewBinding( { inflater, parent -> ItemCategoriesAllBinding.inflate(inflater, parent, false) }, ) { val eventListener = OnClickListener { v -> if (v.id == R.id.imageView_visible) { clickListener.onShowAllClick(!item.isVisible) } else { clickListener.onItemClick(null, v) } } itemView.setOnClickListener(eventListener) binding.imageViewVisible.setOnClickListener(eventListener) bind { binding.textViewSubtitle.text = if (item.mangaCount == 0) { getString(R.string.empty) } else { context.resources.getQuantityStringSafe( R.plurals.items, item.mangaCount, item.mangaCount, ) } binding.imageViewVisible.isVisible = item.isActionsEnabled binding.imageViewVisible.setImageResource( if (item.isVisible) { R.drawable.ic_eye } else { R.drawable.ic_eye_off }, ) binding.imageViewVisible.setTooltipCompat( if (item.isVisible) { R.string.hide } else { R.string.show }, ) binding.coversView.setCoversAsync(item.covers) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryListModel.kt ================================================ package org.koitharu.kotatsu.favourites.ui.categories.adapter import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.favourites.domain.model.Cover import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.model.ListModel class CategoryListModel( val mangaCount: Int, val covers: List, val category: FavouriteCategory, val isTrackerEnabled: Boolean, val isActionsEnabled: Boolean, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is CategoryListModel && other.category.id == category.id } override fun getChangePayload(previousState: ListModel): Any? = when { previousState !is CategoryListModel -> super.getChangePayload(previousState) previousState.isActionsEnabled != isActionsEnabled -> ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED else -> super.getChangePayload(previousState) } override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as CategoryListModel if (mangaCount != other.mangaCount) return false if (isTrackerEnabled != other.isTrackerEnabled) return false if (isActionsEnabled != other.isActionsEnabled) return false if (covers != other.covers) return false if (category.id != other.category.id) return false if (category.title != other.category.title) return false // ignore the category.sortKey field if (category.order != other.category.order) return false if (category.createdAt != other.category.createdAt) return false if (category.isTrackingEnabled != other.category.isTrackingEnabled) return false return category.isVisibleInLibrary == other.category.isVisibleInLibrary } override fun hashCode(): Int { var result = mangaCount result = 31 * result + isTrackerEnabled.hashCode() result = 31 * result + isActionsEnabled.hashCode() result = 31 * result + covers.hashCode() result = 31 * result + category.id.hashCode() result = 31 * result + category.title.hashCode() // ignore the category.sortKey field result = 31 * result + category.order.hashCode() result = 31 * result + category.createdAt.hashCode() result = 31 * result + category.isTrackingEnabled.hashCode() result = 31 * result + category.isVisibleInLibrary.hashCode() return result } override fun toString(): String { return "CategoryListModel(categoryId=${category.id})" } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt ================================================ package org.koitharu.kotatsu.favourites.ui.categories.edit import android.content.Context import android.os.Bundle import android.text.Editable import android.view.View import android.widget.AdapterView import android.widget.ArrayAdapter import android.widget.Filter import androidx.activity.viewModels import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.util.DefaultTextWatcher import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getSerializableCompat import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.setChecked import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.databinding.ActivityCategoryEditBinding import org.koitharu.kotatsu.list.domain.ListSortOrder @AndroidEntryPoint class FavouritesCategoryEditActivity : BaseActivity(), AdapterView.OnItemClickListener, View.OnClickListener, DefaultTextWatcher { private val viewModel by viewModels() private var selectedSortOrder: ListSortOrder? = null private val sortOrders = ListSortOrder.FAVORITES.sortedByOrdinal() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityCategoryEditBinding.inflate(layoutInflater)) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true) initSortSpinner() viewBinding.buttonDone.setOnClickListener(this) viewBinding.editName.addTextChangedListener(this) afterTextChanged(viewBinding.editName.text) viewModel.onSaved.observeEvent(this) { finishAfterTransition() } viewModel.category.observe(this, ::onCategoryChanged) viewModel.isLoading.observe(this, ::onLoadingStateChanged) viewModel.onError.observeEvent(this, ::onError) viewModel.isTrackerEnabled.observe(this) { viewBinding.switchTracker.isVisible = it } } override fun onApplyWindowInsets( v: View, insets: WindowInsetsCompat ): WindowInsetsCompat { val barsInsets = insets.systemBarsInsets viewBinding.root.setPadding( barsInsets.left, barsInsets.top, barsInsets.right, barsInsets.bottom, ) return insets.consumeAllSystemBarsInsets() } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putSerializable(KEY_SORT_ORDER, selectedSortOrder) } override fun onRestoreInstanceState(savedInstanceState: Bundle) { super.onRestoreInstanceState(savedInstanceState) savedInstanceState.getSerializableCompat(KEY_SORT_ORDER)?.let { selectedSortOrder = it } } override fun onClick(v: View) { when (v.id) { R.id.button_done -> viewModel.save( title = viewBinding.editName.text?.toString()?.trim().orEmpty(), sortOrder = getSelectedSortOrder(), isTrackerEnabled = viewBinding.switchTracker.isChecked, isVisibleOnShelf = viewBinding.switchShelf.isChecked, ) } } override fun afterTextChanged(s: Editable?) { viewBinding.buttonDone.isEnabled = !s.isNullOrBlank() && !viewModel.isLoading.value } override fun onItemClick(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { selectedSortOrder = sortOrders.getOrNull(position) } private fun onCategoryChanged(category: FavouriteCategory?) { setTitle(if (category == null) R.string.create_category else R.string.edit_category) if (selectedSortOrder != null) { return } viewBinding.editName.setText(category?.title) selectedSortOrder = category?.order val sortText = getString((category?.order ?: ListSortOrder.NEWEST).titleResId) viewBinding.editSort.setText(sortText, false) viewBinding.switchTracker.setChecked(category?.isTrackingEnabled != false, false) viewBinding.switchShelf.setChecked(category?.isVisibleInLibrary != false, false) } private fun onError(e: Throwable) { viewBinding.textViewError.text = e.getDisplayMessage(resources) viewBinding.textViewError.isVisible = true } private fun onLoadingStateChanged(isLoading: Boolean) { viewBinding.buttonDone.isEnabled = !isLoading && !viewBinding.editName.text.isNullOrBlank() viewBinding.editSort.isEnabled = !isLoading viewBinding.editName.isEnabled = !isLoading viewBinding.switchTracker.isEnabled = !isLoading viewBinding.switchShelf.isEnabled = !isLoading if (isLoading) { viewBinding.textViewError.isVisible = false } } private fun initSortSpinner() { val entries = sortOrders.map { getString(it.titleResId) } val adapter = SortAdapter(this, entries) viewBinding.editSort.setAdapter(adapter) viewBinding.editSort.onItemClickListener = this } private fun getSelectedSortOrder(): ListSortOrder { selectedSortOrder?.let { return it } val entries = sortOrders.map { getString(it.titleResId) } val index = entries.indexOf(viewBinding.editSort.text.toString()) return sortOrders.getOrNull(index) ?: ListSortOrder.NEWEST } private class SortAdapter( context: Context, entries: List, ) : ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, entries) { override fun getFilter(): Filter = EmptyFilter private object EmptyFilter : Filter() { override fun performFiltering(constraint: CharSequence?) = FilterResults() override fun publishResults(constraint: CharSequence?, results: FilterResults?) = Unit } } companion object { const val NO_ID = -1L private const val KEY_SORT_ORDER = "sort" } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt ================================================ package org.koitharu.kotatsu.favourites.ui.categories.edit import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.prefs.AppSettings 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.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity.Companion.NO_ID import org.koitharu.kotatsu.list.domain.ListSortOrder import javax.inject.Inject @HiltViewModel class FavouritesCategoryEditViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val repository: FavouritesRepository, private val settings: AppSettings, ) : BaseViewModel() { private val categoryId = savedStateHandle[AppRouter.KEY_ID] ?: NO_ID val onSaved = MutableEventFlow() val category = MutableStateFlow(null) val isTrackerEnabled = flow { emit(settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) init { launchLoadingJob(Dispatchers.Default) { category.value = if (categoryId != NO_ID) { repository.getCategory(categoryId) } else { null } } } fun save( title: String, sortOrder: ListSortOrder, isTrackerEnabled: Boolean, isVisibleOnShelf: Boolean, ) { launchLoadingJob(Dispatchers.Default) { check(title.isNotEmpty()) if (categoryId == NO_ID) { repository.createCategory(title, sortOrder, isTrackerEnabled, isVisibleOnShelf) } else { repository.updateCategory(categoryId, title, sortOrder, isTrackerEnabled, isVisibleOnShelf) } onSaved.call(Unit) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteDialog.kt ================================================ package org.koitharu.kotatsu.favourites.ui.categories.select import android.content.DialogInterface import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.fragment.app.viewModels import com.google.android.material.checkbox.MaterialCheckBox import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint 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.joinToStringWithLimit import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.DialogFavoriteBinding import org.koitharu.kotatsu.favourites.ui.categories.select.adapter.MangaCategoriesAdapter import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem @AndroidEntryPoint class FavoriteDialog : AlertDialogFragment(), OnListItemClickListener, DialogInterface.OnClickListener { private val viewModel by viewModels() override fun onCreateViewBinding( inflater: LayoutInflater, container: ViewGroup?, ) = DialogFavoriteBinding.inflate(inflater, container, false) override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { return super.onBuildDialog(builder) .setPositiveButton(R.string.done, null) .setNeutralButton(R.string.manage, this) } override fun onViewBindingCreated( binding: DialogFavoriteBinding, savedInstanceState: Bundle?, ) { super.onViewBindingCreated(binding, savedInstanceState) val adapter = MangaCategoriesAdapter(this) binding.recyclerViewCategories.adapter = adapter viewModel.content.observe(viewLifecycleOwner, adapter) viewModel.onError.observeEvent(viewLifecycleOwner, ::onError) bindHeader() } override fun onItemClick(item: MangaCategoryItem, view: View) { viewModel.setChecked(item.category.id, item.checkedState != MaterialCheckBox.STATE_CHECKED) } override fun onClick(dialog: DialogInterface?, which: Int) { router.openFavoriteCategories() } private fun onError(e: Throwable) { Toast.makeText(context ?: return, e.getDisplayMessage(resources), Toast.LENGTH_SHORT).show() } private fun bindHeader() { val manga = viewModel.manga val binding = viewBinding ?: return binding.textViewTitle.text = manga.joinToStringWithLimit(binding.root.context, 92) { it.title } binding.coversStack.setCoversAsync(manga) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteDialogViewModel.kt ================================================ package org.koitharu.kotatsu.favourites.ui.categories.select import androidx.collection.MutableLongObjectMap import androidx.collection.MutableLongSet import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.google.android.material.checkbox.MaterialCheckBox import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.ids import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingState import javax.inject.Inject @HiltViewModel class FavoriteDialogViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val favouritesRepository: FavouritesRepository, settings: AppSettings, ) : BaseViewModel() { val manga = savedStateHandle.require>(AppRouter.KEY_MANGA_LIST).map { it.manga } private val refreshTrigger = MutableStateFlow(Any()) val content = combine( favouritesRepository.observeCategories(), refreshTrigger, settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled }, ) { categories, _, tracker -> mapList(categories, tracker) }.withErrorHandling() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) fun setChecked(categoryId: Long, isChecked: Boolean) { launchJob(Dispatchers.Default) { if (isChecked) { favouritesRepository.addToCategory(categoryId, manga) } else { favouritesRepository.removeFromCategory(categoryId, manga.ids()) } refreshTrigger.value = Any() } } private suspend fun mapList(categories: List, tracker: Boolean): List { if (categories.isEmpty()) { return listOf( EmptyState( icon = 0, textPrimary = R.string.empty_favourite_categories, textSecondary = 0, actionStringRes = 0, ), ) } val cats = MutableLongObjectMap(categories.size) categories.forEach { cats[it.id] = MutableLongSet(manga.size) } for (m in manga) { val ids = favouritesRepository.getCategoriesIds(m.id) ids.forEach { id -> cats[id]?.add(m.id) } } return categories.map { cat -> MangaCategoryItem( category = cat, checkedState = when (cats[cat.id]?.size ?: 0) { 0 -> MaterialCheckBox.STATE_UNCHECKED manga.size -> MaterialCheckBox.STATE_CHECKED else -> MaterialCheckBox.STATE_INDETERMINATE }, isTrackerEnabled = tracker, ) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoriesAdapter.kt ================================================ package org.koitharu.kotatsu.favourites.ui.categories.select.adapter import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.model.ListModel class MangaCategoriesAdapter( clickListener: OnListItemClickListener, ) : BaseListAdapter() { init { addDelegate(ListItemType.NAV_ITEM, mangaCategoryAD(clickListener)) addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(null)) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt ================================================ package org.koitharu.kotatsu.favourites.ui.categories.select.adapter import androidx.core.text.buildSpannedString import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.appendIcon import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.databinding.ItemCategoryCheckableBinding import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.model.ListModel fun mangaCategoryAD( clickListener: OnListItemClickListener, ) = adapterDelegateViewBinding( { inflater, parent -> ItemCategoryCheckableBinding.inflate(inflater, parent, false) }, ) { itemView.setOnClickListener { clickListener.onItemClick(item, itemView) } bind { payloads -> binding.checkBox.checkedState = item.checkedState if (ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED !in payloads) { binding.checkBox.text = buildSpannedString { append(item.category.title) if (item.isTrackerEnabled && item.category.isTrackingEnabled) { append(' ') appendIcon(binding.checkBox, R.drawable.ic_notification) } if (!item.category.isVisibleInLibrary) { append(' ') appendIcon(binding.checkBox, R.drawable.ic_eye_off) } } binding.checkBox.jumpDrawablesToCurrentState() } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/MangaCategoryItem.kt ================================================ package org.koitharu.kotatsu.favourites.ui.categories.select.model import com.google.android.material.checkbox.MaterialCheckBox.CheckedState import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.model.ListModel data class MangaCategoryItem( val category: FavouriteCategory, @CheckedState val checkedState: Int, val isTrackerEnabled: Boolean, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is MangaCategoryItem && other.category.id == category.id } override fun getChangePayload(previousState: ListModel): Any? { return if (previousState is MangaCategoryItem && previousState.checkedState != checkedState) { ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED } else { super.getChangePayload(previousState) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouriteTabModel.kt ================================================ package org.koitharu.kotatsu.favourites.ui.container import org.koitharu.kotatsu.list.ui.model.ListModel data class FavouriteTabModel( val id: Long, val title: String?, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is FavouriteTabModel && other.id == id } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouriteTabPopupMenuProvider.kt ================================================ package org.koitharu.kotatsu.favourites.ui.container import android.content.Context import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.core.view.MenuProvider import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID class FavouriteTabPopupMenuProvider( private val context: Context, private val router: AppRouter, private val viewModel: FavouritesContainerViewModel, private val categoryId: Long ) : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { val menuResId = if (categoryId == NO_ID) { R.menu.popup_fav_tab_all } else { R.menu.popup_fav_tab } menuInflater.inflate(menuResId, menu) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { when (menuItem.itemId) { R.id.action_hide -> viewModel.hide(categoryId) R.id.action_edit -> router.openFavoriteCategoryEdit(categoryId) R.id.action_delete -> confirmDelete() R.id.action_manage -> router.openFavoriteCategories() else -> return false } return true } private fun confirmDelete() { buildAlertDialog(context, isCentered = true) { setMessage(R.string.categories_delete_confirm) setTitle(R.string.remove_category) setIcon(R.drawable.ic_delete) setNegativeButton(android.R.string.cancel, null) setPositiveButton(R.string.remove) { _, _ -> viewModel.deleteCategory(categoryId) } }.show() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerAdapter.kt ================================================ package org.koitharu.kotatsu.favourites.ui.container import androidx.fragment.app.Fragment import androidx.recyclerview.widget.AdapterListUpdateCallback import androidx.recyclerview.widget.AsyncDifferConfig import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.adapter.FragmentStateAdapter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor import kotlinx.coroutines.flow.FlowCollector import org.koitharu.kotatsu.core.util.ContinuationResumeRunnable import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import kotlin.coroutines.suspendCoroutine class FavouritesContainerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment), FlowCollector> { private val differ = AsyncListDiffer( AdapterListUpdateCallback(this), AsyncDifferConfig.Builder(ListModelDiffCallback()) .setBackgroundThreadExecutor(Dispatchers.Default.limitedParallelism(2).asExecutor()) .build(), ) override fun getItemCount(): Int = differ.currentList.size override fun getItemId(position: Int): Long { return differ.currentList.getOrNull(position)?.id ?: RecyclerView.NO_ID } override fun containsItem(itemId: Long): Boolean { return differ.currentList.any { x -> x.id == itemId } } override fun createFragment(position: Int): Fragment { val item = differ.currentList[position] return FavouritesListFragment.newInstance(item.id) } override suspend fun emit(value: List) = suspendCoroutine { cont -> differ.submitList(value, ContinuationResumeRunnable(cont)) } fun getItem(position: Int): FavouriteTabModel = differ.currentList[position] } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerFragment.kt ================================================ package org.koitharu.kotatsu.favourites.ui.container import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.ViewStub import androidx.appcompat.view.ActionMode import androidx.core.view.WindowInsetsCompat import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.recyclerview.widget.RecyclerView import com.google.android.material.tabs.TabLayoutMediator import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.util.ActionModeListener import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.findCurrentPagerFragment import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.recyclerView import org.koitharu.kotatsu.core.util.ext.setTabsEnabled import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.databinding.FragmentFavouritesContainerBinding import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding @AndroidEntryPoint class FavouritesContainerFragment : BaseFragment(), ActionModeListener, RecyclerViewOwner, ViewStub.OnInflateListener, View.OnClickListener { private val viewModel: FavouritesContainerViewModel by viewModels() override val recyclerView: RecyclerView? get() = (findCurrentFragment() as? RecyclerViewOwner)?.recyclerView override fun onCreateViewBinding( inflater: LayoutInflater, container: ViewGroup?, ) = FragmentFavouritesContainerBinding.inflate(inflater, container, false) override fun onViewBindingCreated(binding: FragmentFavouritesContainerBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) val pagerAdapter = FavouritesContainerAdapter(this) binding.pager.adapter = pagerAdapter binding.pager.offscreenPageLimit = 1 binding.pager.recyclerView?.isNestedScrollingEnabled = false TabLayoutMediator( binding.tabs, binding.pager, FavouritesTabConfigurationStrategy(pagerAdapter, viewModel, router), ).attach() binding.stubEmpty.setOnInflateListener(this) actionModeDelegate.addListener(this) viewModel.categories.observe(viewLifecycleOwner, pagerAdapter) viewModel.isEmpty.observe(viewLifecycleOwner, ::onEmptyStateChanged) addMenuProvider(FavouritesContainerMenuProvider(router)) viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.pager)) } override fun onDestroyView() { actionModeDelegate.removeListener(this) super.onDestroyView() } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = insets override fun onActionModeStarted(mode: ActionMode) { viewBinding?.run { pager.isUserInputEnabled = false tabs.setTabsEnabled(false) } } override fun onActionModeFinished(mode: ActionMode) { viewBinding?.run { pager.isUserInputEnabled = true tabs.setTabsEnabled(true) } } override fun onInflate(stub: ViewStub?, inflated: View) { val stubBinding = ItemEmptyStateBinding.bind(inflated) stubBinding.icon.setImageAsync(R.drawable.ic_empty_favourites) stubBinding.textPrimary.setText(R.string.text_empty_holder_primary) stubBinding.textSecondary.setTextAndVisible(R.string.empty_favourite_categories) stubBinding.buttonRetry.setTextAndVisible(R.string.manage) stubBinding.buttonRetry.setOnClickListener(this) } override fun onClick(v: View) { when (v.id) { R.id.button_retry -> router.openFavoriteCategories() } } private fun onEmptyStateChanged(isEmpty: Boolean) { viewBinding?.run { pager.isGone = isEmpty tabs.isGone = isEmpty stubEmpty.isVisible = isEmpty } } private fun findCurrentFragment(): Fragment? { return childFragmentManager.findCurrentPagerFragment( viewBinding?.pager ?: return null, ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerMenuProvider.kt ================================================ package org.koitharu.kotatsu.favourites.ui.container import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.core.view.MenuProvider import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.AppRouter class FavouritesContainerMenuProvider( private val router: AppRouter, ) : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.opt_favourites_container, menu) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { when (menuItem.itemId) { R.id.action_manage -> { router.openFavoriteCategories() } else -> return false } return true } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerViewModel.kt ================================================ package org.koitharu.kotatsu.favourites.ui.container import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleHandle import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID import javax.inject.Inject @HiltViewModel class FavouritesContainerViewModel @Inject constructor( private val settings: AppSettings, private val favouritesRepository: FavouritesRepository, ) : BaseViewModel() { val onActionDone = MutableEventFlow() private val categoriesStateFlow = favouritesRepository.observeCategoriesForLibrary() .withErrorHandling() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) val categories = combine( categoriesStateFlow.filterNotNull(), observeAllFavouritesVisibility(), ) { list, showAll -> list.toUi(showAll) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) val isEmpty = categoriesStateFlow.map { it?.isEmpty() == true }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) private fun List.toUi(showAll: Boolean): List { if (isEmpty()) { return emptyList() } val result = ArrayList(if (showAll) size + 1 else size) if (showAll) { result.add(FavouriteTabModel(NO_ID, null)) } mapTo(result) { FavouriteTabModel(it.id, it.title) } return result } fun hide(categoryId: Long) { launchJob(Dispatchers.Default) { if (categoryId == NO_ID) { settings.isAllFavouritesVisible = false } else { favouritesRepository.updateCategory(categoryId, isVisibleInLibrary = false) val reverse = ReversibleHandle { favouritesRepository.updateCategory(categoryId, isVisibleInLibrary = true) } onActionDone.call(ReversibleAction(R.string.category_hidden_done, reverse)) } } } fun deleteCategory(categoryId: Long) { launchJob(Dispatchers.Default) { favouritesRepository.removeCategories(setOf(categoryId)) } } private fun observeAllFavouritesVisibility() = settings.observeAsFlow( key = AppSettings.KEY_ALL_FAVOURITES_VISIBLE, valueProducer = { isAllFavouritesVisible }, ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesTabConfigurationStrategy.kt ================================================ package org.koitharu.kotatsu.favourites.ui.container import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator.TabConfigurationStrategy import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.util.PopupMenuMediator class FavouritesTabConfigurationStrategy( private val adapter: FavouritesContainerAdapter, private val viewModel: FavouritesContainerViewModel, private val router: AppRouter, ) : TabConfigurationStrategy { override fun onConfigureTab(tab: TabLayout.Tab, position: Int) { val item = adapter.getItem(position) tab.text = item.title ?: tab.view.context.getString(R.string.all_favourites) tab.tag = item PopupMenuMediator( FavouriteTabPopupMenuProvider(tab.view.context, router, viewModel, item.id) ).attach(tab.view) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt ================================================ package org.koitharu.kotatsu.favourites.ui.list import android.os.Bundle import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.PopupMenu 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.nav.AppRouter import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.ui.MangaListFragment @AndroidEntryPoint class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener { override val viewModel by viewModels() override val isSwipeRefreshEnabled = false val categoryId get() = viewModel.categoryId override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) binding.recyclerView.isVP2BugWorkaroundEnabled = true } override fun onScrolledToEnd() = viewModel.requestMoreItems() override fun onEmptyActionClick() = viewModel.clearFilter() override fun onFilterClick(view: View?) { val menu = PopupMenu(view?.context ?: return, view) menu.setOnMenuItemClickListener(this) val orders = ListSortOrder.FAVORITES.sortedByOrdinal() for ((i, item) in orders.withIndex()) { menu.menu.add(Menu.NONE, Menu.NONE, i, item.titleResId) } menu.show() } override fun onMenuItemClick(item: MenuItem): Boolean { val order = ListSortOrder.FAVORITES.sortedByOrdinal().getOrNull(item.order) ?: return false viewModel.setSortOrder(order) return true } override fun onCreateActionMode( controller: ListSelectionController, menuInflater: MenuInflater, menu: Menu ): Boolean { menuInflater.inflate(R.menu.mode_favourites, menu) return super.onCreateActionMode(controller, menuInflater, menu) } override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean { return when (item.itemId) { R.id.action_remove -> { viewModel.removeFromFavourites(selectedItemsIds) mode?.finish() true } R.id.action_mark_current -> { val itemsSnapshot = selectedItems MaterialAlertDialogBuilder(context ?: return false) .setTitle(item.title) .setMessage(R.string.mark_as_completed_prompt) .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.markAsRead(itemsSnapshot) mode?.finish() }.show() true } else -> super.onActionItemClicked(controller, mode, item) } } companion object { const val NO_ID = 0L fun newInstance(categoryId: Long) = FavouritesListFragment().withArgs(1) { putLong(AppRouter.KEY_ID, categoryId) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt ================================================ package org.koitharu.kotatsu.favourites.ui.list import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.flattenLatest import org.koitharu.kotatsu.favourites.domain.FavoritesListQuickFilter import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID import org.koitharu.kotatsu.history.domain.MarkAsReadUseCase import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.list.domain.QuickFilterListener import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.model.EmptyState 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 java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.domain.model.LocalManga import kotlinx.coroutines.flow.SharedFlow private const val PAGE_SIZE = 16 @HiltViewModel class FavouritesListViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val repository: FavouritesRepository, private val mangaListMapper: MangaListMapper, private val markAsReadUseCase: MarkAsReadUseCase, quickFilterFactory: FavoritesListQuickFilter.Factory, settings: AppSettings, mangaDataRepository: MangaDataRepository, @LocalStorageChanges localStorageChanges: SharedFlow, ) : MangaListViewModel(settings, mangaDataRepository, localStorageChanges), QuickFilterListener { val categoryId: Long = savedStateHandle[AppRouter.KEY_ID] ?: NO_ID private val quickFilter = quickFilterFactory.create(categoryId) private val refreshTrigger = MutableStateFlow(Any()) private val limit = MutableStateFlow(PAGE_SIZE) private val isPaginationReady = AtomicBoolean(false) override val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE_FAVORITES) { favoritesListMode } .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.favoritesListMode) val sortOrder: StateFlow = if (categoryId == NO_ID) { settings.observeAsFlow(AppSettings.KEY_FAVORITES_ORDER) { allFavoritesSortOrder } } else { repository.observeCategory(categoryId) .withErrorHandling() .map { it?.order } }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) override val content = combine( observeFavorites(), quickFilter.appliedOptions, observeListModeWithTriggers(), refreshTrigger, ) { list, filters, mode, _ -> list.mapList(mode, filters) }.distinctUntilChanged().onEach { isPaginationReady.set(true) }.catch { emit(listOf(it.toErrorState(canRetry = false))) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) override fun onRefresh() { refreshTrigger.value = Any() } override fun onRetry() = Unit override fun setFilterOption(option: ListFilterOption, isApplied: Boolean) = quickFilter.setFilterOption(option, isApplied) override fun toggleFilterOption(option: ListFilterOption) = quickFilter.toggleFilterOption(option) override fun clearFilter() = quickFilter.clearFilter() fun markAsRead(items: Set) { launchLoadingJob(Dispatchers.Default) { markAsReadUseCase(items) onRefresh() } } fun removeFromFavourites(ids: Set) { if (ids.isEmpty()) { return } launchJob(Dispatchers.Default) { val handle = if (categoryId == NO_ID) { repository.removeFromFavourites(ids) } else { repository.removeFromCategory(categoryId, ids) } onActionDone.call(ReversibleAction(R.string.removed_from_favourites, handle)) } } fun setSortOrder(order: ListSortOrder) { if (categoryId == NO_ID) { return } launchJob { repository.setCategoryOrder(categoryId, order) } } fun requestMoreItems() { if (isPaginationReady.compareAndSet(true, false)) { limit.value += PAGE_SIZE } } private suspend fun List.mapList(mode: ListMode, filters: Set): List { if (isEmpty()) { return if (filters.isEmpty()) { listOf(getEmptyState(hasFilters = false)) } else { listOfNotNull(quickFilter.filterItem(filters), getEmptyState(hasFilters = true)) } } val result = ArrayList(size + 1) quickFilter.filterItem(filters)?.let(result::add) mangaListMapper.toListModelList(result, this, mode, MangaListMapper.NO_FAVORITE) return result } private fun observeFavorites() = if (categoryId == NO_ID) { combine( sortOrder.filterNotNull(), quickFilter.appliedOptions.combineWithSettings(), limit, ) { order, filters, limit -> isPaginationReady.set(false) repository.observeAll(order, filters, limit) }.flattenLatest() } else { combine(quickFilter.appliedOptions.combineWithSettings(), limit) { filters, limit -> repository.observeAll(categoryId, filters, limit) }.flattenLatest() } private fun getEmptyState(hasFilters: Boolean) = if (hasFilters) { EmptyState( icon = R.drawable.ic_empty_favourites, textPrimary = R.string.nothing_found, textSecondary = R.string.text_empty_holder_secondary_filtered, actionStringRes = R.string.reset_filter, ) } else { EmptyState( icon = R.drawable.ic_empty_favourites, textPrimary = R.string.text_empty_holder_primary, textSecondary = if (categoryId == NO_ID) { R.string.you_have_not_favourites_yet } else { R.string.favourites_category_empty }, actionStringRes = 0, ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/filter/data/MangaListFilterSerializer.kt ================================================ package org.koitharu.kotatsu.filter.data import kotlinx.serialization.KSerializer import kotlinx.serialization.builtins.SetSerializer import kotlinx.serialization.builtins.serializer import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor import kotlinx.serialization.descriptors.element import kotlinx.serialization.encoding.CompositeDecoder import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.encoding.decodeStructure import kotlinx.serialization.encoding.encodeStructure import kotlinx.serialization.serializer import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.util.ext.toLocaleOrNull 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 import org.koitharu.kotatsu.parsers.model.MangaTag import java.util.Locale object MangaListFilterSerializer : KSerializer { override val descriptor: SerialDescriptor = buildClassSerialDescriptor(MangaListFilter::class.java.name) { element("query", isOptional = true) element( elementName = "tags", descriptor = SetSerializer(MangaTagSerializer).descriptor, isOptional = true, ) element( elementName = "tagsExclude", descriptor = SetSerializer(MangaTagSerializer).descriptor, isOptional = true, ) element("locale", isOptional = true) element("originalLocale", isOptional = true) element>("states", isOptional = true) element>("contentRating", isOptional = true) element>("types", isOptional = true) element>("demographics", isOptional = true) element("year", isOptional = true) element("yearFrom", isOptional = true) element("yearTo", isOptional = true) element("author", isOptional = true) } override fun serialize( encoder: Encoder, value: MangaListFilter ) = encoder.encodeStructure(descriptor) { encodeNullableSerializableElement(descriptor, 0, String.serializer(), value.query) encodeSerializableElement(descriptor, 1, SetSerializer(MangaTagSerializer), value.tags) encodeSerializableElement(descriptor, 2, SetSerializer(MangaTagSerializer), value.tagsExclude) encodeNullableSerializableElement(descriptor, 3, String.serializer(), value.locale?.toLanguageTag()) encodeNullableSerializableElement(descriptor, 4, String.serializer(), value.originalLocale?.toLanguageTag()) encodeSerializableElement(descriptor, 5, SetSerializer(serializer()), value.states) encodeSerializableElement(descriptor, 6, SetSerializer(serializer()), value.contentRating) encodeSerializableElement(descriptor, 7, SetSerializer(serializer()), value.types) encodeSerializableElement(descriptor, 8, SetSerializer(serializer()), value.demographics) encodeIntElement(descriptor, 9, value.year) encodeIntElement(descriptor, 10, value.yearFrom) encodeIntElement(descriptor, 11, value.yearTo) encodeNullableSerializableElement(descriptor, 12, String.serializer(), value.author) } override fun deserialize( decoder: Decoder ): MangaListFilter = decoder.decodeStructure(descriptor) { var query: String? = MangaListFilter.EMPTY.query var tags: Set = MangaListFilter.EMPTY.tags var tagsExclude: Set = MangaListFilter.EMPTY.tagsExclude var locale: Locale? = MangaListFilter.EMPTY.locale var originalLocale: Locale? = MangaListFilter.EMPTY.originalLocale var states: Set = MangaListFilter.EMPTY.states var contentRating: Set = MangaListFilter.EMPTY.contentRating var types: Set = MangaListFilter.EMPTY.types var demographics: Set = MangaListFilter.EMPTY.demographics var year: Int = MangaListFilter.EMPTY.year var yearFrom: Int = MangaListFilter.EMPTY.yearFrom var yearTo: Int = MangaListFilter.EMPTY.yearTo var author: String? = MangaListFilter.EMPTY.author while (true) { when (decodeElementIndex(descriptor)) { 0 -> query = decodeNullableSerializableElement(descriptor, 0, serializer()) 1 -> tags = decodeSerializableElement(descriptor, 1, SetSerializer(MangaTagSerializer)) 2 -> tagsExclude = decodeSerializableElement(descriptor, 2, SetSerializer(MangaTagSerializer)) 3 -> locale = decodeNullableSerializableElement(descriptor, 3, serializer())?.toLocaleOrNull() 4 -> originalLocale = decodeNullableSerializableElement(descriptor, 4, serializer())?.toLocaleOrNull() 5 -> states = decodeSerializableElement(descriptor, 5, SetSerializer(serializer())) 6 -> contentRating = decodeSerializableElement(descriptor, 6, SetSerializer(serializer())) 7 -> types = decodeSerializableElement(descriptor, 7, SetSerializer(serializer())) 8 -> demographics = decodeSerializableElement(descriptor, 8, SetSerializer(serializer())) 9 -> year = decodeIntElement(descriptor, 9) 10 -> yearFrom = decodeIntElement(descriptor, 10) 11 -> yearTo = decodeIntElement(descriptor, 11) 12 -> author = decodeNullableSerializableElement(descriptor, 12, serializer()) CompositeDecoder.DECODE_DONE -> break } } MangaListFilter( query = query, tags = tags, tagsExclude = tagsExclude, locale = locale, originalLocale = originalLocale, states = states, contentRating = contentRating, types = types, demographics = demographics, year = year, yearFrom = yearFrom, yearTo = yearTo, author = author, ) } private object MangaTagSerializer : KSerializer { override val descriptor: SerialDescriptor = buildClassSerialDescriptor(MangaTag::class.java.name) { element("title") element("key") element("source") } override fun serialize(encoder: Encoder, value: MangaTag) = encoder.encodeStructure(descriptor) { encodeStringElement(descriptor, 0, value.title) encodeStringElement(descriptor, 1, value.key) encodeStringElement(descriptor, 2, value.source.name) } override fun deserialize(decoder: Decoder): MangaTag = decoder.decodeStructure(descriptor) { var title: String? = null var key: String? = null var source: String? = null while (true) { when (decodeElementIndex(descriptor)) { 0 -> title = decodeStringElement(descriptor, 0) 1 -> key = decodeStringElement(descriptor, 1) 2 -> source = decodeStringElement(descriptor, 2) CompositeDecoder.DECODE_DONE -> break } } MangaTag( title = title ?: error("Missing 'title' field"), key = key ?: error("Missing 'key' field"), source = MangaSource(source), ) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/filter/data/PersistableFilter.kt ================================================ package org.koitharu.kotatsu.filter.data import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonIgnoreUnknownKeys import org.koitharu.kotatsu.core.model.MangaSourceSerializer import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaSource @Serializable @JsonIgnoreUnknownKeys data class PersistableFilter( @SerialName("name") val name: String, @Serializable(with = MangaSourceSerializer::class) @SerialName("source") val source: MangaSource, @Serializable(with = MangaListFilterSerializer::class) @SerialName("filter") val filter: MangaListFilter, ) { val id: Int get() = name.hashCode() companion object { const val MAX_TITLE_LENGTH = 18 } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/filter/data/SavedFiltersRepository.kt ================================================ package org.koitharu.kotatsu.filter.data import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit import dagger.Reusable import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.withContext import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import org.koitharu.kotatsu.core.util.ext.observeChanges import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaSource import java.io.File import javax.inject.Inject @Reusable class SavedFiltersRepository @Inject constructor( @ApplicationContext private val context: Context, ) { fun observeAll(source: MangaSource): Flow> = getPrefs(source).observeChanges() .onStart { emit(null) } .map { getAll(source) }.distinctUntilChanged() .flowOn(Dispatchers.Default) suspend fun getAll(source: MangaSource): List = withContext(Dispatchers.Default) { val prefs = getPrefs(source) val keys = prefs.all.keys.filter { it.startsWith(FILTER_PREFIX) } keys.mapNotNull { key -> val value = prefs.getString(key, null) ?: return@mapNotNull null try { Json.decodeFromString(value) } catch (e: SerializationException) { e.printStackTraceDebug() null } } } suspend fun save( source: MangaSource, name: String, filter: MangaListFilter, ): PersistableFilter = withContext(Dispatchers.Default) { val persistableFilter = PersistableFilter( name = name, source = source, filter = filter, ) persist(persistableFilter) persistableFilter } suspend fun save( filter: PersistableFilter, ) = withContext(Dispatchers.Default) { persist(filter) } suspend fun rename(source: MangaSource, id: Int, newName: String) = withContext(Dispatchers.Default) { val filter = load(source, id) ?: return@withContext val newFilter = filter.copy(name = newName) val prefs = getPrefs(source) prefs.edit(commit = true) { remove(key(id)) putString(key(newFilter.id), Json.encodeToString(newFilter)) } newFilter } suspend fun delete(source: MangaSource, id: Int) = withContext(Dispatchers.Default) { val prefs = getPrefs(source) prefs.edit(commit = true) { remove(key(id)) } } private fun persist(persistableFilter: PersistableFilter) { val prefs = getPrefs(persistableFilter.source) val json = Json.encodeToString(persistableFilter) prefs.edit(commit = true) { putString(key(persistableFilter.id), json) } } private fun load(source: MangaSource, id: Int): PersistableFilter? { val prefs = getPrefs(source) val json = prefs.getString(key(id), null) ?: return null return try { Json.decodeFromString(json) } catch (e: SerializationException) { e.printStackTraceDebug() null } } private fun getPrefs(source: MangaSource): SharedPreferences { val key = source.name.replace(File.separatorChar, '$') return context.getSharedPreferences(key, Context.MODE_PRIVATE) } private companion object { const val FILTER_PREFIX = "__pf_" fun key(id: Int) = FILTER_PREFIX + id } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt ================================================ package org.koitharu.kotatsu.filter.ui import androidx.fragment.app.Fragment import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.ViewModelLifecycle import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.util.LocaleComparator import org.koitharu.kotatsu.core.util.ext.asFlow import org.koitharu.kotatsu.core.util.ext.lifecycleScope import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal import org.koitharu.kotatsu.core.util.ext.sortedWithSafe import org.koitharu.kotatsu.filter.data.PersistableFilter import org.koitharu.kotatsu.filter.data.SavedFiltersRepository import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.filter.ui.tags.TagTitleComparator 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.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaSource 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.model.YEAR_MIN import org.koitharu.kotatsu.parsers.util.ifZero import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import org.koitharu.kotatsu.search.domain.MangaSearchRepository import java.util.Calendar import java.util.Locale import javax.inject.Inject @ViewModelScoped class FilterCoordinator @Inject constructor( savedStateHandle: SavedStateHandle, mangaRepositoryFactory: MangaRepository.Factory, private val searchRepository: MangaSearchRepository, private val savedFiltersRepository: SavedFiltersRepository, lifecycle: ViewModelLifecycle, ) { private val coroutineScope = lifecycle.lifecycleScope + Dispatchers.Default private val repository = mangaRepositoryFactory.create(MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE])) private val sourceLocale = (repository.source as? MangaParserSource)?.locale private val currentListFilter = MutableStateFlow(MangaListFilter.EMPTY) private val currentSortOrder = MutableStateFlow(repository.defaultSortOrder) private val availableSortOrders = repository.sortOrders private val filterOptions = suspendLazy { repository.getFilterOptions() } val capabilities = repository.filterCapabilities val mangaSource: MangaSource get() = repository.source val isFilterApplied: Boolean get() = currentListFilter.value.isNotEmpty() val query: StateFlow = currentListFilter.map { it.query } .stateIn(coroutineScope, SharingStarted.Eagerly, null) val sortOrder: StateFlow> = currentSortOrder.map { selected -> FilterProperty( availableItems = availableSortOrders.sortedByOrdinal(), selectedItem = selected, ) }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) val tags: StateFlow> = combine( getTopTags(TAGS_LIMIT), currentListFilter.distinctUntilChangedBy { it.tags }, ) { available, selected -> available.fold( onSuccess = { FilterProperty( availableItems = it.addFirstDistinct(selected.tags), selectedItems = selected.tags, ) }, onFailure = { FilterProperty.error(it) }, ) }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) val tagsExcluded: StateFlow> = if (capabilities.isTagsExclusionSupported) { combine( getBottomTags(TAGS_LIMIT), currentListFilter.distinctUntilChangedBy { it.tagsExclude }, ) { available, selected -> available.fold( onSuccess = { FilterProperty( availableItems = it.addFirstDistinct(selected.tagsExclude), selectedItems = selected.tagsExclude, ) }, onFailure = { FilterProperty.error(it) }, ) }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) } else { MutableStateFlow(FilterProperty.EMPTY) } val authors: StateFlow> = if (capabilities.isAuthorSearchSupported) { combine( flow { emit(searchRepository.getAuthors(repository.source, TAGS_LIMIT)) }, currentListFilter.distinctUntilChangedBy { it.author }, ) { available, selected -> FilterProperty( availableItems = available, selectedItems = setOfNotNull(selected.author), ) }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) } else { MutableStateFlow(FilterProperty.EMPTY) } val states: StateFlow> = combine( filterOptions.asFlow(), currentListFilter.distinctUntilChangedBy { it.states }, ) { available, selected -> available.fold( onSuccess = { FilterProperty( availableItems = it.availableStates.sortedByOrdinal(), selectedItems = selected.states, ) }, onFailure = { FilterProperty.error(it) }, ) }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) val contentRating: StateFlow> = combine( filterOptions.asFlow(), currentListFilter.distinctUntilChangedBy { it.contentRating }, ) { available, selected -> available.fold( onSuccess = { FilterProperty( availableItems = it.availableContentRating.sortedByOrdinal(), selectedItems = selected.contentRating, ) }, onFailure = { FilterProperty.error(it) }, ) }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) val contentTypes: StateFlow> = combine( filterOptions.asFlow(), currentListFilter.distinctUntilChangedBy { it.types }, ) { available, selected -> available.fold( onSuccess = { FilterProperty( availableItems = it.availableContentTypes.sortedByOrdinal(), selectedItems = selected.types, ) }, onFailure = { FilterProperty.error(it) }, ) }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) val demographics: StateFlow> = combine( filterOptions.asFlow(), currentListFilter.distinctUntilChangedBy { it.demographics }, ) { available, selected -> available.fold( onSuccess = { FilterProperty( availableItems = it.availableDemographics.sortedByOrdinal(), selectedItems = selected.demographics, ) }, onFailure = { FilterProperty.error(it) }, ) }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) val locale: StateFlow> = combine( filterOptions.asFlow(), currentListFilter.distinctUntilChangedBy { it.locale }, ) { available, selected -> available.fold( onSuccess = { FilterProperty( availableItems = it.availableLocales.sortedWithSafe(LocaleComparator()).addFirstDistinct(null), selectedItems = setOfNotNull(selected.locale), ) }, onFailure = { FilterProperty.error(it) }, ) }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) val originalLocale: StateFlow> = if (capabilities.isOriginalLocaleSupported) { combine( filterOptions.asFlow(), currentListFilter.distinctUntilChangedBy { it.originalLocale }, ) { available, selected -> available.fold( onSuccess = { FilterProperty( availableItems = it.availableLocales.sortedWithSafe(LocaleComparator()).addFirstDistinct(null), selectedItems = setOfNotNull(selected.originalLocale), ) }, onFailure = { FilterProperty.error(it) }, ) }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) } else { MutableStateFlow(FilterProperty.EMPTY) } val year: StateFlow> = if (capabilities.isYearSupported) { currentListFilter.distinctUntilChangedBy { it.year }.map { selected -> FilterProperty( availableItems = listOf(YEAR_MIN, MAX_YEAR), selectedItems = setOf(selected.year), ) }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) } else { MutableStateFlow(FilterProperty.EMPTY) } val yearRange: StateFlow> = if (capabilities.isYearRangeSupported) { currentListFilter.distinctUntilChanged { old, new -> old.yearTo == new.yearTo && old.yearFrom == new.yearFrom }.map { selected -> FilterProperty( availableItems = listOf(YEAR_MIN, MAX_YEAR), selectedItems = setOf(selected.yearFrom.ifZero { YEAR_MIN }, selected.yearTo.ifZero { MAX_YEAR }), ) }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) } else { MutableStateFlow(FilterProperty.EMPTY) } val savedFilters: StateFlow> = combine( savedFiltersRepository.observeAll(repository.source), currentListFilter, ) { available, applied -> FilterProperty( availableItems = available, selectedItems = setOfNotNull(available.find { it.filter == applied }), ) }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.EMPTY) fun reset() { currentListFilter.value = MangaListFilter.EMPTY } fun snapshot() = Snapshot( sortOrder = currentSortOrder.value, listFilter = currentListFilter.value, ) fun observe(): Flow = combine(currentSortOrder, currentListFilter, ::Snapshot) fun setSortOrder(newSortOrder: SortOrder) { currentSortOrder.value = newSortOrder repository.defaultSortOrder = newSortOrder } fun set(value: MangaListFilter) { currentListFilter.value = value } fun setAdjusted(value: MangaListFilter) { var newFilter = value if (!newFilter.author.isNullOrEmpty() && !capabilities.isAuthorSearchSupported) { newFilter = newFilter.copy( query = newFilter.author, author = null, ) } if (!newFilter.query.isNullOrEmpty() && !newFilter.hasNonSearchOptions() && !capabilities.isSearchWithFiltersSupported) { newFilter = MangaListFilter(query = newFilter.query) } set(newFilter) } fun saveCurrentFilter(name: String) = coroutineScope.launch { savedFiltersRepository.save(repository.source, name, currentListFilter.value) } fun renameSavedFilter(id: Int, newName: String) = coroutineScope.launch { savedFiltersRepository.rename(repository.source, id, newName) } fun deleteSavedFilter(id: Int) = coroutineScope.launch { savedFiltersRepository.delete(repository.source, id) } fun setQuery(value: String?) { val newQuery = value?.trim()?.nullIfEmpty() currentListFilter.update { oldValue -> if (capabilities.isSearchWithFiltersSupported || newQuery == null) { oldValue.copy(query = newQuery) } else { MangaListFilter(query = newQuery) } } } fun setLocale(value: Locale?) { currentListFilter.update { oldValue -> oldValue.copy( locale = value, query = oldValue.takeQueryIfSupported(), ) } } fun setAuthor(value: String?) { currentListFilter.update { oldValue -> oldValue.copy( author = value, query = oldValue.takeQueryIfSupported(), ) } } fun setOriginalLocale(value: Locale?) { currentListFilter.update { oldValue -> oldValue.copy( originalLocale = value, query = oldValue.takeQueryIfSupported(), ) } } fun setYear(value: Int) { currentListFilter.update { oldValue -> oldValue.copy( year = value, query = oldValue.takeQueryIfSupported(), ) } } fun setYearRange(valueFrom: Int, valueTo: Int) { currentListFilter.update { oldValue -> oldValue.copy( yearFrom = valueFrom, yearTo = valueTo, query = oldValue.takeQueryIfSupported(), ) } } fun toggleState(value: MangaState, isSelected: Boolean) { currentListFilter.update { oldValue -> oldValue.copy( states = if (isSelected) oldValue.states + value else oldValue.states - value, query = oldValue.takeQueryIfSupported(), ) } } fun toggleContentRating(value: ContentRating, isSelected: Boolean) { currentListFilter.update { oldValue -> oldValue.copy( contentRating = if (isSelected) oldValue.contentRating + value else oldValue.contentRating - value, query = oldValue.takeQueryIfSupported(), ) } } fun toggleDemographic(value: Demographic, isSelected: Boolean) { currentListFilter.update { oldValue -> oldValue.copy( demographics = if (isSelected) oldValue.demographics + value else oldValue.demographics - value, query = oldValue.takeQueryIfSupported(), ) } } fun toggleContentType(value: ContentType, isSelected: Boolean) { currentListFilter.update { oldValue -> oldValue.copy( types = if (isSelected) oldValue.types + value else oldValue.types - value, query = oldValue.takeQueryIfSupported(), ) } } fun toggleTag(value: MangaTag, isSelected: Boolean) { currentListFilter.update { oldValue -> val newTags = if (capabilities.isMultipleTagsSupported) { if (isSelected) oldValue.tags + value else oldValue.tags - value } else { if (isSelected) setOf(value) else emptySet() } oldValue.copy( tags = newTags, tagsExclude = oldValue.tagsExclude - newTags, query = oldValue.takeQueryIfSupported(), ) } } fun toggleTagExclude(value: MangaTag, isSelected: Boolean) { currentListFilter.update { oldValue -> val newTagsExclude = if (capabilities.isMultipleTagsSupported) { if (isSelected) oldValue.tagsExclude + value else oldValue.tagsExclude - value } else { if (isSelected) setOf(value) else emptySet() } oldValue.copy( tags = oldValue.tags - newTagsExclude, tagsExclude = newTagsExclude, query = oldValue.takeQueryIfSupported(), ) } } fun getAllTags(): Flow>> = filterOptions.asFlow().map { it.map { x -> x.availableTags.sortedWithSafe(TagTitleComparator(sourceLocale)) } } private fun MangaListFilter.takeQueryIfSupported() = when { capabilities.isSearchWithFiltersSupported -> query query.isNullOrEmpty() -> query hasNonSearchOptions() -> null else -> query } private fun getTopTags(limit: Int): Flow>> = combine( flow { emit(searchRepository.getTopTags(repository.source, limit)) }, filterOptions.asFlow(), ) { suggested, options -> val all = options.getOrNull()?.availableTags.orEmpty() val result = ArrayList(limit) result.addAll(suggested.take(limit)) if (result.size < limit) { result.addAll(all.shuffled().take(limit - result.size)) } if (result.isNotEmpty()) { Result.success(result) } else { options.map { result } } }.catch { emit(Result.failure(it)) } private fun getBottomTags(limit: Int): Flow>> = combine( flow { emit(searchRepository.getRareTags(repository.source, limit)) }, filterOptions.asFlow(), ) { suggested, options -> val all = options.getOrNull()?.availableTags.orEmpty() val result = ArrayList(limit) result.addAll(suggested.take(limit)) if (result.size < limit) { result.addAll(all.shuffled().take(limit - result.size)) } if (result.isNotEmpty()) { Result.success(result) } else { options.map { result } } }.catch { emit(Result.failure(it)) } private fun List.addFirstDistinct(other: Collection): List { val result = ArrayDeque(this.size + other.size) result.addAll(this) for (item in other) { if (item !in result) { result.addFirst(item) } } return result } private fun List.addFirstDistinct(item: T): List { val result = ArrayDeque(this.size + 1) result.addAll(this) if (item !in result) { result.addFirst(item) } return result } data class Snapshot( val sortOrder: SortOrder, val listFilter: MangaListFilter, ) interface Owner { val filterCoordinator: FilterCoordinator } companion object { private const val TAGS_LIMIT = 12 private val MAX_YEAR = Calendar.getInstance()[Calendar.YEAR] + 1 fun find(fragment: Fragment): FilterCoordinator? { (fragment.activity as? Owner)?.let { return it.filterCoordinator } var f = fragment while (true) { (f as? Owner)?.let { return it.filterCoordinator } f = f.parentFragment ?: break } return null } fun require(fragment: Fragment): FilterCoordinator { return find(fragment) ?: throw IllegalStateException("FilterCoordinator cannot be found") } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterFieldLayout.kt ================================================ package org.koitharu.kotatsu.filter.ui import android.content.Context import android.util.AttributeSet import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.widget.RelativeLayout import android.widget.TextView import androidx.annotation.AttrRes import androidx.annotation.StringRes import androidx.core.content.ContextCompat import androidx.core.content.withStyledAttributes import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.view.setPadding import androidx.core.widget.TextViewCompat import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.drawableStart import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList import org.koitharu.kotatsu.core.util.ext.setThemeTextAppearance import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ViewFilterFieldBinding import java.util.LinkedList import androidx.appcompat.R as appcompatR import com.google.android.material.R as materialR class FilterFieldLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0, ) : RelativeLayout(context, attrs) { private val contentViews = LinkedList() private val binding = ViewFilterFieldBinding.inflate(LayoutInflater.from(context), this) private var errorView: TextView? = null private var isInitialized = true init { context.withStyledAttributes(attrs, R.styleable.FilterFieldLayout, defStyleAttr) { binding.textViewTitle.text = getString(R.styleable.FilterFieldLayout_title) binding.buttonMore.isInvisible = !getBoolean(R.styleable.FilterFieldLayout_showMoreButton, false) } } override fun onViewAdded(child: View) { super.onViewAdded(child) if (!isInitialized) { return } assert(child.id != NO_ID) val lp = (child.layoutParams as? LayoutParams) ?: (generateDefaultLayoutParams() as LayoutParams) lp.alignWithParent = true lp.width = 0 lp.addRule(ALIGN_PARENT_START) lp.addRule(ALIGN_PARENT_END) lp.addRule(BELOW, contentViews.lastOrNull()?.id ?: binding.textViewTitle.id) child.layoutParams = lp contentViews.add(child) } override fun onViewRemoved(child: View?) { super.onViewRemoved(child) contentViews.remove(child) } fun setValueText(valueText: String?) { if (!binding.buttonMore.isVisible) { binding.textViewValue.textAndVisible = valueText } } fun setTitle(@StringRes titleResId: Int) { binding.textViewTitle.setText(titleResId) } fun setError(errorMessage: String?) { if (errorMessage == null && errorView == null) { return } getErrorLabel().textAndVisible = errorMessage } fun setOnMoreButtonClickListener(clickListener: OnClickListener?) { binding.buttonMore.setOnClickListener(clickListener) } private fun getErrorLabel(): TextView { errorView?.let { return it } val label = TextView(context) label.id = R.id.textView_error label.compoundDrawablePadding = resources.getDimensionPixelOffset(R.dimen.screen_padding) label.gravity = Gravity.CENTER_VERTICAL or Gravity.START label.setPadding(resources.getDimensionPixelOffset(R.dimen.margin_small)) label.setThemeTextAppearance( materialR.attr.textAppearanceBodySmall, materialR.style.TextAppearance_Material3_BodySmall, ) label.drawableStart = ContextCompat.getDrawable(context, R.drawable.ic_error_small) TextViewCompat.setCompoundDrawableTintList( label, context.getThemeColorStateList(appcompatR.attr.colorControlNormal), ) addView(label) errorView = label return label } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt ================================================ package org.koitharu.kotatsu.filter.ui import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import com.google.android.material.chip.Chip import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flowOn import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding import org.koitharu.kotatsu.filter.data.PersistableFilter import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel 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.MangaState import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.YEAR_UNKNOWN import java.util.Locale import javax.inject.Inject @AndroidEntryPoint class FilterHeaderFragment : BaseFragment(), ChipsView.OnChipClickListener, ChipsView.OnChipCloseClickListener { @Inject lateinit var filterHeaderProducer: FilterHeaderProducer private val filter: FilterCoordinator get() = (requireActivity() as FilterCoordinator.Owner).filterCoordinator override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding { return FragmentFilterHeaderBinding.inflate(inflater, container, false) } override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) binding.chipsTags.onChipClickListener = this binding.chipsTags.onChipCloseClickListener = this filterHeaderProducer.observeHeader(filter) .flowOn(Dispatchers.Default) .observe(viewLifecycleOwner, ::onDataChanged) } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = insets override fun onChipClick(chip: Chip, data: Any?) { when (data) { is MangaTag -> filter.toggleTag(data, !chip.isChecked) is PersistableFilter -> if (chip.isChecked) { filter.reset() } else { filter.setAdjusted(data.filter) } is String -> Unit null -> router.showTagsCatalogSheet(excludeMode = false) } } override fun onChipCloseClick(chip: Chip, data: Any?) { when (data) { is String -> if (data == filter.snapshot().listFilter.author) { filter.setAuthor(null) } else { filter.setQuery(null) } is ContentRating -> filter.toggleContentRating(data, false) is Demographic -> filter.toggleDemographic(data, false) is ContentType -> filter.toggleContentType(data, false) is MangaState -> filter.toggleState(data, false) is Locale -> filter.setLocale(null) is Int -> filter.setYear(YEAR_UNKNOWN) is IntRange -> filter.setYearRange(YEAR_UNKNOWN, YEAR_UNKNOWN) } } private fun onDataChanged(header: FilterHeaderModel) { val binding = viewBinding ?: return val chips = header.chips if (chips.isEmpty()) { binding.chipsTags.setChips(emptyList()) binding.root.isVisible = false return } binding.chipsTags.setChips(header.chips) binding.root.isVisible = true if (binding.root.context.isAnimationsEnabled) { binding.scrollView.smoothScrollTo(0, 0) } else { binding.scrollView.scrollTo(0, 0) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt ================================================ package org.koitharu.kotatsu.filter.ui import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.titleResId import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.filter.data.PersistableFilter import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.search.domain.MangaSearchRepository import javax.inject.Inject import androidx.appcompat.R as appcompatR class FilterHeaderProducer @Inject constructor( private val searchRepository: MangaSearchRepository, ) { fun observeHeader(filterCoordinator: FilterCoordinator): Flow { return combine( filterCoordinator.savedFilters, filterCoordinator.tags, filterCoordinator.observe(), ) { saved, tags, snapshot -> val chipList = createChipsList( source = filterCoordinator.mangaSource, capabilities = filterCoordinator.capabilities, savedFilters = saved, tagsProperty = tags, snapshot = snapshot.listFilter, limit = 12, ) FilterHeaderModel( chips = chipList, sortOrder = snapshot.sortOrder, isFilterApplied = !snapshot.listFilter.isEmpty(), ) } } private suspend fun createChipsList( source: MangaSource, capabilities: MangaListFilterCapabilities, savedFilters: FilterProperty, tagsProperty: FilterProperty, snapshot: MangaListFilter, limit: Int, ): List { val result = ArrayDeque(savedFilters.availableItems.size + limit + 3) if (snapshot.query.isNullOrEmpty() || capabilities.isSearchWithFiltersSupported) { val selectedTags = tagsProperty.selectedItems.toMutableSet() var tags = if (selectedTags.isEmpty()) { searchRepository.getTagsSuggestion("", limit, source) } else { searchRepository.getTagsSuggestion(selectedTags).take(limit) } if (tags.size < limit) { tags = tags + tagsProperty.availableItems.take(limit - tags.size) } if (tags.isEmpty() && selectedTags.isEmpty()) { return emptyList() } for (saved in savedFilters.availableItems) { val model = ChipsView.ChipModel( title = saved.name, isChecked = saved in savedFilters.selectedItems, data = saved, ) if (model.isChecked) { selectedTags.removeAll(saved.filter.tags) result.addFirst(model) } else { result.addLast(model) } } for (tag in tags) { val model = ChipsView.ChipModel( title = tag.title, isChecked = selectedTags.remove(tag), data = tag, ) if (model.isChecked) { result.addFirst(model) } else { result.addLast(model) } } for (tag in selectedTags) { val model = ChipsView.ChipModel( title = tag.title, isChecked = true, data = tag, ) result.addFirst(model) } } snapshot.locale?.let { result.addFirst( ChipsView.ChipModel( title = it.getDisplayName(it).toTitleCase(it), icon = R.drawable.ic_language, isCloseable = true, data = it, ), ) } snapshot.types.forEach { result.addFirst( ChipsView.ChipModel( titleResId = it.titleResId, isCloseable = true, data = it, ), ) } snapshot.demographics.forEach { result.addFirst( ChipsView.ChipModel( titleResId = it.titleResId, isCloseable = true, data = it, ), ) } snapshot.contentRating.forEach { result.addFirst( ChipsView.ChipModel( titleResId = it.titleResId, isCloseable = true, data = it, ), ) } snapshot.states.forEach { result.addFirst( ChipsView.ChipModel( titleResId = it.titleResId, isCloseable = true, data = it, ), ) } if (!snapshot.query.isNullOrEmpty()) { result.addFirst( ChipsView.ChipModel( title = snapshot.query, icon = appcompatR.drawable.abc_ic_search_api_material, isCloseable = true, data = snapshot.query, ), ) } if (!snapshot.author.isNullOrEmpty()) { result.addFirst( ChipsView.ChipModel( title = snapshot.author, icon = R.drawable.ic_user, isCloseable = true, data = snapshot.author, ), ) } val hasTags = result.any { it.data is MangaTag } if (hasTags) { result.addFirst(moreTagsChip()) } return result } private fun moreTagsChip() = ChipsView.ChipModel( titleResId = R.string.genres, icon = R.drawable.ic_drawer_menu_open, ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterHeaderModel.kt ================================================ package org.koitharu.kotatsu.filter.ui.model import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.parsers.model.SortOrder data class FilterHeaderModel( val chips: Collection, val sortOrder: SortOrder?, val isFilterApplied: Boolean, ) { val textSummary: String get() = chips.mapNotNull { if (it.isChecked) it.title else null }.joinToString() } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterProperty.kt ================================================ package org.koitharu.kotatsu.filter.ui.model data class FilterProperty( val availableItems: List, val selectedItems: Set, val isLoading: Boolean, val error: Throwable?, ) { constructor( availableItems: List, selectedItems: Set, ) : this( availableItems = availableItems, selectedItems = selectedItems, isLoading = false, error = null, ) constructor( availableItems: List, selectedItem: T, ) : this( availableItems = availableItems, selectedItems = setOf(selectedItem), isLoading = false, error = null, ) fun isEmpty(): Boolean = availableItems.isEmpty() fun isEmptyAndSuccess(): Boolean = availableItems.isEmpty() && error == null companion object { val LOADING = FilterProperty( availableItems = emptyList(), selectedItems = emptySet(), isLoading = true, error = null, ) val EMPTY = FilterProperty( availableItems = emptyList(), selectedItems = emptySet(), ) fun error(error: Throwable) = FilterProperty( availableItems = emptyList(), selectedItems = emptySet(), isLoading = false, error = error, ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/TagCatalogItem.kt ================================================ package org.koitharu.kotatsu.filter.ui.model import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.MangaTag data class TagCatalogItem( val tag: MangaTag, val isChecked: Boolean, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is TagCatalogItem && other.tag == tag } override fun getChangePayload(previousState: ListModel): Any? { return if (previousState is TagCatalogItem && previousState.isChecked != isChecked) { ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED } else { super.getChangePayload(previousState) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt ================================================ package org.koitharu.kotatsu.filter.ui.sheet import android.os.Bundle import android.text.InputFilter import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.inputmethod.EditorInfo import android.widget.AdapterView import android.widget.ArrayAdapter import android.widget.LinearLayout import android.widget.Toast import androidx.appcompat.widget.PopupMenu import androidx.core.view.WindowInsetsCompat import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import com.google.android.material.chip.Chip import com.google.android.material.slider.RangeSlider import com.google.android.material.slider.Slider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.titleResId import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.ui.dialog.setEditText import org.koitharu.kotatsu.core.ui.model.titleRes import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.util.AlphanumComparator import org.koitharu.kotatsu.core.util.ext.consume import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayName import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.parentView import org.koitharu.kotatsu.core.util.ext.setValueRounded import org.koitharu.kotatsu.core.util.ext.setValuesRounded import org.koitharu.kotatsu.databinding.SheetFilterBinding import org.koitharu.kotatsu.filter.data.PersistableFilter import org.koitharu.kotatsu.filter.data.PersistableFilter.Companion.MAX_TITLE_LENGTH import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.model.FilterProperty 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.MangaState import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.YEAR_UNKNOWN import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.toIntUp import java.util.Locale import java.util.TreeSet class FilterSheetFragment : BaseAdaptiveSheet(), AdapterView.OnItemSelectedListener, View.OnClickListener, ChipsView.OnChipClickListener, ChipsView.OnChipLongClickListener, ChipsView.OnChipCloseClickListener { override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding { return SheetFilterBinding.inflate(inflater, container, false) } override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) if (dialog == null) { binding.adjustForEmbeddedLayout() } val filter = FilterCoordinator.require(this) filter.sortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged) filter.locale.observe(viewLifecycleOwner, this::onLocaleChanged) filter.originalLocale.observe(viewLifecycleOwner, this::onOriginalLocaleChanged) filter.tags.observe(viewLifecycleOwner, this::onTagsChanged) filter.tagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged) filter.authors.observe(viewLifecycleOwner, this::onAuthorsChanged) filter.states.observe(viewLifecycleOwner, this::onStateChanged) filter.contentTypes.observe(viewLifecycleOwner, this::onContentTypesChanged) filter.contentRating.observe(viewLifecycleOwner, this::onContentRatingChanged) filter.demographics.observe(viewLifecycleOwner, this::onDemographicsChanged) filter.year.observe(viewLifecycleOwner, this::onYearChanged) filter.yearRange.observe(viewLifecycleOwner, this::onYearRangeChanged) filter.savedFilters.observe(viewLifecycleOwner, ::onSavedPresetsChanged) binding.layoutGenres.setTitle( if (filter.capabilities.isMultipleTagsSupported) { R.string.genres } else { R.string.genre }, ) binding.spinnerLocale.onItemSelectedListener = this binding.spinnerOriginalLocale.onItemSelectedListener = this binding.spinnerOrder.onItemSelectedListener = this binding.chipsSavedFilters.onChipClickListener = this binding.chipsState.onChipClickListener = this binding.chipsTypes.onChipClickListener = this binding.chipsContentRating.onChipClickListener = this binding.chipsDemographics.onChipClickListener = this binding.chipsGenres.onChipClickListener = this binding.chipsGenresExclude.onChipClickListener = this binding.chipsAuthor.onChipClickListener = this binding.chipsSavedFilters.onChipLongClickListener = this binding.chipsSavedFilters.onChipCloseClickListener = this binding.sliderYear.addOnChangeListener(this::onSliderValueChange) binding.sliderYearsRange.addOnChangeListener(this::onRangeSliderValueChange) binding.layoutGenres.setOnMoreButtonClickListener { router.showTagsCatalogSheet(excludeMode = false) } binding.layoutGenresExclude.setOnMoreButtonClickListener { router.showTagsCatalogSheet(excludeMode = true) } combine( filter.observe().map { it.listFilter.isNotEmpty() }.distinctUntilChanged(), filter.savedFilters.map { it.selectedItems.isEmpty() }.distinctUntilChanged(), Boolean::and, ).flowOn(Dispatchers.Default) .observe(viewLifecycleOwner) { binding.buttonSave.isEnabled = it } binding.buttonSave.setOnClickListener(this) binding.buttonDone.setOnClickListener(this) } private fun SheetFilterBinding.adjustForEmbeddedLayout() { layoutBody.updatePadding(top = layoutBody.paddingBottom) scrollView.scrollIndicators = 0 buttonDone.isVisible = false this.root.layoutParams?.height = ViewGroup.LayoutParams.MATCH_PARENT buttonSave.updateLayoutParams { weight = 0f width = LinearLayout.LayoutParams.WRAP_CONTENT gravity = Gravity.END or Gravity.CENTER_VERTICAL } } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val typeMask = WindowInsetsCompat.Type.systemBars() viewBinding?.layoutBottom?.updateLayoutParams { bottomMargin = insets.getInsets(typeMask).bottom } return insets.consume(v, typeMask, bottom = true) } override fun onClick(v: View) { when (v.id) { R.id.button_done -> dismiss() R.id.button_save -> onSaveFilterClick("") } } override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { val filter = FilterCoordinator.require(this) when (parent.id) { R.id.spinner_order -> filter.setSortOrder(filter.sortOrder.value.availableItems[position]) R.id.spinner_locale -> filter.setLocale(filter.locale.value.availableItems[position]) R.id.spinner_original_locale -> filter.setOriginalLocale(filter.originalLocale.value.availableItems[position]) } } override fun onNothingSelected(parent: AdapterView<*>?) = Unit private fun onSliderValueChange(slider: Slider, value: Float, fromUser: Boolean) { if (!fromUser) { return } val intValue = value.toInt() val filter = FilterCoordinator.require(this) when (slider.id) { R.id.slider_year -> filter.setYear( if (intValue <= slider.valueFrom.toIntUp()) { YEAR_UNKNOWN } else { intValue }, ) } } private fun onRangeSliderValueChange(slider: RangeSlider, value: Float, fromUser: Boolean) { if (!fromUser) { return } val filter = FilterCoordinator.require(this) when (slider.id) { R.id.slider_yearsRange -> filter.setYearRange( valueFrom = slider.values.firstOrNull()?.let { if (it <= slider.valueFrom) YEAR_UNKNOWN else it.toInt() } ?: YEAR_UNKNOWN, valueTo = slider.values.lastOrNull()?.let { if (it >= slider.valueTo) YEAR_UNKNOWN else it.toInt() } ?: YEAR_UNKNOWN, ) } } override fun onChipClick(chip: Chip, data: Any?) { val filter = FilterCoordinator.require(this) when (data) { is MangaState -> filter.toggleState(data, !chip.isChecked) is MangaTag -> if (chip.parentView?.id == R.id.chips_genresExclude) { filter.toggleTagExclude(data, !chip.isChecked) } else { filter.toggleTag(data, !chip.isChecked) } is ContentType -> filter.toggleContentType(data, !chip.isChecked) is ContentRating -> filter.toggleContentRating(data, !chip.isChecked) is Demographic -> filter.toggleDemographic(data, !chip.isChecked) is PersistableFilter -> filter.setAdjusted(data.filter) is String -> if (chip.isChecked) { filter.setAuthor(null) } else { filter.setAuthor(data) } null -> router.showTagsCatalogSheet(excludeMode = chip.parentView?.id == R.id.chips_genresExclude) } } override fun onChipLongClick(chip: Chip, data: Any?): Boolean { return when (data) { is PersistableFilter -> { showSavedFilterMenu(chip, data) true } else -> false } } override fun onChipCloseClick(chip: Chip, data: Any?) { when (data) { is PersistableFilter -> { showSavedFilterMenu(chip, data) } } } private fun onSortOrderChanged(value: FilterProperty) { val b = viewBinding ?: return b.layoutOrder.isGone = value.isEmpty() if (value.isEmpty()) { return } val selected = value.selectedItems.single() b.spinnerOrder.adapter = ArrayAdapter( b.spinnerOrder.context, android.R.layout.simple_spinner_dropdown_item, android.R.id.text1, value.availableItems.map { b.spinnerOrder.context.getString(it.titleRes) }, ) val selectedIndex = value.availableItems.indexOf(selected) if (selectedIndex >= 0) { b.spinnerOrder.setSelection(selectedIndex, false) } } private fun onLocaleChanged(value: FilterProperty) { val b = viewBinding ?: return b.layoutLocale.isGone = value.isEmpty() if (value.isEmpty()) { return } val selected = value.selectedItems.singleOrNull() b.spinnerLocale.adapter = ArrayAdapter( b.spinnerLocale.context, android.R.layout.simple_spinner_dropdown_item, android.R.id.text1, value.availableItems.map { it.getDisplayName(b.spinnerLocale.context) }, ) val selectedIndex = value.availableItems.indexOf(selected) if (selectedIndex >= 0) { b.spinnerLocale.setSelection(selectedIndex, false) } } private fun onOriginalLocaleChanged(value: FilterProperty) { val b = viewBinding ?: return b.layoutOriginalLocale.isGone = value.isEmpty() if (value.isEmpty()) { return } val selected = value.selectedItems.singleOrNull() b.spinnerOriginalLocale.adapter = ArrayAdapter( b.spinnerOriginalLocale.context, android.R.layout.simple_spinner_dropdown_item, android.R.id.text1, value.availableItems.map { it.getDisplayName(b.spinnerOriginalLocale.context) }, ) val selectedIndex = value.availableItems.indexOf(selected) if (selectedIndex >= 0) { b.spinnerOriginalLocale.setSelection(selectedIndex, false) } } private fun onTagsChanged(value: FilterProperty) { val b = viewBinding ?: return b.layoutGenres.isGone = value.isEmptyAndSuccess() b.layoutGenres.setError(value.error?.getDisplayMessage(resources)) if (value.isEmpty()) { return } val chips = value.availableItems.map { tag -> ChipsView.ChipModel( title = tag.title, isChecked = tag in value.selectedItems, data = tag, ) } b.chipsGenres.setChips(chips) } private fun onTagsExcludedChanged(value: FilterProperty) { val b = viewBinding ?: return b.layoutGenresExclude.isGone = value.isEmpty() if (value.isEmpty()) { return } val chips = value.availableItems.map { tag -> ChipsView.ChipModel( title = tag.title, isChecked = tag in value.selectedItems, data = tag, ) } b.chipsGenresExclude.setChips(chips) } private fun onAuthorsChanged(value: FilterProperty) { val b = viewBinding ?: return b.layoutAuthor.isGone = value.isEmpty() if (value.isEmpty()) { return } val chips = value.availableItems.map { author -> ChipsView.ChipModel( title = author, isChecked = author in value.selectedItems, data = author, ) } b.chipsAuthor.setChips(chips) } private fun onStateChanged(value: FilterProperty) { val b = viewBinding ?: return b.layoutState.isGone = value.isEmpty() if (value.isEmpty()) { return } val chips = value.availableItems.map { state -> ChipsView.ChipModel( title = getString(state.titleResId), isChecked = state in value.selectedItems, data = state, ) } b.chipsState.setChips(chips) } private fun onContentTypesChanged(value: FilterProperty) { val b = viewBinding ?: return b.layoutTypes.isGone = value.isEmpty() if (value.isEmpty()) { return } val chips = value.availableItems.map { type -> ChipsView.ChipModel( title = getString(type.titleResId), isChecked = type in value.selectedItems, data = type, ) } b.chipsTypes.setChips(chips) } private fun onContentRatingChanged(value: FilterProperty) { val b = viewBinding ?: return b.layoutContentRating.isGone = value.isEmpty() if (value.isEmpty()) { return } val chips = value.availableItems.map { contentRating -> ChipsView.ChipModel( title = getString(contentRating.titleResId), isChecked = contentRating in value.selectedItems, data = contentRating, ) } b.chipsContentRating.setChips(chips) } private fun onDemographicsChanged(value: FilterProperty) { val b = viewBinding ?: return b.layoutDemographics.isGone = value.isEmpty() if (value.isEmpty()) { return } val chips = value.availableItems.map { demographic -> ChipsView.ChipModel( title = getString(demographic.titleResId), isChecked = demographic in value.selectedItems, data = demographic, ) } b.chipsDemographics.setChips(chips) } private fun onYearChanged(value: FilterProperty) { val b = viewBinding ?: return b.layoutYear.isGone = value.isEmpty() if (value.isEmpty()) { return } val currentValue = value.selectedItems.singleOrNull() ?: YEAR_UNKNOWN b.layoutYear.setValueText( if (currentValue == YEAR_UNKNOWN) { getString(R.string.any) } else { currentValue.toString() }, ) b.sliderYear.valueFrom = value.availableItems.first().toFloat() b.sliderYear.valueTo = value.availableItems.last().toFloat() b.sliderYear.setValueRounded(currentValue.toFloat()) } private fun onYearRangeChanged(value: FilterProperty) { val b = viewBinding ?: return b.layoutYearsRange.isGone = value.isEmpty() if (value.isEmpty()) { return } b.sliderYearsRange.valueFrom = value.availableItems.first().toFloat() b.sliderYearsRange.valueTo = value.availableItems.last().toFloat() val currentValueFrom = value.selectedItems.firstOrNull()?.toFloat() ?: b.sliderYearsRange.valueFrom val currentValueTo = value.selectedItems.lastOrNull()?.toFloat() ?: b.sliderYearsRange.valueTo b.layoutYearsRange.setValueText( getString( R.string.memory_usage_pattern, currentValueFrom.toInt().toString(), currentValueTo.toInt().toString(), ), ) b.sliderYearsRange.setValuesRounded(currentValueFrom, currentValueTo) } private fun onSavedPresetsChanged(value: FilterProperty) { val b = viewBinding ?: return b.layoutSavedFilters.isGone = value.isEmpty() if (value.isEmpty()) { return } val chips = value.availableItems.map { f -> ChipsView.ChipModel( title = f.name, isChecked = f in value.selectedItems, data = f, isDropdown = true, ) } b.chipsSavedFilters.setChips(chips) } private fun showSavedFilterMenu(anchor: View, preset: PersistableFilter) { val menu = PopupMenu(context ?: return, anchor) val filter = FilterCoordinator.require(this) menu.inflate(R.menu.popup_saved_filter) menu.setOnMenuItemClickListener { menuItem -> when (menuItem.itemId) { R.id.action_delete -> filter.deleteSavedFilter(preset.id) R.id.action_rename -> onRenameFilterClick(preset) } true } menu.show() } private fun onSaveFilterClick(name: String) { val filter = FilterCoordinator.require(this) val existingNames = filter.savedFilters.value.availableItems .mapTo(TreeSet(AlphanumComparator()), PersistableFilter::name) buildAlertDialog(context ?: return) { val input = setEditText( entries = existingNames.toList(), inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_FLAG_CAP_SENTENCES, singleLine = true, ) input.setHint(R.string.enter_name) input.setText(name) input.filters += InputFilter.LengthFilter(MAX_TITLE_LENGTH) setTitle(R.string.save_filter) setPositiveButton(R.string.save) { _, _ -> val text = input.text?.toString()?.trim() if (text.isNullOrEmpty()) { Toast.makeText(context, R.string.invalid_value_message, Toast.LENGTH_SHORT).show() onSaveFilterClick("") } else if (text in existingNames) { askForFilterOverwrite(filter, text) } else { filter.saveCurrentFilter(text) } } setNegativeButton(android.R.string.cancel, null) }.show() } private fun onRenameFilterClick(preset: PersistableFilter) { val filter = FilterCoordinator.require(this) val existingNames = filter.savedFilters.value.availableItems.mapToSet { it.name } buildAlertDialog(context ?: return) { val input = setEditText( inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_FLAG_CAP_SENTENCES, singleLine = true, ) input.filters += InputFilter.LengthFilter(MAX_TITLE_LENGTH) input.setHint(R.string.enter_name) input.setText(preset.name) setTitle(R.string.rename) setPositiveButton(R.string.save) { _, _ -> val text = input.text?.toString()?.trim() if (text.isNullOrEmpty() || text in existingNames) { Toast.makeText(context, R.string.invalid_value_message, Toast.LENGTH_SHORT).show() } else { filter.renameSavedFilter(preset.id, text) } } setNegativeButton(android.R.string.cancel, null) }.show() } private fun askForFilterOverwrite(filter: FilterCoordinator, name: String) { buildAlertDialog(context ?: return) { setTitle(R.string.save_filter) setMessage(getString(R.string.filter_overwrite_confirm, name)) setPositiveButton(R.string.overwrite) { _, _ -> filter.saveCurrentFilter(name) } setNegativeButton(android.R.string.cancel) { _, _ -> onSaveFilterClick(name) } }.show() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagTitleComparator.kt ================================================ package org.koitharu.kotatsu.filter.ui.tags import org.koitharu.kotatsu.parsers.model.MangaTag import java.text.Collator import java.util.Locale class TagTitleComparator(lc: String?) : Comparator { private val collator = lc?.let { Collator.getInstance(Locale(it)) } override fun compare(o1: MangaTag, o2: MangaTag): Int { val t1 = o1.title.lowercase() val t2 = o2.title.lowercase() return collator?.compare(t1, t2) ?: compareValues(t1, t2) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogAdapter.kt ================================================ package org.koitharu.kotatsu.filter.ui.tags import android.content.Context 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.ui.list.fastscroll.FastScroller import org.koitharu.kotatsu.core.util.ext.setChecked import org.koitharu.kotatsu.databinding.ItemCheckableNewBinding import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.errorFooterAD import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD 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 TagsCatalogAdapter( listener: OnListItemClickListener, ) : BaseListAdapter(), FastScroller.SectionIndexer { init { addDelegate(ListItemType.FILTER_TAG, tagCatalogDelegate(listener)) addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD()) addDelegate(ListItemType.FOOTER_ERROR, errorFooterAD(null)) addDelegate(ListItemType.STATE_ERROR, errorStateListAD(null)) } override fun getSectionText(context: Context, position: Int): CharSequence? { return (items.getOrNull(position) as? TagCatalogItem)?.tag?.title?.firstOrNull()?.uppercase() } private fun tagCatalogDelegate( listener: OnListItemClickListener, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemCheckableNewBinding.inflate(layoutInflater, parent, false) }, ) { itemView.setOnClickListener { listener.onItemClick(item, itemView) } bind { payloads -> binding.root.text = item.tag.title binding.root.setChecked(item.isChecked, ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogSheet.kt ================================================ package org.koitharu.kotatsu.filter.ui.tags import android.os.Bundle import android.text.Editable import android.view.KeyEvent import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.inputmethod.EditorInfo import android.widget.TextView import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.viewModels import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.withCreationCallback import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.ui.util.DefaultTextWatcher import org.koitharu.kotatsu.core.util.ext.consumeAll import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.databinding.SheetTagsBinding import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem @AndroidEntryPoint class TagsCatalogSheet : BaseAdaptiveSheet(), OnListItemClickListener, DefaultTextWatcher, AdaptiveSheetCallback, View.OnFocusChangeListener, TextView.OnEditorActionListener { private val viewModel by viewModels( extrasProducer = { defaultViewModelCreationExtras.withCreationCallback { factory -> factory.create( filter = FilterCoordinator.require(this), isExcludeTag = requireArguments().getBoolean(AppRouter.KEY_EXCLUDE), ) } }, ) override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetTagsBinding { return SheetTagsBinding.inflate(inflater, container, false) } override fun onViewBindingCreated(binding: SheetTagsBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) val adapter = TagsCatalogAdapter(this) binding.recyclerView.adapter = adapter binding.recyclerView.setHasFixedSize(true) binding.editSearch.setText(viewModel.searchQuery.value) binding.editSearch.addTextChangedListener(this) binding.editSearch.onFocusChangeListener = this binding.editSearch.setOnEditorActionListener(this) viewModel.content.observe(viewLifecycleOwner, adapter) addSheetCallback(this, viewLifecycleOwner) disableFitToContents() } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val typeBask = WindowInsetsCompat.Type.systemBars() val barsInsets = insets.getInsets(typeBask) viewBinding?.recyclerView?.setPadding( barsInsets.left, barsInsets.top, barsInsets.right, barsInsets.bottom, ) return insets.consumeAll(typeBask) } override fun onItemClick(item: TagCatalogItem, view: View) { viewModel.handleTagClick(item.tag, item.isChecked) } override fun onFocusChange(v: View?, hasFocus: Boolean) { setExpanded( isExpanded = hasFocus || isExpanded, isLocked = hasFocus, ) } override fun onEditorAction(v: TextView, actionId: Int, event: KeyEvent?): Boolean { return if (actionId == EditorInfo.IME_ACTION_SEARCH) { v.clearFocus() true } else { false } } override fun afterTextChanged(s: Editable?) { val q = s?.toString().orEmpty() viewModel.searchQuery.value = q } override fun onStateChanged(sheet: View, newState: Int) { viewBinding?.recyclerView?.isFastScrollerEnabled = newState == AdaptiveSheetBehavior.STATE_EXPANDED } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogViewModel.kt ================================================ package org.koitharu.kotatsu.filter.ui.tags import androidx.lifecycle.viewModelScope import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.toErrorFooter import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaTag @HiltViewModel(assistedFactory = TagsCatalogViewModel.Factory::class) class TagsCatalogViewModel @AssistedInject constructor( @Assisted private val filter: FilterCoordinator, @Assisted private val isExcluded: Boolean, private val mangaDataRepository: MangaDataRepository, ) : BaseViewModel() { val searchQuery = MutableStateFlow("") private val filterProperty: StateFlow> get() = if (isExcluded) filter.tagsExcluded else filter.tags @Suppress("RemoveExplicitTypeArguments") private val tags: StateFlow> = combine( filter.getAllTags(), flow> { emit(emptyList()); emit(mangaDataRepository.findTags(filter.mangaSource)) }, filterProperty.map { it.selectedItems }, ) { available, cached, selected -> buildList(available, cached, selected) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) val content = combine(tags, searchQuery) { raw, query -> raw.filter { x -> x !is TagCatalogItem || x.tag.title.contains(query, ignoreCase = true) } }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState)) fun handleTagClick(tag: MangaTag, isChecked: Boolean) { if (isExcluded) { filter.toggleTagExclude(tag, !isChecked) } else { filter.toggleTag(tag, !isChecked) } } private fun buildList( available: Result>, cached: Collection, selected: Set, ): List { val capacity = (available.getOrNull()?.size ?: 1) + cached.size val result = ArrayList(capacity) val added = HashSet(capacity) available.getOrNull()?.forEach { tag -> if (added.add(tag.title)) { result.add( TagCatalogItem( tag = tag, isChecked = tag in selected, ), ) } } cached.forEach { tag -> if (added.add(tag.title)) { result.add( TagCatalogItem( tag = tag, isChecked = tag in selected, ), ) } } if (result.isNotEmpty()) { val locale = (filter.mangaSource as? MangaParserSource)?.locale result.sortWith(compareBy(TagTitleComparator(locale)) { (it as TagCatalogItem).tag }) } available.exceptionOrNull()?.let { error -> result.add( if (result.isEmpty()) { error.toErrorState(canRetry = false) } else { error.toErrorFooter() }, ) } return result } @AssistedFactory interface Factory { fun create(filter: FilterCoordinator, isExcludeTag: Boolean): TagsCatalogViewModel } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/history/data/EntityMapping.kt ================================================ package org.koitharu.kotatsu.history.data import org.koitharu.kotatsu.core.model.MangaHistory import java.time.Instant fun HistoryEntity.toMangaHistory() = MangaHistory( createdAt = Instant.ofEpochMilli(createdAt), updatedAt = Instant.ofEpochMilli(updatedAt), chapterId = chapterId, page = page, scroll = scroll.toInt(), percent = percent, chaptersCount = chaptersCount, ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt ================================================ package org.koitharu.kotatsu.history.data import android.database.DatabaseUtils.sqlEscapeString 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.currentCoroutineContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.isActive import org.koitharu.kotatsu.core.db.MangaQueryBuilder import org.koitharu.kotatsu.core.db.TABLE_HISTORY import org.koitharu.kotatsu.core.db.entity.MangaWithTags import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_COMPLETED @Dao abstract class HistoryDao : MangaQueryBuilder.ConditionCallback { @Transaction @Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit OFFSET :offset") abstract suspend fun findAll(offset: Int, limit: Int): List @Transaction @Query("SELECT manga.* FROM history LEFT JOIN manga ON manga.manga_id = history.manga_id WHERE history.deleted_at = 0 AND (manga.title LIKE :query OR manga.alt_title LIKE :query) LIMIT :limit") abstract suspend fun searchByTitle(query: String, limit: Int): List @Transaction @Query("SELECT manga.* FROM history LEFT JOIN manga ON manga.manga_id = history.manga_id WHERE history.deleted_at = 0 AND (manga.author LIKE :query) LIMIT :limit") abstract suspend fun searchByAuthor(query: String, limit: Int): List @Transaction @Query("SELECT manga.* FROM history LEFT JOIN manga ON manga.manga_id = history.manga_id WHERE history.deleted_at = 0 AND EXISTS(SELECT 1 FROM tags LEFT JOIN manga_tags ON manga_tags.tag_id = tags.tag_id WHERE manga_tags.manga_id = manga.manga_id AND tags.title LIKE :query) LIMIT :limit") abstract suspend fun searchByTag(query: String, limit: Int): List @Transaction @Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC") abstract fun observeAll(): Flow> @Transaction @Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit") abstract fun observeAll(limit: Int): Flow> fun observeAll( order: ListSortOrder, filterOptions: Set, limit: Int ): Flow> = observeAllImpl( MangaQueryBuilder(TABLE_HISTORY, this) .join("LEFT JOIN manga ON history.manga_id = manga.manga_id") .where("history.deleted_at = 0") .filters(filterOptions) .orderBy( orderBy = when (order) { ListSortOrder.LAST_READ -> "history.updated_at DESC" ListSortOrder.LONG_AGO_READ -> "history.updated_at ASC" ListSortOrder.NEWEST -> "history.created_at DESC" ListSortOrder.OLDEST -> "history.created_at ASC" ListSortOrder.PROGRESS -> "history.percent DESC" ListSortOrder.UNREAD -> "history.percent ASC" ListSortOrder.ALPHABETIC -> "manga.title" ListSortOrder.ALPHABETIC_REVERSE -> "manga.title DESC" ListSortOrder.NEW_CHAPTERS -> "IFNULL((SELECT chapters_new FROM tracks WHERE tracks.manga_id = manga.manga_id), 0) DESC" ListSortOrder.UPDATED -> "IFNULL((SELECT last_chapter_date FROM tracks WHERE tracks.manga_id = manga.manga_id), 0) DESC" else -> throw IllegalArgumentException("Sort order $order is not supported") }, ) .groupBy("history.manga_id") .limit(limit) .build(), ) @Query("SELECT manga_id FROM history WHERE deleted_at = 0") abstract suspend fun findAllIds(): LongArray @Query( """SELECT tags.* FROM tags LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id INNER JOIN history ON history.manga_id = manga_tags.manga_id WHERE history.deleted_at = 0 GROUP BY manga_tags.tag_id ORDER BY COUNT(manga_tags.manga_id) DESC LIMIT :limit""", ) abstract suspend fun findPopularTags(limit: Int): List @Query("SELECT manga.source AS count FROM history LEFT JOIN manga ON manga.manga_id = history.manga_id GROUP BY manga.source ORDER BY COUNT(manga.source) DESC LIMIT :limit") abstract suspend fun findPopularSources(limit: Int): List @Query("SELECT * FROM history WHERE manga_id = :id AND deleted_at = 0") abstract suspend fun find(id: Long): HistoryEntity? @Query("SELECT * FROM history WHERE manga_id = :id AND deleted_at = 0") abstract fun observe(id: Long): Flow @Query("SELECT COUNT(*) FROM history WHERE deleted_at = 0") abstract fun observeCount(): Flow @Query("SELECT COUNT(*) FROM history WHERE deleted_at = 0") abstract suspend fun getCount(): Int @Query("SELECT percent FROM history WHERE manga_id = :id AND deleted_at = 0") abstract suspend fun findProgress(id: Long): Float? fun dump(): Flow = flow { val window = 10 var offset = 0 while (currentCoroutineContext().isActive) { val list = findAll(offset, window) if (list.isEmpty()) { break } offset += window list.forEach { emit(it) } } } @Insert(onConflict = OnConflictStrategy.IGNORE) abstract suspend fun insert(entity: HistoryEntity): Long @Query( "UPDATE history SET page = :page, chapter_id = :chapterId, scroll = :scroll, percent = :percent, updated_at = :updatedAt, chapters = :chapters, deleted_at = 0 WHERE manga_id = :mangaId", ) abstract suspend fun update( mangaId: Long, page: Int, chapterId: Long, scroll: Float, percent: Float, chapters: Int, updatedAt: Long, ): Int suspend fun delete(mangaId: Long) = setDeletedAt(mangaId, System.currentTimeMillis()) suspend fun recover(mangaId: Long) = setDeletedAt(mangaId, 0L) @Query("DELETE FROM history WHERE deleted_at != 0 AND deleted_at < :maxDeletionTime") abstract suspend fun gc(maxDeletionTime: Long) suspend fun deleteAfter(minDate: Long) = setDeletedAtAfter(minDate, System.currentTimeMillis()) suspend fun deleteNotFavorite() = setDeletedAtNotFavorite(System.currentTimeMillis()) suspend fun clear() = setDeletedAtAfter(0L, System.currentTimeMillis()) suspend fun update(entity: HistoryEntity) = update( mangaId = entity.mangaId, page = entity.page, chapterId = entity.chapterId, scroll = entity.scroll, percent = entity.percent, chapters = entity.chaptersCount, updatedAt = entity.updatedAt, ) @Transaction open suspend fun upsert(entity: HistoryEntity): Boolean { return if (update(entity) == 0) { insert(entity) true } else false } @Transaction open suspend fun upsert(entities: Iterable) { for (e in entities) { if (update(e) == 0) { insert(e) } } } @Query("UPDATE history SET deleted_at = :deletedAt WHERE manga_id = :mangaId") protected abstract suspend fun setDeletedAt(mangaId: Long, deletedAt: Long) @Query("UPDATE history SET deleted_at = :deletedAt WHERE created_at >= :minDate AND deleted_at = 0") protected abstract suspend fun setDeletedAtAfter(minDate: Long, deletedAt: Long) @Query("UPDATE history SET deleted_at = :deletedAt WHERE deleted_at = 0 AND NOT EXISTS(SELECT * FROM favourites WHERE history.manga_id = favourites.manga_id)") protected abstract suspend fun setDeletedAtNotFavorite(deletedAt: Long) @Transaction @RawQuery(observedEntities = [HistoryEntity::class]) protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow> override fun getCondition(option: ListFilterOption): String? = when (option) { is ListFilterOption.Favorite -> "EXISTS(SELECT * FROM favourites WHERE history.manga_id = favourites.manga_id AND category_id = ${option.category.id})" ListFilterOption.Macro.COMPLETED -> "percent >= $PROGRESS_COMPLETED" ListFilterOption.Macro.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = history.manga_id) > 0" ListFilterOption.Macro.FAVORITE -> "EXISTS(SELECT * FROM favourites WHERE history.manga_id = favourites.manga_id)" ListFilterOption.Macro.NSFW -> "manga.nsfw = 1" is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE history.manga_id = manga_tags.manga_id AND tag_id = ${option.tagId})" ListFilterOption.Downloaded -> "EXISTS(SELECT * FROM local_index WHERE local_index.manga_id = history.manga_id)" is ListFilterOption.Source -> "manga.source = ${sqlEscapeString(option.mangaSource.name)}" else -> null } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryEntity.kt ================================================ package org.koitharu.kotatsu.history.data import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.PrimaryKey import org.koitharu.kotatsu.core.db.TABLE_HISTORY import org.koitharu.kotatsu.core.db.entity.MangaEntity @Entity( tableName = TABLE_HISTORY, foreignKeys = [ ForeignKey( entity = MangaEntity::class, parentColumns = ["manga_id"], childColumns = ["manga_id"], onDelete = ForeignKey.CASCADE, ), ], ) data class HistoryEntity( @PrimaryKey(autoGenerate = false) @ColumnInfo(name = "manga_id") val mangaId: Long, @ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "updated_at") val updatedAt: Long, @ColumnInfo(name = "chapter_id") val chapterId: Long, @ColumnInfo(name = "page") val page: Int, @ColumnInfo(name = "scroll") val scroll: Float, @ColumnInfo(name = "percent") val percent: Float, @ColumnInfo(name = "deleted_at") val deletedAt: Long, @ColumnInfo(name = "chapters") val chaptersCount: Int, ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryLocalObserver.kt ================================================ package org.koitharu.kotatsu.history.data import dagger.Reusable import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.history.domain.model.MangaWithHistory import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.local.data.index.LocalMangaIndex import org.koitharu.kotatsu.local.domain.LocalObserveMapper import org.koitharu.kotatsu.parsers.model.Manga import javax.inject.Inject @Reusable class HistoryLocalObserver @Inject constructor( localMangaIndex: LocalMangaIndex, private val db: MangaDatabase, ) : LocalObserveMapper(localMangaIndex) { fun observeAll( order: ListSortOrder, filterOptions: Set, limit: Int ) = db.getHistoryDao().observeAll(order, filterOptions, limit).mapToLocal() override fun toManga(e: HistoryWithManga) = e.manga.toManga(e.tags.toMangaTags(), null) override fun toResult(e: HistoryWithManga, manga: Manga) = MangaWithHistory( manga = manga, history = e.history.toMangaHistory(), ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt ================================================ package org.koitharu.kotatsu.history.data import androidx.room.withTransaction import dagger.Reusable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toMangaList import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTagsList import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.model.toMangaSources import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode import org.koitharu.kotatsu.core.ui.util.ReversibleHandle import org.koitharu.kotatsu.core.util.ext.mapItems import org.koitharu.kotatsu.history.domain.model.MangaWithHistory import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ReadingProgress import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.util.findById import org.koitharu.kotatsu.parsers.util.levenshteinDistance import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.tryScrobble import org.koitharu.kotatsu.search.domain.SearchKind import org.koitharu.kotatsu.tracker.domain.CheckNewChaptersUseCase import javax.inject.Inject import javax.inject.Provider @Reusable class HistoryRepository @Inject constructor( private val db: MangaDatabase, private val settings: AppSettings, private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>, private val mangaRepository: MangaDataRepository, private val localObserver: HistoryLocalObserver, private val newChaptersUseCaseProvider: Provider, ) { suspend fun getList(offset: Int, limit: Int): List { val entities = db.getHistoryDao().findAll(offset, limit) return entities.map { it.toManga() } } suspend fun search(query: String, kind: SearchKind, limit: Int): List { val dao = db.getHistoryDao() val q = "%$query%" val entities = when (kind) { SearchKind.SIMPLE, SearchKind.TITLE -> dao.searchByTitle(q, limit).sortedBy { it.manga.title.levenshteinDistance(query) } SearchKind.AUTHOR -> dao.searchByAuthor(q, limit) SearchKind.TAG -> dao.searchByTag(q, limit) } return entities.toMangaList() } suspend fun getLastOrNull(): Manga? { val entity = db.getHistoryDao().findAll(0, 1).firstOrNull() ?: return null return entity.toManga() } fun observeLast(): Flow { return db.getHistoryDao().observeAll(1).map { val first = it.firstOrNull() first?.toManga() } } fun observeAll(): Flow> { return db.getHistoryDao().observeAll().mapItems { it.toManga() } } fun observeAll(limit: Int): Flow> { return db.getHistoryDao().observeAll(limit).mapItems { it.toManga() } } fun observeAllWithHistory( order: ListSortOrder, filterOptions: Set, limit: Int ): Flow> { if (ListFilterOption.Downloaded in filterOptions) { return localObserver.observeAll(order, filterOptions, limit) } return db.getHistoryDao().observeAll(order, filterOptions, limit).mapItems { MangaWithHistory( it.toManga(), it.history.toMangaHistory(), ) } } fun observeOne(id: Long): Flow { return db.getHistoryDao().observe(id).map { it?.toMangaHistory() } } suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int, percent: Float, force: Boolean) { if (!force && shouldSkip(manga)) { return } assert(manga.chapters != null) db.withTransaction { mangaRepository.storeManga(manga, replaceExisting = true) val branch = manga.chapters?.findById(chapterId)?.branch db.getHistoryDao().upsert( HistoryEntity( mangaId = manga.id, createdAt = System.currentTimeMillis(), updatedAt = System.currentTimeMillis(), chapterId = chapterId, page = page, scroll = scroll.toFloat(), // we migrate to int, but decide to not update database percent = percent, chaptersCount = manga.chapters?.count { it.branch == branch } ?: 0, deletedAt = 0L, ), ) newChaptersUseCaseProvider.get()(manga, chapterId) scrobblers.forEach { it.tryScrobble(manga, chapterId) } } } suspend fun getOne(manga: Manga): MangaHistory? { return db.getHistoryDao().find(manga.id)?.recoverIfNeeded(manga)?.toMangaHistory() } suspend fun getProgress(mangaId: Long, mode: ProgressIndicatorMode): ReadingProgress? { val entity = db.getHistoryDao().find(mangaId) ?: return null val fixedPercent = if (ReadingProgress.isCompleted(entity.percent)) 1f else entity.percent return ReadingProgress( percent = fixedPercent, totalChapters = entity.chaptersCount, mode = mode, ).takeIf { it.isValid() } } suspend fun clear() { db.getHistoryDao().clear() } suspend fun delete(manga: Manga) = db.withTransaction { db.getHistoryDao().delete(manga.id) mangaRepository.gcChaptersCache() } suspend fun deleteAfter(minDate: Long) = db.withTransaction { db.getHistoryDao().deleteAfter(minDate) mangaRepository.gcChaptersCache() } suspend fun deleteNotFavorite() = db.withTransaction { db.getHistoryDao().deleteNotFavorite() mangaRepository.gcChaptersCache() } suspend fun delete(ids: Collection): ReversibleHandle { db.withTransaction { for (id in ids) { db.getHistoryDao().delete(id) } mangaRepository.gcChaptersCache() } return ReversibleHandle { recover(ids) } } /** * Try to replace one manga with another one * Useful for replacing saved manga on deleting it with remote source */ suspend fun deleteOrSwap(manga: Manga, alternative: Manga?) { if (alternative == null || db.getMangaDao().update(alternative.toEntity()) <= 0) { delete(manga) } } suspend fun getPopularTags(limit: Int): List { return db.getHistoryDao().findPopularTags(limit).toMangaTagsList() } suspend fun getPopularSources(limit: Int): List { return db.getHistoryDao().findPopularSources(limit).toMangaSources() } fun shouldSkip(manga: Manga): Boolean = settings.isIncognitoModeEnabled(manga.isNsfw()) fun observeShouldSkip(manga: Manga): Flow { return settings.observe(AppSettings.KEY_INCOGNITO_MODE, AppSettings.KEY_INCOGNITO_NSFW) .map { shouldSkip(manga) } .distinctUntilChanged() } private suspend fun recover(ids: Collection) { db.withTransaction { for (id in ids) { db.getHistoryDao().recover(id) } } } private suspend fun HistoryEntity.recoverIfNeeded(manga: Manga): HistoryEntity { val chapters = manga.chapters if (manga.isLocal || chapters.isNullOrEmpty() || chapters.findById(chapterId) != null) { return this } val newChapterId = chapters.getOrNull( (chapters.size * percent).toInt(), )?.id ?: return this val newEntity = copy(chapterId = newChapterId) db.getHistoryDao().update(newEntity) return newEntity } private fun HistoryWithManga.toManga() = manga.toManga(tags.toMangaTags(), null) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryWithManga.kt ================================================ package org.koitharu.kotatsu.history.data import androidx.room.Embedded import androidx.room.Junction import androidx.room.Relation import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity import org.koitharu.kotatsu.core.db.entity.TagEntity class HistoryWithManga( @Embedded val history: HistoryEntity, @Relation( parentColumn = "manga_id", entityColumn = "manga_id" ) 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/history/domain/HistoryListQuickFilter.kt ================================================ package org.koitharu.kotatsu.history.domain import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.MangaListQuickFilter import javax.inject.Inject class HistoryListQuickFilter @Inject constructor( private val settings: AppSettings, private val repository: HistoryRepository, networkState: NetworkState, ) : MangaListQuickFilter(settings) { init { setFilterOption(ListFilterOption.Downloaded, !networkState.value) } override suspend fun getAvailableFilterOptions(): List = buildList { add(ListFilterOption.Downloaded) if (settings.isTrackerEnabled) { add(ListFilterOption.Macro.NEW_CHAPTERS) } add(ListFilterOption.Macro.COMPLETED) add(ListFilterOption.Macro.FAVORITE) add(ListFilterOption.NOT_FAVORITE) if (!settings.isNsfwContentDisabled) { add(ListFilterOption.Macro.NSFW) } repository.getPopularTags(3).mapTo(this) { ListFilterOption.Tag(it) } repository.getPopularSources(3).mapTo(this) { ListFilterOption.Source(it) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/history/domain/HistoryUpdateUseCase.kt ================================================ package org.koitharu.kotatsu.history.domain import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.reader.ui.ReaderState import javax.inject.Inject class HistoryUpdateUseCase @Inject constructor( private val historyRepository: HistoryRepository, ) { suspend operator fun invoke(manga: Manga, readerState: ReaderState, percent: Float) { historyRepository.addOrUpdate( manga = manga, chapterId = readerState.chapterId, page = readerState.page, scroll = readerState.scroll, percent = percent, force = false, ) } fun invokeAsync( manga: Manga, readerState: ReaderState, percent: Float ) = processLifecycleScope.launch(Dispatchers.Default, CoroutineStart.ATOMIC) { runCatchingCancellable { withContext(NonCancellable) { invoke(manga, readerState, percent) } }.onFailure { it.printStackTraceDebug() } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/history/domain/MarkAsReadUseCase.kt ================================================ package org.koitharu.kotatsu.history.domain import dagger.Reusable import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.parsers.model.Manga import javax.inject.Inject @Reusable class MarkAsReadUseCase @Inject constructor( private val historyRepository: HistoryRepository, private val mangaRepositoryFactory: MangaRepository.Factory, ) { suspend operator fun invoke(manga: Manga) { val repo = mangaRepositoryFactory.create(manga.source) val details = if (manga.chapters.isNullOrEmpty()) { repo.getDetails(manga) } else { manga } val lastChapter = checkNotNull(details.chapters).last() val pages = repo.getPages(lastChapter) historyRepository.addOrUpdate( manga = details, chapterId = lastChapter.id, page = pages.lastIndex, scroll = 0, percent = 1f, force = true, ) } suspend operator fun invoke(manga: Collection) { when (manga.size) { 0 -> Unit 1 -> invoke(manga.first()) else -> supervisorScope { manga.map { launch { invoke(it) } }.joinAll() } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/history/domain/model/MangaWithHistory.kt ================================================ package org.koitharu.kotatsu.history.domain.model import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.parsers.model.Manga data class MangaWithHistory( val manga: Manga, val history: MangaHistory ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryActivity.kt ================================================ package org.koitharu.kotatsu.history.ui import org.koitharu.kotatsu.core.ui.FragmentContainerActivity class HistoryActivity : FragmentContainerActivity(HistoryListFragment::class.java) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListAdapter.kt ================================================ package org.koitharu.kotatsu.history.ui import android.content.Context import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter import org.koitharu.kotatsu.list.ui.adapter.MangaListListener import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver class HistoryListAdapter( listener: MangaListListener, sizeResolver: ItemSizeResolver, ) : MangaListAdapter(listener, sizeResolver), FastScroller.SectionIndexer { override fun getSectionText(context: Context, position: Int): CharSequence? { return findHeader(position)?.getText(context) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt ================================================ package org.koitharu.kotatsu.history.ui import android.os.Bundle import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.appcompat.view.ActionMode import androidx.fragment.app.viewModels import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper import org.koitharu.kotatsu.core.ui.util.MenuInvalidator import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver @AndroidEntryPoint class HistoryListFragment : MangaListFragment() { override val viewModel by viewModels() override val isSwipeRefreshEnabled = false override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) RecyclerScrollKeeper(binding.recyclerView).attach() addMenuProvider(HistoryListMenuProvider(binding.root.context, router, viewModel)) viewModel.isStatsEnabled.observe(viewLifecycleOwner, MenuInvalidator(requireActivity())) } override fun onScrolledToEnd() = viewModel.requestMoreItems() override fun onEmptyActionClick() = viewModel.clearFilter() override fun onCreateActionMode( controller: ListSelectionController, menuInflater: MenuInflater, menu: Menu ): Boolean { menuInflater.inflate(R.menu.mode_history, menu) return super.onCreateActionMode(controller, menuInflater, menu) } override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean { return when (item.itemId) { R.id.action_remove -> { viewModel.removeFromHistory(selectedItemsIds) mode?.finish() true } R.id.action_mark_current -> { val itemsSnapshot = selectedItems buildAlertDialog(context ?: return false, isCentered = true) { setTitle(item.title) setIcon(item.icon) setMessage(R.string.mark_as_completed_prompt) setNegativeButton(android.R.string.cancel, null) setPositiveButton(android.R.string.ok) { _, _ -> viewModel.markAsRead(itemsSnapshot) mode?.finish() } }.show() true } else -> super.onActionItemClicked(controller, mode, item) } } override fun onCreateAdapter() = HistoryListAdapter( this, DynamicItemSizeResolver(resources, viewLifecycleOwner, settings, adjustWidth = false), ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt ================================================ package org.koitharu.kotatsu.history.ui import android.content.Context import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.core.view.MenuProvider import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.dialog.RememberSelectionDialogListener import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import java.time.Instant import java.time.LocalDate import java.time.ZoneId import java.time.temporal.ChronoUnit class HistoryListMenuProvider( private val context: Context, private val router: AppRouter, private val viewModel: HistoryListViewModel, ) : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.opt_history, menu) } override fun onPrepareMenu(menu: Menu) { super.onPrepareMenu(menu) menu.findItem(R.id.action_stats)?.isVisible = viewModel.isStatsEnabled.value } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return when (menuItem.itemId) { R.id.action_clear_history -> { showClearHistoryDialog() true } R.id.action_stats -> { router.openStatistic() true } else -> false } } private fun showClearHistoryDialog() { val selectionListener = RememberSelectionDialogListener(1) buildAlertDialog(context, isCentered = true) { setTitle(R.string.clear_history) setSingleChoiceItems( arrayOf( context.getString(R.string.last_2_hours), context.getString(R.string.today), context.getString(R.string.not_in_favorites), context.getString(R.string.clear_all_history), ), selectionListener.selection, selectionListener, ) setIcon(R.drawable.ic_delete_all) setNegativeButton(android.R.string.cancel, null) setPositiveButton(R.string.clear) { _, _ -> when (selectionListener.selection) { 0 -> viewModel.clearHistory(Instant.now().minus(2, ChronoUnit.HOURS)) 1 -> viewModel.clearHistory(LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant()) 2 -> viewModel.removeNotFavorite() 3 -> viewModel.clearHistory(null) } } }.show() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt ================================================ package org.koitharu.kotatsu.history.ui import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.flattenLatest import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryListQuickFilter import org.koitharu.kotatsu.history.domain.MarkAsReadUseCase import org.koitharu.kotatsu.history.domain.model.MangaWithHistory import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.list.domain.QuickFilterListener import org.koitharu.kotatsu.list.domain.ReadingProgress import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.InfoModel 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 java.time.Instant import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.domain.model.LocalManga import kotlinx.coroutines.flow.SharedFlow private const val PAGE_SIZE = 16 @HiltViewModel class HistoryListViewModel @Inject constructor( private val repository: HistoryRepository, settings: AppSettings, private val mangaListMapper: MangaListMapper, private val markAsReadUseCase: MarkAsReadUseCase, private val quickFilter: HistoryListQuickFilter, mangaDataRepository: MangaDataRepository, @LocalStorageChanges localStorageChanges: SharedFlow, ) : MangaListViewModel(settings, mangaDataRepository, localStorageChanges), QuickFilterListener by quickFilter { private val sortOrder: StateFlow = settings.observeAsStateFlow( scope = viewModelScope + Dispatchers.IO, key = AppSettings.KEY_HISTORY_ORDER, valueProducer = { historySortOrder }, ) override val listMode = settings.observeAsStateFlow( scope = viewModelScope + Dispatchers.Default, key = AppSettings.KEY_LIST_MODE_HISTORY, valueProducer = { historyListMode }, ) private val isGroupingEnabled = settings.observeAsFlow( key = AppSettings.KEY_HISTORY_GROUPING, valueProducer = { isHistoryGroupingEnabled }, ).combine(sortOrder) { g, s -> g && s.isGroupingSupported() } private val limit = MutableStateFlow(PAGE_SIZE) private val isPaginationReady = AtomicBoolean(false) val isStatsEnabled = settings.observeAsStateFlow( scope = viewModelScope + Dispatchers.Default, key = AppSettings.KEY_STATS_ENABLED, valueProducer = { isStatsEnabled }, ) override val content = combine( quickFilter.appliedOptions, observeHistory(), isGroupingEnabled, observeListModeWithTriggers(), settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled }, ) { filters, list, grouped, mode, incognito -> mapList(list, grouped, mode, filters, incognito) }.distinctUntilChanged().onEach { isPaginationReady.set(true) }.catch { e -> emit(listOf(e.toErrorState(canRetry = false))) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) override fun onRefresh() = Unit override fun onRetry() = Unit fun clearHistory(minDate: Instant?) { launchJob(Dispatchers.Default) { val stringRes = if (minDate == null) { repository.clear() R.string.history_cleared } else { repository.deleteAfter(minDate.toEpochMilli()) R.string.removed_from_history } onActionDone.call(ReversibleAction(stringRes, null)) } } fun removeNotFavorite() { launchJob(Dispatchers.Default) { repository.deleteNotFavorite() onActionDone.call(ReversibleAction(R.string.removed_from_history, null)) } } fun removeFromHistory(ids: Set) { if (ids.isEmpty()) { return } launchJob(Dispatchers.Default) { val handle = repository.delete(ids) onActionDone.call(ReversibleAction(R.string.removed_from_history, handle)) } } fun markAsRead(items: Set) { launchLoadingJob(Dispatchers.Default) { markAsReadUseCase(items) } } fun requestMoreItems() { if (isPaginationReady.compareAndSet(true, false)) { limit.value += PAGE_SIZE } } private fun observeHistory() = combine( sortOrder, quickFilter.appliedOptions.combineWithSettings(), limit, ) { order, filters, limit -> isPaginationReady.set(false) repository.observeAllWithHistory(order, filters, limit) }.flattenLatest() private suspend fun mapList( list: List, grouped: Boolean, mode: ListMode, filters: Set, isIncognito: Boolean, ): List { if (list.isEmpty()) { return if (filters.isEmpty()) { listOf(getEmptyState(hasFilters = false)) } else { listOfNotNull(quickFilter.filterItem(filters), getEmptyState(hasFilters = true)) } } val result = ArrayList((if (grouped) (list.size * 1.4).toInt() else list.size) + 2) quickFilter.filterItem(filters)?.let(result::add) if (isIncognito) { result += InfoModel( key = AppSettings.KEY_INCOGNITO_MODE, title = R.string.incognito_mode, text = R.string.incognito_mode_hint, icon = R.drawable.ic_incognito, ) } val order = sortOrder.value var prevHeader: ListHeader? = null var isEmpty = true for ((manga, history) in list) { isEmpty = false if (grouped) { val header = history.header(order) if (header != prevHeader) { if (header != null) { result += header } prevHeader = header } } result += mangaListMapper.toListModel(manga, mode) } if (filters.isNotEmpty() && isEmpty) { result += getEmptyState(hasFilters = true) } return result } private fun MangaHistory.header(order: ListSortOrder): ListHeader? = when (order) { ListSortOrder.LAST_READ, ListSortOrder.LONG_AGO_READ -> calculateTimeAgo(updatedAt)?.let { ListHeader(it) } ?: ListHeader(R.string.unknown) ListSortOrder.OLDEST, ListSortOrder.NEWEST -> calculateTimeAgo(createdAt)?.let { ListHeader(it) } ?: ListHeader(R.string.unknown) ListSortOrder.UNREAD, ListSortOrder.PROGRESS -> ListHeader( when { ReadingProgress.isCompleted(percent) -> R.string.status_completed percent in 0f..0.01f -> R.string.status_planned percent in 0f..1f -> R.string.status_reading else -> R.string.unknown }, ) ListSortOrder.ALPHABETIC, ListSortOrder.ALPHABETIC_REVERSE, ListSortOrder.RELEVANCE, ListSortOrder.NEW_CHAPTERS, ListSortOrder.UPDATED, ListSortOrder.RATING -> null } private fun getEmptyState(hasFilters: Boolean) = if (hasFilters) { EmptyState( icon = R.drawable.ic_empty_history, textPrimary = R.string.nothing_found, textSecondary = R.string.text_empty_holder_secondary_filtered, actionStringRes = R.string.reset_filter, ) } else { EmptyState( icon = R.drawable.ic_empty_history, textPrimary = R.string.text_history_holder_primary, textSecondary = R.string.text_history_holder_secondary, actionStringRes = 0, ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/history/ui/util/ReadingProgressDrawable.kt ================================================ package org.koitharu.kotatsu.history.ui.util import android.content.Context import android.content.res.ColorStateList import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Rect import android.os.Build import androidx.annotation.RequiresApi import androidx.annotation.StyleRes import androidx.appcompat.content.res.AppCompatResources import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.image.PaintDrawable import org.koitharu.kotatsu.core.util.ext.hasFocusStateSpecified import org.koitharu.kotatsu.core.util.ext.scale import org.koitharu.kotatsu.list.domain.ReadingProgress import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE class ReadingProgressDrawable( context: Context, @StyleRes styleResId: Int, ) : PaintDrawable() { override val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG) private val checkDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_check) private val lineColor: ColorStateList private val outlineColor: ColorStateList private val backgroundColor: ColorStateList private val textColor: ColorStateList private val textBounds = Rect() private val tempRect = Rect() private val desiredHeight: Int private val desiredWidth: Int private val autoFitTextSize: Boolean private var currentLineColor: Int = Color.TRANSPARENT private var currentOutlineColor: Int = Color.TRANSPARENT private var currentBackgroundColor: Int = Color.TRANSPARENT private var currentTextColor: Int = Color.TRANSPARENT private var hasBackground: Boolean = false private var hasOutline: Boolean = false private var hasText: Boolean = false var percent: Float = PROGRESS_NONE set(value) { field = value invalidateSelf() } var text = "" set(value) { field = value paint.getTextBounds(text, 0, text.length, textBounds) invalidateSelf() } init { val ta = context.obtainStyledAttributes(styleResId, R.styleable.ProgressDrawable) desiredHeight = ta.getDimensionPixelSize(R.styleable.ProgressDrawable_android_height, -1) desiredWidth = ta.getDimensionPixelSize(R.styleable.ProgressDrawable_android_width, -1) autoFitTextSize = ta.getBoolean(R.styleable.ProgressDrawable_autoFitTextSize, false) lineColor = ta.getColorStateList(R.styleable.ProgressDrawable_android_strokeColor) ?: ColorStateList.valueOf( Color.BLACK, ) outlineColor = ta.getColorStateList(R.styleable.ProgressDrawable_outlineColor) ?: ColorStateList.valueOf(Color.TRANSPARENT) backgroundColor = ta.getColorStateList(R.styleable.ProgressDrawable_android_fillColor)?.withAlpha( (255 * ta.getFloat(R.styleable.ProgressDrawable_android_fillAlpha, 0f)).toInt(), ) ?: ColorStateList.valueOf(Color.TRANSPARENT) textColor = ta.getColorStateList(R.styleable.ProgressDrawable_android_textColor) ?: lineColor paint.strokeCap = Paint.Cap.ROUND paint.textAlign = Paint.Align.CENTER paint.textSize = ta.getDimension(R.styleable.ProgressDrawable_android_textSize, paint.textSize) paint.strokeWidth = ta.getDimension(R.styleable.ProgressDrawable_strokeWidth, 1f) ta.recycle() checkDrawable?.setTintList(textColor) onStateChange(state) } override fun onBoundsChange(bounds: Rect) { super.onBoundsChange(bounds) if (autoFitTextSize) { val innerWidth = bounds.width() - (paint.strokeWidth * 2f) paint.textSize = getTextSizeForWidth(innerWidth, "100%") paint.getTextBounds(text, 0, text.length, textBounds) invalidateSelf() } } override fun draw(canvas: Canvas) { if (percent < 0f) { return } val cx = bounds.exactCenterX() val cy = bounds.exactCenterY() val radius = minOf(bounds.width(), bounds.height()) / 2f if (hasBackground) { paint.style = Paint.Style.FILL paint.color = currentBackgroundColor canvas.drawCircle(cx, cy, radius, paint) } val innerRadius = radius - paint.strokeWidth / 2f paint.style = Paint.Style.STROKE if (hasOutline) { paint.color = currentOutlineColor canvas.drawCircle(cx, cy, innerRadius, paint) } paint.color = currentLineColor canvas.drawArc( cx - innerRadius, cy - innerRadius, cx + innerRadius, cy + innerRadius, -90f, 360f * percent, false, paint, ) if (hasText) { if (checkDrawable != null && ReadingProgress.isCompleted(percent)) { tempRect.set(bounds) tempRect.scale(0.6) checkDrawable.bounds = tempRect checkDrawable.draw(canvas) } else { paint.style = Paint.Style.FILL paint.color = currentTextColor val ty = bounds.height() / 2f + textBounds.height() / 2f - textBounds.bottom canvas.drawText(text, cx, ty, paint) } } } override fun getIntrinsicHeight() = desiredHeight override fun getIntrinsicWidth() = desiredWidth override fun isStateful(): Boolean = lineColor.isStateful || outlineColor.isStateful || backgroundColor.isStateful || textColor.isStateful || checkDrawable?.isStateful == true @RequiresApi(Build.VERSION_CODES.S) override fun hasFocusStateSpecified(): Boolean = lineColor.hasFocusStateSpecified() || outlineColor.hasFocusStateSpecified() || backgroundColor.hasFocusStateSpecified() || textColor.hasFocusStateSpecified() || checkDrawable?.hasFocusStateSpecified() == true override fun onStateChange(state: IntArray): Boolean { val prevLineColor = currentLineColor currentLineColor = lineColor.getColorForState(state, lineColor.defaultColor) val prevOutlineColor = currentOutlineColor currentOutlineColor = outlineColor.getColorForState(state, outlineColor.defaultColor) val prevBackgroundColor = currentBackgroundColor currentBackgroundColor = backgroundColor.getColorForState(state, backgroundColor.defaultColor) val prevTextColor = currentTextColor currentTextColor = textColor.getColorForState(state, textColor.defaultColor) hasBackground = Color.alpha(currentBackgroundColor) != 0 hasOutline = Color.alpha(currentOutlineColor) != 0 hasText = Color.alpha(currentTextColor) != 0 && paint.textSize > 0 return checkDrawable?.setState(state) == true || prevLineColor != currentLineColor || prevOutlineColor != currentOutlineColor || prevBackgroundColor != currentBackgroundColor || prevTextColor != currentTextColor } private fun getTextSizeForWidth(width: Float, text: String): Float { val testTextSize = 48f paint.textSize = testTextSize paint.getTextBounds(text, 0, text.length, tempRect) return testTextSize * width / tempRect.width() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/history/ui/util/ReadingProgressView.kt ================================================ package org.koitharu.kotatsu.history.ui.util import android.animation.Animator import android.animation.ValueAnimator import android.content.Context import android.graphics.Outline import android.util.AttributeSet import android.view.View import android.view.ViewOutlineProvider import android.view.animation.AccelerateDecelerateInterpolator import androidx.annotation.AttrRes import androidx.annotation.StyleRes import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.CHAPTERS_LEFT import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.CHAPTERS_READ import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.NONE import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.PERCENT_LEFT import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.PERCENT_READ import org.koitharu.kotatsu.core.util.ext.getAnimationDuration import org.koitharu.kotatsu.list.domain.ReadingProgress import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE class ReadingProgressView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0, ) : View(context, attrs, defStyleAttr), ValueAnimator.AnimatorUpdateListener, Animator.AnimatorListener { private val percentPattern = context.getString(R.string.percent_string_pattern) private var percentAnimator: ValueAnimator? = null private val animationDuration = context.getAnimationDuration(android.R.integer.config_shortAnimTime) @StyleRes private val drawableStyle: Int var progress: ReadingProgress? = null set(value) { field = value cancelAnimation() getProgressDrawable().also { it.percent = value?.percent ?: PROGRESS_NONE it.text = when (value?.mode) { null, NONE -> "" PERCENT_READ -> percentPattern.format(ReadingProgress.percentToString(value.percent)) PERCENT_LEFT -> "-" + percentPattern.format(ReadingProgress.percentToString(value.percentLeft)) CHAPTERS_READ -> value.chapters.toString() CHAPTERS_LEFT -> "-" + value.chaptersLeft.toString() } } } init { val ta = context.obtainStyledAttributes(attrs, R.styleable.ReadingProgressView, defStyleAttr, 0) drawableStyle = ta.getResourceId(R.styleable.ReadingProgressView_progressStyle, R.style.ProgressDrawable) ta.recycle() outlineProvider = OutlineProvider() if (isInEditMode) { progress = ReadingProgress( percent = 0.27f, totalChapters = 20, mode = PERCENT_READ, ) } } override fun onDetachedFromWindow() { super.onDetachedFromWindow() percentAnimator?.run { if (isRunning) end() } percentAnimator = null } override fun onAnimationUpdate(animation: ValueAnimator) { val p = animation.animatedValue as Float getProgressDrawable().percent = p } override fun onAnimationStart(animation: Animator) = Unit override fun onAnimationEnd(animation: Animator) { if (percentAnimator === animation) { percentAnimator = null } } override fun onAnimationCancel(animation: Animator) = Unit override fun onAnimationRepeat(animation: Animator) = Unit fun setProgress(percent: Float, animate: Boolean) { setProgress( value = ReadingProgress(percent, 1, PERCENT_READ), animate = animate, ) } fun setProgress(value: ReadingProgress?, animate: Boolean) { val currentDrawable = peekProgressDrawable() if (!animate || currentDrawable == null || value == null) { progress = value return } percentAnimator?.cancel() val currentPercent = currentDrawable.percent.coerceAtLeast(0f) progress = value.copy(percent = currentPercent) percentAnimator = ValueAnimator.ofFloat( currentDrawable.percent.coerceAtLeast(0f), value.percent, ).apply { duration = animationDuration interpolator = AccelerateDecelerateInterpolator() addUpdateListener(this@ReadingProgressView) addListener(this@ReadingProgressView) start() } } private fun cancelAnimation() { percentAnimator?.cancel() percentAnimator = null } private fun peekProgressDrawable(): ReadingProgressDrawable? { return background as? ReadingProgressDrawable } private fun getProgressDrawable(): ReadingProgressDrawable { var d = peekProgressDrawable() if (d != null) { return d } d = ReadingProgressDrawable(context, drawableStyle) background = d return d } private class OutlineProvider : ViewOutlineProvider() { override fun getOutline(view: View, outline: Outline) { outline.setOval(0, 0, view.width, view.height) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/image/ui/CoverImageView.kt ================================================ package org.koitharu.kotatsu.image.ui import android.content.Context import android.graphics.drawable.LayerDrawable import android.util.AttributeSet import android.view.Gravity import android.view.ViewGroup import android.view.ViewTreeObserver import android.view.ViewTreeObserver.OnPreDrawListener import androidx.annotation.AttrRes import androidx.core.content.ContextCompat import androidx.core.content.withStyledAttributes import androidx.core.graphics.ColorUtils import androidx.core.graphics.drawable.toDrawable import coil3.network.HttpException import coil3.request.ErrorResult import coil3.request.ImageRequest import coil3.request.SuccessResult import coil3.request.transformations import coil3.size.Dimension import coil3.size.Size import coil3.size.ViewSizeResolver import kotlinx.coroutines.suspendCancellableCoroutine import okio.FileNotFoundException import org.jsoup.HttpStatusException import org.koitharu.kotatsu.R import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException import org.koitharu.kotatsu.core.image.CoilImageView import org.koitharu.kotatsu.core.ui.image.AnimatedPlaceholderDrawable import org.koitharu.kotatsu.core.ui.image.TextDrawable import org.koitharu.kotatsu.core.ui.image.TrimTransformation import org.koitharu.kotatsu.core.util.ext.bookmarkExtra import org.koitharu.kotatsu.core.util.ext.decodeRegion import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.isNetworkError import org.koitharu.kotatsu.core.util.ext.mangaExtra import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.favourites.domain.model.Cover import org.koitharu.kotatsu.parsers.exception.ContentUnavailableException import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import kotlin.coroutines.resume import androidx.appcompat.R as appcompatR import com.google.android.material.R as materialR class CoverImageView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = R.attr.coverImageViewStyle, ) : CoilImageView(context, attrs, defStyleAttr) { private var aspectRationHeight: Int = 0 private var aspectRationWidth: Int = 0 var trimImage: Boolean = false private val hasAspectRatio: Boolean get() = aspectRationHeight > 0 && aspectRationWidth > 0 init { context.withStyledAttributes(attrs, R.styleable.CoverImageView, defStyleAttr) { aspectRationHeight = getInt(R.styleable.CoverImageView_aspectRationHeight, aspectRationHeight) aspectRationWidth = getInt(R.styleable.CoverImageView_aspectRationWidth, aspectRationWidth) trimImage = getBoolean(R.styleable.CoverImageView_trimImage, trimImage) } if (placeholderDrawable == null) { placeholderDrawable = AnimatedPlaceholderDrawable(context) } if (errorDrawable == null) { errorDrawable = ColorUtils.blendARGB( context.getThemeColor(materialR.attr.colorErrorContainer), context.getThemeColor(appcompatR.attr.colorBackgroundFloating), 0.25f, ).toDrawable() } if (fallbackDrawable == null) { fallbackDrawable = context.getThemeColor(materialR.attr.colorSurfaceContainer).toDrawable() } addImageRequestListener(ErrorForegroundListener()) } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) if (!hasAspectRatio) { return } val isExactWidth = MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY val isExactHeight = MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY when { isExactHeight && isExactWidth -> Unit isExactHeight -> setMeasuredDimension( /* measuredWidth = */ measuredHeight * aspectRationWidth / aspectRationHeight, /* measuredHeight = */ measuredHeight, ) isExactWidth -> setMeasuredDimension( /* measuredWidth = */ measuredWidth, /* measuredHeight = */ measuredWidth * aspectRationHeight / aspectRationWidth, ) } } fun setImageAsync(page: ReaderPage) = enqueueRequest( newRequestBuilder() .data(page.toMangaPage()) .mangaSourceExtra(page.source) .build(), ) fun setImageAsync(page: MangaPage) = enqueueRequest( newRequestBuilder() .data(page) .mangaSourceExtra(page.source) .build(), ) fun setImageAsync(cover: Cover?) = enqueueRequest( newRequestBuilder() .data(cover?.url) .mangaSourceExtra(cover?.mangaSource) .build(), ) fun setImageAsync( coverUrl: String?, manga: Manga?, ) = enqueueRequest( newRequestBuilder() .data(coverUrl) .mangaExtra(manga) .build(), ) fun setImageAsync( coverUrl: String?, source: MangaSource, ) = enqueueRequest( newRequestBuilder() .data(coverUrl) .mangaSourceExtra(source) .build(), ) fun setImageAsync( bookmark: Bookmark ) = enqueueRequest( newRequestBuilder() .data(bookmark.toMangaPage()) .decodeRegion(bookmark.scroll) .bookmarkExtra(bookmark) .build(), ) override fun newRequestBuilder() = super.newRequestBuilder().apply { if (trimImage) { transformations(listOf(TrimTransformation())) } if (hasAspectRatio) { size(CoverSizeResolver(this@CoverImageView)) } } private inner class ErrorForegroundListener : ImageRequest.Listener { override fun onSuccess(request: ImageRequest, result: SuccessResult) { super.onSuccess(request, result) foreground = null } override fun onCancel(request: ImageRequest) { super.onCancel(request) foreground = null } override fun onStart(request: ImageRequest) { super.onStart(request) foreground = null } override fun onError(request: ImageRequest, result: ErrorResult) { super.onError(request, result) foreground = if (result.throwable.isNetworkError() && !networkState.isOnline()) { ContextCompat.getDrawable(context, R.drawable.ic_offline)?.let { LayerDrawable(arrayOf(it)).apply { setLayerGravity(0, Gravity.CENTER) setTint(ContextCompat.getColor(context, R.color.dim_lite)) } } } else { result.throwable.getShortMessage()?.let { text -> TextDrawable.create(context, text, materialR.attr.textAppearanceTitleSmall) } } } private fun Throwable.getShortMessage(): String? = when (this) { is HttpException -> response.code.toString() is HttpStatusException -> statusCode.toString() is ContentUnavailableException, is FileNotFoundException -> "404" is TooManyRequestExceptions -> "429" is ParseException -> "" is UnsupportedSourceException -> "X" is CloudFlareProtectedException -> "?" else -> cause?.getShortMessage() } } private class CoverSizeResolver( override val view: CoverImageView, ) : ViewSizeResolver { override suspend fun size(): Size { // Fast path: the view is already measured. getSize()?.let { return it } // Slow path: wait for the view to be measured. return suspendCancellableCoroutine { continuation -> val viewTreeObserver = view.viewTreeObserver val preDrawListener = object : OnPreDrawListener { private var isResumed = false override fun onPreDraw(): Boolean { val size = getSize() if (size != null) { viewTreeObserver.removePreDrawListenerSafe(this) if (!isResumed) { isResumed = true continuation.resume(size) } } return true } } viewTreeObserver.addOnPreDrawListener(preDrawListener) continuation.invokeOnCancellation { viewTreeObserver.removePreDrawListenerSafe(preDrawListener) } } } private fun getSize(): Size? { var width = getWidth() var height = getHeight() when { width == null && height == null -> { return null } height == null -> { height = Dimension(width!!.px * view.aspectRationHeight / view.aspectRationWidth) } width == null -> { width = Dimension(height.px * view.aspectRationWidth / view.aspectRationHeight) } } return Size(width, height) } private fun getWidth() = getDimension( paramSize = view.layoutParams?.width ?: -1, viewSize = view.width, paddingSize = if (subtractPadding) view.paddingLeft + view.paddingRight else 0, ) private fun getHeight() = getDimension( paramSize = view.layoutParams?.height ?: -1, viewSize = view.height, paddingSize = if (subtractPadding) view.paddingTop + view.paddingBottom else 0, ) private fun getDimension(paramSize: Int, viewSize: Int, paddingSize: Int): Dimension.Pixels? { if (paramSize == ViewGroup.LayoutParams.WRAP_CONTENT) { return null } val insetParamSize = paramSize - paddingSize if (insetParamSize > 0) { return Dimension(insetParamSize) } val insetViewSize = viewSize - paddingSize if (insetViewSize > 0) { return Dimension(insetViewSize) } return null } private fun ViewTreeObserver.removePreDrawListenerSafe(victim: OnPreDrawListener) { if (isAlive) { removeOnPreDrawListener(victim) } else { view.viewTreeObserver.removeOnPreDrawListener(victim) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/image/ui/CoverStackView.kt ================================================ package org.koitharu.kotatsu.image.ui import android.content.Context import android.content.res.ColorStateList import android.util.AttributeSet import android.view.LayoutInflater import androidx.annotation.AttrRes import androidx.annotation.Px import androidx.core.content.withStyledAttributes import androidx.core.graphics.ColorUtils import androidx.core.view.children import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.widget.ImageViewCompat import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.UnknownMangaSource import org.koitharu.kotatsu.core.ui.widgets.StackLayout import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.databinding.ViewCoverStackBinding import org.koitharu.kotatsu.favourites.domain.model.Cover import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource class CoverStackView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0, ) : StackLayout(context, attrs, defStyleAttr) { private val binding = ViewCoverStackBinding.inflate(LayoutInflater.from(context), this) private val coverViews = arrayOf( binding.imageViewCover1, binding.imageViewCover2, binding.imageViewCover3, ) private var hideEmptyView: Boolean = false init { context.withStyledAttributes(attrs, R.styleable.CoverStackView, defStyleAttr) { hideEmptyView = getBoolean(R.styleable.CoverStackView_hideEmptyViews, hideEmptyView) children.forEach { it.isGone = hideEmptyView } val coverSize = getDimension(R.styleable.CoverStackView_coverSize, 0f) if (coverSize > 0f) { setCoverSize(coverSize) } } val backgroundColor = context.getThemeColor(android.R.attr.colorBackground) ImageViewCompat.setImageTintList( binding.imageViewCover3, ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 153)), ) ImageViewCompat.setImageTintList( binding.imageViewCover2, ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 76)), ) binding.imageViewCover2.backgroundTintList = ColorStateList.valueOf( ColorUtils.setAlphaComponent(backgroundColor, 76), ) binding.imageViewCover3.backgroundTintList = ColorStateList.valueOf( ColorUtils.setAlphaComponent(backgroundColor, 153), ) coverViews.forEachIndexed { index, view -> view.crossfadeDurationFactor = index + 1f } } fun setCoversAsync(covers: List) { coverViews.forEachIndexed { index, view -> view.setImageAsync(covers.getOrNull(index)) } } @JvmName("setMangaCoversAsync") fun setCoversAsync(manga: List) { coverViews.forEachIndexed { index, view -> val m = manga.getOrNull(index) view.setCoverOrHide(m?.coverUrl, m, m?.source) } } fun setCoverSize(@Px coverSize: Float) { val coverWidth = (coverSize * 13f).toInt() val coverHeight = (coverSize * 18f).toInt() children.forEach { it.updateLayoutParams { width = coverWidth height = coverHeight } } } private fun CoverImageView.setCoverOrHide(url: String?, manga: Manga?, source: MangaSource?) { if (url.isNullOrEmpty() && hideEmptyView) { disposeImage() isVisible = false } else { isVisible = true if (manga != null) { setImageAsync(url, manga) } else { setImageAsync(url, source ?: UnknownMangaSource) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageActivity.kt ================================================ package org.koitharu.kotatsu.image.ui import android.graphics.drawable.Drawable import android.net.Uri import android.os.Bundle import android.view.View import android.view.ViewGroup import androidx.activity.viewModels import androidx.core.graphics.drawable.toBitmap import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.swiperefreshlayout.widget.CircularProgressDrawable import coil3.ImageLoader import coil3.request.CachePolicy import coil3.request.ErrorResult import coil3.request.ImageRequest import coil3.request.SuccessResult import coil3.request.lifecycle import coil3.target.GenericViewTarget import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView 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.image.CoilMemoryCacheKey import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.util.PopupMenuMediator import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ext.consumeAll import org.koitharu.kotatsu.core.util.ext.end import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.getDisplayIcon import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.start import org.koitharu.kotatsu.databinding.ActivityImageBinding import org.koitharu.kotatsu.databinding.ItemErrorStateBinding import javax.inject.Inject import androidx.appcompat.R as appcompatR @AndroidEntryPoint class ImageActivity : BaseActivity(), ImageRequest.Listener, View.OnClickListener { @Inject lateinit var coil: ImageLoader private var errorBinding: ItemErrorStateBinding? = null private val viewModel: ImageViewModel by viewModels() private lateinit var menuMediator: PopupMenuMediator override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityImageBinding.inflate(layoutInflater)) viewBinding.buttonBack.setOnClickListener(this) viewBinding.buttonMenu.setOnClickListener(this) val menuProvider = ImageMenuProvider( activity = this, snackbarHost = viewBinding.root, viewModel = viewModel, ) menuMediator = PopupMenuMediator(menuProvider) viewModel.isLoading.observe(this, ::onLoadingStateChanged) viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.root, null)) viewModel.onImageSaved.observeEvent(this, ::onImageSaved) loadImage() } override fun onClick(v: View) { when (v.id) { R.id.button_back -> dispatchNavigateUp() R.id.button_menu -> menuMediator.onLongClick(v) else -> loadImage() } } override fun onError(request: ImageRequest, result: ErrorResult) { viewBinding.progressBar.hide() with(errorBinding ?: ItemErrorStateBinding.bind(viewBinding.stubError.inflate())) { errorBinding = this root.isVisible = true textViewError.text = result.throwable.getDisplayMessage(resources) textViewError.setCompoundDrawablesWithIntrinsicBounds(0, result.throwable.getDisplayIcon(), 0, 0) buttonRetry.isVisible = true buttonRetry.setOnClickListener(this@ImageActivity) } } override fun onStart(request: ImageRequest) { viewBinding.progressBar.show() (errorBinding?.root ?: viewBinding.stubError).isVisible = false } override fun onSuccess(request: ImageRequest, result: SuccessResult) { viewBinding.progressBar.hide() (errorBinding?.root ?: viewBinding.stubError).isVisible = false } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val typeMask = WindowInsetsCompat.Type.systemBars() val barsInsets = insets.getInsets(typeMask) val baseMargin = v.resources.getDimensionPixelOffset(R.dimen.screen_padding) viewBinding.buttonMenu.updateLayoutParams { marginEnd = barsInsets.end(v) + baseMargin topMargin = barsInsets.top + baseMargin } viewBinding.buttonBack.updateLayoutParams { marginStart = barsInsets.start(v) + baseMargin topMargin = barsInsets.top + baseMargin } return insets.consumeAll(typeMask) } private fun loadImage() { ImageRequest.Builder(this) .data(intent.data) .memoryCacheKey(intent.getParcelableExtraCompat(AppRouter.KEY_PREVIEW)?.data) .memoryCachePolicy(CachePolicy.READ_ONLY) .lifecycle(this) .listener(this) .mangaSourceExtra(MangaSource(intent.getStringExtra(AppRouter.KEY_SOURCE))) .target(SsivTarget(viewBinding.ssiv)) .enqueueWith(coil) } private fun onImageSaved(uri: Uri) { Snackbar.make(viewBinding.root, R.string.page_saved, Snackbar.LENGTH_LONG) .setAction(R.string.share) { ShareHelper(this).shareImage(uri) }.show() } private fun onLoadingStateChanged(isLoading: Boolean) { val button = viewBinding.buttonMenu button.isClickable = !isLoading if (isLoading) { button.setImageDrawable( CircularProgressDrawable(this).also { it.setStyle(CircularProgressDrawable.LARGE) it.setColorSchemeColors(getThemeColor(appcompatR.attr.colorControlNormal)) it.start() }, ) } else { button.setImageResource(appcompatR.drawable.abc_ic_menu_overflow_material) } } private class SsivTarget( override val view: SubsamplingScaleImageView, ) : GenericViewTarget() { override var drawable: Drawable? = null set(value) { field = value setImageDrawable(value) } override fun equals(other: Any?): Boolean { return (this === other) || (other is SsivTarget && view == other.view) } override fun hashCode() = view.hashCode() override fun toString() = "SsivTarget(view=$view)" private fun setImageDrawable(drawable: Drawable?) { if (drawable != null) { view.setImage(ImageSource.bitmap(drawable.toBitmap())) } else { view.recycle() } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageMenuProvider.kt ================================================ package org.koitharu.kotatsu.image.ui import android.Manifest import android.os.Build import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.activity.ComponentActivity import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.MenuProvider import com.google.android.material.snackbar.Snackbar import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.isZipUri import org.koitharu.kotatsu.core.util.ext.tryLaunch class ImageMenuProvider( private val activity: ComponentActivity, private val snackbarHost: View, private val viewModel: ImageViewModel, ) : MenuProvider { private val permissionLauncher = activity.registerForActivityResult( ActivityResultContracts.RequestPermission(), ) { isGranted -> if (isGranted) { saveImage() } } private val saveLauncher = activity.registerForActivityResult( ActivityResultContracts.CreateDocument("image/png"), ) { uri -> if (uri != null) { viewModel.saveImage(uri) } } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.opt_image, menu) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { R.id.action_save -> { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { permissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) } else { saveImage() } true } else -> false } private fun saveImage() { val name = activity.intent.data?.let { if (it.isZipUri()) { it.fragment } else { it.lastPathSegment }?.substringBeforeLast('.')?.plus(".png") } if (name == null || !saveLauncher.tryLaunch(name)) { Snackbar.make(snackbarHost, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageViewModel.kt ================================================ package org.koitharu.kotatsu.image.ui import android.content.Context import android.graphics.Bitmap import android.net.Uri import androidx.core.graphics.drawable.toBitmap import androidx.lifecycle.SavedStateHandle import coil3.ImageLoader import coil3.request.CachePolicy import coil3.request.ImageRequest import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import org.koitharu.kotatsu.core.model.MangaSource 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.getDrawableOrThrow import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.require import javax.inject.Inject @HiltViewModel class ImageViewModel @Inject constructor( @ApplicationContext private val context: Context, private val savedStateHandle: SavedStateHandle, private val coil: ImageLoader, ) : BaseViewModel() { val onImageSaved = MutableEventFlow() fun saveImage(destination: Uri) { launchLoadingJob(Dispatchers.Default) { val request = ImageRequest.Builder(context) .memoryCachePolicy(CachePolicy.READ_ONLY) .data(savedStateHandle.require(AppRouter.KEY_DATA)) .memoryCachePolicy(CachePolicy.DISABLED) .mangaSourceExtra(MangaSource(savedStateHandle[AppRouter.KEY_SOURCE])) .build() val bitmap = coil.execute(request).getDrawableOrThrow().toBitmap() runInterruptible(Dispatchers.IO) { context.contentResolver.openOutputStream(destination)?.use { output -> check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)) } ?: error("Cannot open output stream") } onImageSaved.call(destination) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListFilterOption.kt ================================================ package org.koitharu.kotatsu.list.domain import androidx.annotation.DrawableRes import androidx.annotation.StringRes import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.unwrap import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag sealed interface ListFilterOption { @get:StringRes val titleResId: Int @get:DrawableRes val iconResId: Int val titleText: CharSequence? val groupKey: String fun getIconData(): Any? = null data object Downloaded : ListFilterOption { override val titleResId: Int get() = R.string.on_device override val iconResId: Int get() = R.drawable.ic_storage override val titleText: CharSequence? get() = null override val groupKey: String get() = "_downloaded" } enum class Macro( @StringRes override val titleResId: Int, @DrawableRes override val iconResId: Int, ) : ListFilterOption { COMPLETED(R.string.status_completed, R.drawable.ic_state_finished), NEW_CHAPTERS(R.string.new_chapters, R.drawable.ic_updated), FAVORITE(R.string.favourites, R.drawable.ic_heart_outline), NSFW(R.string.nsfw, R.drawable.ic_nsfw), ; override val titleText: CharSequence? get() = null override val groupKey: String get() = name } data class Branch( override val titleText: String?, val chaptersCount: Int, ) : ListFilterOption { override val titleResId: Int get() = if (titleText == null) R.string.system_default else 0 override val iconResId: Int get() = R.drawable.ic_language override val groupKey: String get() = "_branch" } data class Tag( val tag: MangaTag ) : ListFilterOption { val tagId: Long = tag.toEntity().id override val titleResId: Int get() = 0 override val iconResId: Int get() = R.drawable.ic_tag override val titleText: String get() = tag.title override val groupKey: String get() = "_tag" } data class Favorite( val category: FavouriteCategory ) : ListFilterOption { override val titleResId: Int get() = 0 override val iconResId: Int get() = R.drawable.ic_heart_outline override val titleText: String get() = category.title override val groupKey: String get() = "_favcat" } data class Source( val mangaSource: MangaSource ) : ListFilterOption { override val titleResId: Int get() = when (mangaSource.unwrap()) { is ExternalMangaSource -> R.string.external_source LocalMangaSource -> R.string.local_storage else -> 0 } override val iconResId: Int get() = R.drawable.ic_web override val titleText: CharSequence? get() = when (val source = mangaSource.unwrap()) { is MangaParserSource -> source.title else -> null } override val groupKey: String get() = "_source" override fun getIconData() = mangaSource.faviconUri() } data class Inverted( val option: ListFilterOption, override val iconResId: Int, override val titleResId: Int, override val titleText: CharSequence?, ) : ListFilterOption { override val groupKey: String get() = "_inv" + option.groupKey } companion object { val SFW get() = Inverted( option = Macro.NSFW, iconResId = R.drawable.ic_sfw, titleResId = R.string.sfw, titleText = null, ) val NOT_FAVORITE get() = Inverted( option = Macro.FAVORITE, iconResId = R.drawable.ic_heart_off, titleResId = R.string.not_in_favorites, titleText = null, ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListSortOrder.kt ================================================ package org.koitharu.kotatsu.list.domain import androidx.annotation.StringRes import org.koitharu.kotatsu.R import org.koitharu.kotatsu.parsers.util.find import java.util.EnumSet enum class ListSortOrder( @StringRes val titleResId: Int, ) { NEWEST(R.string.order_added), OLDEST(R.string.order_oldest), PROGRESS(R.string.progress), UNREAD(R.string.unread), ALPHABETIC(R.string.by_name), ALPHABETIC_REVERSE(R.string.by_name_reverse), RATING(R.string.by_rating), RELEVANCE(R.string.by_relevance), NEW_CHAPTERS(R.string.new_chapters), LAST_READ(R.string.last_read), LONG_AGO_READ(R.string.long_ago_read), UPDATED(R.string.updated), ; fun isGroupingSupported() = this == LAST_READ || this == NEWEST || this == PROGRESS companion object { val HISTORY: Set = EnumSet.of( LAST_READ, LONG_AGO_READ, NEWEST, OLDEST, PROGRESS, UNREAD, ALPHABETIC, ALPHABETIC_REVERSE, NEW_CHAPTERS, UPDATED, ) val FAVORITES: Set = EnumSet.of( ALPHABETIC, ALPHABETIC_REVERSE, NEWEST, OLDEST, RATING, NEW_CHAPTERS, PROGRESS, UNREAD, LAST_READ, LONG_AGO_READ, UPDATED, ) val SUGGESTIONS: Set = EnumSet.of(RELEVANCE) operator fun invoke(value: String, fallback: ListSortOrder) = entries.find(value) ?: fallback } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/domain/MangaListMapper.kt ================================================ package org.koitharu.kotatsu.list.domain import android.annotation.SuppressLint import android.content.Context import androidx.annotation.ColorRes import androidx.annotation.IntDef import androidx.collection.MutableScatterSet import androidx.collection.ScatterSet import dagger.Reusable import dagger.hilt.android.qualifiers.ApplicationContext import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.ui.model.MangaOverride import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.list.ui.model.MangaCompactListModel import org.koitharu.kotatsu.list.ui.model.MangaDetailedListModel import org.koitharu.kotatsu.list.ui.model.MangaGridModel import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.local.data.index.LocalMangaIndex import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem import org.koitharu.kotatsu.tracker.ui.feed.model.FeedItem import javax.inject.Inject @Reusable class MangaListMapper @Inject constructor( @ApplicationContext context: Context, private val settings: AppSettings, private val trackingRepository: TrackingRepository, private val historyRepository: HistoryRepository, private val favouritesRepository: FavouritesRepository, private val localMangaIndex: LocalMangaIndex, private val dataRepository: MangaDataRepository, ) { private val dict by lazy { readTagsDict(context) } suspend fun toListModelList( manga: Collection, mode: ListMode, @Flags flags: Int = DEFAULTS, ): List = ArrayList(manga.size).apply { toListModelList( destination = this, manga = manga, mode = mode, flags = flags, ) } suspend fun toListModelList( destination: MutableCollection, manga: Collection, mode: ListMode, @Flags flags: Int = DEFAULTS, ) { val options = getOptions(flags) val overrides = dataRepository.getOverrides() manga.mapTo(destination) { toListModelImpl(it, mode, options, overrides[it.id]) } } suspend fun toListModel( manga: Manga, mode: ListMode, @Flags flags: Int = DEFAULTS, ): MangaListModel = toListModelImpl( manga = manga, mode = mode, options = getOptions(flags), override = dataRepository.getOverride(manga.id), ) suspend fun toFeedItem(logItem: TrackingLogItem) = FeedItem( id = logItem.id, override = dataRepository.getOverride(logItem.manga.id), count = logItem.chapters.size, manga = logItem.manga, isNew = logItem.isNew, ) fun mapTags(tags: Collection) = tags.map { ChipsView.ChipModel( tint = getTagTint(it), title = it.title, data = it, ) } private suspend fun toCompactListModel( manga: Manga, @Options options: Int, override: MangaOverride?, ) = MangaCompactListModel( manga = manga, override = override, subtitle = manga.tags.joinToString(", ") { it.title }, counter = getCounter(manga.id, options), ) private suspend fun toDetailedListModel( manga: Manga, @Options options: Int, override: MangaOverride?, ) = MangaDetailedListModel( subtitle = manga.altTitles.firstOrNull(), manga = manga, override = override, counter = getCounter(manga.id, options), progress = getProgress(manga.id, options), isFavorite = isFavorite(manga.id, options), isSaved = isSaved(manga.id, options), tags = mapTags(manga.tags), ) private suspend fun toGridModel( manga: Manga, @Options options: Int, override: MangaOverride? ) = MangaGridModel( manga = manga, override = override, counter = getCounter(manga.id, options), progress = getProgress(manga.id, options), isFavorite = isFavorite(manga.id, options), isSaved = isSaved(manga.id, options), ) private suspend fun toListModelImpl( manga: Manga, mode: ListMode, @Options options: Int, override: MangaOverride?, ): MangaListModel = when (mode) { ListMode.LIST -> toCompactListModel(manga, options, override) ListMode.DETAILED_LIST -> toDetailedListModel(manga, options, override) ListMode.GRID -> toGridModel(manga, options, override) } private suspend fun getCounter(mangaId: Long, @Options options: Int): Int { return if (settings.isTrackerEnabled) { trackingRepository.getNewChaptersCount(mangaId) } else { 0 } } private suspend fun getProgress(mangaId: Long, @Options options: Int): ReadingProgress? { return if (options.isBadgeEnabled(PROGRESS)) { historyRepository.getProgress(mangaId, settings.progressIndicatorMode) } else { null } } private suspend fun isFavorite(mangaId: Long, @Options options: Int): Boolean { return options.isBadgeEnabled(FAVORITE) && favouritesRepository.isFavorite(mangaId) } private suspend fun isSaved(mangaId: Long, @Options options: Int): Boolean { return options.isBadgeEnabled(SAVED) && mangaId in localMangaIndex } @ColorRes private fun getTagTint(tag: MangaTag): Int { return if (settings.isTagsWarningsEnabled && tag.title.lowercase() in dict) { R.color.warning } else { 0 } } private fun readTagsDict(context: Context): ScatterSet = context.resources.openRawResource(R.raw.tags_warnlist).use { val set = MutableScatterSet() it.bufferedReader().forEachLine { x -> val line = x.trim() if (line.isNotEmpty()) { set.add(line) } } set.trim() set } private fun Int.isBadgeEnabled(@Options badge: Int) = this and badge == badge @Options @SuppressLint("WrongConstant") private fun getOptions(@Flags flags: Int): Int { var options = settings.getMangaListBadges() or PROGRESS options = options and flags.inv() return options } @IntDef(DEFAULTS, NO_SAVED, NO_PROGRESS, NO_FAVORITE, flag = true) @Retention(AnnotationRetention.SOURCE) annotation class Flags @IntDef(NONE, SAVED, FAVORITE, PROGRESS) @Retention(AnnotationRetention.SOURCE) private annotation class Options companion object { private const val NONE = 0 private const val SAVED = 1 private const val PROGRESS = 2 private const val FAVORITE = 4 const val DEFAULTS = NONE const val NO_SAVED = SAVED const val NO_PROGRESS = PROGRESS const val NO_FAVORITE = FAVORITE } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/domain/MangaListQuickFilter.kt ================================================ package org.koitharu.kotatsu.list.domain import androidx.collection.ArraySet import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import org.koitharu.kotatsu.core.model.toChipModel import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.list.ui.model.QuickFilter import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrNull import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy abstract class MangaListQuickFilter( private val settings: AppSettings, ) : QuickFilterListener { private val appliedFilter = MutableStateFlow>(emptySet()) private val availableFilterOptions = suspendLazy { getAvailableFilterOptions() } val appliedOptions get() = appliedFilter.asStateFlow() override fun setFilterOption(option: ListFilterOption, isApplied: Boolean) { appliedFilter.value = ArraySet(appliedFilter.value).also { if (isApplied) { it.addNoConflicts(option) } else { it.remove(option) } } } override fun toggleFilterOption(option: ListFilterOption) { appliedFilter.value = ArraySet(appliedFilter.value).also { if (option in it) { it.remove(option) } else { it.addNoConflicts(option) } } } override fun clearFilter() { appliedFilter.value = emptySet() } suspend fun filterItem( selectedOptions: Set, ): QuickFilter? { if (!settings.isQuickFilterEnabled) { return null } val availableOptions = availableFilterOptions.getOrNull()?.map { option -> option.toChipModel(isChecked = option in selectedOptions) }.orEmpty() return if (availableOptions.isNotEmpty()) { QuickFilter(availableOptions) } else { null } } protected abstract suspend fun getAvailableFilterOptions(): List private fun ArraySet.addNoConflicts(option: ListFilterOption) { add(option) if (option is ListFilterOption.Inverted) { remove(option.option) } else { removeIf { it is ListFilterOption.Inverted && it.option == option } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/domain/QuickFilterListener.kt ================================================ package org.koitharu.kotatsu.list.domain interface QuickFilterListener { fun setFilterOption(option: ListFilterOption, isApplied: Boolean) fun toggleFilterOption(option: ListFilterOption) fun clearFilter() } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ReadingProgress.kt ================================================ package org.koitharu.kotatsu.list.domain import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.CHAPTERS_LEFT import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.CHAPTERS_READ import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.NONE import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.PERCENT_LEFT import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.PERCENT_READ data class ReadingProgress( val percent: Float, val totalChapters: Int, val mode: ProgressIndicatorMode, ) { val percentLeft: Float get() = 1f - percent val chapters: Int get() = (totalChapters * percent).toInt() val chaptersLeft: Int get() = (totalChapters * percentLeft).toInt() fun isValid() = when (mode) { NONE -> false PERCENT_READ, PERCENT_LEFT -> percent in 0f..1f CHAPTERS_READ, CHAPTERS_LEFT -> totalChapters > 0 && percent in 0f..1f } fun isCompleted() = isCompleted(percent) fun isReversed() = mode == PERCENT_LEFT || mode == CHAPTERS_LEFT companion object { const val PROGRESS_NONE = -1f const val PROGRESS_COMPLETED = 1f private const val PROGRESS_COMPLETED_THRESHOLD = 0.99999f fun isValid(percent: Float) = percent in 0f..1f fun isCompleted(percent: Float) = percent >= PROGRESS_COMPLETED_THRESHOLD fun percentToString(percent: Float): String = if (isValid(percent)) { if (isCompleted(percent)) "100" else (percent * 100f).toInt().toString() } else { "0" } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/GridSpanResolver.kt ================================================ package org.koitharu.kotatsu.list.ui import android.content.res.Resources import android.view.View import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import org.koitharu.kotatsu.R import kotlin.math.abs import kotlin.math.roundToInt class GridSpanResolver( resources: Resources, ) : View.OnLayoutChangeListener { var spanCount = 3 private set private val gridWidth = resources.getDimension(R.dimen.preferred_grid_width) private val spacing = resources.getDimension(R.dimen.grid_spacing) private var cellWidth = -1f override fun onLayoutChange( v: View?, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int, ) { if (cellWidth <= 0f) { return } val rv = v as? RecyclerView ?: return val width = abs(right - left) if (width == 0) { return } resolveGridSpanCount(width) (rv.layoutManager as? GridLayoutManager)?.spanCount = spanCount } fun setGridSize(scaleFactor: Float, rv: RecyclerView) { cellWidth = (gridWidth * scaleFactor) + spacing val lm = rv.layoutManager as? GridLayoutManager ?: return val innerWidth = lm.width - lm.paddingEnd - lm.paddingStart if (innerWidth > 0) { resolveGridSpanCount(innerWidth) lm.spanCount = spanCount } } private fun resolveGridSpanCount(width: Int) { val estimatedCount = (width / cellWidth).roundToInt() spanCount = estimatedCount.coerceAtLeast(2) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/ListModelDiffCallback.kt ================================================ package org.koitharu.kotatsu.list.ui import androidx.recyclerview.widget.DiffUtil import org.koitharu.kotatsu.list.ui.model.ListModel open class ListModelDiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: T, newItem: T): Boolean { return oldItem.areItemsTheSame(newItem) } override fun areContentsTheSame(oldItem: T, newItem: T): Boolean { return oldItem == newItem } override fun getChangePayload(oldItem: T, newItem: T): Any? { return newItem.getChangePayload(oldItem) } companion object : ListModelDiffCallback() { val PAYLOAD_CHECKED_CHANGED = Any() val PAYLOAD_NESTED_LIST_CHANGED = Any() val PAYLOAD_PROGRESS_CHANGED = Any() val PAYLOAD_ANYTHING_CHANGED = Any() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt ================================================ package org.koitharu.kotatsu.list.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 androidx.annotation.CallSuper import androidx.appcompat.view.ActionMode import androidx.collection.ArraySet import androidx.core.view.WindowInsetsCompat import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.koitharu.kotatsu.R import org.koitharu.kotatsu.alternatives.ui.AutoFixService import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.ui.list.FitHeightGridLayoutManager import org.koitharu.kotatsu.core.ui.list.FitHeightLinearLayoutManager import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.PaginationScrollListener import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.ui.widgets.TipView import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.consumeAll 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.viewLifecycleScope import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.QuickFilterListener import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter import org.koitharu.kotatsu.list.ui.adapter.MangaListListener import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.search.ui.MangaListActivity import javax.inject.Inject @AndroidEntryPoint abstract class MangaListFragment : BaseFragment(), PaginationScrollListener.Callback, MangaListListener, RecyclerViewOwner, SwipeRefreshLayout.OnRefreshListener, ListSelectionController.Callback, FastScroller.FastScrollListener { @Inject lateinit var coil: ImageLoader @Inject lateinit var settings: AppSettings private var listAdapter: MangaListAdapter? = null private var paginationListener: PaginationScrollListener? = null private var selectionController: ListSelectionController? = null private var spanResolver: GridSpanResolver? = null private val spanSizeLookup = SpanSizeLookup() open val isSwipeRefreshEnabled = true protected abstract val viewModel: MangaListViewModel protected val selectedItemsIds: Set get() = selectionController?.snapshot().orEmpty() protected val selectedItems: Set get() = collectSelectedItems() override val recyclerView: RecyclerView? get() = viewBinding?.recyclerView override fun onCreateViewBinding( inflater: LayoutInflater, container: ViewGroup?, ) = FragmentListBinding.inflate(inflater, container, false) override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) listAdapter = onCreateAdapter() spanResolver = GridSpanResolver(binding.root.resources) selectionController = ListSelectionController( appCompatDelegate = checkNotNull(findAppCompatDelegate()), decoration = MangaSelectionDecoration(binding.root.context), registryOwner = this, callback = this, ) paginationListener = PaginationScrollListener(4, this) with(binding.recyclerView) { setHasFixedSize(true) adapter = listAdapter checkNotNull(selectionController).attachToRecyclerView(this) addItemDecoration(TypedListSpacingDecoration(context, false)) addOnScrollListener(checkNotNull(paginationListener)) fastScroller.setFastScrollListener(this@MangaListFragment) } with(binding.swipeRefreshLayout) { setOnRefreshListener(this@MangaListFragment) isEnabled = isSwipeRefreshEnabled } addMenuProvider(MangaListMenuProvider(this)) viewModel.listMode.observe(viewLifecycleOwner, ::onListModeChanged) viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged) viewModel.content.observe(viewLifecycleOwner, ::onListChanged) viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val typeMask = WindowInsetsCompat.Type.systemBars() val barsInsets = insets.getInsets(typeMask) val basePadding = v.resources.getDimensionPixelOffset(R.dimen.list_spacing_normal) viewBinding?.recyclerView?.setPadding( left = barsInsets.left + basePadding, top = basePadding, right = barsInsets.right + basePadding, bottom = barsInsets.bottom + basePadding, ) return insets.consumeAll(typeMask) } override fun onDestroyView() { listAdapter = null paginationListener = null selectionController = null spanResolver = null spanSizeLookup.invalidateCache() super.onDestroyView() } override fun onItemClick(item: MangaListModel, view: View) { if (selectionController?.onItemClick(item.id) != true) { val manga = item.toMangaWithOverride() if ((activity as? MangaListActivity)?.showPreview(manga) != true) { router.openDetails(manga) } } } override fun onItemLongClick(item: MangaListModel, view: View): Boolean { return selectionController?.onItemLongClick(view, item.id) == true } override fun onItemContextClick(item: MangaListModel, view: View): Boolean { return selectionController?.onItemContextClick(view, item.id) == true } override fun onReadClick(manga: Manga, view: View) { if (selectionController?.onItemClick(manga.id) != true) { router.openReader(manga) } } override fun onTagClick(manga: Manga, tag: MangaTag, view: View) { if (selectionController?.onItemClick(manga.id) != true) { router.showTagDialog(tag) } } @CallSuper override fun onRefresh() { requireViewBinding().swipeRefreshLayout.isRefreshing = true viewModel.onRefresh() } private suspend fun onListChanged(list: List) { listAdapter?.emit(list) spanSizeLookup.invalidateCache() viewBinding?.recyclerView?.let { paginationListener?.postInvalidate(it) } } private fun resolveException(e: Throwable) { if (ExceptionResolver.canResolve(e)) { viewLifecycleScope.launch { if (exceptionResolver.resolve(e)) { viewModel.onRetry() } } } else { viewModel.onRetry() } } @CallSuper protected open fun onLoadingStateChanged(isLoading: Boolean) { requireViewBinding().swipeRefreshLayout.isEnabled = requireViewBinding().swipeRefreshLayout.isRefreshing || isSwipeRefreshEnabled && !isLoading if (!isLoading) { requireViewBinding().swipeRefreshLayout.isRefreshing = false } } protected open fun onCreateAdapter(): MangaListAdapter { return MangaListAdapter( listener = this, sizeResolver = DynamicItemSizeResolver(resources, viewLifecycleOwner, settings, adjustWidth = false), ) } override fun onFilterOptionClick(option: ListFilterOption) { selectionController?.clear() (viewModel as? QuickFilterListener)?.toggleFilterOption(option) } override fun onFilterClick(view: View?) = Unit override fun onEmptyActionClick() = Unit override fun onListHeaderClick(item: ListHeader, view: View) = Unit override fun onPrimaryButtonClick(tipView: TipView) = Unit override fun onSecondaryButtonClick(tipView: TipView) = Unit override fun onRetryClick(error: Throwable) { resolveException(error) } private fun onGridScaleChanged(scale: Float) { spanSizeLookup.invalidateCache() spanResolver?.setGridSize(scale, requireViewBinding().recyclerView) } private fun onListModeChanged(mode: ListMode) { spanSizeLookup.invalidateCache() with(requireViewBinding().recyclerView) { removeOnLayoutChangeListener(spanResolver) when (mode) { ListMode.LIST -> { layoutManager = FitHeightLinearLayoutManager(context) } ListMode.DETAILED_LIST -> { layoutManager = FitHeightLinearLayoutManager(context) } ListMode.GRID -> { layoutManager = FitHeightGridLayoutManager(context, checkNotNull(spanResolver).spanCount).also { it.spanSizeLookup = spanSizeLookup } addOnLayoutChangeListener(spanResolver) } } } } @CallSuper override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode?, menu: Menu): Boolean { val hasNoLocal = selectedItems.none { it.isLocal } val isSingleSelection = controller.count == 1 menu.findItem(R.id.action_save)?.isVisible = hasNoLocal menu.findItem(R.id.action_fix)?.isVisible = hasNoLocal menu.findItem(R.id.action_edit_override)?.isVisible = isSingleSelection return super.onPrepareActionMode(controller, mode, menu) } override fun onCreateActionMode( controller: ListSelectionController, menuInflater: MenuInflater, menu: Menu ): Boolean { return menu.hasVisibleItems() } override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean { return when (item.itemId) { R.id.action_select_all -> { val ids = listAdapter?.items?.mapNotNull { (it as? MangaListModel)?.id } ?: return false selectionController?.addAll(ids) true } R.id.action_share -> { ShareHelper(requireContext()).shareMangaLinks(selectedItems) mode?.finish() true } R.id.action_favourite -> { router.showFavoriteDialog(selectedItems) mode?.finish() true } R.id.action_save -> { router.showDownloadDialog(selectedItems, viewBinding?.recyclerView) mode?.finish() true } R.id.action_edit_override -> { router.openMangaOverrideConfig(selectedItems.singleOrNull() ?: return false) mode?.finish() true } R.id.action_fix -> { val itemsSnapshot = selectedItemsIds buildAlertDialog(context ?: return false, isCentered = true) { setTitle(item.title) setIcon(item.icon) setMessage(R.string.manga_fix_prompt) setNegativeButton(android.R.string.cancel, null) setPositiveButton(R.string.fix) { _, _ -> AutoFixService.start(context, itemsSnapshot) mode?.finish() } }.show() true } else -> false } } override fun onSelectionChanged(controller: ListSelectionController, count: Int) { viewBinding?.recyclerView?.invalidateItemDecorations() } override fun onFastScrollStart(fastScroller: FastScroller) { (activity as? AppBarOwner)?.appBar?.setExpanded(false, true) requireViewBinding().swipeRefreshLayout.isEnabled = false } override fun onFastScrollStop(fastScroller: FastScroller) { requireViewBinding().swipeRefreshLayout.isEnabled = isSwipeRefreshEnabled } private fun collectSelectedItems(): Set { val checkedIds = selectionController?.peekCheckedIds() ?: return emptySet() val items = listAdapter?.items ?: return emptySet() val result = ArraySet(checkedIds.size) for (item in items) { if (item is MangaListModel && item.id in checkedIds) { result.add(item.manga) } } return result } private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() { init { isSpanIndexCacheEnabled = true isSpanGroupIndexCacheEnabled = true } override fun getSpanSize(position: Int): Int { val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1 return when (listAdapter?.getItemViewType(position)) { ListItemType.MANGA_GRID.ordinal -> 1 else -> total } } fun invalidateCache() { invalidateSpanGroupIndexCache() invalidateSpanIndexCache() } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListMenuProvider.kt ================================================ package org.koitharu.kotatsu.list.ui import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment import org.koitharu.kotatsu.history.ui.HistoryListFragment import org.koitharu.kotatsu.list.ui.config.ListConfigSection import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment import org.koitharu.kotatsu.tracker.ui.updates.UpdatesFragment class MangaListMenuProvider( private val fragment: Fragment, ) : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.opt_list, menu) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { R.id.action_list_mode -> { val section: ListConfigSection = when (fragment) { is HistoryListFragment -> ListConfigSection.History is SuggestionsFragment -> ListConfigSection.Suggestions is FavouritesListFragment -> ListConfigSection.Favorites(fragment.categoryId) is UpdatesFragment -> ListConfigSection.Updated else -> ListConfigSection.General } fragment.router.showListConfigSheet(section) true } else -> false } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt ================================================ package org.koitharu.kotatsu.list.ui import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsStateFlow 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.list.domain.ListFilterOption import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.domain.model.LocalManga abstract class MangaListViewModel( private val settings: AppSettings, private val mangaDataRepository: MangaDataRepository, @param:LocalStorageChanges private val localStorageChanges: SharedFlow, ) : BaseViewModel() { abstract val content: StateFlow> open val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE) { listMode } .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.listMode) val onActionDone = MutableEventFlow() val gridScale = settings.observeAsStateFlow( scope = viewModelScope + Dispatchers.Default, key = AppSettings.KEY_GRID_SIZE, valueProducer = { gridSize / 100f }, ) val isIncognitoModeEnabled: Boolean get() = settings.isIncognitoModeEnabled abstract fun onRefresh() abstract fun onRetry() protected fun List.skipNsfwIfNeeded() = if (settings.isNsfwContentDisabled) { filterNot { it.isNsfw() } } else { this } protected fun Flow>.combineWithSettings(): Flow> = combine( settings.observeAsFlow(AppSettings.KEY_DISABLE_NSFW) { isNsfwContentDisabled }, ) { filters, skipNsfw -> if (skipNsfw) { filters + ListFilterOption.SFW } else { filters } } protected fun observeListModeWithTriggers(): Flow = combine( listMode, merge( mangaDataRepository.observeOverridesTrigger(emitInitialState = true), mangaDataRepository.observeFavoritesTrigger(emitInitialState = true), localStorageChanges.onStart { emit(null) }, ), settings.observeChanges().filter { key -> key == AppSettings.KEY_PROGRESS_INDICATORS || key == AppSettings.KEY_TRACKER_ENABLED || key == AppSettings.KEY_QUICK_FILTER || key == AppSettings.KEY_MANGA_LIST_BADGES }.onStart { emit("") }, ) { mode, _, _ -> mode } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt ================================================ package org.koitharu.kotatsu.list.ui import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.RectF import android.view.View import androidx.cardview.widget.CardView import androidx.core.graphics.ColorUtils import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.NO_ID import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration import org.koitharu.kotatsu.core.util.ext.getItem import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.list.ui.model.MangaListModel import androidx.appcompat.R as appcompatR import com.google.android.material.R as materialR open class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() { protected val paint = Paint(Paint.ANTI_ALIAS_FLAG) protected val strokeColor = context.getThemeColor(appcompatR.attr.colorPrimary, Color.RED) protected val fillColor = ColorUtils.setAlphaComponent( ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f), 0x74, ) protected val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner) init { hasBackground = false hasForeground = true isIncludeDecorAndMargins = false paint.strokeWidth = context.resources.getDimension(R.dimen.selection_stroke_width) } override fun getItemId(parent: RecyclerView, child: View): Long { val holder = parent.getChildViewHolder(child) ?: return NO_ID val item = holder.getItem(MangaListModel::class.java) ?: return NO_ID return item.id } override fun onDrawForeground( canvas: Canvas, parent: RecyclerView, child: View, bounds: RectF, state: RecyclerView.State, ) { val radius = (child as? CardView)?.radius ?: defaultRadius paint.color = fillColor paint.style = Paint.Style.FILL canvas.drawRoundRect(bounds, radius, radius, paint) paint.color = strokeColor paint.style = Paint.Style.STROKE canvas.drawRoundRect(bounds, radius, radius, paint) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/BadgeADUtil.kt ================================================ @file:androidx.annotation.OptIn(ExperimentalBadgeUtils::class) package org.koitharu.kotatsu.list.ui.adapter import android.view.View import androidx.annotation.CheckResult import androidx.cardview.widget.CardView import androidx.core.view.doOnNextLayout import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeUtils import com.google.android.material.badge.ExperimentalBadgeUtils import org.koitharu.kotatsu.R import org.koitharu.kotatsu.parsers.util.nullIfEmpty @Deprecated("") @CheckResult fun View.bindBadge(badge: BadgeDrawable?, counter: Int): BadgeDrawable? { return bindBadgeImpl(badge, null, counter) } @Deprecated("") @CheckResult fun View.bindBadge(badge: BadgeDrawable?, text: String?): BadgeDrawable? { return bindBadgeImpl(badge, text, 0) } @Deprecated("") fun View.clearBadge(badge: BadgeDrawable?) { BadgeUtils.detachBadgeDrawable(badge, this) } private fun View.bindBadgeImpl( badge: BadgeDrawable?, text: String?, counter: Int, ): BadgeDrawable? = if (text != null || counter > 0) { val badgeDrawable = badge ?: initBadge(this) if (counter > 0) { badgeDrawable.number = counter } else { badgeDrawable.text = text?.nullIfEmpty() } badgeDrawable.isVisible = true badgeDrawable.align(this) badgeDrawable } else { badge?.isVisible = false badge } private fun initBadge(anchor: View): BadgeDrawable { val badge = BadgeDrawable.create(anchor.context) val resources = anchor.resources badge.maxCharacterCount = resources.getInteger(R.integer.manga_badge_max_character_count) anchor.doOnNextLayout { BadgeUtils.attachBadgeDrawable(badge, it) badge.align(it) } return badge } private fun BadgeDrawable.align(anchor: View) { val extraOffset = if (anchor is CardView) { (anchor.radius / 2f).toInt() } else { anchor.resources.getDimensionPixelOffset(R.dimen.badge_offset) } horizontalOffset = intrinsicWidth + extraOffset verticalOffset = intrinsicHeight + extraOffset } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ButtonFooterAD.kt ================================================ package org.koitharu.kotatsu.list.ui.adapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.databinding.ItemButtonFooterBinding import org.koitharu.kotatsu.list.ui.model.ButtonFooter import org.koitharu.kotatsu.list.ui.model.ListModel fun buttonFooterAD( listener: ListStateHolderListener, ) = adapterDelegateViewBinding( { inflater, parent -> ItemButtonFooterBinding.inflate(inflater, parent, false) }, ) { binding.button.setOnClickListener { listener.onFooterButtonClick() } bind { binding.button.setText(item.textResId) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/EmptyHintAD.kt ================================================ package org.koitharu.kotatsu.list.ui.adapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.databinding.ItemEmptyCardBinding import org.koitharu.kotatsu.list.ui.model.EmptyHint import org.koitharu.kotatsu.list.ui.model.ListModel fun emptyHintAD( listener: ListStateHolderListener, ) = adapterDelegateViewBinding( { inflater, parent -> ItemEmptyCardBinding.inflate(inflater, parent, false) }, ) { binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() } bind { binding.icon.setImageAsync(item.icon) binding.textPrimary.setText(item.textPrimary) binding.textSecondary.setTextAndVisible(item.textSecondary) binding.buttonRetry.setTextAndVisible(item.actionStringRes) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt ================================================ package org.koitharu.kotatsu.list.ui.adapter import androidx.core.view.isVisible import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.ListModel fun emptyStateListAD( listener: ListStateHolderListener?, ) = adapterDelegateViewBinding( { inflater, parent -> ItemEmptyStateBinding.inflate(inflater, parent, false) }, ) { if (listener != null) { binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() } } bind { if (item.icon == 0) { binding.icon.isVisible = false binding.icon.disposeImage() } else { binding.icon.isVisible = true binding.icon.setImageAsync(item.icon) } binding.textPrimary.setText(item.textPrimary) binding.textSecondary.setTextAndVisible(item.textSecondary) if (listener != null) { binding.buttonRetry.setTextAndVisible(item.actionStringRes) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ErrorFooterAD.kt ================================================ package org.koitharu.kotatsu.list.ui.adapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.databinding.ItemErrorFooterBinding import org.koitharu.kotatsu.list.ui.model.ErrorFooter import org.koitharu.kotatsu.list.ui.model.ListModel fun errorFooterAD( listener: ListStateHolderListener?, ) = adapterDelegateViewBinding( { inflater, parent -> ItemErrorFooterBinding.inflate(inflater, parent, false) }, ) { if (listener != null) { binding.root.setOnClickListener { listener.onRetryClick(item.exception) } } bind { binding.textViewTitle.text = item.exception.getDisplayMessage(context.resources) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ErrorStateListAD.kt ================================================ package org.koitharu.kotatsu.list.ui.adapter import android.view.View import androidx.core.view.isVisible import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.databinding.ItemErrorStateBinding import org.koitharu.kotatsu.list.ui.model.ErrorState import org.koitharu.kotatsu.list.ui.model.ListModel fun errorStateListAD( listener: ListStateHolderListener?, ) = adapterDelegateViewBinding( { inflater, parent -> ItemErrorStateBinding.inflate(inflater, parent, false) }, ) { if (listener != null) { val onClickListener = View.OnClickListener { v -> when (v.id) { R.id.button_retry -> listener.onRetryClick(item.exception) R.id.button_secondary -> listener.onSecondaryErrorActionClick(item.exception) } } binding.buttonRetry.setOnClickListener(onClickListener) binding.buttonSecondary.setOnClickListener(onClickListener) } bind { with(binding.textViewError) { text = item.exception.getDisplayMessage(context.resources) setCompoundDrawablesWithIntrinsicBounds(0, item.icon, 0, 0) } with(binding.buttonRetry) { isVisible = item.canRetry && listener != null setText(item.buttonText) } binding.buttonSecondary.setTextAndVisible(item.secondaryButtonText) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/InfoAD.kt ================================================ package org.koitharu.kotatsu.list.ui.adapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.databinding.ItemInfoBinding import org.koitharu.kotatsu.list.ui.model.InfoModel import org.koitharu.kotatsu.list.ui.model.ListModel fun infoAD() = adapterDelegateViewBinding( { layoutInflater, parent -> ItemInfoBinding.inflate(layoutInflater, parent, false) }, ) { bind { binding.textViewTitle.setText(item.title) binding.textViewBody.setTextAndVisible(item.text) binding.textViewTitle.setCompoundDrawablesRelativeWithIntrinsicBounds( item.icon, 0, 0, 0, ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt ================================================ package org.koitharu.kotatsu.list.ui.adapter import androidx.core.view.isInvisible import androidx.core.view.isVisible import com.google.android.material.badge.BadgeDrawable import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.databinding.ItemHeaderBinding import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListModel fun listHeaderAD( listener: ListHeaderClickListener?, ) = adapterDelegateViewBinding( { inflater, parent -> ItemHeaderBinding.inflate(inflater, parent, false) }, ) { var badge: BadgeDrawable? = null if (listener != null) { binding.buttonMore.setOnClickListener { listener.onListHeaderClick(item, it) } } bind { binding.textViewTitle.text = item.getText(context) if (item.buttonTextRes == 0) { binding.buttonMore.isInvisible = true binding.buttonMore.text = null binding.buttonMore.clearBadge(badge) } else { binding.buttonMore.setText(item.buttonTextRes) binding.buttonMore.isVisible = true badge = itemView.bindBadge(badge, item.badge) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListHeaderClickListener.kt ================================================ package org.koitharu.kotatsu.list.ui.adapter import android.view.View import org.koitharu.kotatsu.list.ui.model.ListHeader interface ListHeaderClickListener { fun onListHeaderClick(item: ListHeader, view: View) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListItemType.kt ================================================ package org.koitharu.kotatsu.list.ui.adapter enum class ListItemType { QUICK_FILTER, FILTER_SORT, FILTER_TAG, FILTER_TAG_MULTI, FILTER_STATE, FILTER_LANGUAGE, HEADER, MANGA_LIST, MANGA_LIST_DETAILED, MANGA_GRID, MANGA_NESTED_GROUP, FOOTER_LOADING, FOOTER_ERROR, FOOTER_BUTTON, STATE_LOADING, STATE_ERROR, STATE_EMPTY, EXPLORE_BUTTONS, EXPLORE_SOURCE_GRID, EXPLORE_SOURCE_LIST, EXPLORE_SUGGESTION, TIP, INFO, HINT_EMPTY, PAGE_THUMB, FEED, DOWNLOAD, CATEGORY_LARGE, MANGA_SCROBBLING, NAV_ITEM, CHAPTER_LIST, CHAPTER_GRID, } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListStateHolderListener.kt ================================================ package org.koitharu.kotatsu.list.ui.adapter interface ListStateHolderListener { fun onRetryClick(error: Throwable) fun onSecondaryErrorActionClick(error: Throwable) = Unit fun onEmptyActionClick() fun onFooterButtonClick() = Unit } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/LoadingFooterAD.kt ================================================ package org.koitharu.kotatsu.list.ui.adapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate import org.koitharu.kotatsu.R import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingFooter fun loadingFooterAD() = adapterDelegate(R.layout.item_loading_footer) { } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/LoadingStateAD.kt ================================================ package org.koitharu.kotatsu.list.ui.adapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate import org.koitharu.kotatsu.R import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingState fun loadingStateAD() = adapterDelegate(R.layout.item_loading_state) { } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaDetailsClickListener.kt ================================================ package org.koitharu.kotatsu.list.ui.adapter import android.view.View import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag interface MangaDetailsClickListener : OnListItemClickListener { fun onReadClick(manga: Manga, view: View) fun onTagClick(manga: Manga, tag: MangaTag, view: View) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt ================================================ package org.koitharu.kotatsu.list.ui.adapter import androidx.core.view.isVisible import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.setTooltipCompat import org.koitharu.kotatsu.databinding.ItemMangaGridBinding import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_PROGRESS_CHANGED import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaGridModel import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver fun mangaGridItemAD( sizeResolver: ItemSizeResolver, clickListener: OnListItemClickListener, ) = adapterDelegateViewBinding( { inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) }, ) { AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView) sizeResolver.attachToView(itemView, binding.textViewTitle, binding.progressView) bind { payloads -> itemView.setTooltipCompat(item.getSummary(context)) binding.textViewTitle.text = item.title binding.progressView.setProgress(item.progress, PAYLOAD_PROGRESS_CHANGED in payloads) with(binding.iconsView) { clearIcons() if (item.isSaved) addIcon(R.drawable.ic_storage) if (item.isFavorite) addIcon(R.drawable.ic_heart_outline) isVisible = iconsCount > 0 } binding.imageViewCover.setImageAsync(item.coverUrl, item.manga) binding.badge.number = item.counter binding.badge.isVisible = item.counter > 0 } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt ================================================ package org.koitharu.kotatsu.list.ui.adapter import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver open class MangaListAdapter( listener: MangaListListener, sizeResolver: ItemSizeResolver, ) : BaseListAdapter() { init { addDelegate(ListItemType.MANGA_LIST, mangaListItemAD(listener)) addDelegate(ListItemType.MANGA_LIST_DETAILED, mangaListDetailedItemAD(listener)) addDelegate(ListItemType.MANGA_GRID, mangaGridItemAD(sizeResolver, listener)) addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD()) addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) addDelegate(ListItemType.STATE_ERROR, errorStateListAD(listener)) addDelegate(ListItemType.FOOTER_ERROR, errorFooterAD(listener)) addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(listener)) addDelegate(ListItemType.HINT_EMPTY, emptyHintAD(listener)) addDelegate(ListItemType.HEADER, listHeaderAD(listener)) addDelegate(ListItemType.QUICK_FILTER, quickFilterAD(listener)) addDelegate(ListItemType.TIP, tipAD(listener)) addDelegate(ListItemType.INFO, infoAD()) addDelegate(ListItemType.FOOTER_BUTTON, buttonFooterAD(listener)) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt ================================================ package org.koitharu.kotatsu.list.ui.adapter import androidx.core.view.isVisible import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ItemMangaListDetailsBinding import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaDetailedListModel fun mangaListDetailedItemAD( clickListener: MangaDetailsClickListener, ) = adapterDelegateViewBinding( { inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) }, ) { AdapterDelegateClickListenerAdapter(this, clickListener) .attach(itemView) bind { payloads -> binding.textViewTitle.text = item.title binding.textViewAuthor.textAndVisible = item.manga.authors.joinToString(", ") binding.progressView.setProgress( value = item.progress, animate = ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads, ) with(binding.iconsView) { clearIcons() if (item.isSaved) addIcon(R.drawable.ic_storage) if (item.isFavorite) addIcon(R.drawable.ic_heart_outline) isVisible = iconsCount > 0 } binding.imageViewCover.setImageAsync(item.coverUrl, item.manga) binding.textViewTags.text = item.tags.joinToString(separator = ", ") { it.title ?: "" } binding.badge.number = item.counter binding.badge.isVisible = item.counter > 0 } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt ================================================ package org.koitharu.kotatsu.list.ui.adapter import androidx.core.view.isVisible import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.setTooltipCompat import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ItemMangaListBinding import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaCompactListModel import org.koitharu.kotatsu.list.ui.model.MangaListModel fun mangaListItemAD( clickListener: OnListItemClickListener, ) = adapterDelegateViewBinding( { inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) }, ) { AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView) bind { itemView.setTooltipCompat(item.getSummary(context)) binding.textViewTitle.text = item.title binding.textViewSubtitle.textAndVisible = item.subtitle binding.imageViewCover.setImageAsync(item.coverUrl, item.manga) binding.badge.number = item.counter binding.badge.isVisible = item.counter > 0 } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListListener.kt ================================================ package org.koitharu.kotatsu.list.ui.adapter import android.view.View import org.koitharu.kotatsu.core.ui.widgets.TipView interface MangaListListener : MangaDetailsClickListener, ListStateHolderListener, ListHeaderClickListener, TipView.OnButtonClickListener, QuickFilterClickListener { fun onFilterClick(view: View?) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/QuickFilterAD.kt ================================================ package org.koitharu.kotatsu.list.ui.adapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.databinding.ItemQuickFilterBinding import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.QuickFilter fun quickFilterAD( listener: QuickFilterClickListener, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemQuickFilterBinding.inflate(layoutInflater, parent, false) } ) { binding.chipsTags.onChipClickListener = ChipsView.OnChipClickListener { chip, data -> if (data is ListFilterOption) { listener.onFilterOptionClick(data) } } bind { binding.chipsTags.setChips(item.items) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/QuickFilterClickListener.kt ================================================ package org.koitharu.kotatsu.list.ui.adapter import org.koitharu.kotatsu.list.domain.ListFilterOption interface QuickFilterClickListener { fun onFilterOptionClick(option: ListFilterOption) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TipAD.kt ================================================ package org.koitharu.kotatsu.list.ui.adapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.ui.widgets.TipView import org.koitharu.kotatsu.databinding.ItemTip2Binding import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.TipModel fun tipAD( listener: TipView.OnButtonClickListener, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemTip2Binding.inflate(layoutInflater, parent, false) } ) { binding.root.onButtonClickListener = listener bind { with(binding.root) { tag = item setTitle(item.title) setText(item.text) setIcon(item.icon) setPrimaryButtonText(item.primaryButtonText) setSecondaryButtonText(item.secondaryButtonText) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TypedListSpacingDecoration.kt ================================================ package org.koitharu.kotatsu.list.ui.adapter import android.content.Context import android.graphics.Rect import android.view.View import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ItemDecoration import org.koitharu.kotatsu.R class TypedListSpacingDecoration( context: Context, private val addHorizontalPadding: Boolean, ) : ItemDecoration() { private val spacingSmall = context.resources.getDimensionPixelOffset(R.dimen.list_spacing_small) private val spacingNormal = context.resources.getDimensionPixelOffset(R.dimen.list_spacing_normal) private val spacingLarge = context.resources.getDimensionPixelOffset(R.dimen.list_spacing_large) override fun getItemOffsets( outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State, ) { val itemType = parent.getChildViewHolder(view)?.itemViewType?.let { ListItemType.entries.getOrNull(it) } when (itemType) { ListItemType.FILTER_SORT, ListItemType.FILTER_TAG, ListItemType.FILTER_TAG_MULTI, ListItemType.FILTER_STATE, ListItemType.FILTER_LANGUAGE, ListItemType.QUICK_FILTER, -> outRect.set(0) ListItemType.HEADER, ListItemType.FEED, ListItemType.EXPLORE_SOURCE_LIST, ListItemType.MANGA_SCROBBLING, ListItemType.MANGA_LIST, -> outRect.set(0) ListItemType.DOWNLOAD, ListItemType.HINT_EMPTY, ListItemType.MANGA_LIST_DETAILED, -> outRect.set(spacingNormal) ListItemType.PAGE_THUMB -> outRect.set(spacingNormal) ListItemType.MANGA_GRID -> outRect.set(0) ListItemType.EXPLORE_BUTTONS -> outRect.set(spacingNormal) ListItemType.FOOTER_LOADING, ListItemType.FOOTER_ERROR, ListItemType.FOOTER_BUTTON, ListItemType.STATE_LOADING, ListItemType.STATE_ERROR, ListItemType.STATE_EMPTY, ListItemType.EXPLORE_SOURCE_GRID, ListItemType.EXPLORE_SUGGESTION, ListItemType.MANGA_NESTED_GROUP, ListItemType.CATEGORY_LARGE, ListItemType.NAV_ITEM, ListItemType.CHAPTER_LIST, ListItemType.INFO, null, -> outRect.set(0) ListItemType.CHAPTER_GRID -> outRect.set(spacingSmall) ListItemType.TIP -> outRect.set(0) // TODO } if (addHorizontalPadding && !itemType.isEdgeToEdge()) { outRect.set( outRect.left + spacingNormal, outRect.top, outRect.right + spacingNormal, outRect.bottom, ) } } private fun Rect.set(spacing: Int) = set(spacing, spacing, spacing, spacing) private fun ListItemType?.isEdgeToEdge() = this == ListItemType.MANGA_NESTED_GROUP || this == ListItemType.FILTER_SORT || this == ListItemType.FILTER_TAG || this == ListItemType.CHAPTER_LIST || this == ListItemType.CHAPTER_GRID } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/config/ListConfigBottomSheet.kt ================================================ package org.koitharu.kotatsu.list.ui.config import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.AdapterView import android.widget.ArrayAdapter import android.widget.CompoundButton import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.core.view.updatePadding import androidx.fragment.app.viewModels import com.google.android.material.button.MaterialButtonToggleGroup import com.google.android.material.slider.Slider import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.util.ext.consume import org.koitharu.kotatsu.core.util.ext.setValueRounded import org.koitharu.kotatsu.core.util.progress.IntPercentLabelFormatter import org.koitharu.kotatsu.databinding.SheetListModeBinding @AndroidEntryPoint class ListConfigBottomSheet : BaseAdaptiveSheet(), Slider.OnChangeListener, MaterialButtonToggleGroup.OnButtonCheckedListener, CompoundButton.OnCheckedChangeListener, AdapterView.OnItemSelectedListener { private val viewModel by viewModels() override fun onCreateViewBinding( inflater: LayoutInflater, container: ViewGroup?, ) = SheetListModeBinding.inflate(inflater, container, false) override fun onViewBindingCreated(binding: SheetListModeBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) val mode = viewModel.listMode binding.buttonList.isChecked = mode == ListMode.LIST binding.buttonListDetailed.isChecked = mode == ListMode.DETAILED_LIST binding.buttonGrid.isChecked = mode == ListMode.GRID binding.textViewGridTitle.isVisible = mode == ListMode.GRID binding.sliderGrid.isVisible = mode == ListMode.GRID binding.sliderGrid.setLabelFormatter(IntPercentLabelFormatter(binding.root.context)) binding.sliderGrid.setValueRounded(viewModel.gridSize.toFloat()) binding.sliderGrid.addOnChangeListener(this) binding.checkableGroup.addOnButtonCheckedListener(this) binding.switchGrouping.isVisible = viewModel.isGroupingSupported if (viewModel.isGroupingSupported) { binding.switchGrouping.isEnabled = viewModel.isGroupingAvailable } binding.switchGrouping.isChecked = viewModel.isGroupingEnabled binding.switchGrouping.setOnCheckedChangeListener(this) val sortOrders = viewModel.getSortOrders() if (sortOrders != null) { binding.textViewOrderTitle.isVisible = true binding.spinnerOrder.adapter = ArrayAdapter( binding.spinnerOrder.context, android.R.layout.simple_spinner_dropdown_item, android.R.id.text1, sortOrders.map { binding.spinnerOrder.context.getString(it.titleResId) }, ) val selected = sortOrders.indexOf(viewModel.getSelectedSortOrder()) if (selected >= 0) { binding.spinnerOrder.setSelection(selected, false) } binding.spinnerOrder.onItemSelectedListener = this binding.cardOrder.isVisible = true } } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val typeMask = WindowInsetsCompat.Type.systemBars() viewBinding?.scrollView?.updatePadding( bottom = insets.getInsets(typeMask).bottom, ) return insets.consume(v, typeMask, bottom = true) } override fun onButtonChecked(group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean) { if (!isChecked) { return } val mode = when (checkedId) { R.id.button_list -> ListMode.LIST R.id.button_list_detailed -> ListMode.DETAILED_LIST R.id.button_grid -> ListMode.GRID else -> return } requireViewBinding().textViewGridTitle.isVisible = mode == ListMode.GRID requireViewBinding().sliderGrid.isVisible = mode == ListMode.GRID viewModel.listMode = mode } override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { when (buttonView.id) { R.id.switch_grouping -> viewModel.isGroupingEnabled = isChecked } } override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { if (fromUser) { viewModel.gridSize = value.toInt() } } override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { when (parent.id) { R.id.spinner_order -> { viewModel.setSortOrder(position) viewBinding?.switchGrouping?.isEnabled = viewModel.isGroupingAvailable } } } override fun onNothingSelected(parent: AdapterView<*>?) = Unit } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/config/ListConfigSection.kt ================================================ package org.koitharu.kotatsu.list.ui.config import android.os.Parcelable import kotlinx.parcelize.Parcelize sealed interface ListConfigSection : Parcelable { @Parcelize data object History : ListConfigSection @Parcelize data object General : ListConfigSection @Parcelize data class Favorites( val categoryId: Long, ) : ListConfigSection @Parcelize data object Suggestions : ListConfigSection @Parcelize data object Updated : ListConfigSection } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/config/ListConfigViewModel.kt ================================================ package org.koitharu.kotatsu.list.ui.config import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.runBlocking import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import javax.inject.Inject @HiltViewModel class ListConfigViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val settings: AppSettings, private val favouritesRepository: FavouritesRepository, ) : BaseViewModel() { val section = savedStateHandle.require(AppRouter.KEY_LIST_SECTION) var listMode: ListMode get() = when (section) { is ListConfigSection.Favorites -> settings.favoritesListMode ListConfigSection.History -> settings.historyListMode ListConfigSection.Suggestions -> settings.suggestionsListMode ListConfigSection.General, ListConfigSection.Updated -> settings.listMode } set(value) { when (section) { is ListConfigSection.Favorites -> settings.favoritesListMode = value ListConfigSection.History -> settings.historyListMode = value ListConfigSection.Suggestions -> settings.suggestionsListMode = value ListConfigSection.Updated, ListConfigSection.General -> settings.listMode = value } } var gridSize: Int get() = settings.gridSize set(value) { settings.gridSize = value } val isGroupingSupported: Boolean get() = section == ListConfigSection.History || section == ListConfigSection.Updated val isGroupingAvailable: Boolean get() = when (section) { ListConfigSection.History -> settings.historySortOrder.isGroupingSupported() ListConfigSection.Updated -> true else -> false } var isGroupingEnabled: Boolean get() = when (section) { ListConfigSection.History -> settings.isHistoryGroupingEnabled ListConfigSection.Updated -> settings.isUpdatedGroupingEnabled else -> false } set(value) = when (section) { ListConfigSection.History -> settings.isHistoryGroupingEnabled = value ListConfigSection.Updated -> settings.isUpdatedGroupingEnabled = value else -> Unit } fun getSortOrders(): List? = when (section) { is ListConfigSection.Favorites -> ListSortOrder.FAVORITES ListConfigSection.General -> null ListConfigSection.History -> ListSortOrder.HISTORY ListConfigSection.Suggestions -> ListSortOrder.SUGGESTIONS ListConfigSection.Updated -> null }?.sortedByOrdinal() fun getSelectedSortOrder(): ListSortOrder? = when (section) { is ListConfigSection.Favorites -> getCategorySortOrder(section.categoryId) ListConfigSection.General -> null ListConfigSection.Updated -> null ListConfigSection.History -> settings.historySortOrder ListConfigSection.Suggestions -> ListSortOrder.RELEVANCE } fun setSortOrder(position: Int) { val value = getSortOrders()?.getOrNull(position) ?: return when (section) { is ListConfigSection.Favorites -> launchJob { if (section.categoryId == NO_ID) { settings.allFavoritesSortOrder = value } else { favouritesRepository.setCategoryOrder(section.categoryId, value) } } ListConfigSection.General -> Unit ListConfigSection.History -> settings.historySortOrder = value ListConfigSection.Suggestions -> Unit ListConfigSection.Updated -> Unit } } private fun getCategorySortOrder(id: Long): ListSortOrder = if (id == NO_ID) { settings.allFavoritesSortOrder } else runBlocking { runCatchingCancellable { favouritesRepository.getCategory(id).order }.getOrElse { settings.allFavoritesSortOrder } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ButtonFooter.kt ================================================ package org.koitharu.kotatsu.list.ui.model import androidx.annotation.StringRes data class ButtonFooter( @StringRes val textResId: Int, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is ButtonFooter && textResId == other.textResId } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/EmptyHint.kt ================================================ package org.koitharu.kotatsu.list.ui.model import androidx.annotation.DrawableRes import androidx.annotation.StringRes data class EmptyHint( @DrawableRes val icon: Int, @StringRes val textPrimary: Int, @StringRes val textSecondary: Int, @StringRes val actionStringRes: Int, ) : ListModel { fun toState() = EmptyState(icon, textPrimary, textSecondary, actionStringRes) override fun areItemsTheSame(other: ListModel): Boolean { return other is EmptyHint && textPrimary == other.textPrimary } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/EmptyState.kt ================================================ package org.koitharu.kotatsu.list.ui.model import androidx.annotation.DrawableRes import androidx.annotation.StringRes data class EmptyState( @DrawableRes val icon: Int, @StringRes val textPrimary: Int, @StringRes val textSecondary: Int, @StringRes val actionStringRes: Int, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is EmptyState } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ErrorFooter.kt ================================================ package org.koitharu.kotatsu.list.ui.model data class ErrorFooter( val exception: Throwable, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is ErrorFooter && exception == other.exception } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ErrorState.kt ================================================ package org.koitharu.kotatsu.list.ui.model import androidx.annotation.DrawableRes import androidx.annotation.StringRes data class ErrorState( val exception: Throwable, @DrawableRes val icon: Int, val canRetry: Boolean, @StringRes val buttonText: Int, @StringRes val secondaryButtonText: Int, ) : ListModel { override fun areItemsTheSame(other: ListModel) = other is ErrorState } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/InfoModel.kt ================================================ package org.koitharu.kotatsu.list.ui.model import androidx.annotation.DrawableRes import androidx.annotation.StringRes data class InfoModel( val key: String, @StringRes val title: Int, @StringRes val text: Int, @DrawableRes val icon: Int, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is InfoModel && other.key == key } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListHeader.kt ================================================ package org.koitharu.kotatsu.list.ui.model import android.content.Context import androidx.annotation.StringRes import org.koitharu.kotatsu.core.model.getLocalizedTitle import org.koitharu.kotatsu.core.ui.model.DateTimeAgo import org.koitharu.kotatsu.parsers.model.MangaChapter data class ListHeader private constructor( private val textRaw: Any, @StringRes val buttonTextRes: Int, val payload: Any?, val badge: String?, ) : ListModel { constructor( text: CharSequence, @StringRes buttonTextRes: Int = 0, payload: Any? = null, badge: String? = null, ) : this(textRaw = text, buttonTextRes, payload, badge) constructor( @StringRes textRes: Int, @StringRes buttonTextRes: Int = 0, payload: Any? = null, badge: String? = null, ) : this(textRaw = textRes, buttonTextRes, payload, badge) constructor( chapter: MangaChapter, @StringRes buttonTextRes: Int = 0, payload: Any? = null, badge: String? = null, ) : this(textRaw = chapter, buttonTextRes, payload, badge) constructor( dateTimeAgo: DateTimeAgo, @StringRes buttonTextRes: Int = 0, payload: Any? = null, badge: String? = null, ) : this(textRaw = dateTimeAgo, buttonTextRes, payload, badge) fun getText(context: Context): CharSequence? = when (textRaw) { is CharSequence -> textRaw is Int -> if (textRaw != 0) context.getString(textRaw) else null is DateTimeAgo -> textRaw.format(context) is MangaChapter -> textRaw.getLocalizedTitle(context.resources) else -> null } override fun areItemsTheSame(other: ListModel): Boolean { return other is ListHeader && textRaw == other.textRaw } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListModel.kt ================================================ package org.koitharu.kotatsu.list.ui.model interface ListModel { override fun equals(other: Any?): Boolean fun areItemsTheSame(other: ListModel): Boolean fun getChangePayload(previousState: ListModel): Any? = null } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListModelExt.kt ================================================ package org.koitharu.kotatsu.list.ui.model import androidx.annotation.StringRes import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.util.ext.getDisplayIcon import org.koitharu.kotatsu.parsers.util.ifZero fun Throwable.toErrorState(canRetry: Boolean = true, @StringRes secondaryAction: Int = 0) = ErrorState( exception = this, icon = getDisplayIcon(), canRetry = canRetry, buttonText = ExceptionResolver.getResolveStringId(this).ifZero { R.string.try_again }, secondaryButtonText = secondaryAction, ) fun Throwable.toErrorFooter() = ErrorFooter( exception = this, ) operator fun ListModel.plus(list: List): List { val result = ArrayList(list.size + 1) result.add(this) result.addAll(list) return result } operator fun ListModel.plus(other: ListModel): List = listOf(this, other) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/LoadingFooter.kt ================================================ package org.koitharu.kotatsu.list.ui.model data class LoadingFooter @JvmOverloads constructor( val key: Int = 0, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is LoadingFooter && key == other.key } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/LoadingState.kt ================================================ package org.koitharu.kotatsu.list.ui.model object LoadingState : ListModel { override fun equals(other: Any?): Boolean = other === LoadingState override fun areItemsTheSame(other: ListModel): Boolean { return other is LoadingState } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaCompactListModel.kt ================================================ package org.koitharu.kotatsu.list.ui.model import org.koitharu.kotatsu.core.ui.model.MangaOverride import org.koitharu.kotatsu.parsers.model.Manga data class MangaCompactListModel( override val manga: Manga, override val override: MangaOverride?, val subtitle: String, override val counter: Int, ) : MangaListModel() ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaDetailedListModel.kt ================================================ package org.koitharu.kotatsu.list.ui.model import org.koitharu.kotatsu.core.ui.model.MangaOverride import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.list.domain.ReadingProgress import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_ANYTHING_CHANGED import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_PROGRESS_CHANGED import org.koitharu.kotatsu.parsers.model.Manga data class MangaDetailedListModel( override val manga: Manga, override val override: MangaOverride?, val subtitle: String?, override val counter: Int, val progress: ReadingProgress?, val isFavorite: Boolean, val isSaved: Boolean, val tags: List, ) : MangaListModel() { override fun getChangePayload(previousState: ListModel): Any? = when { previousState !is MangaDetailedListModel || previousState.manga != manga -> null previousState.progress != progress -> PAYLOAD_PROGRESS_CHANGED previousState.isFavorite != isFavorite || previousState.isSaved != isSaved -> PAYLOAD_ANYTHING_CHANGED else -> super.getChangePayload(previousState) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaGridModel.kt ================================================ package org.koitharu.kotatsu.list.ui.model import org.koitharu.kotatsu.core.ui.model.MangaOverride import org.koitharu.kotatsu.list.domain.ReadingProgress import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_ANYTHING_CHANGED import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_PROGRESS_CHANGED import org.koitharu.kotatsu.parsers.model.Manga data class MangaGridModel( override val manga: Manga, override val override: MangaOverride?, override val counter: Int, val progress: ReadingProgress?, val isFavorite: Boolean, val isSaved: Boolean, ) : MangaListModel() { override fun getChangePayload(previousState: ListModel): Any? = when { previousState !is MangaGridModel || previousState.manga != manga -> null previousState.progress != progress -> PAYLOAD_PROGRESS_CHANGED previousState.isFavorite != isFavorite || previousState.isSaved != isSaved -> PAYLOAD_ANYTHING_CHANGED else -> super.getChangePayload(previousState) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaListModel.kt ================================================ package org.koitharu.kotatsu.list.ui.model import android.content.Context import androidx.core.text.bold import androidx.core.text.buildSpannedString import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.model.withOverride import org.koitharu.kotatsu.core.ui.model.MangaOverride import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_ANYTHING_CHANGED import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty sealed class MangaListModel : ListModel { abstract val override: MangaOverride? abstract val manga: Manga abstract val counter: Int val id: Long get() = manga.id val title: String get() = override?.title.ifNullOrEmpty { manga.title } val coverUrl: String? get() = override?.coverUrl.ifNullOrEmpty { manga.coverUrl } val source: MangaSource get() = manga.source fun toMangaWithOverride() = manga.withOverride(override) open fun getSummary(context: Context): CharSequence = buildSpannedString { bold { append(manga.title) } appendLine() if (manga.tags.isNotEmpty()) { manga.tags.joinTo(this) { it.title } appendLine() } append(manga.source.getTitle(context)) } override fun areItemsTheSame(other: ListModel): Boolean { return other is MangaListModel && other.javaClass == javaClass && id == other.id } override fun getChangePayload(previousState: ListModel): Any? = when { previousState !is MangaListModel || previousState.manga != manga -> null previousState.counter != counter -> PAYLOAD_ANYTHING_CHANGED else -> null } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/QuickFilter.kt ================================================ package org.koitharu.kotatsu.list.ui.model import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.list.ui.ListModelDiffCallback data class QuickFilter( val items: List, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean = other is QuickFilter override fun getChangePayload(previousState: ListModel) = ListModelDiffCallback.PAYLOAD_NESTED_LIST_CHANGED } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/TipModel.kt ================================================ package org.koitharu.kotatsu.list.ui.model import androidx.annotation.DrawableRes import androidx.annotation.StringRes data class TipModel( val key: String, @StringRes val title: Int, @StringRes val text: Int, @DrawableRes val icon: Int, @StringRes val primaryButtonText: Int, @StringRes val secondaryButtonText: Int, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is TipModel && other.key == key } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt ================================================ package org.koitharu.kotatsu.list.ui.preview import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.core.text.method.LinkMovementMethodCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.fragment.app.viewModels import com.google.android.material.chip.Chip import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.FragmentPreviewBinding import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.search.ui.MangaListActivity @AndroidEntryPoint class PreviewFragment : BaseFragment(), View.OnClickListener, ChipsView.OnChipClickListener { private val viewModel: PreviewViewModel by viewModels() override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentPreviewBinding { return FragmentPreviewBinding.inflate(inflater, container, false) } override fun onViewBindingCreated(binding: FragmentPreviewBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) binding.buttonClose.isVisible = activity is MangaListActivity binding.buttonClose.setOnClickListener(this) binding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance() binding.chipsTags.onChipClickListener = this binding.textViewAuthor.setOnClickListener(this) binding.imageViewCover.setOnClickListener(this) binding.buttonOpen.setOnClickListener(this) binding.buttonRead.setOnClickListener(this) viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated) viewModel.footer.observe(viewLifecycleOwner, ::onFooterUpdated) viewModel.tagsChips.observe(viewLifecycleOwner, ::onTagsChipsChanged) viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged) } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = insets override fun onClick(v: View) { val manga = viewModel.manga.value when (v.id) { R.id.button_close -> closeSelf() R.id.button_open -> router.openDetails(manga) R.id.button_read -> router.openReader(manga) R.id.textView_author -> router.showAuthorDialog( author = manga.authors.firstOrNull() ?: return, source = manga.source, ) R.id.imageView_cover -> router.openImage( url = manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl } ?: return, source = manga.source, anchor = v, ) } } override fun onChipClick(chip: Chip, data: Any?) { val tag = data as? MangaTag ?: return val filter = FilterCoordinator.find(this) if (filter == null) { router.openList(tag) } else { filter.toggleTag(tag, true) closeSelf() } } private fun onMangaUpdated(manga: Manga) { with(requireViewBinding()) { // Main loadCover(manga) textViewTitle.text = manga.title textViewSubtitle.textAndVisible = manga.altTitles.firstOrNull() textViewAuthor.textAndVisible = manga.authors.firstOrNull() if (manga.hasRating) { ratingBar.rating = manga.rating * ratingBar.numStars ratingBar.isVisible = true } else { ratingBar.isVisible = false } } } private fun onFooterUpdated(footer: PreviewViewModel.FooterInfo?) { with(requireViewBinding()) { buttonRead.isEnabled = footer != null buttonRead.setText( when { footer == null -> R.string.loading_ footer.isIncognito -> R.string.incognito footer.isInProgress() -> R.string._continue else -> R.string.read }, ) } } private fun onDescriptionChanged(description: CharSequence?) { val tv = viewBinding?.textViewDescription ?: return when { description == null -> tv.setText(R.string.loading_) description.isBlank() -> tv.setText(R.string.no_description) else -> tv.setText(description, TextView.BufferType.NORMAL) } } private fun loadCover(manga: Manga) { val imageUrl = manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl } requireViewBinding().imageViewCover.setImageAsync(imageUrl, manga) } private fun onTagsChipsChanged(chips: List) { requireViewBinding().chipsTags.setChips(chips) } private fun closeSelf() { ((activity as? MangaListActivity)?.hidePreview()) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewViewModel.kt ================================================ package org.koitharu.kotatsu.list.ui.preview import android.text.Html import android.text.SpannableString import android.text.Spanned import android.text.style.ForegroundColorSpan import androidx.core.text.getSpans import androidx.core.text.parseAsHtml import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.model.getPreferredBranch 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.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.sanitize import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE import javax.inject.Inject @HiltViewModel class PreviewViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val mangaListMapper: MangaListMapper, private val repositoryFactory: MangaRepository.Factory, private val historyRepository: HistoryRepository, private val imageGetter: Html.ImageGetter, ) : BaseViewModel() { val manga = MutableStateFlow( savedStateHandle.require(AppRouter.KEY_MANGA).manga, ) val footer = combine( manga, historyRepository.observeOne(manga.value.id), manga.flatMapLatest { historyRepository.observeShouldSkip(it) }.distinctUntilChanged(), ) { m, history, incognito -> if (m.chapters == null) { return@combine null } val b = m.getPreferredBranch(history) val chapters = m.getChapters(b) FooterInfo( percent = history?.percent ?: PROGRESS_NONE, currentChapter = history?.chapterId?.let { chapters.indexOfFirst { x -> x.id == it } } ?: -1, totalChapters = chapters.size, isIncognito = incognito, ) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, null) val description = manga .distinctUntilChangedBy { it.description.orEmpty() } .transformLatest { val description = it.description if (description.isNullOrEmpty()) { emit(null) } else { emit(description.parseAsHtml().filterSpans().sanitize()) emit(description.parseAsHtml(imageGetter = imageGetter).filterSpans()) } }.combine(isLoading) { desc, loading -> if (loading) null else desc ?: "" }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), null) val tagsChips = manga.map { mangaListMapper.mapTags(it.tags) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) init { launchLoadingJob(Dispatchers.Default) { val repo = repositoryFactory.create(manga.value.source) manga.value = repo.getDetails(manga.value) } } private fun Spanned.filterSpans(): CharSequence { val spannable = SpannableString.valueOf(this) val spans = spannable.getSpans() for (span in spans) { spannable.removeSpan(span) } return spannable.trim() } data class FooterInfo( val currentChapter: Int, val totalChapters: Int, val isIncognito: Boolean, val percent: Float, ) { fun isInProgress() = currentChapter >= 0 } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/size/DynamicItemSizeResolver.kt ================================================ package org.koitharu.kotatsu.list.ui.size import android.content.SharedPreferences import android.content.res.Resources import android.view.View import android.widget.TextView import androidx.annotation.StyleRes import androidx.core.widget.TextViewCompat import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.history.ui.util.ReadingProgressView import kotlin.math.roundToInt class DynamicItemSizeResolver( resources: Resources, private val lifecycleOwner: LifecycleOwner, private val settings: AppSettings, private val adjustWidth: Boolean, ) : ItemSizeResolver { private val gridWidth = resources.getDimension(R.dimen.preferred_grid_width) private val scaleFactor: Float get() = settings.gridSize / 100f override val cellWidth: Int get() = (gridWidth * scaleFactor).roundToInt() override fun attachToView( view: View, textView: TextView?, progressView: ReadingProgressView? ) { val observer = SizeObserver(view, textView, progressView) view.addOnAttachStateChangeListener(observer) lifecycleOwner.lifecycle.addObserver(observer) if (view.isAttachedToWindow) { observer.update() } } private inner class SizeObserver( private val view: View, private val textView: TextView?, private val progressView: ReadingProgressView?, ) : DefaultLifecycleObserver, SharedPreferences.OnSharedPreferenceChangeListener, View.OnAttachStateChangeListener { private val widthThreshold = view.resources.getDimensionPixelSize(R.dimen.small_grid_width) @StyleRes private var prevTextAppearance = 0 override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { if (key == AppSettings.KEY_GRID_SIZE) { update() } } override fun onViewAttachedToWindow(v: View) { settings.subscribe(this) update() } override fun onViewDetachedFromWindow(v: View) { settings.unsubscribe(this) } override fun onDestroy(owner: LifecycleOwner) { super.onDestroy(owner) settings.unsubscribe(this) view.removeOnAttachStateChangeListener(this) } fun update() { val newWidth = cellWidth textView?.adjustTextAppearance(newWidth) if (adjustWidth) { val lp = view.layoutParams if (lp.width != newWidth) { lp.width = newWidth view.layoutParams = lp } } progressView?.adjustSize(newWidth) } private fun ReadingProgressView.adjustSize(width: Int) { val lp = layoutParams val size = resources.getDimensionPixelSize( if (width < widthThreshold) { R.dimen.card_indicator_size_small } else { R.dimen.card_indicator_size }, ) if (lp.width != size || lp.height != size) { lp.width = size lp.height = size layoutParams = lp } } private fun TextView.adjustTextAppearance(width: Int) { val textAppearanceResId = if (width < widthThreshold) { R.style.TextAppearance_Kotatsu_GridTitle_Small } else { R.style.TextAppearance_Kotatsu_GridTitle } if (textAppearanceResId != prevTextAppearance) { prevTextAppearance = textAppearanceResId TextViewCompat.setTextAppearance(this, textAppearanceResId) requestLayout() } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/size/ItemSizeResolver.kt ================================================ package org.koitharu.kotatsu.list.ui.size import android.view.View import android.widget.TextView import org.koitharu.kotatsu.history.ui.util.ReadingProgressView interface ItemSizeResolver { val cellWidth: Int fun attachToView( view: View, textView: TextView?, progressView: ReadingProgressView?, ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/list/ui/size/StaticItemSizeResolver.kt ================================================ package org.koitharu.kotatsu.list.ui.size import android.view.View import android.widget.TextView import androidx.core.view.updateLayoutParams import androidx.core.widget.TextViewCompat import org.koitharu.kotatsu.R import org.koitharu.kotatsu.history.ui.util.ReadingProgressView class StaticItemSizeResolver( override val cellWidth: Int, ) : ItemSizeResolver { private var widthThreshold: Int = -1 private var textAppearanceResId = R.style.TextAppearance_Kotatsu_GridTitle override fun attachToView( view: View, textView: TextView?, progressView: ReadingProgressView? ) { if (widthThreshold == -1) { widthThreshold = view.resources.getDimensionPixelSize(R.dimen.small_grid_width) textAppearanceResId = if (cellWidth < widthThreshold) { R.style.TextAppearance_Kotatsu_GridTitle_Small } else { R.style.TextAppearance_Kotatsu_GridTitle } } if (textView != null) { TextViewCompat.setTextAppearance(textView, textAppearanceResId) } view.updateLayoutParams { width = cellWidth } progressView?.adjustSize() } private fun ReadingProgressView.adjustSize() { val lp = layoutParams val size = resources.getDimensionPixelSize( if (cellWidth < widthThreshold) { R.dimen.card_indicator_size_small } else { R.dimen.card_indicator_size }, ) if (lp.width != size || lp.height != size) { lp.width = size lp.height = size layoutParams = lp } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/data/CacheDir.kt ================================================ package org.koitharu.kotatsu.local.data enum class CacheDir(val dir: String) { THUMBS("image_cache"), FAVICONS("favicons"), PAGES("pages"); } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/data/Caches.kt ================================================ package org.koitharu.kotatsu.local.data import javax.inject.Qualifier @Qualifier @Retention(AnnotationRetention.BINARY) annotation class PageCache @Qualifier @Retention(AnnotationRetention.BINARY) annotation class FaviconCache ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/data/CbzFilter.kt ================================================ package org.koitharu.kotatsu.local.data import java.io.File private fun isZipExtension(ext: String?): Boolean { return ext.equals("cbz", ignoreCase = true) || ext.equals("zip", ignoreCase = true) } fun hasZipExtension(string: String): Boolean { val ext = string.substringAfterLast('.', "") return isZipExtension(ext) } val File.isZipArchive: Boolean get() = isFile && isZipExtension(extension) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt ================================================ package org.koitharu.kotatsu.local.data import androidx.core.net.toFile import androidx.core.net.toUri import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.toCollection import kotlinx.coroutines.launch import kotlinx.coroutines.runInterruptible import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.AlphanumComparator import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.takeIfWriteable import org.koitharu.kotatsu.core.util.ext.withChildren import org.koitharu.kotatsu.local.data.index.LocalMangaIndex import org.koitharu.kotatsu.local.data.input.LocalMangaParser import org.koitharu.kotatsu.local.data.output.LocalMangaOutput import org.koitharu.kotatsu.local.data.output.LocalMangaUtil import org.koitharu.kotatsu.local.domain.MangaLock import org.koitharu.kotatsu.local.domain.model.LocalManga 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.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.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.levenshteinDistance import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.io.File import java.util.EnumSet import javax.inject.Inject import javax.inject.Singleton private const val MAX_PARALLELISM = 4 private const val FILENAME_SKIP = ".notamanga" @Singleton class LocalMangaRepository @Inject constructor( private val storageManager: LocalStorageManager, private val localMangaIndex: LocalMangaIndex, @LocalStorageChanges private val localStorageChanges: MutableSharedFlow, private val settings: AppSettings, private val lock: MangaLock, ) : MangaRepository { override val source = LocalMangaSource override val filterCapabilities: MangaListFilterCapabilities get() = MangaListFilterCapabilities( isMultipleTagsSupported = true, isTagsExclusionSupported = true, isSearchSupported = true, isSearchWithFiltersSupported = true, ) override val sortOrders: Set = EnumSet.of( SortOrder.ALPHABETICAL, SortOrder.RATING, SortOrder.NEWEST, SortOrder.RELEVANCE, ) override var defaultSortOrder: SortOrder get() = settings.localListOrder set(value) { settings.localListOrder = value } override suspend fun getFilterOptions() = MangaListFilterOptions( availableTags = localMangaIndex.getAvailableTags( skipNsfw = settings.isNsfwContentDisabled, ).mapToSet { MangaTag(title = it, key = it, source = source) }, availableContentRating = if (!settings.isNsfwContentDisabled) { EnumSet.of(ContentRating.SAFE, ContentRating.ADULT) } else { emptySet() }, ) override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List { if (offset > 0) { return emptyList() } val list = getRawList() if (settings.isNsfwContentDisabled) { list.removeAll { it.manga.isNsfw() } } if (filter != null) { val query = filter.query if (!query.isNullOrEmpty()) { list.retainAll { x -> x.isMatchesQuery(query) } } if (filter.tags.isNotEmpty()) { list.retainAll { x -> x.containsTags(filter.tags.mapToSet { it.title }) } } if (filter.tagsExclude.isNotEmpty()) { list.removeAll { x -> x.containsAnyTag(filter.tagsExclude.mapToSet { it.title }) } } filter.contentRating.singleOrNull()?.let { contentRating -> val isNsfw = contentRating == ContentRating.ADULT list.retainAll { it.manga.isNsfw() == isNsfw } } if (!query.isNullOrEmpty() && order == SortOrder.RELEVANCE) { list.sortBy { it.manga.title.levenshteinDistance(query) } } } when (order) { SortOrder.ALPHABETICAL -> list.sortWith(compareBy(AlphanumComparator()) { x -> x.manga.title }) SortOrder.RATING -> list.sortByDescending { it.manga.rating } SortOrder.NEWEST, SortOrder.UPDATED -> list.sortWith(compareBy({ -it.createdAt }, { it.manga.id })) else -> Unit } return list.unwrap() } override suspend fun getDetails(manga: Manga): Manga = when { !manga.isLocal -> requireNotNull(findSavedManga(manga, withDetails = true)?.manga) { "Manga is not local or saved" } else -> LocalMangaParser(manga.url.toUri()).getManga(withDetails = true).manga } override suspend fun getPages(chapter: MangaChapter): List { return LocalMangaParser(chapter.url.toUri()).getPages(chapter) } suspend fun delete(manga: Manga): Boolean { val file = manga.url.toUri().toFile() val result = file.deleteAwait() if (result) { localMangaIndex.delete(manga.id) localStorageChanges.emit(null) } return result } suspend fun deleteChapters(manga: Manga, ids: Set) = lock.withLock(manga) { val subject = if (manga.isLocal) manga else checkNotNull(findSavedManga(manga, withDetails = false)) { "Manga is not stored on local storage" }.manga LocalMangaUtil(subject).deleteChapters(ids) val updated = getDetails(subject) localStorageChanges.emit(LocalManga(updated)) } suspend fun getRemoteManga(localManga: Manga): Manga? { return runCatchingCancellable { LocalMangaParser(localManga.url.toUri()).getMangaInfo()?.takeUnless { it.isLocal } }.onFailure { it.printStackTraceDebug() }.getOrNull() } suspend fun findSavedManga(remoteManga: Manga, withDetails: Boolean = true): LocalManga? = runCatchingCancellable { // very fast path localMangaIndex.get(remoteManga.id, withDetails)?.let { cached -> return@runCatchingCancellable cached } // fast path LocalMangaParser.find(storageManager.getReadableDirs(), remoteManga)?.let { return it.getManga(withDetails) } // slow path val files = getAllFiles() return channelFlow { for (file in files) { launch { val mangaInput = LocalMangaParser.getOrNull(file) runCatchingCancellable { val mangaInfo = mangaInput?.getMangaInfo() if (mangaInfo != null && mangaInfo.id == remoteManga.id) { send(mangaInput) } }.onFailure { it.printStackTraceDebug() } } } }.firstOrNull()?.getManga(withDetails) }.onSuccess { x: LocalManga? -> if (x != null) { localMangaIndex.put(x) } }.onFailure { it.printStackTraceDebug() }.getOrNull() override suspend fun getPageUrl(page: MangaPage) = page.url override suspend fun getRelated(seed: Manga): List = emptyList() suspend fun getOutputDir(manga: Manga, fallback: File?): File? { val defaultDir = fallback?.takeIfWriteable() ?: storageManager.getDefaultWriteableDir() if (defaultDir != null && LocalMangaOutput.get(defaultDir, manga) != null) { return defaultDir } return storageManager.getWriteableDirs() .firstOrNull { LocalMangaOutput.get(it, manga) != null } ?: defaultDir } suspend fun cleanup(): Boolean { if (lock.isNotEmpty()) { return false } val dirs = storageManager.getWriteableDirs() runInterruptible(Dispatchers.IO) { val filter = TempFileFilter() dirs.forEach { dir -> dir.withChildren { children -> children.forEach { child -> if (filter.accept(child)) { child.deleteRecursively() } } } } } return true } fun getRawListAsFlow(): Flow = channelFlow { val files = getAllFiles() val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM) for (file in files) { launch(dispatcher) { runCatchingCancellable { LocalMangaParser.getOrNull(file)?.getManga(withDetails = false) }.onFailure { e -> e.printStackTraceDebug() }.onSuccess { m -> if (m != null) send(m) } } } } private suspend fun getRawList(): ArrayList = getRawListAsFlow().toCollection(ArrayList()) private suspend fun getAllFiles() = storageManager.getReadableDirs() .asSequence() .flatMap { dir -> dir.withChildren { children -> children.filterNot { it.isHidden || it.shouldSkip() }.toList() } } private fun Collection.unwrap(): List = map { it.manga } private fun File.shouldSkip(): Boolean = isDirectory && File(this, FILENAME_SKIP).exists() } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageCache.kt ================================================ package org.koitharu.kotatsu.local.data import android.content.Context import android.graphics.Bitmap import android.os.StatFs import android.webkit.MimeTypeMap import com.tomclaw.cache.DiskLruCache import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext import okio.Source import okio.buffer import okio.sink import okio.use import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.ext.MimeType import org.koitharu.kotatsu.core.util.ext.compressToPNG import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.subdir import org.koitharu.kotatsu.core.util.ext.takeIfReadable import org.koitharu.kotatsu.core.util.ext.takeIfWriteable import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy import java.io.File import java.util.UUID class LocalStorageCache( context: Context, private val dir: CacheDir, private val defaultSize: Long, private val minSize: Long, ) { private val cacheDir = suspendLazy { val dirs = context.externalCacheDirs + context.cacheDir dirs.firstNotNullOf { it?.subdir(dir.dir)?.takeIfWriteable() } } private val lruCache = suspendLazy { val dir = cacheDir.get() val availableSize = (getAvailableSize() * 0.8).toLong() val size = defaultSize.coerceAtMost(availableSize).coerceAtLeast(minSize) runCatchingCancellable { DiskLruCache.create(dir, size) }.recoverCatching { error -> error.printStackTraceDebug() dir.deleteRecursively() dir.mkdir() DiskLruCache.create(dir, size) }.getOrThrow() } suspend operator fun get(url: String): File? = withContext(Dispatchers.IO) { val cache = lruCache.get() runInterruptible { cache.get(url)?.takeIfReadable() } } suspend operator fun set(url: String, source: Source, mimeType: MimeType?): File = withContext(Dispatchers.IO) { val file = createBufferFile(url, mimeType) try { val bytes = file.sink(append = false).buffer().use { it.writeAllCancellable(source) } if (bytes == 0L) { throw NoDataReceivedException(url) } val cache = lruCache.get() runInterruptible { cache.put(url, file) } } finally { file.delete() } } suspend operator fun set(url: String, bitmap: Bitmap): File = withContext(Dispatchers.IO) { val file = createBufferFile(url, MimeType("image/png")) try { bitmap.compressToPNG(file) val cache = lruCache.get() runInterruptible { cache.put(url, file) } } finally { file.delete() } } suspend fun clear() { val cache = lruCache.get() runInterruptible(Dispatchers.IO) { cache.clearCache() } } private suspend fun getAvailableSize(): Long = runCatchingCancellable { val dir = cacheDir.get() runInterruptible(Dispatchers.IO) { val statFs = StatFs(dir.absolutePath) statFs.availableBytes } }.onFailure { it.printStackTraceDebug() }.getOrDefault(defaultSize) private suspend fun createBufferFile(url: String, mimeType: MimeType?): File { val ext = MimeTypes.getExtension(mimeType) ?: MimeTypeMap.getFileExtensionFromUrl(url).ifNullOrEmpty { "dat" } val cacheDir = cacheDir.get() val rootDir = checkNotNull(cacheDir.parentFile) { "Cannot get parent for ${cacheDir.absolutePath}" } val name = UUID.randomUUID().toString() + "." + ext return File(rootDir, name) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageManager.kt ================================================ package org.koitharu.kotatsu.local.data import android.Manifest import android.content.ContentResolver import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Environment import android.os.StatFs import androidx.annotation.WorkerThread import androidx.core.content.ContextCompat import androidx.core.net.toFile import dagger.Reusable import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext import okhttp3.Cache import org.koitharu.kotatsu.core.LocalizedAppContext import org.koitharu.kotatsu.core.exceptions.NonFileUriException import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.computeSize import org.koitharu.kotatsu.core.util.ext.getStorageName import org.koitharu.kotatsu.core.util.ext.isFileUri import org.koitharu.kotatsu.core.util.ext.isReadable import org.koitharu.kotatsu.core.util.ext.isWriteable import org.koitharu.kotatsu.core.util.ext.resolveFile import org.koitharu.kotatsu.core.util.ext.takeIfWriteable import org.koitharu.kotatsu.parsers.util.mapToSet import java.io.File import javax.inject.Inject private const val DIR_NAME = "manga" private const val NOMEDIA = ".nomedia" private const val CACHE_DISK_PERCENTAGE = 0.02 private const val CACHE_SIZE_MIN: Long = 10 * 1024 * 1024 // 10MB private const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB @Reusable class LocalStorageManager @Inject constructor( @LocalizedAppContext private val context: Context, private val settings: AppSettings, ) { val contentResolver: ContentResolver get() = context.contentResolver @WorkerThread fun createHttpCache(): Cache { val directory = File(context.externalCacheDir ?: context.cacheDir, "http") directory.mkdirs() val maxSize = calculateDiskCacheSize(directory) return Cache(directory, maxSize) } suspend fun computeCacheSize(cache: CacheDir) = withContext(Dispatchers.IO) { getCacheDirs(cache.dir).sumOf { it.computeSize() } } suspend fun computeCacheSize() = withContext(Dispatchers.IO) { getCacheDirs().sumOf { it.computeSize() } } suspend fun computeStorageSize() = withContext(Dispatchers.IO) { getConfiguredStorageDirs().sumOf { it.computeSize() } } suspend fun computeAvailableSize() = runInterruptible(Dispatchers.IO) { getConfiguredStorageDirs().mapToSet { it.freeSpace }.sum() } suspend fun clearCache(cache: CacheDir) = runInterruptible(Dispatchers.IO) { getCacheDirs(cache.dir).forEach { it.deleteRecursively() } } suspend fun getReadableDirs(): List = runInterruptible(Dispatchers.IO) { getConfiguredStorageDirs() .filter { it.isReadable() } } suspend fun getWriteableDirs(): List = runInterruptible(Dispatchers.IO) { getConfiguredStorageDirs() .filter { it.isWriteable() } } suspend fun getDefaultWriteableDir(): File? = runInterruptible(Dispatchers.IO) { val preferredDir = settings.mangaStorageDir?.takeIfWriteable() preferredDir ?: getFallbackStorageDir()?.takeIfWriteable() } suspend fun getApplicationStorageDirs(): Set = runInterruptible(Dispatchers.IO) { getAvailableStorageDirs() } suspend fun resolveUri(uri: Uri): File = runInterruptible(Dispatchers.IO) { if (uri.isFileUri()) { uri.toFile() } else { uri.resolveFile(context) ?: throw NonFileUriException(uri) } } suspend fun setDirIsNoMedia(dir: File) = runInterruptible(Dispatchers.IO) { File(dir, NOMEDIA).createNewFile() } fun takePermissions(uri: Uri) { val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION contentResolver.takePersistableUriPermission(uri, flags) } fun isOnExternalStorage(file: File): Boolean { return !file.absolutePath.contains(context.packageName) } fun hasExternalStoragePermission(isReadOnly: Boolean): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { Environment.isExternalStorageManager() } else { val permission = if (isReadOnly) { Manifest.permission.READ_EXTERNAL_STORAGE } else { Manifest.permission.WRITE_EXTERNAL_STORAGE } ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED } } suspend fun getDirectoryDisplayName(dir: File, isFullPath: Boolean): String = runInterruptible(Dispatchers.IO) { val packageName = context.packageName if (dir.absolutePath.contains(packageName)) { dir.getStorageName(context) } else if (isFullPath) { dir.path } else { dir.name } } @WorkerThread private fun getConfiguredStorageDirs(): MutableSet { val set = getAvailableStorageDirs() set.addAll(settings.userSpecifiedMangaDirectories) return set } @WorkerThread private fun getAvailableStorageDirs(): MutableSet { val result = LinkedHashSet() result += File(context.filesDir, DIR_NAME) context.getExternalFilesDirs(DIR_NAME).filterNotNullTo(result) result.retainAll { it.exists() || it.mkdirs() } return result } @WorkerThread private fun getFallbackStorageDir(): File? { return context.getExternalFilesDir(DIR_NAME) ?: File(context.filesDir, DIR_NAME).takeIf { it.exists() || it.mkdirs() } } @WorkerThread private fun getCacheDirs(subDir: String): MutableSet { val result = LinkedHashSet() result += File(context.cacheDir, subDir) context.externalCacheDirs.mapNotNullTo(result) { File(it ?: return@mapNotNullTo null, subDir) } return result } @WorkerThread private fun getCacheDirs(): MutableSet { val result = LinkedHashSet() result += context.cacheDir context.externalCacheDirs.filterNotNullTo(result) return result } private fun calculateDiskCacheSize(cacheDirectory: File): Long { return try { val cacheDir = StatFs(cacheDirectory.absolutePath) val size = CACHE_DISK_PERCENTAGE * cacheDir.blockCountLong * cacheDir.blockSizeLong return size.toLong().coerceIn(CACHE_SIZE_MIN, CACHE_SIZE_MAX) } catch (_: Exception) { CACHE_SIZE_MIN } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/data/MangaIndex.kt ================================================ package org.koitharu.kotatsu.local.data import androidx.annotation.WorkerThread import okio.FileSystem import okio.Path import okio.Path.Companion.toOkioPath import okio.buffer import org.jetbrains.annotations.Blocking import org.json.JSONArray import org.json.JSONObject import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug 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.MangaSource import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault import org.koitharu.kotatsu.parsers.util.json.getEnumValueOrNull import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault import org.koitharu.kotatsu.parsers.util.json.getStringOrNull import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet import org.koitharu.kotatsu.parsers.util.json.toStringSet import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.toTitleCase import java.io.File class MangaIndex(source: String?) { private val json: JSONObject = source?.let(::JSONObject) ?: JSONObject() fun setMangaInfo(manga: Manga) { require(!manga.isLocal) { "Local manga information cannot be stored" } json.put(KEY_ID, manga.id) json.put(KEY_TITLE, manga.title) json.put(KEY_TITLE_ALT, manga.altTitle) // for backward compatibility json.put(KEY_ALT_TITLES, JSONArray(manga.altTitles)) json.put(KEY_URL, manga.url) json.put(KEY_PUBLIC_URL, manga.publicUrl) json.put(KEY_AUTHOR, manga.author) // for backward compatibility json.put(KEY_AUTHORS, JSONArray(manga.authors)) json.put(KEY_COVER, manga.coverUrl) json.put(KEY_DESCRIPTION, manga.description) json.put(KEY_RATING, manga.rating) json.put(KEY_CONTENT_RATING, manga.contentRating) json.put(KEY_NSFW, manga.isNsfw) // for backward compatibility json.put(KEY_STATE, manga.state?.name) json.put(KEY_SOURCE, manga.source.name) json.put(KEY_COVER_LARGE, manga.largeCoverUrl) json.put( KEY_TAGS, JSONArray().also { a -> for (tag in manga.tags) { val jo = JSONObject() jo.put(KEY_KEY, tag.key) jo.put(KEY_TITLE, tag.title) a.put(jo) } }, ) if (!json.has(KEY_CHAPTERS)) { json.put(KEY_CHAPTERS, JSONObject()) } json.put(KEY_APP_ID, BuildConfig.APPLICATION_ID) json.put(KEY_APP_VERSION, BuildConfig.VERSION_CODE) } fun getMangaInfo(): Manga? = if (json.length() == 0) null else runCatching { val source = MangaSource(json.getString(KEY_SOURCE)) Manga( id = json.getLong(KEY_ID), title = json.getString(KEY_TITLE), altTitles = json.optJSONArray(KEY_ALT_TITLES)?.toStringSet() ?: setOfNotNull(json.getStringOrNull(KEY_TITLE_ALT)), url = json.getString(KEY_URL), publicUrl = json.getStringOrNull(KEY_PUBLIC_URL).orEmpty(), authors = json.optJSONArray(KEY_AUTHORS)?.toStringSet() ?: setOfNotNull(json.getStringOrNull(KEY_AUTHOR)), largeCoverUrl = json.getStringOrNull(KEY_COVER_LARGE), source = source, rating = json.getFloatOrDefault(KEY_RATING, RATING_UNKNOWN), contentRating = json.getEnumValueOrNull(KEY_CONTENT_RATING, ContentRating::class.java) ?: if (json.getBooleanOrDefault(KEY_NSFW, false)) ContentRating.ADULT else null, coverUrl = json.getStringOrNull(KEY_COVER), state = json.getEnumValueOrNull(KEY_STATE, MangaState::class.java), description = json.getStringOrNull(KEY_DESCRIPTION), tags = json.getJSONArray(KEY_TAGS).mapJSONToSet { x -> MangaTag( title = x.getString(KEY_TITLE).toTitleCase(), key = x.getString(KEY_KEY), source = source, ) }, chapters = getChapters(json.getJSONObject(KEY_CHAPTERS), source), ) }.getOrNull() fun getCoverEntry(): String? = json.getStringOrNull(KEY_COVER_ENTRY) fun addChapter(chapter: IndexedValue, filename: String?) { val chapters = json.getJSONObject(KEY_CHAPTERS) if (!chapters.has(chapter.value.id.toString())) { val jo = JSONObject() jo.put(KEY_NUMBER, chapter.value.number) jo.put(KEY_VOLUME, chapter.value.volume) jo.put(KEY_URL, chapter.value.url) jo.put(KEY_NAME, chapter.value.title.orEmpty()) jo.put(KEY_UPLOAD_DATE, chapter.value.uploadDate) jo.put(KEY_SCANLATOR, chapter.value.scanlator) jo.put(KEY_BRANCH, chapter.value.branch) jo.put(KEY_ENTRIES, "%08d_%04d\\d{4}".format(chapter.value.branch.hashCode(), chapter.index + 1)) jo.put(KEY_FILE, filename) chapters.put(chapter.value.id.toString(), jo) } } fun removeChapter(id: Long): Boolean { return json.has(KEY_CHAPTERS) && json.getJSONObject(KEY_CHAPTERS).remove(id.toString()) != null } fun getChapterFileName(chapterId: Long): String? { return json.optJSONObject(KEY_CHAPTERS)?.optJSONObject(chapterId.toString())?.getStringOrNull(KEY_FILE) } fun setCoverEntry(name: String) { json.put(KEY_COVER_ENTRY, name) } fun getChapterNamesPattern(chapter: MangaChapter) = Regex( json.getJSONObject(KEY_CHAPTERS) .getJSONObject(chapter.id.toString()) .getString(KEY_ENTRIES), ) fun sortChaptersByName() { val jo = json.getJSONObject(KEY_CHAPTERS) val list = ArrayList(jo.length()) jo.keys().forEach { id -> val item = jo.getJSONObject(id) item.put(KEY_ID, id) list.add(item) } val comparator = org.koitharu.kotatsu.core.util.AlphanumComparator() list.sortWith(compareBy(comparator) { it.getString(KEY_NAME) }) val newJo = JSONObject() list.forEachIndexed { i, obj -> obj.put(KEY_NUMBER, i + 1) val id = obj.remove(KEY_ID) as String newJo.put(id, obj) } json.put(KEY_CHAPTERS, newJo) } fun clear() { val keys = json.keys() while (keys.hasNext()) { json.remove(keys.next()) } } fun setFrom(other: MangaIndex) { clear() other.json.keys().forEach { key -> json.putOpt(key, other.json.opt(key)) } } private fun getChapters(json: JSONObject, source: MangaSource): List { val chapters = ArrayList(json.length()) for (k in json.keys()) { val v = json.getJSONObject(k) chapters.add( MangaChapter( id = k.toLong(), title = v.getStringOrNull(KEY_NAME), url = v.getString(KEY_URL), number = v.getFloatOrDefault(KEY_NUMBER, 0f), volume = v.getIntOrDefault(KEY_VOLUME, 0), uploadDate = v.getLongOrDefault(KEY_UPLOAD_DATE, 0L), scanlator = v.getStringOrNull(KEY_SCANLATOR), branch = v.getStringOrNull(KEY_BRANCH), source = source, ), ) } return chapters.sortedBy { it.number } } override fun toString(): String = if (BuildConfig.DEBUG) { json.toString(4) } else { json.toString() } companion object { private const val KEY_ID = "id" private const val KEY_TITLE = "title" private const val KEY_TITLE_ALT = "title_alt" private const val KEY_ALT_TITLES = "alt_titles" private const val KEY_URL = "url" private const val KEY_PUBLIC_URL = "public_url" private const val KEY_AUTHOR = "author" private const val KEY_AUTHORS = "authors" private const val KEY_COVER = "cover" private const val KEY_DESCRIPTION = "description" private const val KEY_RATING = "rating" private const val KEY_CONTENT_RATING = "content_rating" private const val KEY_NSFW = "nsfw" private const val KEY_STATE = "state" private const val KEY_SOURCE = "source" private const val KEY_COVER_LARGE = "cover_large" private const val KEY_TAGS = "tags" private const val KEY_CHAPTERS = "chapters" private const val KEY_NUMBER = "number" private const val KEY_VOLUME = "volume" private const val KEY_NAME = "name" private const val KEY_UPLOAD_DATE = "uploadDate" private const val KEY_SCANLATOR = "scanlator" private const val KEY_BRANCH = "branch" private const val KEY_ENTRIES = "entries" private const val KEY_FILE = "file" private const val KEY_COVER_ENTRY = "cover_entry" private const val KEY_KEY = "key" private const val KEY_APP_ID = "app_id" private const val KEY_APP_VERSION = "app_version" @Blocking @WorkerThread fun read(fileSystem: FileSystem, path: Path): MangaIndex? = runCatchingCancellable { if (!fileSystem.exists(path)) { return@runCatchingCancellable null } val text = fileSystem.source(path).use { it.buffer().use { buffer -> buffer.readUtf8() } } if (text.length > 2) { MangaIndex(text) } else { null } }.onFailure { e -> e.printStackTraceDebug() }.getOrNull() @Blocking @WorkerThread fun read(file: File): MangaIndex? = read(FileSystem.SYSTEM, file.toOkioPath()) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/data/Qualifiers.kt ================================================ package org.koitharu.kotatsu.local.data import javax.inject.Qualifier @Qualifier @Retention(AnnotationRetention.BINARY) annotation class LocalStorageChanges ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/data/TempFileFilter.kt ================================================ package org.koitharu.kotatsu.local.data import java.io.File import java.io.FileFilter class TempFileFilter : FileFilter { override fun accept(file: File): Boolean { return file.name.endsWith(".tmp", ignoreCase = true) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/data/importer/SingleMangaImporter.kt ================================================ package org.koitharu.kotatsu.local.data.importer import android.content.Context import android.net.Uri import androidx.documentfile.provider.DocumentFile import dagger.Reusable import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext import okio.buffer import okio.sink import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.util.ext.openSource import org.koitharu.kotatsu.core.util.ext.resolveName import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.hasZipExtension import org.koitharu.kotatsu.local.data.input.LocalMangaParser import org.koitharu.kotatsu.local.domain.model.LocalManga import java.io.File import java.io.IOException import javax.inject.Inject @Reusable class SingleMangaImporter @Inject constructor( @ApplicationContext private val context: Context, private val storageManager: LocalStorageManager, @LocalStorageChanges private val localStorageChanges: MutableSharedFlow, ) { private val contentResolver = context.contentResolver suspend fun import(uri: Uri): LocalManga { val result = if (isDirectory(uri)) { importDirectory(uri) } else { importFile(uri) } localStorageChanges.emit(result) return result } private suspend fun importFile(uri: Uri): LocalManga = withContext(Dispatchers.IO) { val contentResolver = storageManager.contentResolver val name = contentResolver.resolveName(uri) ?: throw IOException("Cannot fetch name from uri: $uri") if (!hasZipExtension(name)) { throw UnsupportedFileException("Unsupported file $name on $uri") } val dest = File(getOutputDir(), name) runInterruptible { contentResolver.openSource(uri) }.use { source -> dest.sink().buffer().use { output -> output.writeAllCancellable(source) } } LocalMangaParser(dest).getManga(withDetails = false) } private suspend fun importDirectory(uri: Uri): LocalManga { val root = requireNotNull(DocumentFile.fromTreeUri(context, uri)) { "Provided uri $uri is not a tree" } val dest = File(getOutputDir(), root.requireName()) dest.mkdir() for (docFile in root.listFiles()) { docFile.copyTo(dest) } return LocalMangaParser(dest).getManga(withDetails = false) } private suspend fun DocumentFile.copyTo(destDir: File) { if (isDirectory) { val subDir = File(destDir, requireName()) subDir.mkdir() for (docFile in listFiles()) { docFile.copyTo(subDir) } } else { source().use { input -> File(destDir, requireName()).sink().buffer().use { output -> output.writeAllCancellable(input) } } } } private suspend fun getOutputDir(): File { return storageManager.getDefaultWriteableDir() ?: throw IOException("External files dir unavailable") } private suspend fun DocumentFile.source() = runInterruptible(Dispatchers.IO) { contentResolver.openSource(uri) } private fun DocumentFile.requireName(): String { return name ?: throw IOException("Cannot fetch name from uri: $uri") } private fun isDirectory(uri: Uri): Boolean { return runCatching { DocumentFile.fromTreeUri(context, uri) }.isSuccess } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt ================================================ package org.koitharu.kotatsu.local.data.index import android.content.Context import androidx.core.content.edit import androidx.room.withTransaction import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.input.LocalMangaParser import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.io.File import javax.inject.Inject import javax.inject.Provider import javax.inject.Singleton @Singleton class LocalMangaIndex @Inject constructor( private val mangaDataRepository: MangaDataRepository, private val db: MangaDatabase, @ApplicationContext context: Context, private val localMangaRepositoryProvider: Provider, ) : FlowCollector { private val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) private val mutex = Mutex() private var currentVersion: Int get() = prefs.getInt(KEY_VERSION, 0) set(value) = prefs.edit { putInt(KEY_VERSION, value) } override suspend fun emit(value: LocalManga?) { if (value != null) { put(value) } } suspend fun update() = mutex.withLock { db.withTransaction { val dao = db.getLocalMangaIndexDao() dao.clear() localMangaRepositoryProvider.get() .getRawListAsFlow() .collect { upsert(it) } } currentVersion = VERSION } suspend fun updateIfRequired() { if (isUpdateRequired()) { update() } } suspend fun get(mangaId: Long, withDetails: Boolean): LocalManga? { updateIfRequired() var path = db.getLocalMangaIndexDao().findPath(mangaId) if (path == null && mutex.isLocked) { // wait for updating complete path = mutex.withLock { db.getLocalMangaIndexDao().findPath(mangaId) } } if (path == null) { return null } return runCatchingCancellable { LocalMangaParser(File(path)).getManga(withDetails) }.onFailure { it.printStackTraceDebug() }.getOrNull() } suspend operator fun contains(mangaId: Long): Boolean { return db.getLocalMangaIndexDao().findPath(mangaId) != null } suspend fun put(manga: LocalManga) = mutex.withLock { db.withTransaction { upsert(manga) } } suspend fun delete(mangaId: Long) { db.getLocalMangaIndexDao().delete(mangaId) } suspend fun getAvailableTags(skipNsfw: Boolean): List { val dao = db.getLocalMangaIndexDao() return if (skipNsfw) { dao.findTags(isNsfw = false) } else { dao.findTags() } } private suspend fun upsert(manga: LocalManga) { mangaDataRepository.storeManga(manga.manga, replaceExisting = true) db.getLocalMangaIndexDao().upsert(manga.toEntity()) } private fun LocalManga.toEntity() = LocalMangaIndexEntity( mangaId = manga.id, path = file.path, ) private fun isUpdateRequired() = currentVersion < VERSION companion object { private const val PREF_NAME = "_local_index" private const val KEY_VERSION = "ver" private const val VERSION = 1 } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndexDao.kt ================================================ package org.koitharu.kotatsu.local.data.index import androidx.room.Dao import androidx.room.Query import androidx.room.Upsert @Dao interface LocalMangaIndexDao { @Query("SELECT path FROM local_index WHERE manga_id = :mangaId") suspend fun findPath(mangaId: Long): String? @Query("SELECT title FROM local_index LEFT JOIN manga_tags ON manga_tags.manga_id = local_index.manga_id LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id WHERE title IS NOT NULL GROUP BY title") suspend fun findTags(): List @Query("SELECT title FROM local_index LEFT JOIN manga_tags ON manga_tags.manga_id = local_index.manga_id LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id WHERE (SELECT nsfw FROM manga WHERE manga.manga_id = local_index.manga_id) = :isNsfw AND title IS NOT NULL GROUP BY title") suspend fun findTags(isNsfw: Boolean): List @Upsert suspend fun upsert(entity: LocalMangaIndexEntity) @Query("DELETE FROM local_index WHERE manga_id = :mangaId") suspend fun delete(mangaId: Long) @Query("DELETE FROM local_index") suspend fun clear() } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndexEntity.kt ================================================ package org.koitharu.kotatsu.local.data.index import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.PrimaryKey import org.koitharu.kotatsu.core.db.entity.MangaEntity @Entity( tableName = "local_index", foreignKeys = [ ForeignKey( entity = MangaEntity::class, parentColumns = ["manga_id"], childColumns = ["manga_id"], onDelete = ForeignKey.CASCADE, ), ], ) class LocalMangaIndexEntity( @PrimaryKey(autoGenerate = false) @ColumnInfo(name = "manga_id") val mangaId: Long, @ColumnInfo(name = "path") val path: String, ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaParser.kt ================================================ package org.koitharu.kotatsu.local.data.input import android.net.Uri import androidx.core.net.toFile import androidx.core.net.toUri import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch import kotlinx.coroutines.runInterruptible import okio.FileSystem import okio.Path import okio.Path.Companion.toOkioPath import okio.Path.Companion.toPath import okio.openZip import org.jetbrains.annotations.Blocking import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.util.AlphanumComparator import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP import org.koitharu.kotatsu.core.util.ext.isDirectory import org.koitharu.kotatsu.core.util.ext.isFileUri import org.koitharu.kotatsu.core.util.ext.isImage import org.koitharu.kotatsu.core.util.ext.isRegularFile import org.koitharu.kotatsu.core.util.ext.isZipUri import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.toFileNameSafe import org.koitharu.kotatsu.core.util.ext.toListSorted import org.koitharu.kotatsu.local.data.MangaIndex import org.koitharu.kotatsu.local.data.hasZipExtension import org.koitharu.kotatsu.local.data.isZipArchive import org.koitharu.kotatsu.local.data.output.LocalMangaOutput.Companion.ENTRY_NAME_INDEX import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.util.longHashCode import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.toTitleCase import java.io.File /** * Manga root {dir or zip file} * |--- index.json (optional) * |--- Page 1.png * |--- Page 2.png * |---Chapter 1/(dir or zip, optional) * |------Page 1.1.png * : * L--- Page x.png */ class LocalMangaParser(private val uri: Uri) { constructor(file: File) : this(file.toUri()) private val rootFile: File = File(uri.schemeSpecificPart) suspend fun getManga(withDetails: Boolean): LocalManga = runInterruptible(Dispatchers.IO) { (uri.resolveFsAndPath()).use { (fileSystem, rootPath) -> val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX) val mangaInfo = index?.getMangaInfo() if (mangaInfo != null) { val coverEntry: Path? = index.getCoverEntry()?.let { rootPath / it }?.takeIf { fileSystem.exists(it) } mangaInfo.copy( source = LocalMangaSource, url = rootFile.toUri().toString(), coverUrl = coverEntry?.let { uri.child(it, resolve = true).toString() } ?: fileSystem.findFirstImageUri(rootPath)?.toString(), largeCoverUrl = null, chapters = if (withDetails) { mangaInfo.chapters?.mapNotNull { c -> val path = index.getChapterFileName(c.id)?.toPath() if (path != null && !fileSystem.exists(rootPath / path)) { null } else { c.copy( url = path?.let { uri.child(it, resolve = false).toString() } ?: uri.toString(), source = LocalMangaSource, ) } } } else { null }, ) } else { val title = rootFile.name.fileNameToTitle() Manga( id = rootFile.absolutePath.longHashCode(), title = title, url = rootFile.toUri().toString(), publicUrl = rootFile.toUri().toString(), source = LocalMangaSource, coverUrl = fileSystem.findFirstImageUri(rootPath)?.toString(), chapters = if (withDetails) { val chapters = fileSystem.listRecursively(rootPath) .mapNotNullTo(HashSet()) { path -> when { !fileSystem.isRegularFile(path) -> null path.isImage() -> path.parent hasZipExtension(path.name) -> path else -> null } }.sortedWith(compareBy(AlphanumComparator()) { x -> x.toString() }) chapters.mapIndexed { i, p -> val s = if (p.root == rootPath.root) { p.relativeTo(rootPath).toString() } else { p }.toString().removePrefix(Path.DIRECTORY_SEPARATOR) MangaChapter( id = "$i$s".longHashCode(), title = p.userFriendlyName(), number = 0f, volume = 0, source = LocalMangaSource, uploadDate = 0L, url = uri.child(p.relativeTo(rootPath), resolve = false).toString(), scanlator = null, branch = null, ) } } else { null }, altTitles = emptySet(), rating = -1f, contentRating = null, tags = emptySet(), state = null, authors = emptySet(), largeCoverUrl = null, description = null, ) }.let { LocalManga(it, rootFile) } } } suspend fun getMangaInfo(): Manga? = runInterruptible(Dispatchers.IO) { uri.resolveFsAndPath().use { (fileSystem, rootPath) -> val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX) index?.getMangaInfo() } } suspend fun getPages(chapter: MangaChapter): List = runInterruptible(Dispatchers.IO) { val chapterUri = chapter.url.toUri().resolve() chapterUri.resolveFsAndPath().use { (fileSystem, rootPath) -> val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX) val entries = fileSystem.listRecursively(rootPath) .filter { fileSystem.isRegularFile(it) } if (index != null) { val pattern = index.getChapterNamesPattern(chapter) entries.filter { x -> x.name.substringBefore('.').matches(pattern) } } else { entries.filter { x -> x.isImage() && x.parent == rootPath } }.toListSorted(compareBy(AlphanumComparator()) { x -> x.toString() }) .map { x -> val entryUri = chapterUri.child(x, resolve = true).toString() MangaPage( id = entryUri.longHashCode(), url = entryUri, preview = null, source = LocalMangaSource, ) } } } private fun Uri.child(path: Path, resolve: Boolean): Uri { val file = fileFromPath() val builder = buildUpon() val isZip = isZipUri() || file.isZipArchive if (isZip) { builder.scheme(URI_SCHEME_ZIP) } if (isZip || !resolve) { builder.fragment(path.toString().removePrefix(Path.DIRECTORY_SEPARATOR)) } else { builder.appendEncodedPath(path.relativeTo(file.toOkioPath()).toString()) } return builder.build() } private fun FileSystem.findFirstImageUri( rootPath: Path, recursive: Boolean = false ): Uri? = runCatchingCancellable { val list = list(rootPath) for (file in list.sortedWith(compareBy(AlphanumComparator()) { x -> x.name })) { if (isRegularFile(file)) { if (file.isImage()) { return@runCatchingCancellable uri.child(file, resolve = true) } if (recursive && file.isZip()) { openZip(file).use { zipFs -> zipFs.findFirstImageUri(Path.DIRECTORY_SEPARATOR.toPath())?.let { subUri -> val subPath = subUri.path.orEmpty().removePrefix(uri.path.orEmpty()) .replace(REGEX_PARENT_PATH_PREFIX, "") return@runCatchingCancellable uri.child(file, resolve = true) .child(subPath.toPath(), resolve = false) } } } } else if (recursive && isDirectory(file)) { findFirstImageUri(file, true)?.let { return@runCatchingCancellable it } } } if (recursive) { null } else { findFirstImageUri(rootPath, recursive = true) } }.onFailure { e -> e.printStackTraceDebug() }.getOrNull() private fun Path.userFriendlyName(): String = name.substringBeforeLast('.') .replace('_', ' ') .toTitleCase() private class FsAndPath( val fileSystem: FileSystem, val path: Path, private val isCloseable: Boolean, ) : AutoCloseable { override fun close() { if (isCloseable) { fileSystem.close() } } operator fun component1() = fileSystem operator fun component2() = path } companion object { private val REGEX_PARENT_PATH_PREFIX = Regex("^(/\\.\\.)+") @Blocking fun getOrNull(file: File): LocalMangaParser? = if ((file.isDirectory || file.isZipArchive) && file.canRead()) { LocalMangaParser(file) } else { null } suspend fun find(roots: Iterable, manga: Manga): LocalMangaParser? = channelFlow { val fileName = manga.title.toFileNameSafe() for (root in roots) { launch { val parser = getOrNull(File(root, fileName)) ?: getOrNull(File(root, "$fileName.cbz")) val info = runCatchingCancellable { parser?.getMangaInfo() }.getOrNull() if (info?.id == manga.id) { send(parser) } } } }.flowOn(Dispatchers.Default).firstOrNull() private fun Path.isImage(): Boolean = MimeTypes.getMimeTypeFromExtension(name)?.isImage == true private fun Path.isZip(): Boolean = hasZipExtension(name) private fun Uri.resolve(): Uri = if (isFileUri()) { val file = toFile() if (file.isZipArchive) { this } else if (file.isDirectory) { file.resolve(fragment.orEmpty()).toUri() } else { this } } else { this } private fun Uri.fileFromPath(): File = File(requireNotNull(path) { "Uri path is null: $this" }) @Blocking private fun Uri.resolveFsAndPath(): FsAndPath { val resolved = resolve() return when { resolved.isZipUri() -> FsAndPath( FileSystem.SYSTEM.openZip(resolved.schemeSpecificPart.toPath()), resolved.fragment.orEmpty().toRootedPath(), isCloseable = true, ) isFileUri() -> { val file = toFile() if (file.isZipArchive) { FsAndPath( FileSystem.SYSTEM.openZip(schemeSpecificPart.toPath()), fragment.orEmpty().toRootedPath(), isCloseable = true, ) } else { FsAndPath(FileSystem.SYSTEM, file.toOkioPath(), isCloseable = false) } } else -> throw IllegalArgumentException("Unsupported uri $resolved") } } private fun String.toRootedPath(): Path = if (startsWith(Path.DIRECTORY_SEPARATOR)) { this } else { Path.DIRECTORY_SEPARATOR + this }.toPath() private fun String.fileNameToTitle() = substringBeforeLast('.') .replace('_', ' ') .replaceFirstChar { it.uppercase() } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt ================================================ package org.koitharu.kotatsu.local.data.output import androidx.core.net.toFile import androidx.core.net.toUri import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.internal.closeQuietly import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.ext.MimeType import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.takeIfReadable import org.koitharu.kotatsu.core.util.ext.toFileNameSafe import org.koitharu.kotatsu.core.zip.ZipOutput import org.koitharu.kotatsu.local.data.MangaIndex import org.koitharu.kotatsu.local.data.input.LocalMangaParser import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.util.nullIfEmpty import java.io.File class LocalMangaDirOutput( rootFile: File, manga: Manga, ) : LocalMangaOutput(rootFile) { private val chaptersOutput = HashMap() private val index = MangaIndex(File(rootFile, ENTRY_NAME_INDEX).takeIfReadable()?.readText()) private val mutex = Mutex() init { if (!manga.isLocal) { index.setMangaInfo(manga) } } override suspend fun mergeWithExisting() = Unit override suspend fun addCover(file: File, type: MimeType?) = mutex.withLock { val name = buildString { append("cover") MimeTypes.getExtension(type)?.let { ext -> append('.') append(ext) } } runInterruptible(Dispatchers.IO) { file.copyTo(File(rootFile, name), overwrite = true) } index.setCoverEntry(name) flushIndex() } override suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, type: MimeType?) = mutex.withLock { val output = chaptersOutput.getOrPut(chapter.value) { ZipOutput(File(rootFile, chapterFileName(chapter) + SUFFIX_TMP)) } val name = buildString { append(FILENAME_PATTERN.format(chapter.value.branch.hashCode(), chapter.index + 1, pageNumber)) MimeTypes.getExtension(type)?.let { ext -> append('.') append(ext) } } runInterruptible(Dispatchers.IO) { output.put(name, file) } index.addChapter(chapter, chapterFileName(chapter)) } override suspend fun flushChapter(chapter: MangaChapter): Boolean = mutex.withLock { val output = chaptersOutput.remove(chapter) ?: return@withLock false output.flushAndFinish() flushIndex() true } override suspend fun finish() = mutex.withLock { flushIndex() for (output in chaptersOutput.values) { output.flushAndFinish() } chaptersOutput.clear() } override suspend fun cleanup() = mutex.withLock { for (output in chaptersOutput.values) { output.file.deleteAwait() } } override fun close() { for (output in chaptersOutput.values) { output.closeQuietly() } } suspend fun deleteChapters(ids: Set) = mutex.withLock { val chapters = checkNotNull( (index.getMangaInfo() ?: LocalMangaParser(rootFile).getManga(withDetails = true).manga).chapters, ) { "No chapters found" }.withIndex() val victimsIds = ids.toMutableSet() for (chapter in chapters) { if (!victimsIds.remove(chapter.value.id)) { continue } val chapterFile = index.getChapterFileName(chapter.value.id)?.let { File(rootFile, it) } ?: chapter.value.url.toUri().toFile() chapterFile.deleteAwait() index.removeChapter(chapter.value.id) } check(victimsIds.isEmpty()) { "${victimsIds.size} of ${ids.size} chapters was not removed: not found" } } fun setIndex(newIndex: MangaIndex) { index.setFrom(newIndex) } private suspend fun ZipOutput.flushAndFinish() = runInterruptible(Dispatchers.IO) { val e: Throwable? = try { finish() null } catch (e: Throwable) { e } finally { close() } if (e == null) { val resFile = File(file.absolutePath.removeSuffix(SUFFIX_TMP)) file.renameTo(resFile) } else { file.delete() throw e } } private fun chapterFileName(chapter: IndexedValue): String { index.getChapterFileName(chapter.value.id)?.let { return it } val baseName = buildString { append(chapter.index) chapter.value.title?.nullIfEmpty()?.let { append('_') append(it.toFileNameSafe()) } if (length > 32) { deleteRange(31, lastIndex) } } var i = 0 while (true) { val name = (if (i == 0) baseName else baseName + "_$i") + ".cbz" if (!File(rootFile, name).exists()) { return name } i++ } } private suspend fun flushIndex() = runInterruptible(Dispatchers.IO) { File(rootFile, ENTRY_NAME_INDEX).writeText(index.toString()) } companion object { private const val FILENAME_PATTERN = "%08d_%04d%04d" } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt ================================================ package org.koitharu.kotatsu.local.data.output import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import okio.Closeable import org.koitharu.kotatsu.core.prefs.DownloadFormat import org.koitharu.kotatsu.core.util.ext.MimeType import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.toFileNameSafe import org.koitharu.kotatsu.local.data.input.LocalMangaParser import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.io.File sealed class LocalMangaOutput( val rootFile: File, ) : Closeable { abstract suspend fun mergeWithExisting() abstract suspend fun addCover(file: File, type: MimeType?) abstract suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, type: MimeType?) abstract suspend fun flushChapter(chapter: MangaChapter): Boolean abstract suspend fun finish() abstract suspend fun cleanup() companion object { const val ENTRY_NAME_INDEX = "index.json" const val SUFFIX_TMP = ".tmp" private val mutex = Mutex() suspend fun getOrCreate( root: File, manga: Manga, format: DownloadFormat, ): LocalMangaOutput = withContext(Dispatchers.IO) { val targetFormat = if (format == DownloadFormat.AUTOMATIC) { if (manga.chapters.let { it != null && it.size <= 3 }) { DownloadFormat.SINGLE_CBZ } else { DownloadFormat.MULTIPLE_CBZ } } else { format } checkNotNull(getImpl(root, manga, onlyIfExists = false, format = targetFormat)) } suspend fun get(root: File, manga: Manga): LocalMangaOutput? = withContext(Dispatchers.IO) { getImpl(root, manga, onlyIfExists = true, format = DownloadFormat.AUTOMATIC) } private suspend fun getImpl( root: File, manga: Manga, onlyIfExists: Boolean, format: DownloadFormat, ): LocalMangaOutput? { mutex.withLock { var i = 0 val baseName = manga.title.toFileNameSafe() while (true) { val fileName = if (i == 0) baseName else baseName + "_$i" val dir = File(root, fileName) val zip = File(root, "$fileName.cbz") i++ return when { dir.isDirectory -> { if (canWriteTo(dir, manga)) { LocalMangaDirOutput(dir, manga) } else { continue } } zip.isFile -> if (canWriteTo(zip, manga)) { LocalMangaZipOutput(zip, manga) } else { continue } !onlyIfExists -> when (format) { DownloadFormat.AUTOMATIC -> null DownloadFormat.SINGLE_CBZ -> LocalMangaZipOutput(zip, manga) DownloadFormat.MULTIPLE_CBZ -> LocalMangaDirOutput(dir, manga) } else -> null } } } } private suspend fun canWriteTo(file: File, manga: Manga): Boolean { val info = runCatchingCancellable { LocalMangaParser(file).getMangaInfo() }.onFailure { it.printStackTraceDebug() }.getOrNull() ?: return false return info.id == manga.id } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaUtil.kt ================================================ package org.koitharu.kotatsu.local.data.output import androidx.core.net.toFile import androidx.core.net.toUri import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.parsers.model.Manga class LocalMangaUtil( private val manga: Manga, ) { init { require(manga.isLocal) { "Expected LOCAL source but ${manga.source} found" } } suspend fun deleteChapters(ids: Set) { val file = manga.url.toUri().toFile() if (file.isDirectory) { LocalMangaDirOutput(file, manga).use { output -> output.deleteChapters(ids) output.finish() } } else { LocalMangaZipOutput.filterChapters(file, manga, ids) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt ================================================ package org.koitharu.kotatsu.local.data.output import androidx.annotation.WorkerThread import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.internal.closeQuietly import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.ext.MimeType import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.readText import org.koitharu.kotatsu.core.zip.ZipOutput import org.koitharu.kotatsu.local.data.MangaIndex import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import java.io.File import java.util.zip.ZipFile class LocalMangaZipOutput( rootFile: File, manga: Manga, ) : LocalMangaOutput(rootFile) { private val output = ZipOutput(File(rootFile.path + ".tmp")) private val index = MangaIndex(null) private val mutex = Mutex() init { if (!manga.isLocal) { index.setMangaInfo(manga) } } override suspend fun mergeWithExisting() = mutex.withLock { if (rootFile.exists()) { runInterruptible(Dispatchers.IO) { mergeWith(rootFile) } } } override suspend fun addCover(file: File, type: MimeType?) = mutex.withLock { val name = buildString { append(FILENAME_PATTERN.format(0, 0, 0)) MimeTypes.getExtension(type)?.let { ext -> append('.') append(ext) } } runInterruptible(Dispatchers.IO) { output.put(name, file) } index.setCoverEntry(name) } override suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, type: MimeType?) = mutex.withLock { val name = buildString { append(FILENAME_PATTERN.format(chapter.value.branch.hashCode(), chapter.index + 1, pageNumber)) MimeTypes.getExtension(type)?.let { ext -> append('.') append(ext) } } runInterruptible(Dispatchers.IO) { output.put(name, file) } index.addChapter(chapter, null) } override suspend fun flushChapter(chapter: MangaChapter): Boolean = false override suspend fun finish() = mutex.withLock { runInterruptible(Dispatchers.IO) { output.use { output -> output.put(ENTRY_NAME_INDEX, index.toString()) output.finish() } } rootFile.deleteAwait() output.file.renameTo(rootFile) Unit } override suspend fun cleanup() = mutex.withLock { output.file.deleteAwait() Unit } override fun close() { output.close() } @WorkerThread private fun mergeWith(other: File) { var otherIndex: MangaIndex? = null ZipFile(other).use { zip -> for (entry in zip.entries()) { if (entry.name == ENTRY_NAME_INDEX) { otherIndex = MangaIndex( zip.getInputStream(entry).use { it.reader().readText() }, ) } else { output.copyEntryFrom(zip, entry) } } } otherIndex?.getMangaInfo()?.chapters?.withIndex()?.let { chapters -> for (chapter in chapters) { index.addChapter(chapter, null) } } } companion object { private const val FILENAME_PATTERN = "%08d_%04d%04d" suspend fun filterChapters(file: File, manga: Manga, idsToRemove: Set) = runInterruptible(Dispatchers.IO) { val subject = LocalMangaZipOutput(file, manga) try { ZipFile(subject.rootFile).use { zip -> val index = MangaIndex(zip.readText(zip.getEntry(ENTRY_NAME_INDEX))) idsToRemove.forEach { id -> index.removeChapter(id) } val patterns = requireNotNull(index.getMangaInfo()?.chapters).map { index.getChapterNamesPattern(it) } val coverEntryName = index.getCoverEntry() for (entry in zip.entries()) { when { entry.name == ENTRY_NAME_INDEX -> { subject.output.put(ENTRY_NAME_INDEX, index.toString()) } entry.isDirectory -> { subject.output.addDirectory(entry.name) } entry.name == coverEntryName -> { subject.output.copyEntryFrom(zip, entry) } else -> { val name = entry.name.substringBefore('.') if (patterns.any { it.matches(name) }) { subject.output.copyEntryFrom(zip, entry) } } } } subject.output.finish() subject.output.close() subject.rootFile.delete() subject.output.file.renameTo(subject.rootFile) } } catch (e: Throwable) { subject.closeQuietly() try { subject.output.file.delete() } catch (e2: Throwable) { e.addSuppressed(e2) } throw e } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteLocalMangaUseCase.kt ================================================ package org.koitharu.kotatsu.local.domain import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.io.IOException import javax.inject.Inject class DeleteLocalMangaUseCase @Inject constructor( private val localMangaRepository: LocalMangaRepository, private val historyRepository: HistoryRepository, ) { suspend operator fun invoke(manga: Manga) { val victim = if (manga.isLocal) manga else localMangaRepository.findSavedManga(manga)?.manga checkNotNull(victim) { "Cannot find saved manga for ${manga.title}" } val original = if (manga.isLocal) localMangaRepository.getRemoteManga(manga) else manga localMangaRepository.delete(victim) || throw IOException("Unable to delete file") runCatchingCancellable { historyRepository.deleteOrSwap(victim, original) }.onFailure { it.printStackTraceDebug() } } suspend operator fun invoke(ids: Set) { val list = localMangaRepository.getList(0, null, null) var removed = 0 for (manga in list) { if (manga.id in ids) { invoke(manga) removed++ } } check(removed == ids.size) { "Removed $removed files but ${ids.size} requested" } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteReadChaptersUseCase.kt ================================================ package org.koitharu.kotatsu.local.domain import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.fold import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koitharu.kotatsu.core.model.ids import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.util.findById import org.koitharu.kotatsu.parsers.util.recoverCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import javax.inject.Inject class DeleteReadChaptersUseCase @Inject constructor( private val localMangaRepository: LocalMangaRepository, private val historyRepository: HistoryRepository, private val mangaRepositoryFactory: MangaRepository.Factory, ) { suspend operator fun invoke(manga: Manga): Int { val localManga = if (manga.isLocal) { LocalManga(manga) } else { checkNotNull(localMangaRepository.findSavedManga(manga)) { "Cannot find local manga" } } val task = getDeletionTask(localManga) ?: return 0 localMangaRepository.deleteChapters(task.manga.manga, task.chaptersIds) return task.chaptersIds.size } suspend operator fun invoke(): Int { val list = localMangaRepository.getList(0, null, null) if (list.isEmpty()) { return 0 } return channelFlow { for (manga in list) { launch(Dispatchers.Default) { val task = runCatchingCancellable { getDeletionTask(LocalManga(manga)) }.onFailure { it.printStackTraceDebug() }.getOrNull() if (task != null) { send(task) } } } }.buffer().map { runCatchingCancellable { localMangaRepository.deleteChapters(it.manga.manga, it.chaptersIds) it.chaptersIds.size }.onFailure { it.printStackTraceDebug() }.getOrDefault(0) }.fold(0) { acc, x -> acc + x } } private suspend fun getDeletionTask(manga: LocalManga): DeletionTask? { val history = historyRepository.getOne(manga.manga) ?: return null val chapters = getAllChapters(manga) if (chapters.isEmpty()) { return null } val branch = (chapters.findById(history.chapterId) ?: return null).branch val filteredChapters = chapters.filter { x -> x.branch == branch }.takeWhile { it.id != history.chapterId } return if (filteredChapters.isEmpty()) { null } else { DeletionTask( manga = manga, chaptersIds = filteredChapters.ids(), ) } } private suspend fun getAllChapters(manga: LocalManga): List = runCatchingCancellable { val remoteManga = checkNotNull(localMangaRepository.getRemoteManga(manga.manga)) checkNotNull(mangaRepositoryFactory.create(remoteManga.source).getDetails(remoteManga).chapters) }.recoverCatchingCancellable { checkNotNull( manga.manga.chapters.let { if (it.isNullOrEmpty()) { localMangaRepository.getDetails(manga.manga).chapters } else { it } }, ) }.getOrDefault(manga.manga.chapters.orEmpty()) private class DeletionTask( val manga: LocalManga, val chaptersIds: Set, ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/domain/LocalObserveMapper.kt ================================================ package org.koitharu.kotatsu.local.domain import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onStart import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.local.data.index.LocalMangaIndex import org.koitharu.kotatsu.parsers.model.Manga abstract class LocalObserveMapper( private val localMangaIndex: LocalMangaIndex, ) { protected fun Flow>.mapToLocal() = onStart { localMangaIndex.updateIfRequired() }.mapLatest { it.mapToLocal() } private suspend fun Collection.mapToLocal(): List = coroutineScope { val dispatcher = Dispatchers.IO.limitedParallelism(6) map { item -> val m = toManga(item) async(dispatcher) { val mapped = if (m.isLocal) { m } else { localMangaIndex.get(m.id, withDetails = false)?.manga } mapped?.let { mm -> toResult(item, mm) } } }.awaitAll().filterNotNull() } protected abstract fun toManga(e: E): Manga protected abstract fun toResult(e: E, manga: Manga): R } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/domain/MangaLock.kt ================================================ package org.koitharu.kotatsu.local.domain import org.koitharu.kotatsu.core.util.MultiMutex import org.koitharu.kotatsu.parsers.model.Manga import javax.inject.Inject import javax.inject.Singleton @Singleton class MangaLock @Inject constructor() : MultiMutex() ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/domain/model/LocalManga.kt ================================================ package org.koitharu.kotatsu.local.domain.model import android.net.Uri import androidx.core.net.toFile import androidx.core.net.toUri import org.koitharu.kotatsu.core.util.ext.contains import org.koitharu.kotatsu.core.util.ext.creationTime import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag import java.io.File data class LocalManga( val manga: Manga, val file: File = manga.url.toUri().toFile(), ) { var createdAt: Long = -1L private set get() { if (field == -1L) { field = file.creationTime } return field } fun toUri(): Uri = manga.url.toUri() fun isMatchesQuery(query: String): Boolean { return manga.title.contains(query, ignoreCase = true) || manga.altTitles.contains(query, ignoreCase = true) || manga.authors.contains(query, ignoreCase = true) } fun containsTags(tags: Collection): Boolean { return tags.all { tag -> tag in manga.tags } } fun containsAnyTag(tags: Collection): Boolean { return tags.any { tag -> tag in manga.tags } } private operator fun Collection.contains(title: String): Boolean { return any { it.title.equals(title, ignoreCase = true) } } override fun toString(): String { return "LocalManga(${file.path}: ${manga.title})" } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportDialogFragment.kt ================================================ package org.koitharu.kotatsu.local.ui import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.os.OpenDocumentTreeHelper import org.koitharu.kotatsu.core.ui.AlertDialogFragment import org.koitharu.kotatsu.core.util.ext.tryLaunch import org.koitharu.kotatsu.databinding.DialogImportBinding import org.koitharu.kotatsu.local.data.LocalStorageManager import javax.inject.Inject @AndroidEntryPoint class ImportDialogFragment : AlertDialogFragment(), View.OnClickListener { @Inject lateinit var storageManager: LocalStorageManager private val importFileCall = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { startImport(it) } private val importDirCall = OpenDocumentTreeHelper(this) { startImport(listOfNotNull(it)) } override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): DialogImportBinding { return DialogImportBinding.inflate(inflater, container, false) } override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { return super.onBuildDialog(builder) .setTitle(R.string._import) .setNegativeButton(android.R.string.cancel, null) .setCancelable(true) } override fun onViewBindingCreated(binding: DialogImportBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) binding.buttonDir.setOnClickListener(this) binding.buttonFile.setOnClickListener(this) } override fun onClick(v: View) { val res = when (v.id) { R.id.button_file -> importFileCall.tryLaunch(arrayOf("*/*")) R.id.button_dir -> importDirCall.tryLaunch(null) else -> true } if (!res) { Toast.makeText(v.context, R.string.operation_not_supported, Toast.LENGTH_SHORT).show() } } private fun startImport(uris: Collection) { if (uris.isEmpty()) { return } uris.forEach { storageManager.takePermissions(it) } val ctx = requireContext() val msg = if (ImportService.start(ctx, uris)) { R.string.import_will_start_soon } else { R.string.error_occurred } Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show() dismiss() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportService.kt ================================================ package org.koitharu.kotatsu.local.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 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.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.core.ErrorReporterReceiver 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.toUriOrNull import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock import org.koitharu.kotatsu.local.data.importer.SingleMangaImporter import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import javax.inject.Inject @AndroidEntryPoint class ImportService : CoroutineIntentService() { @Inject lateinit var importer: SingleMangaImporter @Inject lateinit var coil: ImageLoader private lateinit var notificationManager: NotificationManagerCompat override fun onCreate() { super.onCreate() notificationManager = NotificationManagerCompat.from(applicationContext) } override suspend fun IntentJobContext.processIntent(intent: Intent) { val uri = requireNotNull(intent.getStringExtra(DATA_URI)?.toUriOrNull()) { "No input uri" } startForeground(this) powerManager.withPartialWakeLock(TAG) { val result = runCatchingCancellable { importer.import(uri).manga } if (applicationContext.checkNotificationPermission(CHANNEL_ID)) { val notification = buildNotification(startId, result) notificationManager.notify(TAG, startId, notification) } } } override fun IntentJobContext.onError(error: Throwable) { if (applicationContext.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 = applicationContext.getString(R.string.importing_manga) val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT) .setName(title) .setShowBadge(false) .setVibrationEnabled(false) .setSound(null, null) .setLightsEnabled(false) .build() notificationManager.createNotificationChannel(channel) val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) .setContentTitle(title) .setPriority(NotificationCompat.PRIORITY_MIN) .setDefaults(0) .setSilent(true) .setOngoing(true) .setProgress(0, 0, true) .setSmallIcon(android.R.drawable.stat_sys_download) .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) .setCategory(NotificationCompat.CATEGORY_PROGRESS) .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(applicationContext, CHANNEL_ID) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setDefaults(0) .setSilent(true) .setAutoCancel(true) result.onSuccess { manga -> notification.setLargeIcon( coil.execute( ImageRequest.Builder(applicationContext) .data(manga.coverUrl) .mangaSourceExtra(manga.source) .build(), ).toBitmapOrNull(), ) notification.setSubText(manga.title) val intent = AppRouter.detailsIntent(applicationContext, manga) notification.setContentIntent( PendingIntentCompat.getActivity( applicationContext, manga.id.toInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT, false, ), ).setVisibility( if (manga.isNsfw()) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC, ) notification.setContentTitle(applicationContext.getString(R.string.import_completed)) .setContentText(applicationContext.getString(R.string.import_completed_hint)) .setSmallIcon(R.drawable.ic_stat_done) NotificationCompat.BigTextStyle(notification) .bigText(applicationContext.getString(R.string.import_completed_hint)) }.onFailure { error -> notification.setContentTitle(applicationContext.getString(R.string.error_occurred)) .setContentText(error.getDisplayMessage(applicationContext.resources)) .setSmallIcon(android.R.drawable.stat_notify_error) ErrorReporterReceiver.getNotificationAction( context = applicationContext, e = error, notificationId = startId, notificationTag = TAG, )?.let { action -> notification.addAction(action) } } return notification.build() } companion object { private const val DATA_URI = "uri" private const val TAG = "import" private const val CHANNEL_ID = "importing" private const val FOREGROUND_NOTIFICATION_ID = 37 fun start(context: Context, uris: Collection): Boolean = try { require(uris.isNotEmpty()) for (uri in uris) { val intent = Intent(context, ImportService::class.java) intent.putExtra(DATA_URI, uri.toString()) ContextCompat.startForegroundService(context, intent) } true } catch (e: Exception) { e.printStackTraceDebug() false } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt ================================================ package org.koitharu.kotatsu.local.ui import android.annotation.SuppressLint import android.app.NotificationManager 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.content.ContextCompat import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.MutableSharedFlow import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ErrorReporterReceiver import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.ui.CoroutineIntentService import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat import org.koitharu.kotatsu.core.util.ext.powerManager import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga import javax.inject.Inject @AndroidEntryPoint class LocalChaptersRemoveService : CoroutineIntentService() { @Inject lateinit var localMangaRepository: LocalMangaRepository @Inject @LocalStorageChanges lateinit var localStorageChanges: MutableSharedFlow override fun onCreate() { super.onCreate() isRunning = true } override fun onDestroy() { isRunning = false super.onDestroy() } override suspend fun IntentJobContext.processIntent(intent: Intent) { startForeground(this) val manga = intent.getParcelableExtraCompat(EXTRA_MANGA)?.manga ?: return val chaptersIds = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toSet() ?: return powerManager.withPartialWakeLock(TAG) { val mangaWithChapters = localMangaRepository.getDetails(manga) localMangaRepository.deleteChapters(mangaWithChapters, chaptersIds) localStorageChanges.emit(LocalManga(localMangaRepository.getDetails(manga))) } } override fun IntentJobContext.onError(error: Throwable) { val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) .setContentTitle(getString(R.string.error_occurred)) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setDefaults(0) .setSilent(true) .setContentText(error.getDisplayMessage(resources)) .setSmallIcon(android.R.drawable.stat_notify_error) .setAutoCancel(true) .setContentIntent(ErrorReporterReceiver.getPendingIntent(applicationContext, error)) .build() val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager nm.notify(NOTIFICATION_ID + startId, notification) } @SuppressLint("InlinedApi") private fun startForeground(jobContext: IntentJobContext) { val title = getString(R.string.local_manga_processing) val manager = NotificationManagerCompat.from(this) val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW) .setName(title) .setShowBadge(false) .setVibrationEnabled(false) .setSound(null, null) .setLightsEnabled(false) .build() manager.createNotificationChannel(channel) val notification = NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(title) .setPriority(NotificationCompat.PRIORITY_MIN) .setDefaults(0) .setSilent(true) .setProgress(0, 0, true) .setSmallIcon(android.R.drawable.stat_notify_sync) .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED) .setOngoing(false) .build() jobContext.setForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) } companion object { var isRunning: Boolean = false private set private const val CHANNEL_ID = "local_processing" private const val NOTIFICATION_ID = 21 private const val EXTRA_MANGA = "manga" private const val EXTRA_CHAPTERS_IDS = "chapters_ids" private const val TAG = CHANNEL_ID fun start(context: Context, manga: Manga, chaptersIds: Collection) { if (chaptersIds.isEmpty()) { return } val intent = Intent(context, LocalChaptersRemoveService::class.java) intent.putExtra(EXTRA_MANGA, ParcelableManga(manga)) intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray()) ContextCompat.startForegroundService(context, intent) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalIndexUpdateService.kt ================================================ package org.koitharu.kotatsu.local.ui import android.content.Intent import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.core.ui.CoroutineIntentService import org.koitharu.kotatsu.local.data.index.LocalMangaIndex import javax.inject.Inject @AndroidEntryPoint class LocalIndexUpdateService : CoroutineIntentService() { @Inject lateinit var localMangaIndex: LocalMangaIndex override suspend fun IntentJobContext.processIntent(intent: Intent) { localMangaIndex.update() } override fun IntentJobContext.onError(error: Throwable) = Unit } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt ================================================ package org.koitharu.kotatsu.local.ui import android.Manifest import android.os.Build import android.os.Bundle import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.view.ActionMode import androidx.core.net.toFile import androidx.core.net.toUri import androidx.fragment.app.viewModels import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.widgets.TipView import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.tryLaunch import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.remotelist.ui.MangaSearchMenuProvider import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import org.koitharu.kotatsu.settings.storage.RequestStorageManagerPermissionContract class LocalListFragment : MangaListFragment(), FilterCoordinator.Owner { private val permissionRequestLauncher = registerForActivityResult( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { RequestStorageManagerPermissionContract() } else { ActivityResultContracts.RequestPermission() }, ) { if (it) { viewModel.onRefresh() } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val args = arguments ?: Bundle(1) args.putString( RemoteListFragment.ARG_SOURCE, LocalMangaSource.name, ) // required by FilterCoordinator arguments = args } override val viewModel by viewModels() override val filterCoordinator: FilterCoordinator get() = viewModel.filterCoordinator override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) addMenuProvider(LocalListMenuProvider(this, this::onEmptyActionClick)) addMenuProvider(MangaSearchMenuProvider(filterCoordinator, viewModel)) viewModel.onMangaRemoved.observeEvent(viewLifecycleOwner) { onItemRemoved() } } override fun onEmptyActionClick() { router.showImportDialog() } override fun onFilterClick(view: View?) { router.showFilterSheet() } override fun onPrimaryButtonClick(tipView: TipView) { if (!permissionRequestLauncher.tryLaunch(Manifest.permission.READ_EXTERNAL_STORAGE)) { Snackbar.make(tipView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() } } override fun onSecondaryButtonClick(tipView: TipView) { router.openDirectoriesSettings() } override fun onScrolledToEnd() = viewModel.loadNextPage() override fun onCreateActionMode( controller: ListSelectionController, menuInflater: MenuInflater, menu: Menu, ): Boolean { menuInflater.inflate(R.menu.mode_local, menu) return super.onCreateActionMode(controller, menuInflater, menu) } override fun onActionItemClicked( controller: ListSelectionController, mode: ActionMode?, item: MenuItem, ): Boolean { return when (item.itemId) { R.id.action_remove -> { showDeletionConfirm(selectedItemsIds, mode) true } R.id.action_share -> { val files = selectedItems.map { it.url.toUri().toFile() } ShareHelper(requireContext()).shareCbz(files) mode?.finish() true } else -> super.onActionItemClicked(controller, mode, item) } } private fun showDeletionConfirm(ids: Set, mode: ActionMode?) { MaterialAlertDialogBuilder(context ?: return) .setTitle(R.string.delete_manga) .setMessage(getString(R.string.text_delete_local_manga_batch)) .setPositiveButton(R.string.delete) { _, _ -> viewModel.delete(ids) mode?.finish() } .setNegativeButton(android.R.string.cancel, null) .show() } private fun onItemRemoved() { Snackbar.make( requireViewBinding().recyclerView, R.string.removal_completed, Snackbar.LENGTH_SHORT, ).show() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt ================================================ package org.koitharu.kotatsu.local.ui import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.router class LocalListMenuProvider( private val fragment: Fragment, private val onImportClick: Function0, ) : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.opt_local, menu) } override fun onPrepareMenu(menu: Menu) { super.onPrepareMenu(menu) menu.findItem(R.id.action_filter)?.isVisible = fragment.router.isFilterSupported() } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return when (menuItem.itemId) { R.id.action_import -> { onImportClick() true } R.id.action_directories -> { fragment.router.openDirectoriesSettings() true } R.id.action_filter -> { fragment.router.showFilterSheet() true } else -> false } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt ================================================ package org.koitharu.kotatsu.local.ui import android.content.SharedPreferences import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharedFlow import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.toChipModel import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.toFileOrNull import org.koitharu.kotatsu.core.util.ext.toUriOrNull import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.list.domain.QuickFilterListener import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.list.ui.model.QuickFilter import org.koitharu.kotatsu.list.ui.model.TipModel import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel import javax.inject.Inject @HiltViewModel class LocalListViewModel @Inject constructor( savedStateHandle: SavedStateHandle, mangaRepositoryFactory: MangaRepository.Factory, filterCoordinator: FilterCoordinator, private val settings: AppSettings, mangaListMapper: MangaListMapper, private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase, exploreRepository: ExploreRepository, @param:LocalStorageChanges private val localStorageChanges: SharedFlow, private val localStorageManager: LocalStorageManager, sourcesRepository: MangaSourcesRepository, mangaDataRepository: MangaDataRepository, ) : RemoteListViewModel( savedStateHandle = savedStateHandle, mangaRepositoryFactory = mangaRepositoryFactory, filterCoordinator = filterCoordinator, settings = settings, mangaListMapper = mangaListMapper, exploreRepository = exploreRepository, sourcesRepository = sourcesRepository, mangaDataRepository = mangaDataRepository, localStorageChanges = localStorageChanges, ), SharedPreferences.OnSharedPreferenceChangeListener, QuickFilterListener { val onMangaRemoved = MutableEventFlow() private val showInlineFilter: Boolean = savedStateHandle[AppRouter.KEY_IS_BOTTOMTAB] ?: false init { launchJob(Dispatchers.Default) { localStorageChanges .collect { loadList(filterCoordinator.snapshot(), append = false).join() } } settings.subscribe(this) } override suspend fun onBuildList(list: MutableList) { super.onBuildList(list) if (showInlineFilter) { createFilterHeader(maxCount = 16)?.let { list.add(0, it) } } if (!localStorageManager.hasExternalStoragePermission(isReadOnly = true)) { for (item in list) { if (item !is MangaListModel) { continue } val file = item.manga.url.toUriOrNull()?.toFileOrNull() ?: continue if (localStorageManager.isOnExternalStorage(file)) { val tip = TipModel( key = "permission", title = R.string.external_storage, text = R.string.missing_storage_permission, icon = R.drawable.ic_storage, primaryButtonText = R.string.fix, secondaryButtonText = R.string.settings, ) list.add(0, tip) return } } } } override fun setFilterOption(option: ListFilterOption, isApplied: Boolean) { if (option is ListFilterOption.Tag) { filterCoordinator.toggleTag(option.tag, isApplied) } } override fun toggleFilterOption(option: ListFilterOption) { if (option is ListFilterOption.Tag) { val tag = option.tag val isSelected = tag in filterCoordinator.snapshot().listFilter.tags filterCoordinator.toggleTag(option.tag, !isSelected) } } override fun clearFilter() = filterCoordinator.reset() override fun onCleared() { settings.unsubscribe(this) super.onCleared() } override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { if (key == AppSettings.KEY_LOCAL_MANGA_DIRS) { onRefresh() } } fun delete(ids: Set) { launchLoadingJob(Dispatchers.Default) { deleteLocalMangaUseCase(ids) onMangaRemoved.call(Unit) } } override suspend fun mapMangaList( destination: MutableCollection, manga: Collection, mode: ListMode ) = mangaListMapper.toListModelList(destination, manga, mode, MangaListMapper.NO_SAVED) override fun createEmptyState(canResetFilter: Boolean): EmptyState = if (canResetFilter) { super.createEmptyState(true) } else { EmptyState( icon = R.drawable.ic_empty_local, textPrimary = R.string.text_local_holder_primary, textSecondary = R.string.text_local_holder_secondary, actionStringRes = R.string._import, ) } private suspend fun createFilterHeader(maxCount: Int): QuickFilter? { val appliedTags = filterCoordinator.snapshot().listFilter.tags val availableTags = repository.getFilterOptions().availableTags if (appliedTags.isEmpty() && availableTags.size < 3) { return null } val result = ArrayList(minOf(availableTags.size, maxCount)) appliedTags.mapTo(result) { tag -> ListFilterOption.Tag(tag).toChipModel(isChecked = true) } for (tag in availableTags) { if (result.size >= maxCount) { break } if (tag in appliedTags) { continue } result.add(ListFilterOption.Tag(tag).toChipModel(isChecked = false)) } return QuickFilter(result) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalStorageCleanupWorker.kt ================================================ package org.koitharu.kotatsu.local.ui import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo import android.os.Build import android.provider.Settings import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.PendingIntentCompat import androidx.hilt.work.HiltWorker import androidx.work.BackoffPolicy import androidx.work.CoroutineWorker import androidx.work.ExistingWorkPolicy import androidx.work.ForegroundInfo import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OutOfQuotaPolicy import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.await import dagger.assisted.Assisted import dagger.assisted.AssistedInject import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.domain.DeleteReadChaptersUseCase import java.util.concurrent.TimeUnit @HiltWorker class LocalStorageCleanupWorker @AssistedInject constructor( @Assisted appContext: Context, @Assisted params: WorkerParameters, private val settings: AppSettings, private val localMangaRepository: LocalMangaRepository, private val dataRepository: MangaDataRepository, private val deleteReadChaptersUseCase: DeleteReadChaptersUseCase, ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { if (settings.isAutoLocalChaptersCleanupEnabled) { deleteReadChaptersUseCase.invoke() } return if (localMangaRepository.cleanup()) { dataRepository.cleanupLocalManga() Result.success() } else { Result.retry() } } override suspend fun getForegroundInfo(): ForegroundInfo { val title = applicationContext.getString(R.string.local_storage_cleanup) val channel = NotificationChannelCompat.Builder(WORKER_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW) .setName(title) .setShowBadge(true) .setVibrationEnabled(false) .setSound(null, null) .setLightsEnabled(true) .build() NotificationManagerCompat.from(applicationContext).createNotificationChannel(channel) val notification = NotificationCompat.Builder(applicationContext, WORKER_CHANNEL_ID) .setContentTitle(title) .setContentIntent( PendingIntentCompat.getActivity( applicationContext, 0, AppRouter.suggestionsSettingsIntent(applicationContext), 0, false, ), ) .setPriority(NotificationCompat.PRIORITY_MIN) .setCategory(NotificationCompat.CATEGORY_SERVICE) .setDefaults(0) .setOngoing(false) .setSilent(true) .setProgress(0, 0, true) .setSmallIcon(android.R.drawable.stat_notify_sync) .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val actionIntent = PendingIntentCompat.getActivity( applicationContext, SETTINGS_ACTION_CODE, Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) .putExtra(Settings.EXTRA_APP_PACKAGE, applicationContext.packageName) .putExtra(Settings.EXTRA_CHANNEL_ID, WORKER_CHANNEL_ID), 0, false, ) notification.addAction( R.drawable.ic_settings, applicationContext.getString(R.string.notifications_settings), actionIntent, ) } return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { ForegroundInfo(WORKER_NOTIFICATION_ID, notification.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) } else { ForegroundInfo(WORKER_NOTIFICATION_ID, notification.build()) } } companion object { private const val TAG = "cleanup" private const val WORKER_CHANNEL_ID = "storage_cleanup" private const val WORKER_NOTIFICATION_ID = 32 private const val SETTINGS_ACTION_CODE = 6 suspend fun enqueue(context: Context) { val request = OneTimeWorkRequestBuilder() .addTag(TAG) .setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.MINUTES) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build() WorkManager.getInstance(context).enqueueUniqueWork(TAG, ExistingWorkPolicy.KEEP, request).await() } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoDialog.kt ================================================ package org.koitharu.kotatsu.local.ui.info import android.content.res.ColorStateList import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.core.widget.TextViewCompat 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.ui.AlertDialogFragment import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView import org.koitharu.kotatsu.core.util.FileSize import org.koitharu.kotatsu.core.util.KotatsuColors import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.setProgressIcon import org.koitharu.kotatsu.databinding.DialogLocalInfoBinding import androidx.appcompat.R as appcompatR @AndroidEntryPoint class LocalInfoDialog : AlertDialogFragment(), View.OnClickListener { private val viewModel: LocalInfoViewModel by viewModels() override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { return super.onBuildDialog(builder).setTitle(R.string.saved_manga).setNegativeButton(R.string.close, null) } override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): DialogLocalInfoBinding { return DialogLocalInfoBinding.inflate(inflater, container, false) } override fun onViewBindingCreated(binding: DialogLocalInfoBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) viewModel.path.observe(this) { binding.textViewPath.text = it } binding.chipCleanup.setOnClickListener(this) combine(viewModel.size, viewModel.availableSize, ::Pair).observe(viewLifecycleOwner) { if (it.first >= 0 && it.second >= 0) { setSegments(it.first, it.second) } else { binding.barView.animateSegments(emptyList()) } } viewModel.onCleanedUp.observeEvent(viewLifecycleOwner, ::onCleanedUp) viewModel.isCleaningUp.observe(viewLifecycleOwner) { loading -> binding.chipCleanup.isClickable = !loading dialog?.setCancelable(!loading) if (loading) { binding.chipCleanup.setProgressIcon() } else { binding.chipCleanup.setChipIconResource(R.drawable.ic_delete) } } } override fun onClick(v: View) { when (v.id) { R.id.chip_cleanup -> viewModel.cleanup() } } private fun onCleanedUp(result: Pair) { val c = context ?: return val text = if (result.first == 0 && result.second == 0L) { c.getString(R.string.no_chapters_deleted) } else { c.getString( R.string.chapters_deleted_pattern, c.resources.getQuantityStringSafe(R.plurals.chapters, result.first, result.first), FileSize.BYTES.format(c, result.second), ) } Toast.makeText(c, text, Toast.LENGTH_SHORT).show() } private fun setSegments(size: Long, available: Long) { val view = viewBinding?.barView ?: return val total = size + available val segment = SegmentedBarView.Segment( percent = (size.toDouble() / total.toDouble()).toFloat(), color = KotatsuColors.segmentColor(view.context, appcompatR.attr.colorPrimary), ) requireViewBinding().labelUsed.text = view.context.getString( R.string.memory_usage_pattern, getString(R.string.this_manga), FileSize.BYTES.format(view.context, size), ) requireViewBinding().labelAvailable.text = view.context.getString( R.string.memory_usage_pattern, getString(R.string.available), FileSize.BYTES.format(view.context, available), ) TextViewCompat.setCompoundDrawableTintList( requireViewBinding().labelUsed, ColorStateList.valueOf(segment.color), ) view.animateSegments(listOf(segment)) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoViewModel.kt ================================================ package org.koitharu.kotatsu.local.ui.info import androidx.core.net.toUri import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga 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.computeSize import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.toFileOrNull import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.domain.DeleteReadChaptersUseCase import javax.inject.Inject @HiltViewModel class LocalInfoViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val localMangaRepository: LocalMangaRepository, private val storageManager: LocalStorageManager, private val deleteReadChaptersUseCase: DeleteReadChaptersUseCase, ) : BaseViewModel() { private val manga = savedStateHandle.require(AppRouter.KEY_MANGA).manga val isCleaningUp = MutableStateFlow(false) val onCleanedUp = MutableEventFlow>() val path = MutableStateFlow(null) val size = MutableStateFlow(-1L) val availableSize = MutableStateFlow(-1L) init { computeSize() } fun cleanup() { launchJob(Dispatchers.Default) { try { isCleaningUp.value = true val oldSize = size.value val chaptersCount = deleteReadChaptersUseCase.invoke(manga) computeSize().join() val newSize = size.value onCleanedUp.call(chaptersCount to oldSize - newSize) } finally { isCleaningUp.value = false } } } private fun computeSize() = launchLoadingJob(Dispatchers.Default) { val file = manga.url.toUri().toFileOrNull() ?: localMangaRepository.findSavedManga(manga)?.file requireNotNull(file) path.value = file.path size.value = file.computeSize() availableSize.value = storageManager.computeAvailableSize() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/main/domain/CoverRestoreInterceptor.kt ================================================ package org.koitharu.kotatsu.main.domain import androidx.collection.ArraySet import coil3.intercept.Interceptor import coil3.request.ErrorResult import coil3.request.ImageResult import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.util.ext.bookmarkKey import org.koitharu.kotatsu.core.util.ext.mangaKey import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.findById import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.util.Collections import javax.inject.Inject class CoverRestoreInterceptor @Inject constructor( private val dataRepository: MangaDataRepository, private val bookmarksRepository: BookmarksRepository, private val repositoryFactory: MangaRepository.Factory, ) : Interceptor { private val blacklist = Collections.synchronizedSet(ArraySet()) override suspend fun intercept(chain: Interceptor.Chain): ImageResult { val request = chain.request val result = chain.proceed() if (result is ErrorResult && result.throwable.shouldRestore()) { request.extras[bookmarkKey]?.let { return if (restoreBookmark(it)) { chain.withRequest(request.newBuilder().build()).proceed() } else { result } } request.extras[mangaKey]?.let { return if (restoreManga(it)) { chain.withRequest(request.newBuilder().build()).proceed() } else { result } } } return result } private suspend fun restoreManga(manga: Manga): Boolean { val key = manga.publicUrl if (!blacklist.add(key)) { return false } val restored = runCatchingCancellable { restoreMangaImpl(manga) }.onFailure { e -> e.printStackTraceDebug() }.getOrDefault(false) if (restored) { blacklist.remove(key) } return restored } private suspend fun restoreMangaImpl(manga: Manga): Boolean { if (dataRepository.findMangaById(manga.id, withChapters = false) == null || manga.isLocal) { return false } val repo = repositoryFactory.create(manga.source) val fixed = repo.find(manga) ?: return false return if (fixed != manga) { dataRepository.storeManga(fixed, replaceExisting = true) fixed.coverUrl != manga.coverUrl } else { false } } private suspend fun restoreBookmark(bookmark: Bookmark): Boolean { val key = bookmark.imageUrl if (!blacklist.add(key)) { return false } val restored = runCatchingCancellable { restoreBookmarkImpl(bookmark) }.onFailure { e -> e.printStackTraceDebug() }.getOrDefault(false) if (restored) { blacklist.remove(key) } return restored } private suspend fun restoreBookmarkImpl(bookmark: Bookmark): Boolean { if (bookmark.manga.isLocal) { return false } val repo = repositoryFactory.create(bookmark.manga.source) val chapter = repo.getDetails(bookmark.manga).chapters?.findById(bookmark.chapterId) ?: return false val page = repo.getPages(chapter)[bookmark.page] val imageUrl = page.preview.ifNullOrEmpty { page.url } return if (imageUrl != bookmark.imageUrl) { bookmarksRepository.updateBookmark(bookmark, imageUrl) true } else { false } } private fun Throwable.shouldRestore(): Boolean { return this is Exception // any Exception but not Error } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/main/domain/ReadingResumeEnabledUseCase.kt ================================================ package org.koitharu.kotatsu.main.domain import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.history.data.HistoryRepository import javax.inject.Inject class ReadingResumeEnabledUseCase @Inject constructor( private val networkState: NetworkState, private val historyRepository: HistoryRepository, private val settings: AppSettings, ) { operator fun invoke(): Flow = settings.observe( AppSettings.KEY_MAIN_FAB, AppSettings.KEY_INCOGNITO_MODE, ).map { settings.isMainFabEnabled && !settings.isIncognitoModeEnabled }.distinctUntilChanged() .flatMapLatest { isFabEnabled -> if (isFabEnabled) { observeCanResume() } else { flowOf(false) } } private fun observeCanResume() = combine(networkState, historyRepository.observeLast()) { isOnline, last -> last != null && (isOnline || last.isLocal) }.distinctUntilChanged() } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/main/ui/ExitCallback.kt ================================================ package org.koitharu.kotatsu.main.ui import android.view.View import androidx.activity.OnBackPressedCallback import androidx.lifecycle.lifecycleScope import com.google.android.material.search.SearchView import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner class ExitCallback( private val activity: MainActivity, private val snackbarHost: View, ) : OnBackPressedCallback(false), SearchView.TransitionListener { private var job: Job? = null private val isSearchOpen = MutableStateFlow(activity.viewBinding.searchView.isShowing) private val isDisabledByTimeout = MutableStateFlow(false) init { activity.lifecycleScope.launch { combine( observeSettings(), isSearchOpen, isDisabledByTimeout, ) { enabledInSettings, searchOpen, disabledTemporary -> enabledInSettings && !searchOpen && !disabledTemporary }.collect { isEnabled = it } } } override fun handleOnBackPressed() { job?.cancel() job = activity.lifecycleScope.launch { resetExitConfirmation() } } override fun onStateChanged( searchView: SearchView, previousState: SearchView.TransitionState, newState: SearchView.TransitionState ) { isSearchOpen.value = newState >= SearchView.TransitionState.SHOWING } private suspend fun resetExitConfirmation() { isDisabledByTimeout.value = true val snackbar = Snackbar.make(snackbarHost, R.string.confirm_exit, Snackbar.LENGTH_INDEFINITE) snackbar.anchorView = (activity as? BottomNavOwner)?.bottomNav snackbar.show() delay(2000) snackbar.dismiss() isDisabledByTimeout.value = false } private fun observeSettings(): Flow = activity.settings .observeAsFlow(AppSettings.KEY_EXIT_CONFIRM) { isExitConfirmationEnabled } .flowOn(Dispatchers.Default) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActionButtonBehavior.kt ================================================ package org.koitharu.kotatsu.main.ui import android.content.Context import android.util.AttributeSet import android.view.View import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.ViewCompat import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton import org.koitharu.kotatsu.core.ui.util.ShrinkOnScrollBehavior import org.koitharu.kotatsu.core.ui.widgets.SlidingBottomNavigationView class MainActionButtonBehavior : ShrinkOnScrollBehavior { constructor() : super() constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) override fun layoutDependsOn( parent: CoordinatorLayout, child: ExtendedFloatingActionButton, dependency: View ): Boolean { return dependency is SlidingBottomNavigationView || super.layoutDependsOn(parent, child, dependency) } override fun onDependentViewChanged( parent: CoordinatorLayout, child: ExtendedFloatingActionButton, dependency: View ): Boolean { val bottom = child.bottom val bottomLine = parent.height return if (bottom > bottomLine) { ViewCompat.offsetTopAndBottom(child, bottomLine - bottom) true } else { false } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt ================================================ package org.koitharu.kotatsu.main.ui import android.Manifest import android.app.BackgroundServiceStartNotAllowedException import android.app.ServiceStartNotAllowedException import android.content.Intent import android.content.pm.PackageManager.PERMISSION_GRANTED import android.os.Build import android.os.Bundle import android.view.View import android.view.ViewGroup.MarginLayoutParams import androidx.activity.viewModels import androidx.appcompat.view.ActionMode import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.view.MenuProvider import androidx.core.view.WindowInsetsCompat import androidx.core.view.children import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.lifecycle.withResumed import androidx.recyclerview.widget.ItemTouchHelper import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SNAP import com.google.android.material.search.SearchView import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R import org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupService import org.koitharu.kotatsu.browser.AdListUpdateService import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.os.VoiceInputContract import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.NavItem import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.util.FadingAppbarMediator import org.koitharu.kotatsu.core.ui.util.MenuInvalidator import org.koitharu.kotatsu.core.ui.widgets.SlidingBottomNavigationView import org.koitharu.kotatsu.core.util.ext.consume import org.koitharu.kotatsu.core.util.ext.end import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.start import org.koitharu.kotatsu.databinding.ActivityMainBinding import org.koitharu.kotatsu.details.service.MangaPrefetchService import org.koitharu.kotatsu.favourites.ui.container.FavouritesContainerFragment import org.koitharu.kotatsu.history.ui.HistoryListFragment import org.koitharu.kotatsu.local.ui.LocalIndexUpdateService import org.koitharu.kotatsu.local.ui.LocalStorageCleanupWorker import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.remotelist.ui.MangaSearchMenuProvider import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionItemCallback import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListenerImpl import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionMenuProvider import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel import org.koitharu.kotatsu.search.ui.suggestion.adapter.SearchSuggestionAdapter import javax.inject.Inject import com.google.android.material.R as materialR @AndroidEntryPoint class MainActivity : BaseActivity(), AppBarOwner, BottomNavOwner, View.OnClickListener, SearchSuggestionItemCallback.SuggestionItemListener, MainNavigationDelegate.OnFragmentChangedListener, View.OnLayoutChangeListener, SearchView.TransitionListener { @Inject lateinit var settings: AppSettings private val viewModel by viewModels() private val searchSuggestionViewModel by viewModels() private val voiceInputLauncher = registerForActivityResult(VoiceInputContract()) { result -> if (result != null) { viewBinding.searchView.setText(result) } } private lateinit var navigationDelegate: MainNavigationDelegate private lateinit var fadingAppbarMediator: FadingAppbarMediator override val appBar: AppBarLayout get() = viewBinding.appbar override val bottomNav: SlidingBottomNavigationView? get() = viewBinding.bottomNav override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityMainBinding.inflate(layoutInflater)) setSupportActionBar(viewBinding.searchBar) viewBinding.fab?.setOnClickListener(this) viewBinding.navRail?.headerView?.findViewById(R.id.railFab)?.setOnClickListener(this) fadingAppbarMediator = FadingAppbarMediator(viewBinding.appbar, viewBinding.layoutSearch ?: viewBinding.searchBar) navigationDelegate = MainNavigationDelegate( navBar = checkNotNull(bottomNav ?: viewBinding.navRail), fragmentManager = supportFragmentManager, settings = settings, ) navigationDelegate.addOnFragmentChangedListener(this) navigationDelegate.onCreate(this, savedInstanceState) viewBinding.textViewTitle?.let { tv -> navigationDelegate.observeTitle().observe(this) { tv.text = it } } addMenuProvider(MainMenuProvider(router, viewModel)) val exitCallback = ExitCallback(this, viewBinding.container) onBackPressedDispatcher.addCallback(exitCallback) onBackPressedDispatcher.addCallback(navigationDelegate) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || !resources.getBoolean(R.bool.is_predictive_back_enabled)) { val legacySearchCallback = SearchViewLegacyBackCallback(viewBinding.searchView) viewBinding.searchView.addTransitionListener(legacySearchCallback) onBackPressedDispatcher.addCallback(legacySearchCallback) } if (savedInstanceState == null) { onFirstStart() } viewModel.onOpenReader.observeEvent(this, this::onOpenReader) viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.container, null)) viewModel.isLoading.observe(this, this::onLoadingStateChanged) viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged) viewModel.feedCounter.observe(this, ::onFeedCounterChanged) viewModel.appUpdate.observe(this, MenuInvalidator(this)) viewModel.onFirstStart.observeEvent(this) { router.showWelcomeSheet() } viewModel.isBottomNavPinned.observe(this, ::setNavbarPinned) searchSuggestionViewModel.isIncognitoModeEnabled.observe(this, this::onIncognitoModeChanged) viewBinding.bottomNav?.addOnLayoutChangeListener(this) viewBinding.searchView.addTransitionListener(this) viewBinding.searchView.addTransitionListener(exitCallback) initSearch() } override fun onRestoreInstanceState(savedInstanceState: Bundle) { super.onRestoreInstanceState(savedInstanceState) adjustSearchUI(viewBinding.searchView.isShowing) navigationDelegate.syncSelectedItem() } override fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) { adjustFabVisibility(topFragment = fragment) adjustAppbar(topFragment = fragment) if (fromUser) { actionModeDelegate.finishActionMode() viewBinding.appbar.setExpanded(true) } } override fun addMenuProvider(provider: MenuProvider, owner: LifecycleOwner, state: Lifecycle.State) { if (provider !is MangaSearchMenuProvider) { // do not duplicate search menu item super.addMenuProvider(provider, owner, state) } } override fun onClick(v: View) { when (v.id) { R.id.fab, R.id.railFab -> viewModel.openLastReader() } } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val typeMask = WindowInsetsCompat.Type.systemBars() val barsInsets = insets.getInsets(typeMask) val searchBarDefaultMargin = resources.getDimensionPixelOffset(materialR.dimen.m3_searchbar_margin_horizontal) viewBinding.searchBar.updateLayoutParams { marginEnd = searchBarDefaultMargin + barsInsets.end(v) marginStart = if (viewBinding.navRail != null) { searchBarDefaultMargin } else { searchBarDefaultMargin + barsInsets.start(v) } } viewBinding.bottomNav?.updatePadding( left = barsInsets.left, right = barsInsets.right, bottom = barsInsets.bottom, ) viewBinding.navRail?.updateLayoutParams { marginStart = barsInsets.start(v) topMargin = barsInsets.top bottomMargin = barsInsets.bottom } updateContainerBottomMargin() return insets.consume(v, typeMask, start = viewBinding.navRail != null).also { handleSearchSuggestionsInsets(it) } } override fun onLayoutChange( v: View?, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int ) { if (top != oldTop || bottom != oldBottom) { updateContainerBottomMargin() } } override fun onStateChanged( searchView: SearchView, previousState: SearchView.TransitionState, newState: SearchView.TransitionState, ) { val wasOpened = previousState >= SearchView.TransitionState.SHOWING val isOpened = newState >= SearchView.TransitionState.SHOWING if (isOpened != wasOpened) { adjustSearchUI(isOpened) } } override fun onRemoveQuery(query: String) { searchSuggestionViewModel.deleteQuery(query) } override fun onSupportActionModeStarted(mode: ActionMode) { super.onSupportActionModeStarted(mode) adjustFabVisibility() bottomNav?.hide() (viewBinding.layoutSearch ?: viewBinding.searchBar).isInvisible = true updateContainerBottomMargin() } override fun onSupportActionModeFinished(mode: ActionMode) { super.onSupportActionModeFinished(mode) adjustFabVisibility() bottomNav?.show() (viewBinding.layoutSearch ?: viewBinding.searchBar).isInvisible = false updateContainerBottomMargin() } private fun onOpenReader(manga: Manga) { val fab = viewBinding.fab ?: viewBinding.navRail?.headerView router.openReader(manga, fab) } private fun onFeedCounterChanged(counter: Int) { navigationDelegate.setCounter(NavItem.FEED, counter) } private fun onIncognitoModeChanged(isIncognito: Boolean) { var options = viewBinding.searchView.getEditText().imeOptions options = if (isIncognito) { options or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING } else { options and EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING.inv() } viewBinding.searchView.getEditText().imeOptions = options invalidateOptionsMenu() } private fun onLoadingStateChanged(isLoading: Boolean) { val fab = viewBinding.fab ?: viewBinding.navRail?.headerView ?: return fab.isEnabled = !isLoading } private fun onResumeEnabledChanged(isEnabled: Boolean) { adjustFabVisibility(isResumeEnabled = isEnabled) } private fun onFirstStart() = try { lifecycleScope.launch(Dispatchers.Main) { // not a default `Main.immediate` dispatcher withContext(Dispatchers.Default) { LocalStorageCleanupWorker.enqueue(applicationContext) } withResumed { MangaPrefetchService.prefetchLast(this@MainActivity) requestNotificationsPermission() startService(Intent(this@MainActivity, LocalIndexUpdateService::class.java)) startService(Intent(this@MainActivity, PeriodicalBackupService::class.java)) if (settings.isAdBlockEnabled) { startService(Intent(this@MainActivity, AdListUpdateService::class.java)) } } } } catch (e: IllegalStateException) { e.printStackTraceDebug() } private fun adjustAppbar(topFragment: Fragment) { if (topFragment is FavouritesContainerFragment) { viewBinding.appbar.fitsSystemWindows = true fadingAppbarMediator.bind() } else { viewBinding.appbar.fitsSystemWindows = false fadingAppbarMediator.unbind() } } private fun adjustFabVisibility( isResumeEnabled: Boolean = viewModel.isResumeEnabled.value, topFragment: Fragment? = navigationDelegate.primaryFragment, isSearchOpened: Boolean = viewBinding.searchView.isShowing, ) { navigationDelegate.navRailHeader?.railFab?.isVisible = isResumeEnabled val fab = viewBinding.fab ?: return if (isResumeEnabled && !actionModeDelegate.isActionModeStarted && !isSearchOpened && topFragment is HistoryListFragment) { if (!fab.isVisible) { fab.show() } } else { if (fab.isVisible) { fab.hide() } } } private fun adjustSearchUI(isOpened: Boolean) { val appBarScrollFlags = if (isOpened) { SCROLL_FLAG_NO_SCROLL } else { SCROLL_FLAG_SCROLL or SCROLL_FLAG_ENTER_ALWAYS or SCROLL_FLAG_SNAP } viewBinding.insetsHolder.updateLayoutParams { scrollFlags = appBarScrollFlags } adjustFabVisibility(isSearchOpened = isOpened) bottomNav?.showOrHide(!isOpened) updateContainerBottomMargin() } private fun requestNotificationsPermission() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission( this, Manifest.permission.POST_NOTIFICATIONS, ) != PERMISSION_GRANTED ) { ActivityCompat.requestPermissions( this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1, ) } } private fun handleSearchSuggestionsInsets(insets: WindowInsetsCompat) { val typeMask = WindowInsetsCompat.Type.ime() or WindowInsetsCompat.Type.systemBars() val barsInsets = insets.getInsets(typeMask) viewBinding.recyclerViewSearch.setPadding(barsInsets.left, 0, barsInsets.right, barsInsets.bottom) } private fun initSearch() { val listener = SearchSuggestionListenerImpl(router, viewBinding.searchView, searchSuggestionViewModel) val adapter = SearchSuggestionAdapter(listener) viewBinding.searchView.toolbar.addMenuProvider( SearchSuggestionMenuProvider(this, voiceInputLauncher, searchSuggestionViewModel), ) viewBinding.searchView.editText.addTextChangedListener(listener) viewBinding.recyclerViewSearch.adapter = adapter viewBinding.searchView.editText.setOnEditorActionListener(listener) viewBinding.searchView.observeState() .map { it >= SearchView.TransitionState.SHOWING } .distinctUntilChanged() .flatMapLatest { isShowing -> if (isShowing) { searchSuggestionViewModel.suggestion } else { emptyFlow() } }.observe(this, adapter) searchSuggestionViewModel.onError.observeEvent( this, SnackbarErrorObserver(viewBinding.recyclerViewSearch, null), ) ItemTouchHelper(SearchSuggestionItemCallback(this)) .attachToRecyclerView(viewBinding.recyclerViewSearch) } private fun setNavbarPinned(isPinned: Boolean) { val bottomNavBar = viewBinding.bottomNav bottomNavBar?.isPinned = isPinned for (view in viewBinding.appbar.children) { val lp = view.layoutParams as? AppBarLayout.LayoutParams ?: continue val scrollFlags = if (isPinned) { lp.scrollFlags and SCROLL_FLAG_SCROLL.inv() } else { lp.scrollFlags or SCROLL_FLAG_SCROLL } if (scrollFlags != lp.scrollFlags) { lp.scrollFlags = scrollFlags view.layoutParams = lp } } updateContainerBottomMargin() } private fun updateContainerBottomMargin() { val bottomNavBar = viewBinding.bottomNav ?: return val newMargin = if (bottomNavBar.isPinned && bottomNavBar.isShownOrShowing) bottomNavBar.height else 0 with(viewBinding.container) { val params = layoutParams as MarginLayoutParams if (params.bottomMargin != newMargin) { params.bottomMargin = newMargin layoutParams = params } } } private fun SearchView.observeState() = callbackFlow { val listener = SearchView.TransitionListener { _, _, state -> trySendBlocking(state) } addTransitionListener(listener) awaitClose { removeTransitionListener(listener) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainMenuProvider.kt ================================================ package org.koitharu.kotatsu.main.ui import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.core.view.MenuProvider import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.AppRouter class MainMenuProvider( private val router: AppRouter, private val viewModel: MainViewModel, ) : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.opt_main, menu) } override fun onPrepareMenu(menu: Menu) { menu.findItem(R.id.action_incognito)?.isChecked = viewModel.isIncognitoModeEnabled.value val hasAppUpdate = viewModel.appUpdate.value != null menu.findItem(R.id.action_app_update)?.isVisible = hasAppUpdate } override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { R.id.action_settings -> { router.openSettings() true } R.id.action_incognito -> { viewModel.setIncognitoMode(!menuItem.isChecked) true } R.id.action_app_update -> { router.openAppUpdate() true } else -> false } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainNavigationDelegate.kt ================================================ package org.koitharu.kotatsu.main.ui import android.os.Bundle import android.view.Gravity import android.view.Menu import android.view.MenuItem import android.view.View import android.widget.FrameLayout import androidx.activity.OnBackPressedCallback import androidx.annotation.IdRes import androidx.core.view.isEmpty import androidx.core.view.isVisible import androidx.core.view.iterator import androidx.core.view.size import androidx.core.view.updateLayoutParams import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import com.google.android.material.navigation.NavigationBarView import com.google.android.material.navigationrail.NavigationRailView import com.google.android.material.transition.MaterialFadeThrough import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import org.koitharu.kotatsu.R import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksFragment import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.NavItem import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.widgets.SlidingBottomNavigationView import org.koitharu.kotatsu.core.util.ext.buildBundle import org.koitharu.kotatsu.core.util.ext.setContentDescriptionAndTooltip import org.koitharu.kotatsu.core.util.ext.smoothScrollToTop import org.koitharu.kotatsu.databinding.NavigationRailFabBinding import org.koitharu.kotatsu.explore.ui.ExploreFragment import org.koitharu.kotatsu.favourites.ui.container.FavouritesContainerFragment import org.koitharu.kotatsu.history.ui.HistoryListFragment import org.koitharu.kotatsu.local.ui.LocalListFragment import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment import org.koitharu.kotatsu.tracker.ui.feed.FeedFragment import org.koitharu.kotatsu.tracker.ui.updates.UpdatesFragment import java.util.LinkedList import com.google.android.material.R as materialR private const val TAG_PRIMARY = "primary" class MainNavigationDelegate( private val navBar: NavigationBarView, private val fragmentManager: FragmentManager, private val settings: AppSettings, ) : OnBackPressedCallback(false), NavigationBarView.OnItemSelectedListener, NavigationBarView.OnItemReselectedListener, View.OnClickListener { private val listeners = LinkedList() val navRailHeader = (navBar as? NavigationRailView)?.headerView?.let { NavigationRailFabBinding.bind(it) } val primaryFragment: Fragment? get() = fragmentManager.findFragmentByTag(TAG_PRIMARY) init { navBar.setOnItemSelectedListener(this) navBar.setOnItemReselectedListener(this) navRailHeader?.run { root.updateLayoutParams { gravity = Gravity.TOP or Gravity.CENTER } val horizontalPadding = (navBar as NavigationRailView).itemActiveIndicatorMarginHorizontal root.setPadding(horizontalPadding, 0, horizontalPadding, 0) buttonExpand.setOnClickListener(this@MainNavigationDelegate) buttonExpand.setContentDescriptionAndTooltip(R.string.expand) railFab.isExtended = false railFab.isAnimationEnabled = false } } override fun onNavigationItemSelected(item: MenuItem): Boolean { return if (onNavigationItemSelected(item.itemId)) { item.isChecked = true true } else { false } } override fun onNavigationItemReselected(item: MenuItem) { onNavigationItemReselected() } override fun onClick(v: View) { when (v.id) { R.id.button_expand -> { if (navBar is NavigationRailView) { setNavbarIsExpanded(!navBar.isExpanded) } } } } override fun handleOnBackPressed() { navBar.selectedItemId = firstItem()?.itemId ?: return } fun onCreate(lifecycleOwner: LifecycleOwner, savedInstanceState: Bundle?) { if (navBar.menu.isEmpty()) { createMenu(settings.mainNavItems, navBar.menu) } observeSettings(lifecycleOwner) val fragment = primaryFragment if (fragment != null) { onFragmentChanged(fragment, fromUser = false) val itemId = getItemId(fragment) if (navBar.selectedItemId != itemId) { navBar.selectedItemId = itemId } } else { val itemId = if (savedInstanceState == null) { firstItem()?.itemId ?: navBar.selectedItemId } else { navBar.selectedItemId } onNavigationItemSelected(itemId) } } fun observeTitle() = callbackFlow { val listener = OnFragmentChangedListener { f, _ -> trySendBlocking(getItemId(f)) } addOnFragmentChangedListener(listener) awaitClose { removeOnFragmentChangedListener(listener) } }.map { navBar.menu.findItem(it)?.title } fun setCounter(item: NavItem, counter: Int) { setCounter(item.id, counter) } fun syncSelectedItem() { val fragment = primaryFragment ?: return onFragmentChanged(fragment, fromUser = false) val itemId = getItemId(fragment) if (navBar.selectedItemId != itemId) { navBar.selectedItemId = itemId } } private fun setCounter(@IdRes id: Int, counter: Int) { if (counter == 0) { navBar.getBadge(id)?.isVisible = false } else { val badge = navBar.getOrCreateBadge(id) if (counter < 0) { badge.clearNumber() } else { badge.number = counter } badge.isVisible = true } } fun setItemVisibility(@IdRes itemId: Int, isVisible: Boolean) { val item = navBar.menu.findItem(itemId) ?: return item.isVisible = isVisible if (item.isChecked && !isVisible) { navBar.selectedItemId = firstItem()?.itemId ?: return } } fun addOnFragmentChangedListener(listener: OnFragmentChangedListener) { listeners.add(listener) } fun removeOnFragmentChangedListener(listener: OnFragmentChangedListener) { listeners.remove(listener) } private fun onNavigationItemSelected(@IdRes itemId: Int): Boolean { val newFragment = when (itemId) { R.id.nav_history -> HistoryListFragment::class.java R.id.nav_favorites -> FavouritesContainerFragment::class.java R.id.nav_explore -> ExploreFragment::class.java R.id.nav_feed -> FeedFragment::class.java R.id.nav_local -> LocalListFragment::class.java R.id.nav_suggestions -> SuggestionsFragment::class.java R.id.nav_bookmarks -> AllBookmarksFragment::class.java R.id.nav_updated -> UpdatesFragment::class.java else -> return false } if (!setPrimaryFragment(newFragment)) { // probably already selected onNavigationItemReselected() } return true } private fun getItemId(fragment: Fragment) = when (fragment) { is HistoryListFragment -> R.id.nav_history is FavouritesContainerFragment -> R.id.nav_favorites is ExploreFragment -> R.id.nav_explore is FeedFragment -> R.id.nav_feed is LocalListFragment -> R.id.nav_local is SuggestionsFragment -> R.id.nav_suggestions is AllBookmarksFragment -> R.id.nav_bookmarks is UpdatesFragment -> R.id.nav_updated else -> 0 } private fun setPrimaryFragment(fragmentClass: Class): Boolean { if (fragmentManager.isStateSaved || fragmentClass.isInstance(primaryFragment)) { return false } val fragment = instantiateFragment(fragmentClass) val args = buildBundle(1) { putBoolean(AppRouter.KEY_IS_BOTTOMTAB, true) } fragment.enterTransition = MaterialFadeThrough() fragmentManager.beginTransaction() .setReorderingAllowed(true) .replace(R.id.container, fragmentClass, args, TAG_PRIMARY) .runOnCommit { onFragmentChanged(fragment, fromUser = true) } .commit() return true } private fun onNavigationItemReselected() { val recyclerView = (primaryFragment as? RecyclerViewOwner)?.recyclerView ?: return recyclerView.smoothScrollToTop() } private fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) { isEnabled = getItemId(fragment) != firstItem()?.itemId listeners.forEach { it.onFragmentChanged(fragment, fromUser) } } private fun createMenu(items: List, menu: Menu) { for (item in items) { menu.add(Menu.NONE, item.id, Menu.NONE, item.title) .setIcon(item.icon) if (menu.size >= navBar.maxItemCount) { break } } } private fun instantiateFragment(fragmentClass: Class): Fragment { val classLoader = navBar.context.classLoader return fragmentManager.fragmentFactory.instantiate(classLoader, fragmentClass.name) } private fun observeSettings(lifecycleOwner: LifecycleOwner) { settings.observe(AppSettings.KEY_TRACKER_ENABLED, AppSettings.KEY_SUGGESTIONS, AppSettings.KEY_NAV_LABELS) .onEach { setItemVisibility(R.id.nav_suggestions, settings.isSuggestionsEnabled) setItemVisibility(R.id.nav_feed, settings.isTrackerEnabled) setNavbarIsLabeled(settings.isNavLabelsVisible) }.launchIn(lifecycleOwner.lifecycleScope) } private fun firstItem(): MenuItem? { val menu = navBar.menu for (item in menu) { if (item.isVisible) return item } return null } private fun setNavbarIsLabeled(value: Boolean) { if (navBar is SlidingBottomNavigationView) { navBar.minimumHeight = navBar.resources.getDimensionPixelSize( if (value) { materialR.dimen.m3_bottom_nav_min_height } else { R.dimen.nav_bar_height_compact }, ) } navRailHeader?.buttonExpand?.isVisible = value if (!value) { setNavbarIsExpanded(false) } navBar.labelVisibilityMode = if (value) { NavigationBarView.LABEL_VISIBILITY_LABELED } else { NavigationBarView.LABEL_VISIBILITY_UNLABELED } } private fun setNavbarIsExpanded(value: Boolean) { if (navBar !is NavigationRailView) { return } if (value) { navBar.expand() navRailHeader?.run { root.updateLayoutParams { gravity = Gravity.TOP or Gravity.START } railFab.extend() buttonExpand.setImageResource(R.drawable.ic_drawer_menu_open) buttonExpand.setContentDescriptionAndTooltip(R.string.collapse) val horizontalPadding = navBar.itemActiveIndicatorExpandedMarginHorizontal root.setPadding(horizontalPadding, 0, horizontalPadding, 0) } } else { navBar.collapse() navRailHeader?.run { root.updateLayoutParams { gravity = Gravity.TOP or Gravity.CENTER } railFab.shrink() buttonExpand.setImageResource(R.drawable.ic_drawer_menu) buttonExpand.setContentDescriptionAndTooltip(R.string.expand) val horizontalPadding = navBar.itemActiveIndicatorMarginHorizontal root.setPadding(horizontalPadding, 0, horizontalPadding, 0) } } } fun interface OnFragmentChangedListener { fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) } companion object { const val MAX_ITEM_COUNT = 6 } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainViewModel.kt ================================================ package org.koitharu.kotatsu.main.ui import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException import org.koitharu.kotatsu.core.github.AppUpdateRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsStateFlow 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.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.main.domain.ReadingResumeEnabledUseCase import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.tracker.domain.TrackingRepository import javax.inject.Inject @HiltViewModel class MainViewModel @Inject constructor( private val historyRepository: HistoryRepository, private val appUpdateRepository: AppUpdateRepository, trackingRepository: TrackingRepository, private val settings: AppSettings, readingResumeEnabledUseCase: ReadingResumeEnabledUseCase, private val sourcesRepository: MangaSourcesRepository, ) : BaseViewModel() { val onOpenReader = MutableEventFlow() val onFirstStart = MutableEventFlow() val isResumeEnabled = readingResumeEnabledUseCase() .withErrorHandling() .stateIn( scope = viewModelScope + Dispatchers.Default, started = SharingStarted.WhileSubscribed(5000), initialValue = false, ) val appUpdate = appUpdateRepository.observeAvailableUpdate() val feedCounter = trackingRepository.observeUnreadUpdatesCount() .withErrorHandling() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, 0) val isBottomNavPinned = settings.observeAsFlow( AppSettings.KEY_NAV_PINNED, ) { isNavBarPinned }.flowOn(Dispatchers.Default) val isIncognitoModeEnabled = settings.observeAsStateFlow( scope = viewModelScope + Dispatchers.Default, key = AppSettings.KEY_INCOGNITO_MODE, valueProducer = { isIncognitoModeEnabled }, ) init { launchJob { appUpdateRepository.fetchUpdate() } launchJob(Dispatchers.Default) { if (sourcesRepository.isSetupRequired()) { onFirstStart.call(Unit) } } } fun openLastReader() { launchLoadingJob(Dispatchers.Default) { val manga = historyRepository.getLastOrNull() ?: throw EmptyHistoryException() onOpenReader.call(manga) } } fun setIncognitoMode(isEnabled: Boolean) { settings.isIncognitoModeEnabled = isEnabled } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/main/ui/SearchViewLegacyBackCallback.kt ================================================ package org.koitharu.kotatsu.main.ui import android.os.Build import androidx.activity.OnBackPressedCallback import androidx.annotation.DeprecatedSinceApi import com.google.android.material.search.SearchView @DeprecatedSinceApi(Build.VERSION_CODES.TIRAMISU) class SearchViewLegacyBackCallback( private val searchView: SearchView ) : OnBackPressedCallback(searchView.isShowing), SearchView.TransitionListener { override fun handleOnBackPressed() { searchView.hide() } override fun onStateChanged( searchView: SearchView, previousState: SearchView.TransitionState, newState: SearchView.TransitionState ) { isEnabled = newState >= SearchView.TransitionState.SHOWING } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/main/ui/owners/AppBarOwner.kt ================================================ package org.koitharu.kotatsu.main.ui.owners import com.google.android.material.appbar.AppBarLayout interface AppBarOwner { val appBar: AppBarLayout } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/main/ui/owners/BottomNavOwner.kt ================================================ package org.koitharu.kotatsu.main.ui.owners import org.koitharu.kotatsu.core.ui.widgets.SlidingBottomNavigationView interface BottomNavOwner { val bottomNav: SlidingBottomNavigationView? } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/main/ui/owners/BottomSheetOwner.kt ================================================ package org.koitharu.kotatsu.main.ui.owners import android.view.View interface BottomSheetOwner { val bottomSheet: View? } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/main/ui/owners/SnackbarOwner.kt ================================================ package org.koitharu.kotatsu.main.ui.owners import androidx.coordinatorlayout.widget.CoordinatorLayout interface SnackbarOwner { val snackbarHost: CoordinatorLayout } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/AppProtectHelper.kt ================================================ package org.koitharu.kotatsu.main.ui.protect import android.app.Activity import android.content.Intent import android.os.Bundle import org.acra.dialog.CrashReportDialog import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.DefaultActivityLifecycleCallbacks import javax.inject.Inject import javax.inject.Singleton @Singleton class AppProtectHelper @Inject constructor(private val settings: AppSettings) : DefaultActivityLifecycleCallbacks { private var isUnlocked = settings.appPassword.isNullOrEmpty() override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { if (!isUnlocked && activity !is ProtectActivity && activity !is CrashReportDialog) { val sourceIntent = Intent(activity, activity.javaClass) activity.intent?.let { sourceIntent.putExtras(it) sourceIntent.action = it.action sourceIntent.setDataAndType(it.data, it.type) } activity.startActivity(ProtectActivity.newIntent(activity, sourceIntent)) activity.finishAfterTransition() } } override fun onActivityDestroyed(activity: Activity) { if (activity !is ProtectActivity && activity.isFinishing && activity.isTaskRoot) { restoreLock() } } fun unlock() { isUnlocked = true } private fun restoreLock() { isUnlocked = settings.appPassword.isNullOrEmpty() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt ================================================ package org.koitharu.kotatsu.main.ui.protect import android.content.Context import android.content.Intent import android.os.Bundle import android.text.Editable import android.view.KeyEvent import android.view.View import android.view.WindowManager import android.view.inputmethod.EditorInfo import android.widget.TextView import androidx.activity.viewModels import androidx.biometric.AuthenticationRequest import androidx.biometric.AuthenticationRequest.Biometric import androidx.biometric.AuthenticationResult import androidx.biometric.AuthenticationResultCallback import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS import androidx.biometric.registerForAuthenticationResult import androidx.core.view.WindowInsetsCompat import androidx.lifecycle.lifecycleScope import androidx.lifecycle.withResumed import com.google.android.material.textfield.TextInputLayout import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.util.DefaultTextWatcher import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat 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.ActivityProtectBinding import com.google.android.material.R as materialR @AndroidEntryPoint class ProtectActivity : BaseActivity(), TextView.OnEditorActionListener, DefaultTextWatcher, View.OnClickListener, AuthenticationResultCallback { private val viewModel by viewModels() private var canUseBiometric = false private val biometricPrompt = registerForAuthenticationResult(resultCallback = this) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) setContentView(ActivityProtectBinding.inflate(layoutInflater)) viewBinding.editPassword.setOnEditorActionListener(this) viewBinding.editPassword.addTextChangedListener(this) viewBinding.buttonNext.setOnClickListener(this) viewBinding.buttonCancel.setOnClickListener(this) viewBinding.editPassword.inputType = if (viewModel.isNumericPassword) { EditorInfo.TYPE_CLASS_NUMBER or EditorInfo.TYPE_NUMBER_VARIATION_PASSWORD } else { EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_PASSWORD } viewModel.onError.observeEvent(this, this::onError) viewModel.isLoading.observe(this, this::onLoadingStateChanged) viewModel.onUnlockSuccess.observeEvent(this) { val intent = intent.getParcelableExtraCompat(EXTRA_INTENT) startActivity(intent) finishAfterTransition() } lifecycleScope.launch { withResumed { canUseBiometric = useFingerprint() updateEndIcon() if (!canUseBiometric) { viewBinding.editPassword.requestFocus() } } } } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val barsInsets = insets.systemBarsInsets val basePadding = resources.getDimensionPixelOffset(R.dimen.screen_padding) viewBinding.root.setPadding( barsInsets.left + basePadding, barsInsets.top + basePadding, barsInsets.right + basePadding, barsInsets.bottom + basePadding, ) return insets.consumeAllSystemBarsInsets() } override fun onClick(v: View) { when (v.id) { R.id.button_next -> viewModel.tryUnlock(viewBinding.editPassword.text?.toString().orEmpty()) R.id.button_cancel -> finish() materialR.id.text_input_end_icon -> useFingerprint() } } override fun onEditorAction(v: TextView?, actionId: Int, event: KeyEvent?): Boolean { return if (actionId == EditorInfo.IME_ACTION_DONE && viewBinding.buttonNext.isEnabled) { viewBinding.buttonNext.performClick() true } else { false } } override fun afterTextChanged(s: Editable?) { viewBinding.layoutPassword.error = null viewBinding.buttonNext.isEnabled = !s.isNullOrEmpty() updateEndIcon() } override fun onAuthResult(result: AuthenticationResult) { if (result.isSuccess()) { viewModel.unlock() } } private fun onError(e: Throwable) { viewBinding.layoutPassword.error = e.getDisplayMessage(resources) } private fun onLoadingStateChanged(isLoading: Boolean) { viewBinding.layoutPassword.isEnabled = !isLoading } private fun useFingerprint(): Boolean { if (!viewModel.isBiometricEnabled) { return false } if (BiometricManager.from(this).canAuthenticate(BIOMETRIC_WEAK) != BIOMETRIC_SUCCESS) { return false } val request = AuthenticationRequest.biometricRequest( title = getString(R.string.app_name), authFallback = Biometric.Fallback.NegativeButton(getString(android.R.string.cancel)), init = { setMinStrength(Biometric.Strength.Class2) setIsConfirmationRequired(false) }, ) biometricPrompt.launch(request) return true } private fun updateEndIcon() = with(viewBinding.layoutPassword) { val isFingerprintIcon = canUseBiometric && viewBinding.editPassword.text.isNullOrEmpty() if (isFingerprintIcon == (endIconMode == TextInputLayout.END_ICON_CUSTOM)) { return@with } if (isFingerprintIcon) { endIconMode = TextInputLayout.END_ICON_CUSTOM setEndIconDrawable(androidx.biometric.R.drawable.fingerprint_dialog_fp_icon) endIconContentDescription = getString(androidx.biometric.R.string.use_biometric_label) setEndIconOnClickListener(this@ProtectActivity) } else { setEndIconOnClickListener(null) setEndIconDrawable(0) endIconContentDescription = null endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE } } companion object { private const val EXTRA_INTENT = "src_intent" fun newIntent(context: Context, sourceIntent: Intent): Intent { return Intent(context, ProtectActivity::class.java) .putExtra(EXTRA_INTENT, sourceIntent) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ProtectViewModel.kt ================================================ package org.koitharu.kotatsu.main.ui.protect import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.delay import org.koitharu.kotatsu.core.exceptions.WrongPasswordException import org.koitharu.kotatsu.core.prefs.AppSettings 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.parsers.util.md5 import javax.inject.Inject private const val PASSWORD_COMPARE_DELAY = 1_000L @HiltViewModel class ProtectViewModel @Inject constructor( private val settings: AppSettings, private val protectHelper: AppProtectHelper, ) : BaseViewModel() { private var job: Job? = null val onUnlockSuccess = MutableEventFlow() val isBiometricEnabled get() = settings.isBiometricProtectionEnabled val isNumericPassword get() = settings.isAppPasswordNumeric fun tryUnlock(password: String) { if (job?.isActive == true) { return } job = launchLoadingJob { val passwordHash = password.md5() val appPasswordHash = settings.appPassword if (passwordHash == appPasswordHash) { unlock() } else { delay(PASSWORD_COMPARE_DELAY) throw WrongPasswordException() } } } fun unlock() { protectHelper.unlock() onUnlockSuccess.call(Unit) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ScreenshotPolicyHelper.kt ================================================ package org.koitharu.kotatsu.main.ui.protect import android.app.Activity import android.os.Bundle import android.view.WindowManager import androidx.annotation.MainThread import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.ui.DefaultActivityLifecycleCallbacks import javax.inject.Inject class ScreenshotPolicyHelper @Inject constructor( private val settings: AppSettings, ) : DefaultActivityLifecycleCallbacks { override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { (activity as? ContentContainer)?.setupScreenshotPolicy(activity) } private fun ContentContainer.setupScreenshotPolicy(activity: Activity) = lifecycleScope.launch(Dispatchers.Default) { settings.observeAsFlow(AppSettings.KEY_SCREENSHOTS_POLICY) { screenshotsPolicy } .flatMapLatest { policy -> when (policy) { ScreenshotsPolicy.ALLOW -> flowOf(false) ScreenshotsPolicy.BLOCK_NSFW -> withContext(Dispatchers.Main) { isNsfwContent() }.distinctUntilChanged() ScreenshotsPolicy.BLOCK_ALL -> flowOf(true) ScreenshotsPolicy.BLOCK_INCOGNITO -> settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled } } }.collect { isSecure -> withContext(Dispatchers.Main) { if (isSecure) { activity.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) } else { activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } } } } interface ContentContainer : LifecycleOwner { @MainThread fun isNsfwContent(): Flow } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeSheet.kt ================================================ package org.koitharu.kotatsu.main.ui.welcome import android.accounts.AccountManager import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.activity.result.ActivityResultCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.WindowInsetsCompat import androidx.core.view.isGone import androidx.core.view.updatePadding import androidx.fragment.app.viewModels import com.google.android.material.chip.Chip import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.titleResId import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.util.ext.consume import org.koitharu.kotatsu.core.util.ext.getDisplayName import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.tryLaunch import org.koitharu.kotatsu.databinding.SheetWelcomeBinding import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.parsers.model.ContentType import java.util.Locale @AndroidEntryPoint class WelcomeSheet : BaseAdaptiveSheet(), ChipsView.OnChipClickListener, View.OnClickListener, ActivityResultCallback { private val viewModel by viewModels() private val backupSelectCall = registerForActivityResult( ActivityResultContracts.OpenDocument(), this, ) override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetWelcomeBinding { return SheetWelcomeBinding.inflate(inflater, container, false) } override fun onViewBindingCreated(binding: SheetWelcomeBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) binding.textViewWelcomeTitle.isGone = resources.getBoolean(R.bool.is_tablet) binding.chipsLocales.onChipClickListener = this binding.chipsType.onChipClickListener = this binding.chipBackup.setOnClickListener(this) binding.chipSync.setOnClickListener(this) binding.chipDirectories.setOnClickListener(this) viewModel.locales.observe(viewLifecycleOwner, ::onLocalesChanged) viewModel.types.observe(viewLifecycleOwner, ::onTypesChanged) } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val typeMask = WindowInsetsCompat.Type.systemBars() viewBinding?.scrollView?.updatePadding( bottom = insets.getInsets(typeMask).bottom, ) return insets.consume(v, typeMask, bottom = true) } override fun onChipClick(chip: Chip, data: Any?) { when (data) { is ContentType -> viewModel.setTypeChecked(data, !chip.isChecked) is Locale -> viewModel.setLocaleChecked(data, !chip.isChecked) } } override fun onClick(v: View) { when (v.id) { R.id.chip_backup -> { if (!backupSelectCall.tryLaunch(arrayOf("*/*"))) { Snackbar.make( v, R.string.operation_not_supported, Snackbar.LENGTH_SHORT, ).show() } } R.id.chip_sync -> { val am = AccountManager.get(v.context) val accountType = getString(R.string.account_type_sync) am.addAccount(accountType, accountType, null, null, requireActivity(), null, null) } R.id.chip_directories -> { router.openDirectoriesSettings() } } } override fun onActivityResult(result: Uri?) { if (result != null) { router.showBackupRestoreDialog(result) } } private fun onLocalesChanged(value: FilterProperty) { val chips = viewBinding?.chipsLocales ?: return chips.setChips( value.availableItems.map { ChipsView.ChipModel( title = it.getDisplayName(chips.context), isChecked = it in value.selectedItems, data = it, ) }, ) } private fun onTypesChanged(value: FilterProperty) { val chips = viewBinding?.chipsType ?: return chips.setChips( value.availableItems.map { ChipsView.ChipModel( title = getString(it.titleResId), isChecked = it in value.selectedItems, data = it, ) }, ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeViewModel.kt ================================================ package org.koitharu.kotatsu.main.ui.welcome import android.content.Context import androidx.core.os.ConfigurationCompat import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import org.koitharu.kotatsu.core.LocalizedAppContext import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.LocaleComparator import org.koitharu.kotatsu.core.util.ext.mapSortedByCount import org.koitharu.kotatsu.core.util.ext.sortedWithSafe import org.koitharu.kotatsu.core.util.ext.toList import org.koitharu.kotatsu.core.util.ext.toLocale import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.util.mapToSet import java.util.EnumSet import java.util.Locale import javax.inject.Inject @HiltViewModel class WelcomeViewModel @Inject constructor( private val repository: MangaSourcesRepository, @LocalizedAppContext context: Context, ) : BaseViewModel() { private val allSources = repository.allMangaSources private val localesGroups by lazy { allSources.groupBy { it.locale.toLocale() } } private var updateJob: Job val locales = MutableStateFlow( FilterProperty( availableItems = listOf(Locale.ROOT), selectedItems = setOf(Locale.ROOT), isLoading = true, error = null, ), ) val types = MutableStateFlow( FilterProperty( availableItems = listOf(ContentType.MANGA), selectedItems = setOf(ContentType.MANGA), isLoading = true, error = null, ), ) init { updateJob = launchJob(Dispatchers.Default) { val contentTypes = allSources.mapSortedByCount { it.contentType } types.value = types.value.copy( availableItems = contentTypes, isLoading = false, ) val languages = localesGroups.keys.associateBy { x -> x.language } val selectedLocales = HashSet(2) ConfigurationCompat.getLocales(context.resources.configuration).toList() .firstNotNullOfOrNull { lc -> languages[lc.language] } ?.let { selectedLocales += it } selectedLocales += Locale.ROOT locales.value = locales.value.copy( availableItems = localesGroups.keys.sortedWithSafe(LocaleComparator()), selectedItems = selectedLocales, isLoading = false, ) repository.clearNewSourcesBadge() commit() } } fun setLocaleChecked(locale: Locale, isChecked: Boolean) { val snapshot = locales.value locales.value = snapshot.copy( selectedItems = if (isChecked) { snapshot.selectedItems + locale } else { snapshot.selectedItems - locale }, ) val prevJob = updateJob updateJob = launchJob(Dispatchers.Default) { prevJob.join() commit() } } fun setTypeChecked(type: ContentType, isChecked: Boolean) { val snapshot = types.value types.value = snapshot.copy( selectedItems = if (isChecked) { snapshot.selectedItems + type } else { snapshot.selectedItems - type }, ) val prevJob = updateJob updateJob = launchJob(Dispatchers.Default) { prevJob.join() commit() } } private suspend fun commit() { val languages = locales.value.selectedItems.mapToSet { it.language } val types = types.value.selectedItems val enabledSources = allSources.filterTo(EnumSet.noneOf(MangaParserSource::class.java)) { x -> x.contentType in types && x.locale in languages } repository.setSourcesEnabledExclusive(enabledSources) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/picker/ui/PageImagePickActivity.kt ================================================ package org.koitharu.kotatsu.picker.ui import android.content.Intent import android.os.Bundle import android.view.View import androidx.activity.viewModels import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.FileProvider import androidx.core.view.WindowInsetsCompat import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.updatePadding import androidx.fragment.app.commit import com.google.android.material.appbar.AppBarLayout import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.DialogErrorObserver import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.util.ext.consume import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.ActivityPickerBinding import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.picker.ui.manga.MangaPickerFragment import org.koitharu.kotatsu.picker.ui.page.PagePickerFragment import org.koitharu.kotatsu.reader.ui.PageSaveHelper import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import java.io.File import javax.inject.Inject @AndroidEntryPoint class PageImagePickActivity : BaseActivity(), AppBarOwner, SnackbarOwner { @Inject lateinit var pageSaveHelperFactory: PageSaveHelper.Factory override val appBar: AppBarLayout get() = viewBinding.appbar override val snackbarHost: CoordinatorLayout get() = viewBinding.root private lateinit var pageSaveHelper: PageSaveHelper private val viewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityPickerBinding.inflate(layoutInflater)) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) pageSaveHelper = pageSaveHelperFactory.create(this) viewModel.onError.observeEvent(this, DialogErrorObserver(viewBinding.container, null)) viewModel.onFileReady.observeEvent(this, ::finishWithResult) viewModel.isLoading.observe(this, ::onLoadingStateChanged) val fm = supportFragmentManager if (fm.findFragmentById(R.id.container) == null) { fm.commit { setReorderingAllowed(true) if (intent?.hasExtra(AppRouter.KEY_MANGA) == true) { replace(R.id.container, PagePickerFragment::class.java, intent.extras) } else { replace(R.id.container, MangaPickerFragment::class.java, null) } } } } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val typeMask = WindowInsetsCompat.Type.systemBars() val bars = insets.getInsets(typeMask) viewBinding.appbar.updatePadding( left = bars.left, right = bars.right, top = bars.top, ) return insets.consume(v, typeMask, top = true) } fun onMangaPicked(manga: Manga) { val args = Bundle(1) args.putLong(AppRouter.KEY_ID, manga.id) supportFragmentManager.commit { setReorderingAllowed(true) replace(R.id.container, PagePickerFragment::class.java, args) addToBackStack(null) } } fun onPagePicked(manga: Manga, page: ReaderPage) { val task = PageSaveHelper.Task( manga = manga, chapterId = page.chapterId, pageNumber = page.index + 1, page = page.toMangaPage(), ) viewModel.savePageToTempFile(pageSaveHelper, task) } private fun onLoadingStateChanged(isLoading: Boolean) { viewBinding.container.isGone = isLoading viewBinding.progressBar.isVisible = isLoading } private fun finishWithResult(file: File) { val uri = FileProvider.getUriForFile(applicationContext, "${BuildConfig.APPLICATION_ID}.files", file) val result = Intent() result.setData(uri) result.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) setResult(RESULT_OK, result) finish() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/picker/ui/PageImagePickContract.kt ================================================ package org.koitharu.kotatsu.picker.ui import android.content.Context import android.content.Intent import android.net.Uri import androidx.activity.result.contract.ActivityResultContract import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.parsers.model.Manga class PageImagePickContract : ActivityResultContract() { override fun createIntent(context: Context, input: Manga?): Intent = Intent(context, PageImagePickActivity::class.java) .putExtra(AppRouter.KEY_MANGA, input?.let { ParcelableManga(it) }) override fun parseResult(resultCode: Int, intent: Intent?): Uri? = intent?.data } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/picker/ui/PageImagePickViewModel.kt ================================================ package org.koitharu.kotatsu.picker.ui import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers 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.reader.ui.PageSaveHelper import java.io.File import javax.inject.Inject @HiltViewModel class PageImagePickViewModel @Inject constructor() : BaseViewModel() { val onFileReady = MutableEventFlow() fun savePageToTempFile(pageSaveHelper: PageSaveHelper, task: PageSaveHelper.Task) { launchLoadingJob(Dispatchers.Default) { val file = pageSaveHelper.saveToTempFile(task) onFileReady.call(file) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/picker/ui/manga/MangaPickerFragment.kt ================================================ package org.koitharu.kotatsu.picker.ui.manga import android.view.View import androidx.fragment.app.viewModels import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.picker.ui.PageImagePickActivity @AndroidEntryPoint class MangaPickerFragment : MangaListFragment() { override val isSwipeRefreshEnabled = false override val viewModel by viewModels() override fun onScrolledToEnd() = Unit override fun onItemClick(item: MangaListModel, view: View) { (activity as PageImagePickActivity).onMangaPicked(item.manga) } override fun onResume() { super.onResume() activity?.setTitle(R.string.pick_manga_page) } override fun onItemLongClick(item: MangaListModel, view: View): Boolean = false override fun onItemContextClick(item: MangaListModel, view: View): Boolean = false } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/picker/ui/manga/MangaPickerViewModel.kt ================================================ package org.koitharu.kotatsu.picker.ui.manga 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.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.list.ui.MangaListViewModel 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 javax.inject.Inject import kotlinx.coroutines.flow.SharedFlow import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.domain.model.LocalManga @HiltViewModel class MangaPickerViewModel @Inject constructor( private val settings: AppSettings, mangaDataRepository: MangaDataRepository, private val historyRepository: HistoryRepository, private val favouritesRepository: FavouritesRepository, private val mangaListMapper: MangaListMapper, @LocalStorageChanges localStorageChanges: SharedFlow, ) : MangaListViewModel(settings, mangaDataRepository, localStorageChanges) { override val content: StateFlow> get() = flow { emit(loadList()) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState)) override fun onRefresh() = Unit override fun onRetry() = Unit private suspend fun loadList() = buildList { val history = historyRepository.getList(0, Int.MAX_VALUE) if (history.isNotEmpty()) { add(ListHeader(R.string.history)) mangaListMapper.toListModelList(this, history, settings.listMode) } val categories = favouritesRepository.observeCategoriesForLibrary().first() for (category in categories) { val favorites = favouritesRepository.getManga(category.id) if (favorites.isNotEmpty()) { add(ListHeader(category.title)) mangaListMapper.toListModelList(this, favorites, settings.listMode) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/picker/ui/page/PagePickerFragment.kt ================================================ package org.koitharu.kotatsu.picker.ui.page import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.WindowInsetsCompat import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.list.BoundsScrollListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper import org.koitharu.kotatsu.core.util.ext.consumeAll import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.showOrHide import org.koitharu.kotatsu.databinding.FragmentPagesBinding import org.koitharu.kotatsu.details.ui.pager.pages.PageThumbnail import org.koitharu.kotatsu.details.ui.pager.pages.PageThumbnailAdapter import org.koitharu.kotatsu.list.ui.GridSpanResolver import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.picker.ui.PageImagePickActivity import javax.inject.Inject @AndroidEntryPoint class PagePickerFragment : BaseFragment(), OnListItemClickListener { @Inject lateinit var settings: AppSettings private val viewModel by viewModels() private var thumbnailsAdapter: PageThumbnailAdapter? = null private var spanResolver: GridSpanResolver? = null private var scrollListener: ScrollListener? = null private val spanSizeLookup = SpanSizeLookup() override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentPagesBinding { return FragmentPagesBinding.inflate(inflater, container, false) } override fun onViewBindingCreated(binding: FragmentPagesBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) spanResolver = GridSpanResolver(binding.root.resources) thumbnailsAdapter = PageThumbnailAdapter( clickListener = this@PagePickerFragment, ) viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) // before rv initialization with(binding.recyclerView) { addItemDecoration(TypedListSpacingDecoration(context, false)) adapter = thumbnailsAdapter setHasFixedSize(true) PagerNestedScrollHelper(this).bind(viewLifecycleOwner) addOnLayoutChangeListener(spanResolver) addOnScrollListener(ScrollListener().also { scrollListener = it }) (layoutManager as GridLayoutManager).let { it.spanSizeLookup = spanSizeLookup it.spanCount = checkNotNull(spanResolver).spanCount } } viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged) viewModel.isNoChapters.observe(viewLifecycleOwner, ::onNoChaptersChanged) viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) viewModel.isLoading.observe(viewLifecycleOwner) { binding.progressBar.showOrHide(it) } viewModel.isLoadingDown.observe(viewLifecycleOwner) { binding.progressBarBottom.showOrHide(it) } viewModel.manga.observe(viewLifecycleOwner, Lifecycle.State.RESUMED) { activity?.title = it?.toManga()?.title.ifNullOrEmpty { getString(R.string.pick_manga_page) } } } override fun onDestroyView() { spanResolver = null scrollListener = null thumbnailsAdapter = null spanSizeLookup.invalidateCache() super.onDestroyView() } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val typeBask = WindowInsetsCompat.Type.systemBars() val barsInsets = insets.getInsets(typeBask) viewBinding?.recyclerView?.setPadding( barsInsets.left, barsInsets.top, barsInsets.right, barsInsets.bottom, ) return insets.consumeAll(typeBask) } override fun onItemClick(item: PageThumbnail, view: View) { val manga = viewModel.manga.value?.toManga() ?: return (activity as PageImagePickActivity).onPagePicked(manga, item.page) } override fun onItemLongClick(item: PageThumbnail, view: View): Boolean = false override fun onItemContextClick(item: PageThumbnail, view: View): Boolean = false private suspend fun onThumbnailsChanged(list: List) { val adapter = thumbnailsAdapter ?: return adapter.emit(list) spanSizeLookup.invalidateCache() viewBinding?.recyclerView?.let { scrollListener?.postInvalidate(it) } } private fun onGridScaleChanged(scale: Float) { spanSizeLookup.invalidateCache() spanResolver?.setGridSize(scale, requireViewBinding().recyclerView) } private fun onNoChaptersChanged(isNoChapters: Boolean) { with(viewBinding ?: return) { textViewHolder.isVisible = isNoChapters recyclerView.isInvisible = isNoChapters } } private inner class ScrollListener : BoundsScrollListener(3, 3) { override fun onScrolledToStart(recyclerView: RecyclerView) = Unit override fun onScrolledToEnd(recyclerView: RecyclerView) { viewModel.loadNextChapter() } } private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() { init { isSpanIndexCacheEnabled = true isSpanGroupIndexCacheEnabled = true } override fun getSpanSize(position: Int): Int { val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1 return when (thumbnailsAdapter?.getItemViewType(position)) { ListItemType.PAGE_THUMB.ordinal -> 1 else -> total } } fun invalidateCache() { invalidateSpanGroupIndexCache() invalidateSpanIndexCache() } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/picker/ui/page/PagePickerViewModel.kt ================================================ package org.koitharu.kotatsu.picker.ui.page 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.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.nav.MangaIntent import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.firstNotNull import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.details.domain.DetailsLoadUseCase import org.koitharu.kotatsu.details.ui.pager.pages.PageThumbnail import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.reader.domain.ChaptersLoader import javax.inject.Inject @HiltViewModel class PagePickerViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val chaptersLoader: ChaptersLoader, private val detailsLoadUseCase: DetailsLoadUseCase, settings: AppSettings, ) : BaseViewModel() { private val intent = MangaIntent(savedStateHandle) private var loadingJob: Job? = null private var loadingNextJob: Job? = null val thumbnails = MutableStateFlow>(emptyList()) val isLoadingDown = MutableStateFlow(false) val manga = MutableStateFlow(intent.manga?.let { MangaDetails(it) }) val isNoChapters = manga.map { it != null && it.isLoaded && it.allChapters.isEmpty() } val gridScale = settings.observeAsStateFlow( scope = viewModelScope + Dispatchers.Default, key = AppSettings.KEY_GRID_SIZE_PAGES, valueProducer = { gridSizePages / 100f }, ) init { loadingJob = launchLoadingJob(Dispatchers.Default) { doInit() } } private suspend fun doInit() { val details = detailsLoadUseCase.invoke(intent, force = false) .onEach { manga.value = it } .first { x -> x.isLoaded } chaptersLoader.init(details) val initialChapterId = details.allChapters.firstOrNull()?.id ?: return if (!chaptersLoader.hasPages(initialChapterId)) { chaptersLoader.loadSingleChapter(initialChapterId) } updateList() } fun loadNextChapter() { if (loadingJob?.isActive == true || loadingNextJob?.isActive == true) { return } loadingNextJob = launchJob(Dispatchers.Default) { isLoadingDown.value = true try { val currentId = chaptersLoader.last().chapterId chaptersLoader.loadPrevNextChapter(manga.firstNotNull(), currentId, isNext = true) updateList() } finally { isLoadingDown.value = false } } } private fun updateList() { val snapshot = chaptersLoader.snapshot() val pages = buildList(snapshot.size + chaptersLoader.size + 2) { var previousChapterId = 0L for (page in snapshot) { if (page.chapterId != previousChapterId) { chaptersLoader.peekChapter(page.chapterId)?.let { add(ListHeader(it)) } previousChapterId = page.chapterId } this += PageThumbnail( isCurrent = false, page = page, ) } } thumbnails.value = pages } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/data/ModelMapping.kt ================================================ package org.koitharu.kotatsu.reader.data import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter fun Manga.filterChapters(branch: String?): Manga { if (chapters.isNullOrEmpty()) return this return withChapters(chapters = chapters?.filter { it.branch == branch }) } private fun Manga.withChapters(chapters: List?) = copy( chapters = chapters, ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/data/TapGridSettings.kt ================================================ package org.koitharu.kotatsu.reader.data import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit import dagger.Reusable import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flowOn import org.koitharu.kotatsu.core.util.ext.getEnumValue import org.koitharu.kotatsu.core.util.ext.observeChanges import org.koitharu.kotatsu.core.util.ext.putAll import org.koitharu.kotatsu.core.util.ext.putEnumValue import org.koitharu.kotatsu.reader.domain.TapGridArea import org.koitharu.kotatsu.reader.ui.tapgrid.TapAction import javax.inject.Inject @Reusable class TapGridSettings @Inject constructor(@ApplicationContext context: Context) { private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) init { if (!prefs.getBoolean(KEY_INIT, false)) { initPrefs(withDefaultValues = true) } } fun getTapAction(area: TapGridArea, isLongTap: Boolean): TapAction? { val key = getPrefKey(area, isLongTap) return prefs.getEnumValue(key, TapAction::class.java) } fun setTapAction(area: TapGridArea, isLongTap: Boolean, action: TapAction?) { val key = getPrefKey(area, isLongTap) prefs.edit { putEnumValue(key, action) } } fun reset() { initPrefs(withDefaultValues = true) } fun disableAll() { initPrefs(withDefaultValues = false) } fun observeChanges() = prefs.observeChanges().flowOn(Dispatchers.IO) fun getAllValues(): Map = prefs.all fun upsertAll(m: Map) = prefs.edit { clear() putAll(m) } private fun initPrefs(withDefaultValues: Boolean) { prefs.edit { clear() if (withDefaultValues) { initDefaultActions(this) } putBoolean(KEY_INIT, true) } } private fun getPrefKey(area: TapGridArea, isLongTap: Boolean): String = if (isLongTap) { area.name + SUFFIX_LONG } else { area.name } private fun initDefaultActions(editor: SharedPreferences.Editor) { editor.putEnumValue(getPrefKey(TapGridArea.TOP_LEFT, false), TapAction.PAGE_PREV) editor.putEnumValue(getPrefKey(TapGridArea.TOP_CENTER, false), TapAction.PAGE_PREV) editor.putEnumValue(getPrefKey(TapGridArea.CENTER_LEFT, false), TapAction.PAGE_PREV) editor.putEnumValue(getPrefKey(TapGridArea.BOTTOM_LEFT, false), TapAction.PAGE_PREV) editor.putEnumValue(getPrefKey(TapGridArea.CENTER, false), TapAction.TOGGLE_UI) editor.putEnumValue(getPrefKey(TapGridArea.CENTER, true), TapAction.SHOW_MENU) editor.putEnumValue(getPrefKey(TapGridArea.TOP_RIGHT, false), TapAction.PAGE_NEXT) editor.putEnumValue(getPrefKey(TapGridArea.CENTER_RIGHT, false), TapAction.PAGE_NEXT) editor.putEnumValue(getPrefKey(TapGridArea.BOTTOM_CENTER, false), TapAction.PAGE_NEXT) editor.putEnumValue(getPrefKey(TapGridArea.BOTTOM_RIGHT, false), TapAction.PAGE_NEXT) } private companion object { private const val PREFS_NAME = "tap_grid" private const val KEY_INIT = "_init" private const val SUFFIX_LONG = "_long" } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/ChapterPages.kt ================================================ package org.koitharu.kotatsu.reader.domain import androidx.collection.LongSparseArray import androidx.collection.contains import org.koitharu.kotatsu.reader.ui.pager.ReaderPage class ChapterPages private constructor(private val pages: ArrayDeque) : List by pages { // map chapterId to index in pages deque private val indices = LongSparseArray() constructor() : this(ArrayDeque()) val chaptersSize: Int get() = indices.size() @Synchronized fun removeFirst() { val chapterId = pages.first().chapterId indices.remove(chapterId) var delta = 0 while (pages.first().chapterId == chapterId) { pages.removeFirst() delta-- } shiftIndices(delta) } @Synchronized fun removeLast() { val chapterId = pages.last().chapterId indices.remove(chapterId) while (pages.last().chapterId == chapterId) { pages.removeLast() } } @Synchronized fun addLast(id: Long, newPages: List): Boolean { if (id in indices) { return false } indices.put(id, pages.size until (pages.size + newPages.size)) pages.addAll(newPages) return true } @Synchronized fun addFirst(id: Long, newPages: List): Boolean { if (id in indices) { return false } shiftIndices(newPages.size) indices.put(id, newPages.indices) pages.addAll(0, newPages) return true } @Synchronized fun clear() { indices.clear() pages.clear() } fun size(id: Long) = indices[id]?.run { endInclusive - start + 1 } ?: 0 fun subList(id: Long): List { val range = indices[id] ?: return emptyList() return pages.subList(range.first, range.last + 1) } operator fun contains(chapterId: Long) = chapterId in indices private fun shiftIndices(delta: Int) { for (i in 0 until indices.size()) { val range = indices.valueAt(i) indices.setValueAt(i, range + delta) } } private operator fun IntRange.plus(delta: Int): IntRange { return IntRange(start + delta, endInclusive + delta) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/ChaptersLoader.kt ================================================ package org.koitharu.kotatsu.reader.domain import android.util.LongSparseArray import androidx.annotation.CheckResult import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import javax.inject.Inject private const val PAGES_TRIM_THRESHOLD = 120 @ViewModelScoped class ChaptersLoader @Inject constructor( private val mangaRepositoryFactory: MangaRepository.Factory, ) { private val chapters = LongSparseArray() private val chapterPages = ChapterPages() private val mutex = Mutex() val size: Int get() = chapters.size() suspend fun init(manga: MangaDetails) = mutex.withLock { chapters.clear() manga.allChapters.forEach { chapters.put(it.id, it) } } suspend fun loadPrevNextChapter(manga: MangaDetails, currentId: Long, isNext: Boolean): Boolean { val chapters = manga.allChapters val predicate: (MangaChapter) -> Boolean = { it.id == currentId } val index = if (isNext) chapters.indexOfFirst(predicate) else chapters.indexOfLast(predicate) if (index == -1) return false val newChapter = chapters.getOrNull(if (isNext) index + 1 else index - 1) ?: return false val newPages = loadChapter(newChapter.id) mutex.withLock { if (chapterPages.chaptersSize > 1) { // trim pages if (chapterPages.size > PAGES_TRIM_THRESHOLD) { if (isNext) { chapterPages.removeFirst() } else { chapterPages.removeLast() } } } if (isNext) { chapterPages.addLast(newChapter.id, newPages) } else { chapterPages.addFirst(newChapter.id, newPages) } } return true } @CheckResult suspend fun loadSingleChapter(chapterId: Long): Boolean { val pages = loadChapter(chapterId) return mutex.withLock { chapterPages.clear() chapterPages.addLast(chapterId, pages) pages.isNotEmpty() } } fun peekChapter(chapterId: Long): MangaChapter? = chapters[chapterId] fun hasPages(chapterId: Long): Boolean { return chapterId in chapterPages } fun getPages(chapterId: Long): List = synchronized(chapterPages) { return chapterPages.subList(chapterId).map { it.toMangaPage() } } fun getPagesCount(chapterId: Long): Int { return chapterPages.size(chapterId) } fun last() = chapterPages.last() fun first() = chapterPages.first() fun snapshot() = chapterPages.toList() private suspend fun loadChapter(chapterId: Long): List { val chapter = checkNotNull(chapters[chapterId]) { "Requested chapter not found" } val repo = mangaRepositoryFactory.create(chapter.source) return repo.getPages(chapter).mapIndexed { index, page -> ReaderPage(page, index, chapterId) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/DetectReaderModeUseCase.kt ================================================ package org.koitharu.kotatsu.reader.domain import android.graphics.BitmapFactory import android.util.Size import androidx.core.net.toFile import androidx.core.net.toUri import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import okhttp3.OkHttpClient import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.util.ext.isFileUri import org.koitharu.kotatsu.core.util.ext.isZipUri import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.reader.ui.ReaderState import java.io.InputStream import java.util.zip.ZipFile import javax.inject.Inject import kotlin.math.roundToInt class DetectReaderModeUseCase @Inject constructor( private val dataRepository: MangaDataRepository, private val settings: AppSettings, private val mangaRepositoryFactory: MangaRepository.Factory, @MangaHttpClient private val okHttpClient: OkHttpClient, private val imageProxyInterceptor: ImageProxyInterceptor, ) { suspend operator fun invoke(manga: Manga, state: ReaderState?): ReaderMode { dataRepository.getReaderMode(manga.id)?.let { return it } val defaultMode = settings.defaultReaderMode if (!settings.isReaderModeDetectionEnabled || defaultMode == ReaderMode.WEBTOON) { return defaultMode } val chapter = state?.let { manga.findChapterById(it.chapterId) } ?: manga.chapters?.firstOrNull() ?: error("There are no chapters in this manga") val repo = mangaRepositoryFactory.create(manga.source) val pages = repo.getPages(chapter) return runCatchingCancellable { val isWebtoon = guessMangaIsWebtoon(repo, pages) if (isWebtoon) ReaderMode.WEBTOON else defaultMode }.onSuccess { dataRepository.saveReaderMode(manga, it) }.onFailure { it.printStackTraceDebug() }.getOrDefault(defaultMode) } /** * Automatic determine type of manga by page size * @return ReaderMode.WEBTOON if page is wide */ private suspend fun guessMangaIsWebtoon(repository: MangaRepository, pages: List): Boolean { val pageIndex = (pages.size * 0.3).roundToInt() val page = requireNotNull(pages.getOrNull(pageIndex)) { "No pages" } val url = repository.getPageUrl(page) val uri = url.toUri() val size = when { uri.isZipUri() -> runInterruptible(Dispatchers.IO) { ZipFile(uri.schemeSpecificPart).use { zip -> val entry = zip.getEntry(uri.fragment) zip.getInputStream(entry).use { getBitmapSize(it) } } } uri.isFileUri() -> runInterruptible(Dispatchers.IO) { uri.toFile().inputStream().use { getBitmapSize(it) } } else -> { val request = PageLoader.createPageRequest(url, page.source) imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { runInterruptible(Dispatchers.IO) { getBitmapSize(it.body?.byteStream()) } } } } return size.width * MIN_WEBTOON_RATIO < size.height } companion object { private const val MIN_WEBTOON_RATIO = 1.8 private fun getBitmapSize(input: InputStream?): Size { val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeStream(input, null, options)?.recycle() val imageHeight: Int = options.outHeight val imageWidth: Int = options.outWidth check(imageHeight > 0 && imageWidth > 0) return Size(imageWidth, imageHeight) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/EdgeDetector.kt ================================================ package org.koitharu.kotatsu.reader.domain import android.content.Context import android.graphics.Bitmap import android.graphics.Color import android.graphics.Point import android.graphics.Rect import androidx.annotation.ColorInt import androidx.core.graphics.alpha import androidx.core.graphics.blue import androidx.core.graphics.green import androidx.core.graphics.red import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.koitharu.kotatsu.core.util.SynchronizedSieveCache import kotlin.math.abs import kotlin.math.max import kotlin.math.min class EdgeDetector(private val context: Context) { private val mutex = Mutex() private val cache = SynchronizedSieveCache(CACHE_SIZE) suspend fun getBounds(imageSource: ImageSource): Rect? { cache[imageSource]?.let { rect -> return if (rect.isEmpty) null else rect } return mutex.withLock { withContext(Dispatchers.IO) { val decoder = SkiaPooledImageRegionDecoder(Bitmap.Config.RGB_565) try { val size = runInterruptible { decoder.init(context, imageSource) } val scaleFactor = calculateScaleFactor(size) val sampleSize = (1f / scaleFactor).toInt().coerceAtLeast(1) val fullBitmap = decoder.decodeRegion( Rect(0, 0, size.x, size.y), sampleSize, ) try { val edges = coroutineScope { listOf( async { detectLeftRightEdge(fullBitmap, size, sampleSize, isLeft = true) }, async { detectTopBottomEdge(fullBitmap, size, sampleSize, isTop = true) }, async { detectLeftRightEdge(fullBitmap, size, sampleSize, isLeft = false) }, async { detectTopBottomEdge(fullBitmap, size, sampleSize, isTop = false) }, ).awaitAll() } var hasEdges = false for (edge in edges) { if (edge > 0) { hasEdges = true } else if (edge < 0) { return@withContext null } } if (hasEdges) { Rect(edges[0], edges[1], size.x - edges[2], size.y - edges[3]) } else { null } } finally { fullBitmap.recycle() } } finally { decoder.recycle() } } }.also { cache.put(imageSource, it ?: EMPTY_RECT) } } private fun detectLeftRightEdge(bitmap: Bitmap, size: Point, sampleSize: Int, isLeft: Boolean): Int { var width = size.x val rectCount = size.x / BLOCK_SIZE val maxRect = rectCount / 3 val blockPixels = IntArray(BLOCK_SIZE * BLOCK_SIZE) val bitmapWidth = bitmap.width val bitmapHeight = bitmap.height for (i in 0 until rectCount) { if (i > maxRect) { return -1 } var dd = BLOCK_SIZE for (j in 0 until size.y / BLOCK_SIZE) { val regionX = if (isLeft) i * BLOCK_SIZE else size.x - (i + 1) * BLOCK_SIZE val regionY = j * BLOCK_SIZE // Convert to bitmap coordinates val bitmapX = regionX / sampleSize val bitmapY = regionY / sampleSize val blockWidth = min(BLOCK_SIZE / sampleSize, bitmapWidth - bitmapX) val blockHeight = min(BLOCK_SIZE / sampleSize, bitmapHeight - bitmapY) if (blockWidth > 0 && blockHeight > 0) { bitmap.getPixels(blockPixels, 0, blockWidth, bitmapX, bitmapY, blockWidth, blockHeight) for (ii in 0 until minOf(blockWidth, dd / sampleSize)) { for (jj in 0 until blockHeight) { val bi = if (isLeft) ii else blockWidth - ii - 1 val pixel = blockPixels[jj * blockWidth + bi] if (pixel.isNotWhite()) { width = minOf(width, BLOCK_SIZE * i + ii * sampleSize) dd -= sampleSize break } } } } if (dd == 0) { break } } if (dd < BLOCK_SIZE) { break // We have already found vertical field or it is not exist } } return width } private fun detectTopBottomEdge(bitmap: Bitmap, size: Point, sampleSize: Int, isTop: Boolean): Int { var height = size.y val rectCount = size.y / BLOCK_SIZE val maxRect = rectCount / 3 val blockPixels = IntArray(BLOCK_SIZE * BLOCK_SIZE) val bitmapWidth = bitmap.width val bitmapHeight = bitmap.height for (j in 0 until rectCount) { if (j > maxRect) { return -1 } var dd = BLOCK_SIZE for (i in 0 until size.x / BLOCK_SIZE) { val regionX = i * BLOCK_SIZE val regionY = if (isTop) j * BLOCK_SIZE else size.y - (j + 1) * BLOCK_SIZE // Convert to bitmap coordinates val bitmapX = regionX / sampleSize val bitmapY = regionY / sampleSize val blockWidth = min(BLOCK_SIZE / sampleSize, bitmapWidth - bitmapX) val blockHeight = min(BLOCK_SIZE / sampleSize, bitmapHeight - bitmapY) if (blockWidth > 0 && blockHeight > 0) { bitmap.getPixels(blockPixels, 0, blockWidth, bitmapX, bitmapY, blockWidth, blockHeight) for (jj in 0 until minOf(blockHeight, dd / sampleSize)) { for (ii in 0 until blockWidth) { val bj = if (isTop) jj else blockHeight - jj - 1 val pixel = blockPixels[bj * blockWidth + ii] if (pixel.isNotWhite()) { height = minOf(height, BLOCK_SIZE * j + jj * sampleSize) dd -= sampleSize break } } } } if (dd == 0) { break } } if (dd < BLOCK_SIZE) { break // We have already found vertical field or it is not exist } } return height } /** * Calculate scale factor for performance optimization. * Large images can be downscaled for edge detection without losing accuracy. */ private fun calculateScaleFactor(size: Point): Float { val maxDimension = max(size.x, size.y) return when { maxDimension <= 1024 -> 1.0f maxDimension <= 2048 -> 0.75f maxDimension <= 4096 -> 0.5f else -> 0.25f } } companion object { private const val BLOCK_SIZE = 100 private const val COLOR_TOLERANCE = 16 private const val CACHE_SIZE = 24 private val EMPTY_RECT = Rect(0, 0, 0, 0) fun isColorTheSame(@ColorInt a: Int, @ColorInt b: Int, tolerance: Int): Boolean { return abs(a.red - b.red) <= tolerance && abs(a.green - b.green) <= tolerance && abs(a.blue - b.blue) <= tolerance && abs(a.alpha - b.alpha) <= tolerance } private fun Int.isNotWhite() = !isColorTheSame(this, Color.WHITE, COLOR_TOLERANCE) private fun region(x: Int, y: Int) = Rect(x, y, x + BLOCK_SIZE, y + BLOCK_SIZE) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt ================================================ package org.koitharu.kotatsu.reader.domain import android.content.Context import android.graphics.Rect import android.net.Uri import androidx.annotation.AnyThread import androidx.annotation.CheckResult import androidx.collection.LongSparseArray import androidx.collection.set import androidx.core.net.toFile import androidx.core.net.toUri import coil3.BitmapImage import coil3.Image import coil3.ImageLoader import coil3.memory.MemoryCache import coil3.request.ImageRequest import coil3.request.transformations import coil3.toBitmap import com.davemorrissey.labs.subscaleview.ImageSource import dagger.hilt.android.ActivityRetainedLifecycle import dagger.hilt.android.scopes.ActivityRetainedScoped import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.plus import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit import okhttp3.OkHttpClient import okhttp3.Request import okio.use import org.jetbrains.annotations.Blocking import org.koitharu.kotatsu.core.LocalizedAppContext import org.koitharu.kotatsu.core.image.BitmapDecoderCompat import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor import org.koitharu.kotatsu.core.parser.CachingMangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.image.TrimTransformation import org.koitharu.kotatsu.core.util.FileSize import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP import org.koitharu.kotatsu.core.util.ext.cancelChildrenAndJoin import org.koitharu.kotatsu.core.util.ext.compressToPNG import org.koitharu.kotatsu.core.util.ext.ensureRamAtLeast import org.koitharu.kotatsu.core.util.ext.ensureSuccess import org.koitharu.kotatsu.core.util.ext.getCompletionResultOrNull import org.koitharu.kotatsu.core.util.ext.isFileUri import org.koitharu.kotatsu.core.util.ext.isNotEmpty import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode import org.koitharu.kotatsu.core.util.ext.isZipUri import org.koitharu.kotatsu.core.util.ext.lifecycleScope import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.ramAvailable import org.koitharu.kotatsu.core.util.ext.toMimeType import org.koitharu.kotatsu.core.util.ext.use import org.koitharu.kotatsu.core.util.ext.withProgress import org.koitharu.kotatsu.core.util.progress.ProgressDeferred import org.koitharu.kotatsu.download.ui.worker.DownloadSlowdownDispatcher import org.koitharu.kotatsu.local.data.LocalStorageCache import org.koitharu.kotatsu.local.data.PageCache import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.requireBody import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import java.io.File import java.util.LinkedList import java.util.concurrent.atomic.AtomicInteger import java.util.zip.ZipFile import javax.inject.Inject import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.CoroutineContext @ActivityRetainedScoped class PageLoader @Inject constructor( @LocalizedAppContext private val context: Context, lifecycle: ActivityRetainedLifecycle, @MangaHttpClient private val okHttp: OkHttpClient, @PageCache private val cache: LocalStorageCache, private val coil: ImageLoader, private val settings: AppSettings, private val mangaRepositoryFactory: MangaRepository.Factory, private val imageProxyInterceptor: ImageProxyInterceptor, private val downloadSlowdownDispatcher: DownloadSlowdownDispatcher, ) { val loaderScope = lifecycle.lifecycleScope + InternalErrorHandler() + Dispatchers.Default private val tasks = LongSparseArray>() private val semaphore = Semaphore(3) private val convertLock = Mutex() private val prefetchLock = Mutex() @Volatile private var repository: MangaRepository? = null private val prefetchQueue = LinkedList() private val counter = AtomicInteger(0) private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive private val edgeDetector = EdgeDetector(context) fun isPrefetchApplicable(): Boolean { return repository is CachingMangaRepository && settings.isPagesPreloadEnabled && !context.isPowerSaveMode() && !isLowRam() } @AnyThread fun prefetch(pages: List) = loaderScope.launch { prefetchLock.withLock { for (page in pages.asReversed()) { if (tasks.containsKey(page.id)) { continue } prefetchQueue.offerFirst(page.toMangaPage()) if (prefetchQueue.size > prefetchQueueLimit) { prefetchQueue.pollLast() } } } if (counter.get() == 0) { onIdle() } } suspend fun loadPreview(page: MangaPage): ImageSource? { val preview = page.preview if (preview.isNullOrEmpty()) { return null } val request = ImageRequest.Builder(context) .data(preview) .mangaSourceExtra(page.source) .transformations(TrimTransformation()) .build() return coil.execute(request).image?.toImageSource() } fun peekPreviewSource(preview: String?): ImageSource? { if (preview.isNullOrEmpty()) { return null } coil.memoryCache?.let { cache -> val key = MemoryCache.Key(preview) cache[key]?.image?.let { return if (it is BitmapImage) { ImageSource.cachedBitmap(it.toBitmap()) } else { ImageSource.bitmap(it.toBitmap()) } } } coil.diskCache?.let { cache -> cache.openSnapshot(preview)?.use { snapshot -> return ImageSource.file(snapshot.data.toFile()) } } return null } fun loadPageAsync(page: MangaPage, force: Boolean): ProgressDeferred { var task = tasks[page.id]?.takeIf { it.isValid() } if (force) { task?.cancel() } else if (task?.isCancelled == false) { return task } task = loadPageAsyncImpl(page, skipCache = force, isPrefetch = false) synchronized(tasks) { tasks[page.id] = task } return task } suspend fun loadPage(page: MangaPage, force: Boolean): Uri { return loadPageAsync(page, force).await() } @CheckResult suspend fun convertBimap(uri: Uri): Uri = convertLock.withLock { if (uri.isZipUri()) { runInterruptible(Dispatchers.IO) { ZipFile(uri.schemeSpecificPart).use { zip -> val entry = zip.getEntry(uri.fragment) context.ensureRamAtLeast(entry.size * 2) zip.getInputStream(entry).use { BitmapDecoderCompat.decode(it, MimeTypes.getMimeTypeFromExtension(entry.name)) } } }.use { image -> cache.set(uri.toString(), image).toUri() } } else { val file = uri.toFile() runInterruptible(Dispatchers.IO) { context.ensureRamAtLeast(file.length() * 2) BitmapDecoderCompat.decode(file) }.use { image -> image.compressToPNG(file) } uri } } suspend fun getTrimmedBounds(uri: Uri): Rect? = runCatchingCancellable { edgeDetector.getBounds(ImageSource.uri(uri)) }.onFailure { error -> error.printStackTraceDebug() }.getOrNull() suspend fun getPageUrl(page: MangaPage): String { return getRepository(page.source).getPageUrl(page) } suspend fun invalidate(clearCache: Boolean) { tasks.clear() loaderScope.cancelChildrenAndJoin() if (clearCache) { cache.clear() } } private fun onIdle() = loaderScope.launch { prefetchLock.withLock { while (prefetchQueue.isNotEmpty()) { val page = prefetchQueue.pollFirst() ?: return@launch synchronized(tasks) { tasks[page.id] = loadPageAsyncImpl(page, skipCache = false, isPrefetch = true) } } } } private fun loadPageAsyncImpl( page: MangaPage, skipCache: Boolean, isPrefetch: Boolean, ): ProgressDeferred { val progress = MutableStateFlow(PROGRESS_UNDEFINED) val deferred = loaderScope.async { counter.incrementAndGet() try { loadPageImpl( page = page, progress = progress, isPrefetch = isPrefetch, skipCache = skipCache, ) } finally { if (counter.decrementAndGet() == 0) { onIdle() } } } return ProgressDeferred(deferred, progress) } @Synchronized private fun getRepository(source: MangaSource): MangaRepository { val result = repository return if (result != null && result.source == source) { result } else { mangaRepositoryFactory.create(source).also { repository = it } } } private suspend fun loadPageImpl( page: MangaPage, progress: MutableStateFlow, isPrefetch: Boolean, skipCache: Boolean, ): Uri = semaphore.withPermit { val pageUrl = getPageUrl(page) check(pageUrl.isNotBlank()) { "Cannot obtain full image url for $page" } if (!skipCache) { cache.get(pageUrl)?.let { return it.toUri() } } val uri = pageUrl.toUri() return when { uri.isZipUri() -> if (uri.scheme == URI_SCHEME_ZIP) { uri } else { // legacy uri uri.buildUpon().scheme(URI_SCHEME_ZIP).build() } uri.isFileUri() -> uri else -> { if (isPrefetch) { downloadSlowdownDispatcher.delay(page.source) } val request = createPageRequest(pageUrl, page.source) imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response -> response.requireBody().withProgress(progress).use { cache.set(pageUrl, it.source(), it.contentType()?.toMimeType()) } }.toUri() } } } private fun isLowRam(): Boolean { return context.ramAvailable <= FileSize.MEGABYTES.convert(PREFETCH_MIN_RAM_MB, FileSize.BYTES) } private fun Image.toImageSource(): ImageSource = if (this is BitmapImage) { ImageSource.cachedBitmap(toBitmap()) } else { ImageSource.bitmap(toBitmap()) } private fun Deferred.isValid(): Boolean { return getCompletionResultOrNull()?.map { uri -> uri.exists() && uri.isTargetNotEmpty() }?.getOrDefault(false) != false } private class InternalErrorHandler : AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler { override fun handleException(context: CoroutineContext, exception: Throwable) { exception.printStackTraceDebug() } } companion object { private const val PROGRESS_UNDEFINED = -1f private const val PREFETCH_LIMIT_DEFAULT = 6 private const val PREFETCH_MIN_RAM_MB = 80L fun createPageRequest(pageUrl: String, mangaSource: MangaSource) = Request.Builder() .url(pageUrl) .get() .header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8") .cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE) .tag(MangaSource::class.java, mangaSource) .build() @Blocking private fun Uri.exists(): Boolean = when { isFileUri() -> toFile().exists() isZipUri() -> { val file = File(requireNotNull(schemeSpecificPart)) file.exists() && ZipFile(file).use { it.getEntry(fragment) != null } } else -> false } @Blocking private fun Uri.isTargetNotEmpty(): Boolean = when { isFileUri() -> toFile().isNotEmpty() isZipUri() -> { val file = File(requireNotNull(schemeSpecificPart)) file.exists() && ZipFile(file).use { (it.getEntry(fragment)?.size ?: 0L) != 0L } } else -> false } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/ReaderColorFilter.kt ================================================ package org.koitharu.kotatsu.reader.domain import android.content.res.ColorStateList import android.graphics.Color import android.graphics.ColorMatrix import android.graphics.ColorMatrixColorFilter data class ReaderColorFilter( val brightness: Float, val contrast: Float, val isInverted: Boolean, val isGrayscale: Boolean, val isBookBackground: Boolean, ) { val isEmpty: Boolean get() = !isGrayscale && !isInverted && !isBookBackground && brightness == 0f && contrast == 0f fun toColorFilter(): ColorMatrixColorFilter { val cm = ColorMatrix() if (isGrayscale) { cm.grayscale() } if (isInverted) { cm.inverted() } cm.setBrightness(brightness) cm.setContrast(contrast) if (isBookBackground) { cm.addBookEffect() } return ColorMatrixColorFilter(cm) } fun getBackgroundTint(): ColorStateList? = if (isBookBackground) { val color = Color.rgb(255, 255, (255 * BOOK_BLUE_FACTOR).toInt()) ColorStateList.valueOf(color) } else { null } private fun ColorMatrix.setBrightness(brightness: Float) { val scale = brightness + 1f val matrix = ColorMatrix() matrix.setScale(scale, scale, scale, 1f) postConcat(matrix) } private fun ColorMatrix.setContrast(contrast: Float) { val scale = contrast + 1f val translate = (-.5f * scale + .5f) * 255f val array = floatArrayOf( scale, 0f, 0f, 0f, translate, 0f, scale, 0f, 0f, translate, 0f, 0f, scale, 0f, translate, 0f, 0f, 0f, 1f, 0f, ) val matrix = ColorMatrix(array) postConcat(matrix) } private fun ColorMatrix.inverted() { val matrix = floatArrayOf( -1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, -1.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, -1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, ) postConcat(ColorMatrix(matrix)) } private fun ColorMatrix.grayscale() { setSaturation(0f) } private fun ColorMatrix.addBookEffect() { val removeBlueMatrix = floatArrayOf( 1f, 0f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 0f, BOOK_BLUE_FACTOR, 0f, 0f, 0f, 0f, 0f, 1f, 0f, ) postConcat(ColorMatrix(removeBlueMatrix)) } companion object { private const val BOOK_BLUE_FACTOR = 0.92f val EMPTY = ReaderColorFilter( brightness = 0.0f, contrast = 0.0f, isInverted = false, isGrayscale = false, isBookBackground = false, ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/TapGridArea.kt ================================================ package org.koitharu.kotatsu.reader.domain enum class TapGridArea { TOP_LEFT, TOP_CENTER, TOP_RIGHT, CENTER_LEFT, CENTER, CENTER_RIGHT, BOTTOM_LEFT, BOTTOM_CENTER, BOTTOM_RIGHT; } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageLabelFormatter.kt ================================================ package org.koitharu.kotatsu.reader.ui import com.google.android.material.slider.LabelFormatter import org.koitharu.kotatsu.parsers.util.format class PageLabelFormatter : LabelFormatter { override fun getFormattedValue(value: Float): String { return (value + 1).format(0) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveContract.kt ================================================ package org.koitharu.kotatsu.reader.ui import android.content.Context import android.content.Intent import android.os.Build import android.os.Environment import android.provider.DocumentsContract import androidx.activity.result.contract.ActivityResultContracts import androidx.core.net.toUri import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.ext.toUriOrNull import java.io.File class PageSaveContract : ActivityResultContracts.CreateDocument("image/*") { override fun createIntent(context: Context, input: String): Intent { val intent = super.createIntent(context, input.substringAfterLast(File.separatorChar)) intent.type = MimeTypes.getMimeTypeFromExtension(input)?.toString() ?: "image/*" if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val defaultUri = input.toUriOrNull()?.run { path?.let { p -> buildUpon().path(p.substringBeforeLast('/')).build() } } intent.putExtra( DocumentsContract.EXTRA_INITIAL_URI, defaultUri ?: Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).toUri(), ) } return intent } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt ================================================ package org.koitharu.kotatsu.reader.ui import android.content.Context import android.net.Uri import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultCaller import androidx.activity.result.ActivityResultLauncher import androidx.core.net.toFile import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import okio.FileSystem import okio.IOException import okio.Path.Companion.toPath import okio.Source import okio.buffer import okio.openZip import okio.sink import okio.source import org.koitharu.kotatsu.core.LocalizedAppContext import org.koitharu.kotatsu.core.image.BitmapDecoderCompat import org.koitharu.kotatsu.core.os.OpenDocumentTreeHelper import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.ext.isFileUri import org.koitharu.kotatsu.core.util.ext.isZipUri import org.koitharu.kotatsu.core.util.ext.toFileNameSafe import org.koitharu.kotatsu.core.util.ext.toFileOrNull import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.reader.domain.PageLoader import java.io.File import java.text.SimpleDateFormat import java.util.Date import javax.inject.Provider import kotlin.coroutines.resume class PageSaveHelper @AssistedInject constructor( @Assisted activityResultCaller: ActivityResultCaller, @LocalizedAppContext private val context: Context, private val settings: AppSettings, private val pageLoaderProvider: Provider, ) : ActivityResultCallback { private val savePageRequest = activityResultCaller.registerForActivityResult(PageSaveContract(), this) private val pickDirectoryRequest = OpenDocumentTreeHelper(activityResultCaller, this) private var continuation: CancellableContinuation? = null override fun onActivityResult(result: Uri?) { continuation?.also { cont -> if (result != null) { cont.resume(result) } else { cont.cancel() } } } suspend fun save(tasks: Collection): Collection = when (tasks.size) { 0 -> emptySet() 1 -> setOf(saveImpl(tasks.first())) else -> saveImpl(tasks) } suspend fun saveToTempFile(task: Task): File { val pageLoader = getPageLoader() val pageUrl = pageLoader.getPageUrl(task.page).toUri() val pageUri = pageLoader.loadPage(task.page, force = false) val proposedName = task.getFileBaseName() + "." + getPageExtension(pageUrl, pageUri) val destination = File(checkNotNull(context.getExternalFilesDir(TEMP_DIR)), proposedName) copyImpl(pageUri, destination.toUri()) return destination } private suspend fun saveImpl(task: Task): Uri { val pageLoader = getPageLoader() val pageUrl = pageLoader.getPageUrl(task.page).toUri() val pageUri = pageLoader.loadPage(task.page, force = false) val proposedName = task.getFileBaseName() + "." + getPageExtension(pageUrl, pageUri) val destination = getDefaultFileUri(proposedName)?.uri ?: run { val defaultUri = settings.getPagesSaveDir(context)?.uri?.buildUpon()?.appendPath(proposedName)?.toString() savePageRequest.launchAndAwait(defaultUri ?: proposedName) } copyImpl(pageUri, destination) return destination } private suspend fun saveImpl(tasks: Collection): Collection { val pageLoader = getPageLoader() val destinationDir = getDefaultFileUri(null) ?: run { val defaultUri = settings.getPagesSaveDir(context)?.uri DocumentFile.fromTreeUri(context, pickDirectoryRequest.launchAndAwait(defaultUri)) } ?: throw IOException("Cannot get destination directory") val result = ArrayList(tasks.size) for (task in tasks) { val pageUrl = pageLoader.getPageUrl(task.page).toUri() val pageUri = pageLoader.loadPage(task.page, force = false) val proposedName = task.getFileBaseName() val ext = getPageExtension(pageUrl, pageUri) val mime = requireNotNull(MimeTypes.getMimeTypeFromExtension("_.$ext")) { "Unknown type of $proposedName" } val destination = destinationDir.createFile(mime.toString(), proposedName) copyImpl(pageUri, destination?.uri ?: throw IOException("Cannot create destination file")) result.add(destination.uri) } return result } private suspend fun getPageExtension(url: Uri, fileUri: Uri): String { val name = requireNotNull( if (url.isZipUri()) { url.fragment?.substringAfterLast(File.separatorChar) } else { url.lastPathSegment }, ) { "Invalid page url: $url" } var extension = name.substringAfterLast('.', "") if (extension.length !in 2..4) { extension = fileUri.toFileOrNull()?.let { file -> getImageExtension(file) } ?: EXTENSION_FALLBACK } return extension } private suspend fun ActivityResultLauncher.launchAndAwait(input: I): Uri { continuation?.cancel() return withContext(Dispatchers.Main) { try { suspendCancellableCoroutine { cont -> continuation = cont launch(input) } } finally { continuation = null } } } private suspend fun getPageLoader() = withContext(Dispatchers.Main.immediate) { pageLoaderProvider.get() } private fun getDefaultFileUri(proposedName: String?): DocumentFile? { if (settings.isPagesSavingAskEnabled) { return null } val dir = settings.getPagesSaveDir(context) ?: return null if (proposedName == null) { return dir } else { val mime = MimeTypes.getMimeTypeFromExtension(proposedName)?.toString() ?: return null return dir.createFile(mime, proposedName.substringBeforeLast('.')) } } private fun getSource(uri: Uri): Source = when { uri.isFileUri() -> uri.toFile().source() uri.isZipUri() -> FileSystem.SYSTEM.openZip(uri.schemeSpecificPart.toPath()) .source(requireNotNull(uri.fragment).toPath()) else -> throw IllegalArgumentException("Bad uri $uri: unsupported scheme") } private suspend fun copyImpl(source: Uri, destination: Uri) = withContext(Dispatchers.IO) { runInterruptible { context.contentResolver.openOutputStream(destination) ?: throw IOException("Output stream is null") }.sink().buffer().use { sink -> getSource(source).use { input -> sink.writeAllCancellable(input) } } } private suspend fun getImageExtension(file: File): String? = runInterruptible(Dispatchers.IO) { MimeTypes.getExtension(BitmapDecoderCompat.probeMimeType(file)) } data class Task( val manga: Manga, val chapterId: Long, val pageNumber: Int, val page: MangaPage, ) { fun getFileBaseName() = buildString { append(manga.title.toFileNameSafe().take(MAX_BASENAME_LENGTH)) manga.findChapterById(chapterId)?.let { chapter -> append('-') append(chapter.number) } append('-') append(pageNumber) append('_') append(SimpleDateFormat("yyyy-MM-dd_HHmm").format(Date())) } } @AssistedFactory interface Factory { fun create(activityResultCaller: ActivityResultCaller): PageSaveHelper } private companion object { private const val MAX_BASENAME_LENGTH = 12 private const val EXTENSION_FALLBACK = "png" private const val TEMP_DIR = "pages" } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActionsView.kt ================================================ package org.koitharu.kotatsu.reader.ui import android.content.Context import android.content.SharedPreferences import android.database.ContentObserver import android.provider.Settings import android.util.AttributeSet import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.widget.Button import android.widget.FrameLayout import android.widget.LinearLayout import androidx.annotation.AttrRes import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import com.google.android.material.slider.Slider import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderControl import org.koitharu.kotatsu.core.util.ext.hasVisibleChildren import org.koitharu.kotatsu.core.util.ext.isRtl import org.koitharu.kotatsu.core.util.ext.setContentDescriptionAndTooltip import org.koitharu.kotatsu.core.util.ext.setTooltipCompat import org.koitharu.kotatsu.core.util.ext.setValueRounded import org.koitharu.kotatsu.databinding.LayoutReaderActionsBinding import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet.Companion.TAB_PAGES import org.koitharu.kotatsu.reader.ui.ReaderControlDelegate.OnInteractionListener import javax.inject.Inject import com.google.android.material.R as materialR @AndroidEntryPoint class ReaderActionsView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0, ) : LinearLayout(context, attrs, defStyleAttr), View.OnClickListener, SharedPreferences.OnSharedPreferenceChangeListener, Slider.OnChangeListener, Slider.OnSliderTouchListener, View.OnLongClickListener { @Inject lateinit var settings: AppSettings private val binding = LayoutReaderActionsBinding.inflate(LayoutInflater.from(context), this) private val rotationObserver = object : ContentObserver(handler) { override fun onChange(selfChange: Boolean) { post { updateRotationButton() } } } private var isSliderChanged = false private var isSliderTracking = false var isSliderEnabled: Boolean get() = binding.slider.isEnabled set(value) { binding.slider.isEnabled = value binding.slider.setThumbVisible(value) } var isNextEnabled: Boolean get() = binding.buttonNext.isEnabled set(value) { binding.buttonNext.isEnabled = value } var isPrevEnabled: Boolean get() = binding.buttonPrev.isEnabled set(value) { binding.buttonPrev.isEnabled = value } var isBookmarkAdded: Boolean = false set(value) { if (field != value) { field = value updateBookmarkButton() } } var listener: OnInteractionListener? = null init { orientation = HORIZONTAL gravity = Gravity.CENTER_VERTICAL binding.buttonNext.initAction() binding.buttonPrev.initAction() binding.buttonSave.initAction() binding.buttonOptions.initAction() binding.buttonScreenRotation.initAction() binding.buttonPagesThumbs.initAction() binding.buttonTimer.initAction() binding.buttonBookmark.initAction() binding.slider.setLabelFormatter(PageLabelFormatter()) binding.slider.addOnChangeListener(this) binding.slider.addOnSliderTouchListener(this) updateControlsVisibility() updatePagesSheetButton() updateRotationButton() } override fun onAttachedToWindow() { super.onAttachedToWindow() settings.subscribe(this) context.contentResolver.registerContentObserver( Settings.System.CONTENT_URI, true, rotationObserver, ) } override fun onDetachedFromWindow() { settings.unsubscribe(this) context.contentResolver.unregisterContentObserver(rotationObserver) super.onDetachedFromWindow() } override fun onClick(v: View) { when (v.id) { R.id.button_prev -> listener?.switchChapterBy(-1) R.id.button_next -> listener?.switchChapterBy(1) R.id.button_save -> listener?.onSavePageClick() R.id.button_timer -> listener?.onScrollTimerClick(isLongClick = false) R.id.button_pages_thumbs -> AppRouter.from(this)?.showChapterPagesSheet() R.id.button_screen_rotation -> listener?.toggleScreenOrientation() R.id.button_options -> listener?.openMenu() R.id.button_bookmark -> listener?.onBookmarkClick() } } override fun onLongClick(v: View): Boolean = when (v.id) { R.id.button_bookmark -> AppRouter.from(this) ?.showChapterPagesSheet(ChaptersPagesSheet.TAB_BOOKMARKS) R.id.button_timer -> listener?.onScrollTimerClick(isLongClick = true) R.id.button_options -> AppRouter.from(this)?.openReaderSettings() else -> null } != null override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { if (fromUser) { if (isSliderTracking) { isSliderChanged = true } else { listener?.switchPageTo(value.toInt()) } } } override fun onStartTrackingTouch(slider: Slider) { if (!isSliderTracking) { isSliderChanged = false isSliderTracking = true } } override fun onStopTrackingTouch(slider: Slider) { isSliderTracking = false if (isSliderChanged) { listener?.switchPageTo(slider.value.toInt()) } } override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { when (key) { AppSettings.KEY_READER_CONTROLS -> updateControlsVisibility() AppSettings.KEY_PAGES_TAB, AppSettings.KEY_DETAILS_TAB, AppSettings.KEY_DETAILS_LAST_TAB -> updatePagesSheetButton() } } fun setSliderValue(value: Int, max: Int) { binding.slider.valueTo = max.toFloat() binding.slider.setValueRounded(value.toFloat()) } fun setSliderReversed(reversed: Boolean) { binding.slider.isRtl = reversed != isRtl } fun setTimerActive(isActive: Boolean) { binding.buttonTimer.setIconResource( if (isActive) R.drawable.ic_timer_run else R.drawable.ic_timer, ) } private fun updateControlsVisibility() { val controls = settings.readerControls binding.buttonPrev.isVisible = ReaderControl.PREV_CHAPTER in controls binding.buttonNext.isVisible = ReaderControl.NEXT_CHAPTER in controls binding.buttonPagesThumbs.isVisible = ReaderControl.PAGES_SHEET in controls binding.buttonScreenRotation.isVisible = ReaderControl.SCREEN_ROTATION in controls binding.buttonSave.isVisible = ReaderControl.SAVE_PAGE in controls binding.buttonTimer.isVisible = ReaderControl.TIMER in controls binding.buttonBookmark.isVisible = ReaderControl.BOOKMARK in controls binding.slider.isVisible = ReaderControl.SLIDER in controls adjustLayoutParams() } private fun updatePagesSheetButton() { val isPagesMode = settings.defaultDetailsTab == TAB_PAGES val button = binding.buttonPagesThumbs button.setIconResource( if (isPagesMode) R.drawable.ic_grid else R.drawable.ic_list, ) button.setContentDescriptionAndTooltip( if (isPagesMode) R.string.pages else R.string.chapters, ) } private fun updateBookmarkButton() { val button = binding.buttonBookmark button.setIconResource( if (isBookmarkAdded) R.drawable.ic_bookmark_added else R.drawable.ic_bookmark, ) button.setContentDescriptionAndTooltip( if (isBookmarkAdded) R.string.bookmark_remove else R.string.bookmark_add, ) } private fun adjustLayoutParams() { val isSliderVisible = binding.slider.isVisible repeat(childCount) { i -> val child = getChildAt(i) if (child is FrameLayout) { child.isVisible = child.hasVisibleChildren child.updateLayoutParams { width = if (isSliderVisible) LayoutParams.WRAP_CONTENT else 0 weight = if (isSliderVisible) 0f else 1f } } } } private fun updateRotationButton() { val button = binding.buttonScreenRotation when { !button.isVisible -> return isAutoRotationEnabled() -> { button.setContentDescriptionAndTooltip(R.string.lock_screen_rotation) button.setIconResource(R.drawable.ic_screen_rotation_lock) } else -> { button.setContentDescriptionAndTooltip(R.string.rotate_screen) button.setIconResource(R.drawable.ic_screen_rotation) } } } private fun Button.initAction() { setOnClickListener(this@ReaderActionsView) setOnLongClickListener(this@ReaderActionsView) setTooltipCompat(contentDescription) } private fun isAutoRotationEnabled(): Boolean = Settings.System.getInt( context.contentResolver, Settings.System.ACCELEROMETER_ROTATION, 0, ) == 1 private fun Slider.setThumbVisible(visible: Boolean) { thumbWidth = if (visible) { resources.getDimensionPixelSize(materialR.dimen.m3_comp_slider_active_handle_width) } else { 0 } thumbHeight = if (visible) { resources.getDimensionPixelSize(materialR.dimen.m3_comp_slider_active_handle_height) } else { 0 } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt ================================================ package org.koitharu.kotatsu.reader.ui import android.app.assist.AssistContent import android.content.DialogInterface import android.content.Intent import android.content.res.Configuration import android.os.Bundle import android.view.Gravity import android.view.KeyEvent import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.view.WindowManager import androidx.activity.viewModels import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.graphics.Insets import androidx.core.view.WindowInsetsCompat import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.transition.Fade import androidx.transition.Slide import androidx.transition.TransitionManager import androidx.transition.TransitionSet import androidx.window.layout.FoldingFeature import androidx.window.layout.WindowInfoTracker import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.DialogErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver 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.prefs.ReaderMode import org.koitharu.kotatsu.core.ui.BaseFullscreenActivity import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.ui.dialog.setCheckbox import org.koitharu.kotatsu.core.ui.util.MenuInvalidator import org.koitharu.kotatsu.core.ui.widgets.ZoomControl import org.koitharu.kotatsu.core.util.IdlingDetector import org.koitharu.kotatsu.core.util.ext.getThemeDimensionPixelOffset import org.koitharu.kotatsu.core.util.ext.hasGlobalPoint import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.postDelayed import org.koitharu.kotatsu.core.util.ext.toUriOrNull import org.koitharu.kotatsu.core.util.ext.zipWithPrevious import org.koitharu.kotatsu.databinding.ActivityReaderBinding import org.koitharu.kotatsu.details.ui.pager.pages.PagesSavedObserver import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.reader.data.TapGridSettings import org.koitharu.kotatsu.reader.domain.TapGridArea import org.koitharu.kotatsu.reader.ui.config.ReaderConfigSheet import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState import org.koitharu.kotatsu.reader.ui.tapgrid.TapGridDispatcher import java.util.concurrent.TimeUnit import javax.inject.Inject import androidx.appcompat.R as appcompatR @AndroidEntryPoint class ReaderActivity : BaseFullscreenActivity(), TapGridDispatcher.OnGridTouchListener, ReaderConfigSheet.Callback, ReaderControlDelegate.OnInteractionListener, ReaderNavigationCallback, IdlingDetector.Callback, ZoomControl.ZoomControlListener, View.OnClickListener, ScrollTimerControlView.OnVisibilityChangeListener { @Inject lateinit var settings: AppSettings @Inject lateinit var tapGridSettings: TapGridSettings @Inject lateinit var pageSaveHelperFactory: PageSaveHelper.Factory @Inject lateinit var scrollTimerFactory: ScrollTimer.Factory @Inject lateinit var screenOrientationHelper: ScreenOrientationHelper private val idlingDetector = IdlingDetector(TimeUnit.SECONDS.toMillis(10), this) private val viewModel: ReaderViewModel by viewModels() override val readerMode: ReaderMode? get() = readerManager.currentMode private lateinit var scrollTimer: ScrollTimer private lateinit var pageSaveHelper: PageSaveHelper private lateinit var touchHelper: TapGridDispatcher private lateinit var controlDelegate: ReaderControlDelegate private var gestureInsets: Insets = Insets.NONE private lateinit var readerManager: ReaderManager private val hideUiRunnable = Runnable { setUiIsVisible(false) } // Tracks whether the foldable device is in an unfolded state (half-opened or flat) private var isFoldUnfolded: Boolean = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityReaderBinding.inflate(layoutInflater)) readerManager = ReaderManager(supportFragmentManager, viewBinding.container, settings) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) touchHelper = TapGridDispatcher(viewBinding.root, this) scrollTimer = scrollTimerFactory.create(resources, this, this) pageSaveHelper = pageSaveHelperFactory.create(this) controlDelegate = ReaderControlDelegate(resources, settings, tapGridSettings, this) viewBinding.zoomControl.listener = this viewBinding.actionsView.listener = this viewBinding.buttonTimer?.setOnClickListener(this) idlingDetector.bindToLifecycle(this) screenOrientationHelper.applySettings() viewModel.isBookmarkAdded.observe(this) { viewBinding.actionsView.isBookmarkAdded = it } scrollTimer.isActive.observe(this) { updateScrollTimerButton() viewBinding.actionsView.setTimerActive(it) } viewBinding.timerControl.onVisibilityChangeListener = this viewBinding.timerControl.attach(scrollTimer, this) if (resources.getBoolean(R.bool.is_tablet)) { viewBinding.timerControl.updateLayoutParams { topMargin = marginEnd + getThemeDimensionPixelOffset(appcompatR.attr.actionBarSize) } } viewModel.onLoadingError.observeEvent( this, DialogErrorObserver( host = viewBinding.container, fragment = null, resolver = exceptionResolver, onResolved = { isResolved -> if (isResolved) { viewModel.reload() } else if (viewModel.content.value.pages.isEmpty()) { dispatchNavigateUp() } }, ), ) viewModel.onError.observeEvent( this, SnackbarErrorObserver( host = viewBinding.container, fragment = null, resolver = exceptionResolver, onResolved = null, ), ) viewModel.readerMode.observe(this, Lifecycle.State.STARTED, this::onInitReader) viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(viewBinding.container)) viewModel.uiState.zipWithPrevious().observe(this, this::onUiStateChanged) combine( viewModel.isLoading, viewModel.content.map { it.pages.isNotEmpty() }.distinctUntilChanged(), ::Pair, ).flowOn(Dispatchers.Default) .observe(this, this::onLoadingStateChanged) viewModel.isKeepScreenOnEnabled.observe(this, this::setKeepScreenOn) viewModel.isInfoBarTransparent.observe(this) { viewBinding.infoBar.drawBackground = !it } viewModel.isInfoBarEnabled.observe(this, ::onReaderBarChanged) viewModel.isBookmarkAdded.observe(this, MenuInvalidator(this)) viewModel.onAskNsfwIncognito.observeEvent(this) { askForIncognitoMode() } viewModel.onShowToast.observeEvent(this) { msgId -> Snackbar.make(viewBinding.container, msgId, Snackbar.LENGTH_SHORT) .setAnchorView(viewBinding.toolbarDocked) .show() } viewModel.readerSettingsProducer.observe(this) { viewBinding.infoBar.applyColorScheme(isBlackOnWhite = it.background.isLight(this)) } viewModel.isZoomControlsEnabled.observe(this) { viewBinding.zoomControl.isVisible = it } addMenuProvider(ReaderMenuProvider(viewModel)) observeWindowLayout() // Apply initial double-mode considering foldable setting applyDoubleModeAuto() } override fun getParentActivityIntent(): Intent? { val manga = viewModel.getMangaOrNull() ?: return null return AppRouter.detailsIntent(this, manga) } override fun onUserInteraction() { super.onUserInteraction() if (!viewBinding.timerControl.isVisible) { scrollTimer.onUserInteraction() } idlingDetector.onUserInteraction() } override fun onPause() { super.onPause() viewModel.onPause() } override fun onStop() { super.onStop() viewModel.onStop() } override fun onProvideAssistContent(outContent: AssistContent) { super.onProvideAssistContent(outContent) viewModel.getMangaOrNull()?.publicUrl?.toUriOrNull()?.let { outContent.webUri = it } } override fun isNsfwContent(): Flow = viewModel.isMangaNsfw override fun onIdle() { viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) viewModel.onIdle() } override fun onVisibilityChanged(v: View, visibility: Int) { updateScrollTimerButton() } override fun onZoomIn() { readerManager.currentReader?.onZoomIn() } override fun onZoomOut() { readerManager.currentReader?.onZoomOut() } override fun onClick(v: View) { when (v.id) { R.id.button_timer -> onScrollTimerClick(isLongClick = false) } } private fun onInitReader(mode: ReaderMode?) { if (mode == null) { return } if (readerManager.currentMode != mode) { readerManager.replace(mode) } if (viewBinding.appbarTop.isVisible) { lifecycle.postDelayed(TimeUnit.SECONDS.toMillis(1), hideUiRunnable) } viewBinding.actionsView.setSliderReversed(mode == ReaderMode.REVERSED) viewBinding.timerControl.onReaderModeChanged(mode) } private fun onLoadingStateChanged(value: Pair) { val (isLoading, hasPages) = value val showLoadingLayout = isLoading && !hasPages if (viewBinding.layoutLoading.isVisible != showLoadingLayout) { val transition = Fade().addTarget(viewBinding.layoutLoading) TransitionManager.beginDelayedTransition(viewBinding.root, transition) viewBinding.layoutLoading.isVisible = showLoadingLayout } if (isLoading && hasPages) { viewBinding.toastView.show(R.string.loading_) } else { viewBinding.toastView.hide() } invalidateOptionsMenu() } override fun onGridTouch(area: TapGridArea): Boolean { return isReaderResumed() && controlDelegate.onGridTouch(area) } override fun onGridLongTouch(area: TapGridArea) { if (isReaderResumed()) { controlDelegate.onGridLongTouch(area) } } override fun onProcessTouch(rawX: Int, rawY: Int): Boolean { return if ( rawX <= gestureInsets.left || rawY <= gestureInsets.top || rawX >= viewBinding.root.width - gestureInsets.right || rawY >= viewBinding.root.height - gestureInsets.bottom || viewBinding.appbarTop.hasGlobalPoint(rawX, rawY) || viewBinding.toolbarDocked?.hasGlobalPoint(rawX, rawY) == true ) { false } else { val touchables = window.peekDecorView()?.touchables touchables?.none { it.hasGlobalPoint(rawX, rawY) } != false } } override fun dispatchTouchEvent(ev: MotionEvent): Boolean { touchHelper.dispatchTouchEvent(ev) if (!viewBinding.timerControl.hasGlobalPoint(ev.rawX.toInt(), ev.rawY.toInt())) { scrollTimer.onTouchEvent(ev) } return super.dispatchTouchEvent(ev) } override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { return controlDelegate.onKeyDown(keyCode, event) || super.onKeyDown(keyCode, event) } override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { return controlDelegate.onKeyUp(keyCode, event) || super.onKeyUp(keyCode, event) } override fun onChapterSelected(chapter: MangaChapter): Boolean { viewModel.switchChapter(chapter.id, 0) return true } override fun onPageSelected(page: ReaderPage): Boolean { lifecycleScope.launch(Dispatchers.Default) { val pages = viewModel.content.value.pages val index = pages.indexOfFirst { it.chapterId == page.chapterId && it.id == page.id } if (index != -1) { withContext(Dispatchers.Main) { readerManager.currentReader?.switchPageTo(index, true) } } else { viewModel.switchChapter(page.chapterId, page.index) } } return true } override fun onReaderModeChanged(mode: ReaderMode) { viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) viewModel.switchMode(mode) viewBinding.timerControl.onReaderModeChanged(mode) } override fun onDoubleModeChanged(isEnabled: Boolean) { // Combine manual toggle with foldable auto setting applyDoubleModeAuto(isEnabled) } private fun applyDoubleModeAuto(manualEnabled: Boolean? = null) { val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE // Auto double-page on foldable when device is unfolded (half-opened or flat) val autoFoldable = settings.isReaderDoubleOnFoldable && isFoldUnfolded val manualLandscape = (manualEnabled ?: settings.isReaderDoubleOnLandscape) && isLandscape val autoEnabled = autoFoldable || manualLandscape readerManager.setDoubleReaderMode(autoEnabled) } private fun setKeepScreenOn(isKeep: Boolean) { if (isKeep) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } else { window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } } private fun setUiIsVisible(isUiVisible: Boolean) { if (viewBinding.appbarTop.isVisible != isUiVisible) { if (isAnimationsEnabled) { val transition = TransitionSet() .setOrdering(TransitionSet.ORDERING_TOGETHER) .addTransition(Slide(Gravity.TOP).addTarget(viewBinding.appbarTop)) .addTransition(Fade().addTarget(viewBinding.infoBar)) viewBinding.toolbarDocked?.let { transition.addTransition(Slide(Gravity.BOTTOM).addTarget(it)) } TransitionManager.beginDelayedTransition(viewBinding.root, transition) } val isFullscreen = settings.isReaderFullscreenEnabled viewBinding.appbarTop.isVisible = isUiVisible viewBinding.toolbarDocked?.isVisible = isUiVisible viewBinding.infoBar.isGone = isUiVisible || (!viewModel.isInfoBarEnabled.value) viewBinding.infoBar.isTimeVisible = isFullscreen updateScrollTimerButton() systemUiController.setSystemUiVisible(isUiVisible || !isFullscreen) viewBinding.root.requestApplyInsets() } } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { gestureInsets = insets.getInsets(WindowInsetsCompat.Type.systemGestures()) val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) viewBinding.toolbar.updateLayoutParams { topMargin = systemBars.top rightMargin = systemBars.right leftMargin = systemBars.left } if (viewBinding.toolbarDocked != null) { viewBinding.actionsView.updateLayoutParams { bottomMargin = systemBars.bottom rightMargin = systemBars.right leftMargin = systemBars.left } } viewBinding.infoBar.updatePadding( top = systemBars.top, ) val innerInsets = Insets.of( systemBars.left, if (viewBinding.appbarTop.isVisible) viewBinding.appbarTop.height else systemBars.top, systemBars.right, viewBinding.toolbarDocked?.takeIf { it.isVisible }?.height ?: systemBars.bottom, ) return WindowInsetsCompat.Builder(insets) .setInsets(WindowInsetsCompat.Type.systemBars(), innerInsets) .build() } override fun switchPageBy(delta: Int) { readerManager.currentReader?.switchPageBy(delta) } override fun switchChapterBy(delta: Int) { viewModel.switchChapterBy(delta) } override fun openMenu() { viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) val currentMode = readerManager.currentMode ?: return router.showReaderConfigSheet(currentMode) } override fun scrollBy(delta: Int, smooth: Boolean): Boolean { return readerManager.currentReader?.scrollBy(delta, smooth) == true } override fun toggleUiVisibility() { setUiIsVisible(!viewBinding.appbarTop.isVisible) } override fun isReaderResumed(): Boolean { val reader = readerManager.currentReader ?: return false return reader.isResumed && supportFragmentManager.fragments.lastOrNull() === reader } override fun onBookmarkClick() { viewModel.toggleBookmark() } override fun onSavePageClick() { viewModel.saveCurrentPage(pageSaveHelper) } override fun onScrollTimerClick(isLongClick: Boolean) { if (isLongClick) { scrollTimer.setActive(!scrollTimer.isActive.value) } else { viewBinding.timerControl.showOrHide() } } override fun toggleScreenOrientation() { if (screenOrientationHelper.toggleScreenOrientation()) { Snackbar.make( viewBinding.container, if (screenOrientationHelper.isLocked) { R.string.screen_rotation_locked } else { R.string.screen_rotation_unlocked }, Snackbar.LENGTH_SHORT, ).setAnchorView(viewBinding.toolbarDocked) .show() } } override fun switchPageTo(index: Int) { val pages = viewModel.getCurrentChapterPages() val page = pages?.getOrNull(index) ?: return val chapterId = viewModel.getCurrentState()?.chapterId ?: return onPageSelected(ReaderPage(page, index, chapterId)) } private fun onReaderBarChanged(isBarEnabled: Boolean) { viewBinding.infoBar.isVisible = isBarEnabled && viewBinding.appbarTop.isGone } private fun onUiStateChanged(pair: Pair) { val (previous: ReaderUiState?, uiState: ReaderUiState?) = pair title = uiState?.mangaName ?: getString(R.string.loading_) viewBinding.infoBar.update(uiState) if (uiState == null) { supportActionBar?.subtitle = null viewBinding.actionsView.setSliderValue(0, 1) viewBinding.actionsView.isSliderEnabled = false return } val chapterTitle = uiState.getChapterTitle(resources) supportActionBar?.subtitle = when { uiState.incognito -> getString(R.string.incognito_mode) else -> chapterTitle } if ( settings.isReaderChapterToastEnabled && chapterTitle != previous?.getChapterTitle(resources) && chapterTitle.isNotEmpty() ) { viewBinding.toastView.showTemporary(chapterTitle, TOAST_DURATION) } if (uiState.isSliderAvailable()) { viewBinding.actionsView.setSliderValue( value = uiState.currentPage, max = uiState.totalPages - 1, ) } else { viewBinding.actionsView.setSliderValue(0, 1) } viewBinding.actionsView.isSliderEnabled = uiState.isSliderAvailable() viewBinding.actionsView.isNextEnabled = uiState.hasNextChapter() viewBinding.actionsView.isPrevEnabled = uiState.hasPreviousChapter() } private fun updateScrollTimerButton() { val button = viewBinding.buttonTimer ?: return val isButtonVisible = scrollTimer.isActive.value && settings.isReaderAutoscrollFabVisible && !viewBinding.appbarTop.isVisible && !viewBinding.timerControl.isVisible if (button.isVisible != isButtonVisible) { val transition = Fade().addTarget(button) TransitionManager.beginDelayedTransition(viewBinding.root, transition) button.isVisible = isButtonVisible } } // Observe foldable window layout to auto-enable double-page if configured private fun observeWindowLayout() { WindowInfoTracker.getOrCreate(this) .windowLayoutInfo(this) .onEach { info -> val fold = info.displayFeatures.filterIsInstance().firstOrNull() val unfolded = when (fold?.state) { FoldingFeature.State.HALF_OPENED, FoldingFeature.State.FLAT -> true else -> false } if (unfolded != isFoldUnfolded) { isFoldUnfolded = unfolded applyDoubleModeAuto() } } .launchIn(lifecycleScope) } private fun askForIncognitoMode() { buildAlertDialog(this, isCentered = true) { var dontAskAgain = false val listener = DialogInterface.OnClickListener { _, which -> if (which == DialogInterface.BUTTON_NEUTRAL) { finishAfterTransition() } else { viewModel.setIncognitoMode(which == DialogInterface.BUTTON_POSITIVE, dontAskAgain) } } setCheckbox(R.string.dont_ask_again, dontAskAgain) { _, isChecked -> dontAskAgain = isChecked } setIcon(R.drawable.ic_incognito) setTitle(R.string.incognito_mode) setMessage(R.string.incognito_mode_hint_nsfw) setPositiveButton(R.string.incognito, listener) setNegativeButton(R.string.disable, listener) setNeutralButton(android.R.string.cancel, listener) setOnCancelListener { finishAfterTransition() } setCancelable(true) }.show() } companion object { private const val TOAST_DURATION = 2000L } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderContent.kt ================================================ package org.koitharu.kotatsu.reader.ui import org.koitharu.kotatsu.reader.ui.pager.ReaderPage data class ReaderContent( val pages: List, val state: ReaderState? ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderControlDelegate.kt ================================================ package org.koitharu.kotatsu.reader.ui import android.content.res.Resources import android.view.KeyEvent import android.view.View import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.reader.data.TapGridSettings import org.koitharu.kotatsu.reader.domain.TapGridArea import org.koitharu.kotatsu.reader.ui.tapgrid.TapAction import kotlin.math.sign class ReaderControlDelegate( resources: Resources, private val settings: AppSettings, private val tapGridSettings: TapGridSettings, private val listener: OnInteractionListener, ) : View.OnClickListener { private var minScrollDelta = resources.getDimensionPixelSize(R.dimen.reader_scroll_delta_min) override fun onClick(v: View) { when (v.id) { R.id.button_prev -> listener.switchChapterBy(-1) R.id.button_next -> listener.switchChapterBy(1) } } fun onGridTouch(area: TapGridArea): Boolean { val action = tapGridSettings.getTapAction( area = area, isLongTap = false, ) ?: return false processAction(action) return true } fun onGridLongTouch(area: TapGridArea) { val action = tapGridSettings.getTapAction( area = area, isLongTap = true, ) ?: return processAction(action) } fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { when (keyCode) { KeyEvent.KEYCODE_NAVIGATE_NEXT, KeyEvent.KEYCODE_SPACE -> switchBy(1, event, false) KeyEvent.KEYCODE_PAGE_DOWN -> switchBy(1, event, false) KeyEvent.KEYCODE_NAVIGATE_PREVIOUS -> switchBy(-1, event, false) KeyEvent.KEYCODE_PAGE_UP -> switchBy(-1, event, false) KeyEvent.KEYCODE_R -> switchBy(1, null, false) KeyEvent.KEYCODE_L -> switchBy(-1, null, false) KeyEvent.KEYCODE_VOLUME_UP -> if (settings.isReaderVolumeButtonsEnabled) { switchBy(if (settings.isReaderNavigationInverted) 1 else -1, event, false) } else { return false } KeyEvent.KEYCODE_VOLUME_DOWN -> if (settings.isReaderVolumeButtonsEnabled) { switchBy(if (settings.isReaderNavigationInverted) -1 else 1, event, false) } else { return false } KeyEvent.KEYCODE_DPAD_RIGHT -> switchByRelative(if (settings.isReaderNavigationInverted) -1 else 1, event) KeyEvent.KEYCODE_DPAD_LEFT -> switchByRelative(if (settings.isReaderNavigationInverted) 1 else -1, event) KeyEvent.KEYCODE_DPAD_CENTER -> listener.toggleUiVisibility() KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP, KeyEvent.KEYCODE_DPAD_UP -> switchBy(if (settings.isReaderNavigationInverted) 1 else -1, event, true) KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN, KeyEvent.KEYCODE_DPAD_DOWN -> switchBy(if (settings.isReaderNavigationInverted) -1 else 1, event, true) else -> return false } return true } fun onKeyUp(keyCode: Int, @Suppress("UNUSED_PARAMETER") event: KeyEvent?): Boolean { return (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || keyCode == KeyEvent.KEYCODE_VOLUME_UP) && settings.isReaderVolumeButtonsEnabled } private fun processAction(action: TapAction) { when (action) { TapAction.PAGE_NEXT -> listener.switchPageBy(1) TapAction.PAGE_PREV -> listener.switchPageBy(-1) TapAction.CHAPTER_NEXT -> listener.switchChapterBy(1) TapAction.CHAPTER_PREV -> listener.switchChapterBy(-1) TapAction.TOGGLE_UI -> listener.toggleUiVisibility() TapAction.SHOW_MENU -> listener.openMenu() } } private fun isReaderTapsReversed(): Boolean { return settings.isReaderControlAlwaysLTR && listener.readerMode == ReaderMode.REVERSED } private fun switchBy(delta: Int, event: KeyEvent?, scroll: Boolean) { if (event?.isCtrlPressed == true) { listener.switchChapterBy(delta) } else if (scroll) { if (!listener.scrollBy(minScrollDelta * delta.sign, smooth = true)) { listener.switchPageBy(delta) } } else { listener.switchPageBy(delta) } } private fun switchByRelative(delta: Int, event: KeyEvent?) { return switchBy(if (isReaderTapsReversed()) -delta else delta, event, scroll = false) } interface OnInteractionListener { val readerMode: ReaderMode? fun switchPageBy(delta: Int) fun switchPageTo(index: Int) fun switchChapterBy(delta: Int) fun scrollBy(delta: Int, smooth: Boolean): Boolean fun toggleUiVisibility() fun onBookmarkClick() fun openMenu() fun onSavePageClick() fun onScrollTimerClick(isLongClick: Boolean) fun toggleScreenOrientation() fun isReaderResumed(): Boolean } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderInfoBarView.kt ================================================ package org.koitharu.kotatsu.reader.ui import android.annotation.SuppressLint import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.res.ColorStateList import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Rect import android.graphics.drawable.Drawable import android.os.BatteryManager import android.os.Build import android.util.AttributeSet import android.view.RoundedCorner import android.view.View import android.view.WindowInsets import androidx.annotation.AttrRes import androidx.core.content.ContextCompat import androidx.core.content.withStyledAttributes import androidx.core.graphics.ColorUtils import androidx.core.graphics.withScale import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList import org.koitharu.kotatsu.core.util.ext.isNightMode import org.koitharu.kotatsu.core.util.ext.measureDimension import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState import java.time.LocalTime import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import com.google.android.material.R as materialR private const val ALPHA_TEXT = 200 private const val ALPHA_BG = 180 class ReaderInfoBarView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0, ) : View(context, attrs, defStyleAttr) { private val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG) private val textBounds = Rect() private val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) private val systemStateReceiver = SystemStateReceiver() private var insetLeft: Int = 0 private var insetRight: Int = 0 private var insetTop: Int = 0 private val insetLeftFallback: Int private val insetRightFallback: Int private val insetTopFallback: Int private val insetCornerFallback = getSystemUiDimensionOffset("rounded_corner_content_padding") private var colorText = (context.getThemeColorStateList(materialR.attr.colorOnSurface) ?: ColorStateList.valueOf(Color.BLACK)).withAlpha(ALPHA_TEXT) private var colorBackground = (context.getThemeColorStateList(materialR.attr.colorSurface) ?: ColorStateList.valueOf(Color.WHITE)).withAlpha(ALPHA_BG) private val batteryIcon = ContextCompat.getDrawable(context, R.drawable.ic_battery_outline) private var currentTextColor: Int = Color.TRANSPARENT private var currentBackgroundColor: Int = Color.TRANSPARENT private var currentOutlineColor: Int = Color.TRANSPARENT private var timeText = timeFormat.format(LocalTime.now()) private var batteryText = "" private var text: String = "" private var prevTextHeight: Int = 0 private val innerHeight get() = height - paddingTop - paddingBottom - insetTop private val innerWidth get() = width - paddingLeft - paddingRight - insetLeft - insetRight var drawBackground: Boolean = false set(value) { field = value invalidate() } var isTimeVisible: Boolean = true set(value) { field = value invalidate() } init { context.withStyledAttributes(attrs, R.styleable.ReaderInfoBarView, defStyleAttr) { paint.strokeWidth = getDimension(R.styleable.ReaderInfoBarView_android_strokeWidth, 2f) paint.textSize = getDimension(R.styleable.ReaderInfoBarView_android_textSize, 16f) } val insetStart = getSystemUiDimensionOffset("status_bar_padding_start").coerceAtLeast(0) val insetEnd = getSystemUiDimensionOffset("status_bar_padding_end").coerceAtLeast(0) val isRtl = layoutDirection == LAYOUT_DIRECTION_RTL insetLeftFallback = if (isRtl) insetEnd else insetStart insetRightFallback = if (isRtl) insetStart else insetEnd insetTopFallback = minOf(insetLeftFallback, insetRightFallback) batteryIcon?.setTintList(colorText) } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val desiredWidth = suggestedMinimumWidth + paddingLeft + paddingRight + insetLeft + insetRight val desiredHeight = maxOf( computeTextHeight().also { prevTextHeight = it }, suggestedMinimumHeight, ) + paddingTop + paddingBottom + insetTop setMeasuredDimension( measureDimension(desiredWidth, widthMeasureSpec), measureDimension(desiredHeight, heightMeasureSpec), ) } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) if (drawBackground) { canvas.drawColor(currentBackgroundColor) } computeTextHeight() val h = innerHeight.toFloat() val ty = h / 2f + textBounds.height() / 2f - textBounds.bottom paint.textAlign = Paint.Align.LEFT paint.color = currentTextColor paint.style = Paint.Style.FILL if (drawBackground) { canvas.drawText(text, (paddingLeft + insetLeft).toFloat(), paddingTop + insetTop + ty, paint) } else { canvas.drawTextOutline(text, (paddingLeft + insetLeft).toFloat(), paddingTop + insetTop + ty) } if (isTimeVisible) { paint.textAlign = Paint.Align.RIGHT var endX = (width - paddingRight - insetRight).toFloat() if (drawBackground) { canvas.drawText(timeText, endX, paddingTop + insetTop + ty, paint) } else { canvas.drawTextOutline(timeText, endX, paddingTop + insetTop + ty) } if (batteryText.isNotEmpty()) { paint.getTextBounds(timeText, 0, timeText.length, textBounds) endX -= textBounds.width() endX -= h * 0.6f if (drawBackground) { canvas.drawText(batteryText, endX, paddingTop + insetTop + ty, paint) } else { canvas.drawTextOutline(batteryText, endX, paddingTop + insetTop + ty) } batteryIcon?.let { paint.getTextBounds(batteryText, 0, batteryText.length, textBounds) endX -= textBounds.width() val iconCenter = paddingTop + insetTop + textBounds.height() / 2 it.setBounds( (endX - h).toInt(), (iconCenter - h / 2).toInt(), endX.toInt(), (iconCenter + h / 2).toInt(), ) if (drawBackground) { it.draw(canvas) } else { it.drawWithOutline(canvas) } } } } } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) updateCutoutInsets(ViewCompat.getRootWindowInsets(this)) } override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { updateCutoutInsets(WindowInsetsCompat.toWindowInsetsCompat(insets)) return super.onApplyWindowInsets(insets) } override fun onAttachedToWindow() { super.onAttachedToWindow() ContextCompat.registerReceiver( context, systemStateReceiver, IntentFilter().apply { addAction(Intent.ACTION_TIME_TICK) addAction(Intent.ACTION_BATTERY_CHANGED) }, ContextCompat.RECEIVER_EXPORTED, ) updateCutoutInsets(ViewCompat.getRootWindowInsets(this)) } override fun onDetachedFromWindow() { super.onDetachedFromWindow() context.unregisterReceiver(systemStateReceiver) } override fun verifyDrawable(who: Drawable): Boolean { return who == batteryIcon || super.verifyDrawable(who) } override fun jumpDrawablesToCurrentState() { super.jumpDrawablesToCurrentState() batteryIcon?.jumpToCurrentState() } override fun onCreateDrawableState(extraSpace: Int): IntArray? { val iconState = batteryIcon?.state ?: return super.onCreateDrawableState(extraSpace) return mergeDrawableStates(super.onCreateDrawableState(extraSpace + iconState.size), iconState) } override fun drawableStateChanged() { currentTextColor = colorText.getColorForState(drawableState, colorText.defaultColor) currentBackgroundColor = colorBackground.getColorForState(drawableState, colorBackground.defaultColor) currentOutlineColor = ColorUtils.setAlphaComponent(currentBackgroundColor, Color.alpha(currentTextColor)) super.drawableStateChanged() if (batteryIcon != null && batteryIcon.isStateful && batteryIcon.setState(drawableState)) { invalidateDrawable(batteryIcon) } } fun applyColorScheme(isBlackOnWhite: Boolean) { val isDarkTheme = resources.isNightMode colorText = (context.getThemeColorStateList( if (isBlackOnWhite != isDarkTheme) materialR.attr.colorOnSurface else materialR.attr.colorOnSurfaceInverse, ) ?: ColorStateList.valueOf(if (isBlackOnWhite) Color.BLACK else Color.WHITE)).withAlpha(ALPHA_TEXT) colorBackground = (context.getThemeColorStateList( if (isBlackOnWhite != isDarkTheme) materialR.attr.colorSurface else materialR.attr.colorSurfaceInverse, ) ?: ColorStateList.valueOf(if (isBlackOnWhite) Color.WHITE else Color.BLACK)).withAlpha(ALPHA_BG) batteryIcon?.setTintList(colorText) drawableStateChanged() } @SuppressLint("StringFormatMatches") fun update(state: ReaderUiState?) { text = if (state != null) { context.getString( R.string.reader_info_pattern, state.chapterNumber, state.chaptersTotal, state.currentPage + 1, state.totalPages, ) + if (state.percent in 0f..1f) { " " + context.getString(R.string.percent_string_pattern, (state.percent * 100).format()) } else { "" } } else { "" } val newHeight = computeTextHeight() if (newHeight != prevTextHeight) { prevTextHeight = newHeight requestLayout() } invalidate() } private fun computeTextHeight(): Int { val str = text + batteryText + timeText paint.getTextBounds(str, 0, str.length, textBounds) return textBounds.height() } private fun updateCutoutInsets(insetsCompat: WindowInsetsCompat?) { insetLeft = insetLeftFallback insetRight = insetRightFallback insetTop = insetTopFallback if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && insetsCompat != null) { val nativeInsets = insetsCompat.toWindowInsets() nativeInsets?.getRoundedCorner(RoundedCorner.POSITION_TOP_LEFT)?.let { corner -> insetLeft += corner.radius } nativeInsets?.getRoundedCorner(RoundedCorner.POSITION_TOP_RIGHT)?.let { corner -> insetRight += corner.radius } } else { insetLeft += insetCornerFallback insetRight += insetCornerFallback } insetsCompat?.displayCutout?.let { cutout -> for (rect in cutout.boundingRects) { if (rect.left <= paddingLeft) { insetLeft += rect.width() } if (rect.right >= width - paddingRight) { insetRight += rect.width() } } } } private fun Canvas.drawTextOutline(text: String, x: Float, y: Float) { paint.color = currentOutlineColor paint.style = Paint.Style.STROKE drawText(text, x, y, paint) paint.color = currentTextColor paint.style = Paint.Style.FILL drawText(text, x, y, paint) } private fun Drawable.drawWithOutline(canvas: Canvas) { if (bounds.isEmpty) { return } var requiredScale = (bounds.width() + paint.strokeWidth * 2f) / bounds.width().toFloat() setTint(currentOutlineColor) canvas.withScale(requiredScale, requiredScale, bounds.exactCenterX(), bounds.exactCenterY()) { draw(canvas) } requiredScale = 1f / requiredScale canvas.withScale(requiredScale, requiredScale, bounds.exactCenterX(), bounds.exactCenterY()) { draw(canvas) } setTint(currentTextColor) draw(canvas) } private inner class SystemStateReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1) if (level != -1 && scale != -1) { batteryText = context.getString(R.string.percent_string_pattern, (level * 100 / scale).toString()) } timeText = timeFormat.format(LocalTime.now()) if (isTimeVisible) { invalidate() } } } @SuppressLint("DiscouragedApi") private fun getSystemUiDimensionOffset(name: String, fallback: Int = 0): Int = runCatching { val manager = context.packageManager val resources = manager.getResourcesForApplication("com.android.systemui") val resId = resources.getIdentifier(name, "dimen", "com.android.systemui") resources.getDimensionPixelOffset(resId) }.onFailure { it.printStackTraceDebug() }.getOrDefault(fallback) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderManager.kt ================================================ package org.koitharu.kotatsu.reader.ui import android.content.res.Configuration import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.FragmentManager import androidx.fragment.app.commit import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.util.ext.findKeyByValue import org.koitharu.kotatsu.reader.ui.pager.BaseReaderFragment import org.koitharu.kotatsu.reader.ui.pager.doublepage.DoubleReaderFragment import org.koitharu.kotatsu.reader.ui.pager.doublereversed.ReversedDoubleReaderFragment import org.koitharu.kotatsu.reader.ui.pager.reversed.ReversedReaderFragment import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment import org.koitharu.kotatsu.reader.ui.pager.vertical.VerticalReaderFragment import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonReaderFragment import java.util.EnumMap class ReaderManager( private val fragmentManager: FragmentManager, private val container: FragmentContainerView, settings: AppSettings, ) { private val modeMap = EnumMap>>(ReaderMode::class.java) init { val useDoublePages = isLandscape() && settings.isReaderDoubleOnLandscape invalidateTypesMap(useDoublePages) } val currentReader: BaseReaderFragment<*>? get() = fragmentManager.findFragmentById(container.id) as? BaseReaderFragment<*> val currentMode: ReaderMode? get() { val readerClass = currentReader?.javaClass ?: return null return modeMap.findKeyByValue(readerClass) } fun replace(newMode: ReaderMode) { val readerClass = requireNotNull(modeMap[newMode]) fragmentManager.commit { setReorderingAllowed(true) replace(container.id, readerClass, null, null) } } fun setDoubleReaderMode(isEnabled: Boolean) { val mode = currentMode val prevReader = currentReader?.javaClass invalidateTypesMap(isEnabled) val newReader = modeMap[mode] if (mode != null && newReader != prevReader) { replace(mode) } } private fun invalidateTypesMap(useDoublePages: Boolean) { modeMap[ReaderMode.STANDARD] = if (useDoublePages) { DoubleReaderFragment::class.java } else { PagerReaderFragment::class.java } modeMap[ReaderMode.REVERSED] = if (useDoublePages) { ReversedDoubleReaderFragment::class.java } else { ReversedReaderFragment::class.java } modeMap[ReaderMode.WEBTOON] = WebtoonReaderFragment::class.java modeMap[ReaderMode.VERTICAL] = VerticalReaderFragment::class.java } private fun isLandscape() = container.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderMenuProvider.kt ================================================ package org.koitharu.kotatsu.reader.ui import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.core.view.MenuProvider import org.koitharu.kotatsu.R class ReaderMenuProvider( private val viewModel: ReaderViewModel, ) : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.opt_reader, menu) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return when (menuItem.itemId) { R.id.action_info -> { // TODO true } else -> false } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderNavigationCallback.kt ================================================ package org.koitharu.kotatsu.reader.ui import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.reader.ui.pager.ReaderPage interface ReaderNavigationCallback { fun onPageSelected(page: ReaderPage): Boolean fun onChapterSelected(chapter: MangaChapter): Boolean fun onBookmarkSelected(bookmark: Bookmark): Boolean = onPageSelected( ReaderPage(bookmark.toMangaPage(), bookmark.page, bookmark.chapterId), ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderState.kt ================================================ package org.koitharu.kotatsu.reader.ui import android.os.Parcelable import kotlinx.parcelize.Parcelize import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.parsers.model.Manga @Parcelize data class ReaderState( val chapterId: Long, val page: Int, val scroll: Int, ) : Parcelable { constructor(history: MangaHistory) : this( chapterId = history.chapterId, page = history.page, scroll = history.scroll, ) constructor(manga: Manga, branch: String?) : this( chapterId = manga.chapters?.let { it.firstOrNull { x -> x.branch == branch } ?: it.firstOrNull() }?.id ?: error("Cannot find first chapter"), page = 0, scroll = 0, ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderToastView.kt ================================================ package org.koitharu.kotatsu.reader.ui import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.content.Context import android.util.AttributeSet import android.view.ViewPropertyAnimator import android.view.animation.AccelerateInterpolator import android.view.animation.DecelerateInterpolator import androidx.annotation.StringRes import androidx.core.view.isGone import androidx.core.view.isVisible import com.google.android.material.textview.MaterialTextView import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.getAnimationDuration import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled class ReaderToastView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, ) : MaterialTextView(context, attrs, defStyleAttr) { private var currentAnimator: ViewPropertyAnimator? = null private var hideRunnable = Runnable { hide() } fun show(message: CharSequence) { removeCallbacks(hideRunnable) text = message showImpl() } fun show(@StringRes messageId: Int) { show(context.getString(messageId)) } fun showTemporary(message: CharSequence, duration: Long) { show(message) postDelayed(hideRunnable, duration) } fun hide() { removeCallbacks(hideRunnable) hideImpl() } override fun onDetachedFromWindow() { removeCallbacks(hideRunnable) super.onDetachedFromWindow() } private fun showImpl() { currentAnimator?.cancel() clearAnimation() if (!context.isAnimationsEnabled) { currentAnimator = null isVisible = true return } alpha = 0f isVisible = true currentAnimator = animate() .alpha(1f) .setInterpolator(DecelerateInterpolator()) .setDuration(context.getAnimationDuration(R.integer.config_shorterAnimTime)) .setListener(null) } private fun hideImpl() { currentAnimator?.cancel() clearAnimation() if (!context.isAnimationsEnabled) { currentAnimator = null isGone = true return } currentAnimator = animate() .alpha(0f) .setInterpolator(AccelerateInterpolator()) .setDuration(context.getAnimationDuration(R.integer.config_shorterAnimTime)) .setListener( object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { isGone = true } }, ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt ================================================ package org.koitharu.kotatsu.reader.ui import android.net.Uri import androidx.annotation.AnyThread import androidx.annotation.MainThread import androidx.annotation.WorkerThread import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update 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.exceptions.EmptyMangaException import org.koitharu.kotatsu.core.model.getPreferredBranch import org.koitharu.kotatsu.core.nav.MangaIntent import org.koitharu.kotatsu.core.nav.ReaderIntent import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.TriStateOption import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.firstNotNull import org.koitharu.kotatsu.core.util.ext.requireValue import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.details.domain.DetailsInteractor import org.koitharu.kotatsu.details.domain.DetailsLoadUseCase import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryUpdateUseCase import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.sizeOrZero import org.koitharu.kotatsu.reader.domain.ChaptersLoader import org.koitharu.kotatsu.reader.domain.DetectReaderModeUseCase import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState import org.koitharu.kotatsu.scrobbling.discord.ui.DiscordRpc import org.koitharu.kotatsu.stats.domain.StatsCollector import java.time.Instant import javax.inject.Inject private const val BOUNDS_PAGE_OFFSET = 2 private const val PREFETCH_LIMIT = 10 @HiltViewModel class ReaderViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, private val dataRepository: MangaDataRepository, private val historyRepository: HistoryRepository, private val bookmarksRepository: BookmarksRepository, settings: AppSettings, private val pageLoader: PageLoader, private val chaptersLoader: ChaptersLoader, private val appShortcutManager: AppShortcutManager, private val detailsLoadUseCase: DetailsLoadUseCase, private val historyUpdateUseCase: HistoryUpdateUseCase, private val detectReaderModeUseCase: DetectReaderModeUseCase, private val statsCollector: StatsCollector, private val discordRpc: DiscordRpc, @LocalStorageChanges localStorageChanges: SharedFlow, interactor: DetailsInteractor, deleteLocalMangaUseCase: DeleteLocalMangaUseCase, downloadScheduler: DownloadWorker.Scheduler, readerSettingsProducerFactory: ReaderSettings.Producer.Factory, ) : ChaptersPagesViewModel( settings = settings, interactor = interactor, bookmarksRepository = bookmarksRepository, historyRepository = historyRepository, downloadScheduler = downloadScheduler, deleteLocalMangaUseCase = deleteLocalMangaUseCase, localStorageChanges = localStorageChanges, ) { private val intent = MangaIntent(savedStateHandle) private var loadingJob: Job? = null private var pageSaveJob: Job? = null private var bookmarkJob: Job? = null private var stateChangeJob: Job? = null init { mangaDetails.value = intent.manga?.let { MangaDetails(it) } } val readerMode = MutableStateFlow(null) val onPageSaved = MutableEventFlow>() val onLoadingError = MutableEventFlow() val onShowToast = MutableEventFlow() val onAskNsfwIncognito = MutableEventFlow() val uiState = MutableStateFlow(null) val isIncognitoMode = MutableStateFlow(savedStateHandle.get(ReaderIntent.EXTRA_INCOGNITO)) val content = MutableStateFlow(ReaderContent(emptyList(), null)) val pageAnimation = settings.observeAsStateFlow( scope = viewModelScope + Dispatchers.Default, key = AppSettings.KEY_READER_ANIMATION, valueProducer = { readerAnimation }, ) val isInfoBarEnabled = settings.observeAsStateFlow( scope = viewModelScope + Dispatchers.Default, key = AppSettings.KEY_READER_BAR, valueProducer = { isReaderBarEnabled }, ) val isInfoBarTransparent = settings.observeAsStateFlow( scope = viewModelScope + Dispatchers.Default, key = AppSettings.KEY_READER_BAR_TRANSPARENT, valueProducer = { isReaderBarTransparent }, ) val isKeepScreenOnEnabled = settings.observeAsStateFlow( scope = viewModelScope + Dispatchers.Default, key = AppSettings.KEY_READER_SCREEN_ON, valueProducer = { isReaderKeepScreenOn }, ) val isWebtoonZooEnabled = observeIsWebtoonZoomEnabled() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false) val isWebtoonGapsEnabled = settings.observeAsStateFlow( scope = viewModelScope + Dispatchers.Default, key = AppSettings.KEY_WEBTOON_GAPS, valueProducer = { isWebtoonGapsEnabled }, ) val isWebtoonPullGestureEnabled = settings.observeAsStateFlow( scope = viewModelScope + Dispatchers.Default, key = AppSettings.KEY_WEBTOON_PULL_GESTURE, valueProducer = { isWebtoonPullGestureEnabled }, ) val defaultWebtoonZoomOut = observeIsWebtoonZoomEnabled().flatMapLatest { if (it) { observeWebtoonZoomOut() } else { flowOf(0f) } }.flowOn(Dispatchers.Default) val isZoomControlsEnabled = getObserveIsZoomControlEnabled().flatMapLatest { zoom -> if (zoom) { combine(readerMode, isWebtoonZooEnabled) { mode, ze -> ze || mode != ReaderMode.WEBTOON } } else { flowOf(false) } }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false) val readerSettingsProducer = readerSettingsProducerFactory.create( manga.mapNotNull { it?.id }, ) val isMangaNsfw = manga.map { it?.contentRating == ContentRating.ADULT } val isBookmarkAdded = readingState.flatMapLatest { state -> val manga = mangaDetails.value?.toManga() if (state == null || manga == null) { flowOf(false) } else { bookmarksRepository.observeBookmark(manga, state.chapterId, state.page) .map { it != null && it.chapterId == state.chapterId && it.page == state.page } } }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) init { initIncognitoMode() loadImpl() launchJob(Dispatchers.Default) { val mangaId = manga.filterNotNull().first().id if (!isIncognitoMode.firstNotNull()) { appShortcutManager.notifyMangaOpened(mangaId) } } } fun reload() { loadingJob?.cancel() loadImpl() } fun onPause() { getMangaOrNull()?.let { statsCollector.onPause(it.id) } } fun onStop() { discordRpc.clearRpc() } fun onIdle() { discordRpc.setIdle() } fun switchMode(newMode: ReaderMode) { launchJob { val manga = checkNotNull(getMangaOrNull()) dataRepository.saveReaderMode( manga = manga, mode = newMode, ) readerMode.value = newMode content.update { it.copy(state = getCurrentState()) } } } fun saveCurrentState(state: ReaderState? = null) { if (state != null) { readingState.value = state savedStateHandle[ReaderIntent.EXTRA_STATE] = state } if (isIncognitoMode.value != false) { return } val readerState = state ?: readingState.value ?: return historyUpdateUseCase.invokeAsync( manga = getMangaOrNull() ?: return, readerState = readerState, percent = computePercent(readerState.chapterId, readerState.page), ) } fun getCurrentState() = readingState.value fun getCurrentChapterPages(): List? { val chapterId = readingState.value?.chapterId ?: return null return chaptersLoader.getPages(chapterId) } fun saveCurrentPage( pageSaveHelper: PageSaveHelper ) { val prevJob = pageSaveJob pageSaveJob = launchLoadingJob(Dispatchers.Default) { prevJob?.cancelAndJoin() val state = checkNotNull(getCurrentState()) val currentManga = manga.requireValue() val task = PageSaveHelper.Task( manga = currentManga, chapterId = state.chapterId, pageNumber = state.page + 1, page = checkNotNull(getCurrentPage()) { "Cannot find current page" }, ) val dest = pageSaveHelper.save(setOf(task)) onPageSaved.call(dest) } } fun getCurrentPage(): MangaPage? { val state = readingState.value ?: return null return content.value.pages.find { it.chapterId == state.chapterId && it.index == state.page }?.toMangaPage() } fun switchChapter(id: Long, page: Int) { val prevJob = loadingJob loadingJob = launchLoadingJob(Dispatchers.Default) { prevJob?.cancelAndJoin() content.value = ReaderContent(emptyList(), null) chaptersLoader.loadSingleChapter(id) val newState = ReaderState(id, page, 0) content.value = ReaderContent(chaptersLoader.snapshot(), newState) saveCurrentState(newState) } } fun switchChapterBy(delta: Int) { val prevJob = loadingJob loadingJob = launchLoadingJob(Dispatchers.Default) { prevJob?.cancelAndJoin() val prevState = readingState.requireValue() val newChapterId = if (delta != 0) { val allChapters = mangaDetails.requireValue().allChapters var index = allChapters.indexOfFirst { x -> x.id == prevState.chapterId } if (index < 0) { return@launchLoadingJob } index += delta (allChapters.getOrNull(index) ?: return@launchLoadingJob).id } else { prevState.chapterId } content.value = ReaderContent(emptyList(), null) chaptersLoader.loadSingleChapter(newChapterId) val newState = ReaderState( chapterId = newChapterId, page = if (delta == 0) prevState.page else 0, scroll = if (delta == 0) prevState.scroll else 0, ) content.value = ReaderContent(chaptersLoader.snapshot(), newState) saveCurrentState(newState) } } @MainThread fun onCurrentPageChanged(lowerPos: Int, upperPos: Int) { val prevJob = stateChangeJob val pages = content.value.pages // capture immediately stateChangeJob = launchJob(Dispatchers.Default) { prevJob?.cancelAndJoin() loadingJob?.join() if (pages.size != content.value.pages.size) { return@launchJob // TODO } val centerPos = (lowerPos + upperPos) / 2 pages.getOrNull(centerPos)?.let { page -> readingState.update { cs -> cs?.copy(chapterId = page.chapterId, page = page.index) } } notifyStateChanged() if (pages.isEmpty() || loadingJob?.isActive == true) { return@launchJob } ensureActive() val autoLoadAllowed = readerMode.value != ReaderMode.WEBTOON || !isWebtoonPullGestureEnabled.value if (autoLoadAllowed) { if (upperPos >= pages.lastIndex - BOUNDS_PAGE_OFFSET) { loadPrevNextChapter(pages.last().chapterId, isNext = true) } if (lowerPos <= BOUNDS_PAGE_OFFSET) { loadPrevNextChapter(pages.first().chapterId, isNext = false) } } if (pageLoader.isPrefetchApplicable()) { pageLoader.prefetch(pages.trySublist(upperPos + 1, upperPos + PREFETCH_LIMIT)) } } } fun toggleBookmark() { if (bookmarkJob?.isActive == true) { return } bookmarkJob = launchJob(Dispatchers.Default) { loadingJob?.join() val state = checkNotNull(getCurrentState()) if (isBookmarkAdded.value) { val manga = requireManga() bookmarksRepository.removeBookmark(manga.id, state.chapterId, state.page) onShowToast.call(R.string.bookmark_removed) } else { val page = checkNotNull(getCurrentPage()) { "Page not found" } val bookmark = Bookmark( manga = requireManga(), pageId = page.id, chapterId = state.chapterId, page = state.page, scroll = state.scroll, imageUrl = page.preview.ifNullOrEmpty { page.url }, createdAt = Instant.now(), percent = computePercent(state.chapterId, state.page), ) bookmarksRepository.addBookmark(bookmark) onShowToast.call(R.string.bookmark_added) } } } fun setIncognitoMode(value: Boolean, dontAskAgain: Boolean) { isIncognitoMode.value = value if (dontAskAgain) { settings.incognitoModeForNsfw = if (value) TriStateOption.ENABLED else TriStateOption.DISABLED } } private fun loadImpl() { loadingJob = launchLoadingJob(Dispatchers.Default + EventExceptionHandler(onLoadingError)) { var exception: Exception? = null var loadedDetails: MangaDetails? = null try { detailsLoadUseCase(intent, force = false) .collect { details -> loadedDetails = details if (mangaDetails.value == null) { mangaDetails.value = details } chaptersLoader.init(details) val manga = details.toManga() // obtain state if (readingState.value == null) { val newState = getStateFromIntent(manga) if (newState == null) { return@collect // manga not loaded yet if cannot get state } readingState.value = newState val mode = runCatchingCancellable { detectReaderModeUseCase(manga, newState) }.getOrDefault(settings.defaultReaderMode) val branch = chaptersLoader.peekChapter(newState.chapterId)?.branch selectedBranch.value = branch readerMode.value = mode try { chaptersLoader.loadSingleChapter(newState.chapterId) } catch (e: Exception) { readingState.value = null // try next time exception = e.mergeWith(exception) return@collect } } mangaDetails.value = details.filterChapters(selectedBranch.value) // save state if (!isIncognitoMode.firstNotNull()) { readingState.value?.let { val percent = computePercent(it.chapterId, it.page) historyUpdateUseCase(manga, it, percent) } } notifyStateChanged() content.value = ReaderContent(chaptersLoader.snapshot(), readingState.value) } } catch (e: CancellationException) { throw e } catch (e: Exception) { exception = e.mergeWith(exception) } if (readingState.value == null) { val loadedManga = loadedDetails // for smart cast if (loadedManga != null) { mangaDetails.value = loadedManga.filterChapters(selectedBranch.value) } val loadingError = when { exception != null -> exception loadedManga == null || !loadedManga.isLoaded -> null loadedManga.isRestricted -> EmptyMangaException( EmptyMangaReason.RESTRICTED, loadedManga.toManga(), null, ) loadedManga.allChapters.isEmpty() -> EmptyMangaException( EmptyMangaReason.NO_CHAPTERS, loadedManga.toManga(), null, ) else -> null } ?: IllegalStateException("Unable to load manga. This should never happen. Please report") onLoadingError.call(loadingError) } else exception?.let { e -> // manga has been loaded but error occurred errorEvent.call(e) } } } @AnyThread private fun loadPrevNextChapter(currentId: Long, isNext: Boolean) { val prevJob = loadingJob loadingJob = launchLoadingJob(Dispatchers.Default) { prevJob?.join() chaptersLoader.loadPrevNextChapter(mangaDetails.requireValue(), currentId, isNext) content.value = ReaderContent(chaptersLoader.snapshot(), null) } } private fun List.trySublist(fromIndex: Int, toIndex: Int): List { val fromIndexBounded = fromIndex.coerceAtMost(lastIndex) val toIndexBounded = toIndex.coerceIn(fromIndexBounded, lastIndex) return if (fromIndexBounded == toIndexBounded) { emptyList() } else { subList(fromIndexBounded, toIndexBounded) } } @WorkerThread private fun notifyStateChanged() { val state = getCurrentState() ?: return val chapter = chaptersLoader.peekChapter(state.chapterId) ?: return val m = mangaDetails.value ?: return val chapterIndex = m.chapters[chapter.branch]?.indexOfFirst { it.id == chapter.id } ?: -1 val newState = ReaderUiState( mangaName = m.toManga().title, chapter = chapter, chapterIndex = chapterIndex, chaptersTotal = m.chapters[chapter.branch].sizeOrZero(), totalPages = chaptersLoader.getPagesCount(chapter.id), currentPage = state.page, percent = computePercent(state.chapterId, state.page), incognito = isIncognitoMode.value == true, ) uiState.value = newState if (isIncognitoMode.value == false) { statsCollector.onStateChanged(m.id, state) discordRpc.updateRpc(m.toManga(), newState) } } private fun computePercent(chapterId: Long, pageIndex: Int): Float { val branch = chaptersLoader.peekChapter(chapterId)?.branch val chapters = mangaDetails.value?.chapters?.get(branch) ?: return PROGRESS_NONE val chaptersCount = chapters.size val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId } val pagesCount = chaptersLoader.getPagesCount(chapterId) if (chaptersCount == 0 || pagesCount == 0) { return PROGRESS_NONE } val pagePercent = (pageIndex + 1) / pagesCount.toFloat() val ppc = 1f / chaptersCount return ppc * chapterIndex + ppc * pagePercent } private fun observeIsWebtoonZoomEnabled() = settings.observeAsFlow( key = AppSettings.KEY_WEBTOON_ZOOM, valueProducer = { isWebtoonZoomEnabled }, ) private fun observeWebtoonZoomOut() = settings.observeAsFlow( key = AppSettings.KEY_WEBTOON_ZOOM_OUT, valueProducer = { defaultWebtoonZoomOut }, ) private fun getObserveIsZoomControlEnabled() = settings.observeAsFlow( key = AppSettings.KEY_READER_ZOOM_BUTTONS, valueProducer = { isReaderZoomButtonsEnabled }, ) private fun initIncognitoMode() { if (isIncognitoMode.value != null) { return } launchJob(Dispatchers.Default) { interactor.observeIncognitoMode(manga) .collect { when (it) { TriStateOption.ENABLED -> isIncognitoMode.value = true TriStateOption.ASK -> { onAskNsfwIncognito.call(Unit) return@collect } TriStateOption.DISABLED -> isIncognitoMode.value = false } } } } private suspend fun getStateFromIntent(manga: Manga): ReaderState? { // check if we have at least some chapters loaded if (manga.chapters.isNullOrEmpty()) { return null } // specific state is requested val requestedState: ReaderState? = savedStateHandle[ReaderIntent.EXTRA_STATE] if (requestedState != null) { return if (manga.findChapterById(requestedState.chapterId) != null) { requestedState } else { null } } val requestedBranch: String? = savedStateHandle[ReaderIntent.EXTRA_BRANCH] // continue reading val history = historyRepository.getOne(manga) if (history != null) { val chapter = manga.findChapterById(history.chapterId) ?: return null // specified branch is requested return if (ReaderIntent.EXTRA_BRANCH in savedStateHandle) { if (chapter.branch == requestedBranch) { ReaderState(history) } else { ReaderState(manga, requestedBranch) } } else { ReaderState(history) } } // start from beginning val preferredBranch = requestedBranch ?: manga.getPreferredBranch(null) return ReaderState(manga, preferredBranch) } private fun Exception.mergeWith(other: Exception?): Exception = if (other == null) { this } else { other.addSuppressed(this) other } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ScreenOrientationHelper.kt ================================================ package org.koitharu.kotatsu.reader.ui import android.app.Activity import android.content.pm.ActivityInfo import android.content.res.Configuration import android.database.ContentObserver import android.os.Handler import android.provider.Settings import dagger.hilt.android.scopes.ActivityScoped import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.onStart import org.koitharu.kotatsu.core.prefs.AppSettings import javax.inject.Inject @ActivityScoped class ScreenOrientationHelper @Inject constructor( private val activity: Activity, private val settings: AppSettings, ) { val isAutoRotationEnabled: Boolean get() = Settings.System.getInt( activity.contentResolver, Settings.System.ACCELEROMETER_ROTATION, 0, ) == 1 var isLandscape: Boolean get() = activity.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE set(value) { activity.requestedOrientation = if (value) { ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE } else { ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT } } var isLocked: Boolean get() = activity.requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_LOCKED set(value) { activity.requestedOrientation = if (value) { ActivityInfo.SCREEN_ORIENTATION_LOCKED } else { ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED } } fun applySettings() { if (activity.requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) { // https://developer.android.com/reference/android/R.attr.html#screenOrientation activity.requestedOrientation = settings.readerScreenOrientation } } fun observeAutoOrientation() = callbackFlow { val observer = object : ContentObserver(Handler(activity.mainLooper)) { override fun onChange(selfChange: Boolean) { trySendBlocking(isAutoRotationEnabled) } } activity.contentResolver.registerContentObserver( Settings.System.CONTENT_URI, true, observer, ) awaitClose { activity.contentResolver.unregisterContentObserver(observer) } }.onStart { emit(isAutoRotationEnabled) }.distinctUntilChanged() .conflate() fun toggleScreenOrientation(): Boolean = if (isAutoRotationEnabled) { val newValue = !isLocked isLocked = newValue true } else { isLandscape = !isLandscape false } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ScrollTimer.kt ================================================ package org.koitharu.kotatsu.reader.ui import android.content.res.Resources import android.os.SystemClock import android.view.MotionEvent import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.yield import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.util.ext.resolveDp import kotlin.math.roundToLong private const val MAX_DELAY = 32L private const val MAX_SWITCH_DELAY = 10_000L private const val INTERACTION_SKIP_MS = 2_000L private const val SPEED_FACTOR_DELTA = 0.02f class ScrollTimer @AssistedInject constructor( @Assisted resources: Resources, @Assisted private val listener: ReaderControlDelegate.OnInteractionListener, @Assisted lifecycleOwner: LifecycleOwner, settings: AppSettings, ) { private val coroutineScope = lifecycleOwner.lifecycleScope private var job: Job? = null private var delayMs: Long = 10L var pageSwitchDelay: Long = 100L private set private var resumeAt = 0L private var isTouchDown = MutableStateFlow(false) private val isRunning = MutableStateFlow(false) private val scrollDelta = resources.resolveDp(1) val isActive: StateFlow get() = isRunning init { settings.observeAsFlow(AppSettings.KEY_READER_AUTOSCROLL_SPEED) { readerAutoscrollSpeed }.flowOn(Dispatchers.Default) .onEach { onSpeedChanged(it) }.launchIn(coroutineScope) } fun setActive(value: Boolean) { if (isRunning.value != value) { isRunning.value = value restartJob() } } fun onUserInteraction() { resumeAt = SystemClock.elapsedRealtime() + INTERACTION_SKIP_MS } fun onTouchEvent(event: MotionEvent) { when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { isTouchDown.value = true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { isTouchDown.value = false } } } private fun onSpeedChanged(speed: Float) { if (speed <= 0f) { delayMs = 0L pageSwitchDelay = 0L } else { val speedFactor = 1f - speed delayMs = (MAX_DELAY * speedFactor).roundToLong() pageSwitchDelay = (MAX_SWITCH_DELAY * speedFactor).roundToLong() } if ((job == null) != (delayMs == 0L)) { restartJob() } } private fun restartJob() { job?.cancel() resumeAt = 0L if (!isRunning.value || delayMs == 0L) { job = null return } job = coroutineScope.launch { var accumulator = 0L var speedFactor = 1f while (isActive) { if (isPaused()) { speedFactor = (speedFactor - SPEED_FACTOR_DELTA).coerceAtLeast(0f) } else if (speedFactor < 1f) { speedFactor = (speedFactor + SPEED_FACTOR_DELTA).coerceAtMost(1f) } if (speedFactor == 1f) { delay(delayMs) } else if (speedFactor == 0f) { delayUntilResumed() continue } else { delay((delayMs * (1f + speedFactor * 2)).toLong()) } if (!listener.isReaderResumed()) { continue } if (!listener.scrollBy(scrollDelta, false)) { accumulator += delayMs } if (accumulator >= pageSwitchDelay) { listener.switchPageBy(1) accumulator -= pageSwitchDelay } } } } private fun isPaused(): Boolean { return isTouchDown.value || resumeAt > SystemClock.elapsedRealtime() } private suspend fun delayUntilResumed() { while (isPaused()) { val delayTime = resumeAt - SystemClock.elapsedRealtime() if (delayTime > 0) { delay(delayTime) } else { yield() } isTouchDown.first { !it } } } @AssistedFactory interface Factory { fun create( resources: Resources, lifecycleOwner: LifecycleOwner, listener: ReaderControlDelegate.OnInteractionListener, ): ScrollTimer } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ScrollTimerControlView.kt ================================================ package org.koitharu.kotatsu.reader.ui import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.View import android.widget.CompoundButton import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.transition.Slide import androidx.transition.TransitionManager import com.google.android.material.slider.LabelFormatter import com.google.android.material.slider.Slider import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.parentView import org.koitharu.kotatsu.databinding.ViewScrollTimerBinding import java.util.concurrent.TimeUnit import javax.inject.Inject import kotlin.math.abs @AndroidEntryPoint class ScrollTimerControlView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : ConstraintLayout(context, attrs), CompoundButton.OnCheckedChangeListener, Slider.OnChangeListener, View.OnClickListener, LabelFormatter { @Inject lateinit var settings: AppSettings var onVisibilityChangeListener: OnVisibilityChangeListener? = null private val binding = ViewScrollTimerBinding.inflate(LayoutInflater.from(context), this) private var scrollTimer: ScrollTimer? = null private var labelPattern = context.getString(R.string.speed_value) private var readerMode: ReaderMode = ReaderMode.STANDARD init { binding.switchScrollTimer.setOnCheckedChangeListener(this) binding.sliderTimer.addOnChangeListener(this) binding.buttonFab.setOnClickListener(this) binding.sliderTimer.setLabelFormatter(this) binding.buttonClose.setOnClickListener(this) binding.buttonFab.isGone = resources.getBoolean(R.bool.is_tablet) setPadding(0, 0, 0, context.resources.getDimensionPixelOffset(R.dimen.margin_normal)) } fun attach(timer: ScrollTimer, lifecycleOwner: LifecycleOwner) { scrollTimer = timer timer.isActive.observe(lifecycleOwner) { binding.switchScrollTimer.setOnCheckedChangeListener(null) binding.switchScrollTimer.isChecked = it binding.switchScrollTimer.setOnCheckedChangeListener(this) } settings.observeAsStateFlow( scope = lifecycleOwner.lifecycleScope + Dispatchers.Default, key = AppSettings.KEY_READER_AUTOSCROLL_SPEED, valueProducer = { readerAutoscrollSpeed }, ).observe(lifecycleOwner) { if (abs(it - binding.sliderTimer.value) > 0.0001) { binding.sliderTimer.value = it.coerceIn( binding.sliderTimer.valueFrom, binding.sliderTimer.valueTo, ) } } settings.observeAsStateFlow( scope = lifecycleOwner.lifecycleScope + Dispatchers.Default, key = AppSettings.KEY_READER_AUTOSCROLL_FAB, valueProducer = { isReaderAutoscrollFabVisible }, ).observe(lifecycleOwner) { binding.buttonFab.isChecked = it } updateDescription() } fun onReaderModeChanged(mode: ReaderMode) { readerMode = mode updateDescription() } override fun onClick(v: View) { when (v.id) { R.id.button_close -> hide() R.id.button_fab -> settings.isReaderAutoscrollFabVisible = !settings.isReaderAutoscrollFabVisible } } override fun getFormattedValue(value: Float): String { val valueFrom = binding.sliderTimer.valueFrom val valueTo = binding.sliderTimer.valueTo val percent = (value - valueFrom) / (valueTo - valueFrom) return labelPattern.format(0.1 + percent * 10) // just something to display } override fun onValueChange( slider: Slider, value: Float, fromUser: Boolean ) { if (fromUser) { settings.readerAutoscrollSpeed = value } updateDescription() } override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { scrollTimer?.setActive(isChecked) } override fun setVisibility(visibility: Int) { super.setVisibility(visibility) onVisibilityChangeListener?.onVisibilityChanged(this, visibility) } fun show() { setupVisibilityTransition() isVisible = true } fun hide() { setupVisibilityTransition() isVisible = false } fun showOrHide() { setupVisibilityTransition() isVisible = !isVisible } private fun setupVisibilityTransition() { if (context.isAnimationsEnabled) { val sceneRoot = parentView ?: return val transition = Slide() transition.addTarget(this) TransitionManager.beginDelayedTransition(sceneRoot, transition) } } private fun updateDescription() { val timePerPage = scrollTimer?.pageSwitchDelay ?: 0L if (timePerPage <= 0L || readerMode == ReaderMode.WEBTOON) { binding.textViewDescription.isVisible = false } else { binding.textViewDescription.text = context.getString( R.string.page_switch_timer, TimeUnit.MILLISECONDS.toSeconds((scrollTimer ?: return).pageSwitchDelay), ) binding.textViewDescription.isVisible = true } } fun interface OnVisibilityChangeListener { fun onVisibilityChanged(v: View, visibility: Int) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt ================================================ package org.koitharu.kotatsu.reader.ui.colorfilter import android.content.res.Resources import android.os.Bundle import android.view.View import android.widget.CompoundButton import android.widget.ImageView import androidx.activity.viewModels import androidx.core.view.WindowInsetsCompat import coil3.ImageLoader import coil3.asDrawable import coil3.request.ErrorResult import coil3.request.ImageRequest import coil3.request.SuccessResult import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.slider.LabelFormatter import com.google.android.material.slider.Slider import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseActivity 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.setChecked import org.koitharu.kotatsu.core.util.ext.setValueRounded import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.core.util.progress.ImageRequestIndicatorListener import org.koitharu.kotatsu.databinding.ActivityColorFilterBinding import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.reader.domain.ReaderColorFilter import javax.inject.Inject @AndroidEntryPoint class ColorFilterConfigActivity : BaseActivity(), Slider.OnChangeListener, View.OnClickListener, CompoundButton.OnCheckedChangeListener { @Inject lateinit var coil: ImageLoader private val viewModel: ColorFilterConfigViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityColorFilterBinding.inflate(layoutInflater)) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true) viewBinding.sliderBrightness.addOnChangeListener(this) viewBinding.sliderContrast.addOnChangeListener(this) val formatter = PercentLabelFormatter(resources) viewBinding.sliderContrast.setLabelFormatter(formatter) viewBinding.sliderBrightness.setLabelFormatter(formatter) viewBinding.switchInvert.setOnCheckedChangeListener(this) viewBinding.switchGrayscale.setOnCheckedChangeListener(this) viewBinding.switchBook.setOnCheckedChangeListener(this) viewBinding.buttonDone.setOnClickListener(this) viewBinding.buttonReset.setOnClickListener(this) onBackPressedDispatcher.addCallback(ColorFilterConfigBackPressedDispatcher(this, viewModel)) viewModel.colorFilter.observe(this, this::onColorFilterChanged) viewModel.isLoading.observe(this, this::onLoadingChanged) viewModel.onDismiss.observeEvent(this) { finishAfterTransition() } loadPreview(viewModel.preview) } override fun onApplyWindowInsets( v: View, insets: WindowInsetsCompat ): WindowInsetsCompat { val barsInsets = insets.systemBarsInsets viewBinding.root.setPadding( barsInsets.left, barsInsets.top, barsInsets.right, barsInsets.bottom, ) return insets.consumeAllSystemBarsInsets() } override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { if (fromUser) { when (slider.id) { R.id.slider_brightness -> viewModel.setBrightness(value) R.id.slider_contrast -> viewModel.setContrast(value) } } } override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { when (buttonView.id) { R.id.switch_invert -> viewModel.setInversion(isChecked) R.id.switch_grayscale -> viewModel.setGrayscale(isChecked) R.id.switch_book -> viewModel.setBookEffect(isChecked) } } override fun onClick(v: View) { when (v.id) { R.id.button_done -> showSaveConfirmation() R.id.button_reset -> viewModel.reset() } } fun showSaveConfirmation() { MaterialAlertDialogBuilder(this) .setTitle(R.string.apply) .setMessage(R.string.color_correction_apply_text) .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(R.string.this_manga) { _, _ -> viewModel.save() }.setNeutralButton(R.string.globally) { _, _ -> viewModel.saveGlobally() }.show() } private fun onColorFilterChanged(readerColorFilter: ReaderColorFilter?) { viewBinding.sliderBrightness.setValueRounded(readerColorFilter?.brightness ?: 0f) viewBinding.sliderContrast.setValueRounded(readerColorFilter?.contrast ?: 0f) viewBinding.switchInvert.setChecked(readerColorFilter?.isInverted == true, false) viewBinding.switchGrayscale.setChecked(readerColorFilter?.isGrayscale == true, false) viewBinding.switchBook.setChecked(readerColorFilter?.isBookBackground == true, false) viewBinding.imageViewAfter.colorFilter = readerColorFilter?.toColorFilter() } private fun loadPreview(page: MangaPage) = with(viewBinding.imageViewBefore) { addImageRequestListener( ImageRequestIndicatorListener( listOf( viewBinding.progressBefore, viewBinding.progressAfter, ), ), ) addImageRequestListener(ShadowImageListener(viewBinding.imageViewAfter)) setImageAsync(page) } private fun onLoadingChanged(isLoading: Boolean) { viewBinding.sliderContrast.isEnabled = !isLoading viewBinding.sliderBrightness.isEnabled = !isLoading viewBinding.switchInvert.isEnabled = !isLoading viewBinding.switchGrayscale.isEnabled = !isLoading viewBinding.buttonDone.isEnabled = !isLoading } private class PercentLabelFormatter(resources: Resources) : LabelFormatter { private val pattern = resources.getString(R.string.percent_string_pattern) override fun getFormattedValue(value: Float): String { val percent = ((value + 1f) * 100).format(0) return pattern.format(percent) } } private class ShadowImageListener( private val imageView: ImageView ) : ImageRequest.Listener { override fun onError(request: ImageRequest, result: ErrorResult) { super.onError(request, result) imageView.setImageDrawable(result.image?.asDrawable(imageView.resources)) } override fun onStart(request: ImageRequest) { super.onStart(request) imageView.setImageDrawable(request.placeholder()?.asDrawable(imageView.resources)) } override fun onSuccess(request: ImageRequest, result: SuccessResult) { super.onSuccess(request, result) imageView.setImageDrawable(result.image.asDrawable(imageView.resources)) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigBackPressedDispatcher.kt ================================================ package org.koitharu.kotatsu.reader.ui.colorfilter import android.content.DialogInterface import androidx.activity.OnBackPressedCallback import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.call class ColorFilterConfigBackPressedDispatcher( private val activity: ColorFilterConfigActivity, private val viewModel: ColorFilterConfigViewModel, ) : OnBackPressedCallback(true), DialogInterface.OnClickListener { override fun handleOnBackPressed() { if (viewModel.isChanged) { showConfirmation() } else { viewModel.onDismiss.call(Unit) } } override fun onClick(dialog: DialogInterface, which: Int) { when (which) { DialogInterface.BUTTON_NEGATIVE -> viewModel.onDismiss.call(Unit) DialogInterface.BUTTON_NEUTRAL -> dialog.dismiss() DialogInterface.BUTTON_POSITIVE -> activity.showSaveConfirmation() } } private fun showConfirmation() { MaterialAlertDialogBuilder(activity) .setTitle(R.string.color_correction) .setMessage(R.string.text_unsaved_changes_prompt) .setNegativeButton(R.string.discard, this) .setNeutralButton(android.R.string.cancel, this) .setPositiveButton(R.string.save, this) .show() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigViewModel.kt ================================================ package org.koitharu.kotatsu.reader.ui.colorfilter import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPage import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.prefs.AppSettings 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.reader.domain.ReaderColorFilter import javax.inject.Inject @HiltViewModel class ColorFilterConfigViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val settings: AppSettings, private val mangaDataRepository: MangaDataRepository, ) : BaseViewModel() { private val manga = savedStateHandle.require(AppRouter.KEY_MANGA).manga private var initialColorFilter: ReaderColorFilter? = null val colorFilter = MutableStateFlow(null) val onDismiss = MutableEventFlow() val preview = savedStateHandle.require(AppRouter.KEY_PAGES).page val isChanged: Boolean get() = colorFilter.value != initialColorFilter init { launchLoadingJob { initialColorFilter = mangaDataRepository.getColorFilter(manga.id) ?: settings.readerColorFilter colorFilter.value = initialColorFilter } } fun setBrightness(brightness: Float) { updateColorFilter { it.copy(brightness = brightness) } } fun setContrast(contrast: Float) { updateColorFilter { it.copy(contrast = contrast) } } fun setInversion(invert: Boolean) { updateColorFilter { it.copy(isInverted = invert) } } fun setGrayscale(grayscale: Boolean) { updateColorFilter { it.copy(isGrayscale = grayscale) } } fun setBookEffect(book: Boolean) { updateColorFilter { it.copy(isBookBackground = book) } } fun reset() { colorFilter.value = null } fun save() { launchLoadingJob(Dispatchers.Default) { mangaDataRepository.saveColorFilter(manga, colorFilter.value) onDismiss.call(Unit) } } fun saveGlobally() { launchLoadingJob(Dispatchers.Default) { settings.readerColorFilter = colorFilter.value mangaDataRepository.resetColorFilters() onDismiss.call(Unit) } } private inline fun updateColorFilter(block: (ReaderColorFilter) -> ReaderColorFilter) { colorFilter.value = block( colorFilter.value ?: ReaderColorFilter.EMPTY, ).takeUnless { it.isEmpty } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ImageServerDelegate.kt ================================================ package org.koitharu.kotatsu.reader.ui.config import android.content.Context import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.mapToArray import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrNull import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy import kotlin.coroutines.resume class ImageServerDelegate( private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaSource: MangaSource?, ) { private val repositoryLazy = suspendLazy { mangaRepositoryFactory.create(checkNotNull(mangaSource)) as ParserMangaRepository } suspend fun isAvailable() = withContext(Dispatchers.Default) { repositoryLazy.getOrNull()?.let { repository -> repository.getConfigKeys().any { it is ConfigKey.PreferredImageServer } } == true } suspend fun getValue(): String? = withContext(Dispatchers.Default) { repositoryLazy.getOrNull()?.let { repository -> val key = repository.getConfigKeys().firstNotNullOfOrNull { it as? ConfigKey.PreferredImageServer } if (key != null) { key.presetValues[repository.getConfig()[key]] } else { null } } } suspend fun showDialog(context: Context): Boolean { val repository = withContext(Dispatchers.Default) { repositoryLazy.getOrNull() } ?: return false val key = repository.getConfigKeys().firstNotNullOfOrNull { it as? ConfigKey.PreferredImageServer } ?: return false val entries = key.presetValues.values.mapToArray { it ?: context.getString(R.string.automatic) } val entryValues = key.presetValues.keys.toTypedArray() val config = repository.getConfig() val initialValue = config[key] var currentValue = initialValue val changed = suspendCancellableCoroutine { cont -> val dialog = MaterialAlertDialogBuilder(context) .setTitle(R.string.image_server) .setCancelable(true) .setSingleChoiceItems(entries, entryValues.indexOf(initialValue)) { _, i -> currentValue = entryValues[i] }.setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.cancel() }.setPositiveButton(android.R.string.ok) { _, _ -> if (currentValue != initialValue) { config[key] = currentValue cont.resume(true) } else { cont.resume(false) } }.setOnCancelListener { cont.resume(false) }.create() dialog.show() cont.invokeOnCancellation { dialog.cancel() } } if (changed) { repository.invalidateCache() } return changed } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt ================================================ package org.koitharu.kotatsu.reader.ui.config import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.CompoundButton import androidx.core.view.WindowInsetsCompat import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.updatePadding import androidx.fragment.app.activityViewModels import androidx.transition.TransitionManager import com.google.android.material.button.MaterialButtonToggleGroup import com.google.android.material.slider.Slider import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.util.ext.consume import org.koitharu.kotatsu.core.util.ext.findParentCallback import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.setValueRounded import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.core.util.progress.IntPercentLabelFormatter import org.koitharu.kotatsu.databinding.SheetReaderConfigBinding import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.ReaderViewModel import org.koitharu.kotatsu.reader.ui.ScreenOrientationHelper import javax.inject.Inject @AndroidEntryPoint class ReaderConfigSheet : BaseAdaptiveSheet(), View.OnClickListener, MaterialButtonToggleGroup.OnButtonCheckedListener, CompoundButton.OnCheckedChangeListener, Slider.OnChangeListener { private val viewModel by activityViewModels() @Inject lateinit var orientationHelper: ScreenOrientationHelper @Inject lateinit var mangaRepositoryFactory: MangaRepository.Factory @Inject lateinit var pageLoader: PageLoader private lateinit var mode: ReaderMode private lateinit var imageServerDelegate: ImageServerDelegate @Inject lateinit var settings: AppSettings override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) mode = arguments?.getInt(AppRouter.KEY_READER_MODE) ?.let { ReaderMode.valueOf(it) } ?: ReaderMode.STANDARD imageServerDelegate = ImageServerDelegate( mangaRepositoryFactory = mangaRepositoryFactory, mangaSource = viewModel.getMangaOrNull()?.source, ) } override fun onCreateViewBinding( inflater: LayoutInflater, container: ViewGroup?, ): SheetReaderConfigBinding { return SheetReaderConfigBinding.inflate(inflater, container, false) } override fun onViewBindingCreated( binding: SheetReaderConfigBinding, savedInstanceState: Bundle?, ) { super.onViewBindingCreated(binding, savedInstanceState) observeScreenOrientation() binding.buttonStandard.isChecked = mode == ReaderMode.STANDARD binding.buttonReversed.isChecked = mode == ReaderMode.REVERSED binding.buttonWebtoon.isChecked = mode == ReaderMode.WEBTOON binding.buttonVertical.isChecked = mode == ReaderMode.VERTICAL binding.switchDoubleReader.isChecked = settings.isReaderDoubleOnLandscape binding.switchDoubleReader.isEnabled = mode == ReaderMode.STANDARD || mode == ReaderMode.REVERSED binding.switchDoubleFoldable.isChecked = settings.isReaderDoubleOnFoldable binding.switchDoubleFoldable.isEnabled = binding.switchDoubleReader.isEnabled binding.sliderDoubleSensitivity.setValueRounded(settings.readerDoublePagesSensitivity * 100f) binding.sliderDoubleSensitivity.setLabelFormatter(IntPercentLabelFormatter(binding.root.context)) binding.adjustSensitivitySlider(withAnimation = false) binding.checkableGroup.addOnButtonCheckedListener(this) binding.buttonSavePage.setOnClickListener(this) binding.buttonScreenRotate.setOnClickListener(this) binding.buttonSettings.setOnClickListener(this) binding.buttonImageServer.setOnClickListener(this) binding.buttonColorFilter.setOnClickListener(this) binding.buttonScrollTimer.setOnClickListener(this) binding.buttonBookmark.setOnClickListener(this) binding.switchDoubleReader.setOnCheckedChangeListener(this) binding.switchDoubleFoldable.setOnCheckedChangeListener(this) binding.sliderDoubleSensitivity.addOnChangeListener(this) viewModel.isBookmarkAdded.observe(viewLifecycleOwner) { binding.buttonBookmark.setText(if (it) R.string.bookmark_remove else R.string.bookmark_add) binding.buttonBookmark.setCompoundDrawablesRelativeWithIntrinsicBounds( if (it) R.drawable.ic_bookmark_checked else R.drawable.ic_bookmark, 0, 0, 0, ) } viewLifecycleScope.launch { val isAvailable = imageServerDelegate.isAvailable() if (isAvailable) { bindImageServerTitle() } binding.buttonImageServer.isVisible = isAvailable } } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val typeMask = WindowInsetsCompat.Type.systemBars() viewBinding?.scrollView?.updatePadding( bottom = insets.getInsets(typeMask).bottom, ) return insets.consume(v, typeMask, bottom = true) } override fun onClick(v: View) { when (v.id) { R.id.button_settings -> { router.openReaderSettings() dismissAllowingStateLoss() } R.id.button_scroll_timer -> { findParentCallback(Callback::class.java)?.onScrollTimerClick(false) ?: return dismissAllowingStateLoss() } R.id.button_save_page -> { findParentCallback(Callback::class.java)?.onSavePageClick() ?: return dismissAllowingStateLoss() } R.id.button_screen_rotate -> { orientationHelper.isLandscape = !orientationHelper.isLandscape } R.id.button_bookmark -> { viewModel.toggleBookmark() } R.id.button_color_filter -> { val page = viewModel.getCurrentPage() ?: return val manga = viewModel.getMangaOrNull() ?: return router.openColorFilterConfig(manga, page) } R.id.button_image_server -> viewLifecycleScope.launch { if (imageServerDelegate.showDialog(v.context)) { bindImageServerTitle() pageLoader.invalidate(clearCache = true) viewModel.switchChapterBy(0) } } } } override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { when (buttonView.id) { R.id.switch_screen_lock_rotation -> { orientationHelper.isLocked = isChecked } R.id.switch_double_reader -> { settings.isReaderDoubleOnLandscape = isChecked viewBinding?.adjustSensitivitySlider(withAnimation = true) findParentCallback(Callback::class.java)?.onDoubleModeChanged(isChecked) } R.id.switch_double_foldable -> { settings.isReaderDoubleOnFoldable = isChecked // Re-evaluate double-page considering foldable state and current manual toggle findParentCallback(Callback::class.java)?.onDoubleModeChanged(settings.isReaderDoubleOnLandscape) } } } override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { settings.readerDoublePagesSensitivity = value / 100f } override fun onButtonChecked( group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean, ) { if (!isChecked) { return } val newMode = when (checkedId) { R.id.button_standard -> ReaderMode.STANDARD R.id.button_webtoon -> ReaderMode.WEBTOON R.id.button_reversed -> ReaderMode.REVERSED R.id.button_vertical -> ReaderMode.VERTICAL else -> return } viewBinding?.run { switchDoubleReader.isEnabled = newMode == ReaderMode.STANDARD || newMode == ReaderMode.REVERSED switchDoubleFoldable.isEnabled = switchDoubleReader.isEnabled adjustSensitivitySlider(withAnimation = true) } if (newMode == mode) { return } findParentCallback(Callback::class.java)?.onReaderModeChanged(newMode) ?: return mode = newMode } private fun observeScreenOrientation() { orientationHelper.observeAutoOrientation() .onEach { with(requireViewBinding()) { buttonScreenRotate.isGone = it switchScreenLockRotation.isVisible = it updateOrientationLockSwitch() } }.launchIn(viewLifecycleScope) } private fun updateOrientationLockSwitch() { val switch = viewBinding?.switchScreenLockRotation ?: return switch.setOnCheckedChangeListener(null) switch.isChecked = orientationHelper.isLocked switch.setOnCheckedChangeListener(this) } private suspend fun bindImageServerTitle() { viewBinding?.buttonImageServer?.text = getString( R.string.inline_preference_pattern, getString(R.string.image_server), imageServerDelegate.getValue() ?: getString(R.string.automatic), ) } private fun SheetReaderConfigBinding.adjustSensitivitySlider(withAnimation: Boolean) { val isSubOptionsVisible = switchDoubleReader.isEnabled && switchDoubleReader.isChecked val needTransition = withAnimation && ( (isSubOptionsVisible != sliderDoubleSensitivity.isVisible) || (isSubOptionsVisible != textDoubleSensitivity.isVisible) || (isSubOptionsVisible != switchDoubleFoldable.isVisible) ) if (needTransition) { TransitionManager.beginDelayedTransition(layoutMain) } sliderDoubleSensitivity.isVisible = isSubOptionsVisible textDoubleSensitivity.isVisible = isSubOptionsVisible switchDoubleFoldable.isVisible = isSubOptionsVisible } interface Callback { fun onReaderModeChanged(mode: ReaderMode) fun onDoubleModeChanged(isEnabled: Boolean) fun onSavePageClick() fun onScrollTimerClick(isLongClick: Boolean) fun onBookmarkClick() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderSettings.kt ================================================ package org.koitharu.kotatsu.reader.ui.config import android.graphics.Bitmap import android.view.View import androidx.annotation.CheckResult import androidx.collection.scatterSetOf import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.decoder.SkiaImageDecoder import com.davemorrissey.labs.subscaleview.decoder.SkiaImageRegionDecoder import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderBackground import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.util.MediatorStateFlow import org.koitharu.kotatsu.core.util.ext.isLowRamDevice import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.reader.domain.ReaderColorFilter data class ReaderSettings( val zoomMode: ZoomMode, val background: ReaderBackground, val colorFilter: ReaderColorFilter?, val isReaderOptimizationEnabled: Boolean, val bitmapConfig: Bitmap.Config, val isPagesNumbersEnabled: Boolean, val isPagesCropEnabledStandard: Boolean, val isPagesCropEnabledWebtoon: Boolean, ) { private constructor(settings: AppSettings, colorFilterOverride: ReaderColorFilter?) : this( zoomMode = settings.zoomMode, background = settings.readerBackground, colorFilter = colorFilterOverride?.takeUnless { it.isEmpty } ?: settings.readerColorFilter, isReaderOptimizationEnabled = settings.isReaderOptimizationEnabled, bitmapConfig = if (settings.is32BitColorsEnabled) { Bitmap.Config.ARGB_8888 } else { Bitmap.Config.RGB_565 }, isPagesNumbersEnabled = settings.isPagesNumbersEnabled, isPagesCropEnabledStandard = settings.isPagesCropEnabled(ReaderMode.STANDARD), isPagesCropEnabledWebtoon = settings.isPagesCropEnabled(ReaderMode.WEBTOON), ) fun applyBackground(view: View) { view.background = background.resolve(view.context) view.backgroundTintList = if (background.isLight(view.context)) { colorFilter?.getBackgroundTint() } else { null } } fun isPagesCropEnabled(isWebtoon: Boolean) = if (isWebtoon) { isPagesCropEnabledWebtoon } else { isPagesCropEnabledStandard } @CheckResult fun applyBitmapConfig(ssiv: SubsamplingScaleImageView): Boolean { val config = bitmapConfig return if (ssiv.regionDecoderFactory.bitmapConfig != config) { ssiv.regionDecoderFactory = if (ssiv.context.isLowRamDevice()) { SkiaImageRegionDecoder.Factory(config) } else { SkiaPooledImageRegionDecoder.Factory(config) } ssiv.bitmapDecoderFactory = SkiaImageDecoder.Factory(config) true } else { false } } class Producer @AssistedInject constructor( @Assisted private val mangaId: Flow, private val settings: AppSettings, private val mangaDataRepository: MangaDataRepository, ) : MediatorStateFlow(ReaderSettings(settings, null)) { private val settingsKeys = scatterSetOf( AppSettings.KEY_ZOOM_MODE, AppSettings.KEY_PAGES_NUMBERS, AppSettings.KEY_READER_BACKGROUND, AppSettings.KEY_32BIT_COLOR, AppSettings.KEY_READER_OPTIMIZE, AppSettings.KEY_CF_CONTRAST, AppSettings.KEY_CF_BRIGHTNESS, AppSettings.KEY_CF_INVERTED, AppSettings.KEY_CF_GRAYSCALE, AppSettings.KEY_READER_CROP, ) private var job: Job? = null override fun onActive() { assert(job?.isActive != true) job?.cancel() job = processLifecycleScope.launch(Dispatchers.Default) { observeImpl() } } override fun onInactive() { job?.cancel() job = null } private suspend fun observeImpl() { combine( mangaId.flatMapLatest { mangaDataRepository.observeColorFilter(it) }, settings.observeChanges().filter { x -> x == null || x in settingsKeys }.onStart { emit(null) }, ) { mangaCf, settingsKey -> ReaderSettings(settings, mangaCf) }.collect { publishValue(it) } } @AssistedFactory interface Factory { fun create(mangaId: Flow): Producer } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager import android.content.ComponentCallbacks2 import android.content.ComponentCallbacks2.TRIM_MEMORY_COMPLETE import android.content.Context import android.content.res.Configuration import android.view.View import androidx.annotation.CallSuper import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.viewbinding.ViewBinding import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.ui.list.lifecycle.LifecycleAwareViewHolder import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.isLowRamDevice import org.koitharu.kotatsu.core.util.ext.isSerializable import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.databinding.LayoutPageInfoBinding import org.koitharu.kotatsu.parsers.util.ifZero import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.pager.vm.PageState import org.koitharu.kotatsu.reader.ui.pager.vm.PageViewModel import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonHolder abstract class BasePageHolder( protected val binding: B, loader: PageLoader, readerSettingsProducer: ReaderSettings.Producer, networkState: NetworkState, exceptionResolver: ExceptionResolver, lifecycleOwner: LifecycleOwner, ) : LifecycleAwareViewHolder(binding.root, lifecycleOwner), DefaultOnImageEventListener, ComponentCallbacks2 { protected val viewModel = PageViewModel( loader = loader, settingsProducer = readerSettingsProducer, networkState = networkState, exceptionResolver = exceptionResolver, isWebtoon = this is WebtoonHolder, ) protected val bindingInfo = LayoutPageInfoBinding.bind(binding.root) protected abstract val ssiv: SubsamplingScaleImageView protected val settings: ReaderSettings get() = viewModel.settingsProducer.value val context: Context get() = itemView.context var boundData: ReaderPage? = null private set init { lifecycleScope.launch(Dispatchers.Main) { ssiv.bindToLifecycle(this@BasePageHolder) ssiv.isEagerLoadingEnabled = !context.isLowRamDevice() ssiv.addOnImageEventListener(viewModel) ssiv.addOnImageEventListener(this@BasePageHolder) } val clickListener = View.OnClickListener { v -> when (v.id) { R.id.button_retry -> viewModel.retry( page = boundData?.toMangaPage() ?: return@OnClickListener, isFromUser = true, ) R.id.button_error_details -> viewModel.showErrorDetails(boundData?.url) } } bindingInfo.buttonRetry.setOnClickListener(clickListener) bindingInfo.buttonErrorDetails.setOnClickListener(clickListener) } @CallSuper protected open fun onConfigChanged(settings: ReaderSettings) { settings.applyBackground(itemView) if (settings.applyBitmapConfig(ssiv)) { reloadImage() } else if (viewModel.state.value is PageState.Shown) { onReady() } ssiv.applyDownSampling(isResumed()) } fun reloadImage() { val source = (viewModel.state.value as? PageState.Shown)?.source ?: return ssiv.setImage(source) } fun bind(data: ReaderPage) { boundData = data viewModel.onBind(data.toMangaPage()) onBind(data) } @CallSuper protected open fun onBind(data: ReaderPage) = Unit override fun onCreate() { super.onCreate() context.registerComponentCallbacks(this) viewModel.state.observe(this, ::onStateChanged) viewModel.settingsProducer.observe(this, ::onConfigChanged) } override fun onResume() { super.onResume() ssiv.applyDownSampling(isForeground = true) if (viewModel.state.value is PageState.Error && !viewModel.isLoading()) { boundData?.let { viewModel.retry(it.toMangaPage(), isFromUser = false) } } } override fun onPause() { super.onPause() ssiv.applyDownSampling(isForeground = false) } override fun onDestroy() { context.unregisterComponentCallbacks(this) super.onDestroy() } open fun onAttachedToWindow() = Unit open fun onDetachedFromWindow() = Unit @CallSuper open fun onRecycled() { viewModel.onRecycle() ssiv.recycle() } override fun onTrimMemory(level: Int) { // TODO } override fun onConfigurationChanged(newConfig: Configuration) = Unit @Deprecated("Deprecated in Java") final override fun onLowMemory() = onTrimMemory(TRIM_MEMORY_COMPLETE) protected open fun onStateChanged(state: PageState) { bindingInfo.layoutError.isVisible = state is PageState.Error bindingInfo.layoutProgress.isGone = state.isFinalState() val progress = (state as? PageState.Loading)?.progress ?: -1 if (progress in 0..100) { bindingInfo.progressBar.isIndeterminate = false bindingInfo.progressBar.setProgressCompat(progress, true) bindingInfo.textViewStatus.text = context.getString(R.string.percent_string_pattern, progress.toString()) } else { bindingInfo.progressBar.isIndeterminate = true bindingInfo.textViewStatus.setText(R.string.loading_) } when (state) { is PageState.Converting -> { bindingInfo.textViewStatus.setText(R.string.processing_) } is PageState.Empty -> Unit is PageState.Error -> { val e = state.error bindingInfo.textViewError.text = e.getDisplayMessage(context.resources) bindingInfo.buttonRetry.setText( ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again }, ) bindingInfo.buttonErrorDetails.isVisible = e.isSerializable() bindingInfo.layoutError.isVisible = true bindingInfo.progressBar.hide() } is PageState.Loaded -> { bindingInfo.textViewStatus.setText(R.string.preparing_) ssiv.setImage(state.source) } is PageState.Loading -> { if (state.preview != null && ssiv.getState() == null) { ssiv.setImage(state.preview) } } is PageState.Shown -> Unit } } protected fun SubsamplingScaleImageView.applyDownSampling(isForeground: Boolean) { downSampling = when { isForeground || !settings.isReaderOptimizationEnabled -> 1 BuildConfig.DEBUG -> 32 context.isLowRamDevice() -> 8 else -> 4 } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BasePagerReaderFragment.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager import android.os.Build import android.os.Bundle import android.view.InputDevice import android.view.KeyEvent import android.view.LayoutInflater import android.view.MotionEvent import android.view.View import android.view.ViewGroup import androidx.core.view.children import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2.PageTransformer import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.yield import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.prefs.ReaderAnimation import org.koitharu.kotatsu.core.ui.list.lifecycle.PagerLifecycleDispatcher import org.koitharu.kotatsu.core.util.ext.doOnPageChanged import org.koitharu.kotatsu.core.util.ext.findCurrentViewHolder import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.recyclerView import org.koitharu.kotatsu.core.util.ext.resetTransformations import org.koitharu.kotatsu.databinding.FragmentReaderPagerBinding import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.pager.standard.NoAnimPageTransformer import org.koitharu.kotatsu.reader.ui.pager.standard.PageAnimTransformer import org.koitharu.kotatsu.reader.ui.pager.standard.PageHolder import org.koitharu.kotatsu.reader.ui.pager.standard.PagerEventSupplier import org.koitharu.kotatsu.reader.ui.pager.standard.PagesAdapter import javax.inject.Inject import kotlin.math.absoluteValue import kotlin.math.sign @AndroidEntryPoint abstract class BasePagerReaderFragment : BaseReaderFragment(), View.OnGenericMotionListener { @Inject lateinit var networkState: NetworkState @Inject lateinit var pageLoader: PageLoader private var pagerLifecycleDispatcher: PagerLifecycleDispatcher? = null override fun onCreateViewBinding( inflater: LayoutInflater, container: ViewGroup?, ) = FragmentReaderPagerBinding.inflate(inflater, container, false) override fun onViewBindingCreated( binding: FragmentReaderPagerBinding, savedInstanceState: Bundle?, ) { super.onViewBindingCreated(binding, savedInstanceState) with(binding.pager) { onInitPager(this) doOnPageChanged(::notifyPageChanged) setOnGenericMotionListener(this@BasePagerReaderFragment) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { recyclerView?.defaultFocusHighlightEnabled = false } PagerEventSupplier(this).attach() pagerLifecycleDispatcher = PagerLifecycleDispatcher(this).also { registerOnPageChangeCallback(it) } adapter = readerAdapter } viewModel.pageAnimation.observe(viewLifecycleOwner) { val transformer = when (it) { ReaderAnimation.NONE -> NoAnimPageTransformer(binding.pager.orientation) ReaderAnimation.DEFAULT -> null ReaderAnimation.ADVANCED -> onCreateAdvancedTransformer() } binding.pager.setPageTransformer(transformer) if (transformer == null) { binding.pager.recyclerView?.children?.forEach { view -> view.resetTransformations() } } } } override fun onDestroyView() { pagerLifecycleDispatcher = null requireViewBinding().pager.adapter = null super.onDestroyView() } override fun onZoomIn() { (viewBinding?.pager?.findCurrentViewHolder() as? PageHolder)?.onZoomIn() } override fun onZoomOut() { (viewBinding?.pager?.findCurrentViewHolder() as? PageHolder)?.onZoomOut() } override fun onGenericMotion(v: View?, event: MotionEvent): Boolean { if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) { if (event.actionMasked == MotionEvent.ACTION_SCROLL) { val axisValue = event.getAxisValue(MotionEvent.AXIS_VSCROLL) val withCtrl = event.metaState and KeyEvent.META_CTRL_MASK != 0 if (!withCtrl) { onWheelScroll(axisValue) return true } } } return false } override suspend fun onPagesChanged(pages: List, pendingState: ReaderState?) = coroutineScope { val items = launch { requireAdapter().setItems(pages) yield() pagerLifecycleDispatcher?.postInvalidate() } if (pendingState != null) { val position = pages.indexOfFirst { it.chapterId == pendingState.chapterId && it.index == pendingState.page } items.join() if (position != -1) { requireViewBinding().pager.setCurrentItem(position, false) notifyPageChanged(position) } else { Snackbar.make(requireView(), R.string.not_found_404, Snackbar.LENGTH_SHORT) .show() } } else { items.join() } } override fun onCreateAdapter(): BaseReaderAdapter<*> = PagesAdapter( lifecycleOwner = viewLifecycleOwner, loader = pageLoader, readerSettingsProducer = viewModel.readerSettingsProducer, networkState = networkState, exceptionResolver = exceptionResolver, ) override fun switchPageBy(delta: Int) { with(requireViewBinding().pager) { setCurrentItem(currentItem + delta, isAnimationEnabled()) } } override fun switchPageTo(position: Int, smooth: Boolean) { with(requireViewBinding().pager) { setCurrentItem( position, smooth && isAnimationEnabled() && (currentItem - position).absoluteValue < SMOOTH_SCROLL_LIMIT, ) } } override fun getCurrentState(): ReaderState? = viewBinding?.run { val adapter = pager.adapter as? BaseReaderAdapter<*> val page = adapter?.getItemOrNull(pager.currentItem) ?: return@run null ReaderState( chapterId = page.chapterId, page = page.index, scroll = 0, ) } protected open fun onWheelScroll(axisValue: Float) { switchPageBy(-axisValue.sign.toInt()) } protected open fun onCreateAdvancedTransformer(): PageTransformer = PageAnimTransformer() protected open fun onInitPager(pager: ViewPager2) { pager.offscreenPageLimit = 2 } protected open fun notifyPageChanged(page: Int) { viewModel.onCurrentPageChanged(page, page) } companion object { const val SMOOTH_SCROLL_LIMIT = 3 } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BaseReaderAdapter.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager import android.view.ViewGroup import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.util.ext.resetTransformations import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine @Suppress("LeakingThis") abstract class BaseReaderAdapter>( private val loader: PageLoader, private val readerSettingsProducer: ReaderSettings.Producer, private val networkState: NetworkState, private val exceptionResolver: ExceptionResolver, ) : RecyclerView.Adapter() { private val differ = AsyncListDiffer(this, DiffCallback()) val hasItems: Boolean get() = itemCount != 0 init { stateRestorationPolicy = StateRestorationPolicy.PREVENT } override fun onBindViewHolder(holder: H, position: Int) { holder.bind(differ.currentList[position]) } override fun onViewRecycled(holder: H) { holder.onRecycled() holder.itemView.resetTransformations() super.onViewRecycled(holder) } override fun onViewAttachedToWindow(holder: H) { super.onViewAttachedToWindow(holder) holder.onAttachedToWindow() } override fun onViewDetachedFromWindow(holder: H) { holder.onDetachedFromWindow() super.onViewDetachedFromWindow(holder) } open fun getItem(position: Int): ReaderPage = differ.currentList[position] open fun getItemOrNull(position: Int) = differ.currentList.getOrNull(position) final override fun getItemCount() = differ.currentList.size final override fun onCreateViewHolder( parent: ViewGroup, viewType: Int, ): H = onCreateViewHolder(parent, loader, readerSettingsProducer, networkState, exceptionResolver) suspend fun setItems(items: List) = suspendCoroutine { cont -> differ.submitList(items) { cont.resume(Unit) } } protected abstract fun onCreateViewHolder( parent: ViewGroup, loader: PageLoader, readerSettingsProducer: ReaderSettings.Producer, networkState: NetworkState, exceptionResolver: ExceptionResolver, ): H private class DiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: ReaderPage, newItem: ReaderPage): Boolean { return oldItem.id == newItem.id && oldItem.chapterId == newItem.chapterId } override fun areContentsTheSame(oldItem: ReaderPage, newItem: ReaderPage): Boolean { return oldItem == newItem } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BaseReaderFragment.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager import android.os.Bundle import android.view.View import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.activityViewModels import androidx.viewbinding.ViewBinding import org.koitharu.kotatsu.core.prefs.ReaderAnimation import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.widgets.ZoomControl import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderViewModel abstract class BaseReaderFragment : BaseFragment(), ZoomControl.ZoomControlListener { protected val viewModel by activityViewModels() protected var readerAdapter: BaseReaderAdapter<*>? = null private set override fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) readerAdapter = onCreateAdapter() viewModel.content.observe(viewLifecycleOwner) { // Determine which state to use for restoring position: // - content.state: explicitly set state (e.g., after mode switch or chapter change) // - getCurrentState(): current reading position saved in SavedStateHandle val currentState = viewModel.getCurrentState() val pendingState = when { // If content.state is null and we have pages, use getCurrentState it.state == null && it.pages.isNotEmpty() && readerAdapter?.hasItems != true -> currentState // use currentState only if it matches the current pages (to avoid the error message) readerAdapter?.hasItems != true && it.state != currentState && currentState != null && it.pages.any { page -> page.chapterId == currentState.chapterId } -> currentState // Otherwise, use content.state (normal flow, mode switch, chapter change) else -> it.state } onPagesChanged(it.pages, pendingState) } } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = insets override fun onPause() { super.onPause() viewModel.saveCurrentState(getCurrentState()) } override fun onDestroyView() { viewModel.saveCurrentState(getCurrentState()) readerAdapter = null super.onDestroyView() } protected fun requireAdapter() = checkNotNull(readerAdapter) { "Adapter was not created or already destroyed" } protected fun isAnimationEnabled(): Boolean { return context?.isAnimationsEnabled == true && viewModel.pageAnimation.value != ReaderAnimation.NONE } abstract fun switchPageBy(delta: Int) abstract fun switchPageTo(position: Int, smooth: Boolean) open fun scrollBy(delta: Int, smooth: Boolean): Boolean = false abstract fun getCurrentState(): ReaderState? protected abstract fun onCreateAdapter(): BaseReaderAdapter<*> protected abstract suspend fun onPagesChanged(pages: List, pendingState: ReaderState?) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/ReaderPage.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager import android.os.Parcelable import kotlinx.parcelize.Parcelize import kotlinx.parcelize.TypeParceler import org.koitharu.kotatsu.core.model.parcelable.MangaSourceParceler import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaSource @Parcelize @TypeParceler data class ReaderPage( val id: Long, val url: String, val preview: String?, val chapterId: Long, val index: Int, val source: MangaSource, ) : Parcelable { constructor(page: MangaPage, index: Int, chapterId: Long) : this( id = page.id, url = page.url, preview = page.preview, chapterId = chapterId, index = index, source = page.source, ) fun toMangaPage() = MangaPage( id = id, url = url, preview = preview, source = source, ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/ReaderUiState.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager import android.content.res.Resources import org.koitharu.kotatsu.core.model.getLocalizedTitle import org.koitharu.kotatsu.parsers.model.MangaChapter data class ReaderUiState( val mangaName: String?, val chapter: MangaChapter, val chapterIndex: Int, val chaptersTotal: Int, val currentPage: Int, val totalPages: Int, val percent: Float, val incognito: Boolean, ) { val chapterNumber: Int get() = chapterIndex + 1 fun hasNextChapter(): Boolean = chapterNumber < chaptersTotal fun hasPreviousChapter(): Boolean = chapterIndex > 0 fun isSliderAvailable(): Boolean = totalPages > 1 && currentPage < totalPages fun getChapterTitle(resources: Resources) = chapter.getLocalizedTitle(resources, chapterIndex) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/doublepage/DoublePageHolder.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.doublepage import android.graphics.PointF import android.view.Gravity import android.widget.FrameLayout import androidx.lifecycle.LifecycleOwner import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.databinding.ItemPageBinding import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.standard.PageHolder class DoublePageHolder( owner: LifecycleOwner, binding: ItemPageBinding, loader: PageLoader, readerSettingsProducer: ReaderSettings.Producer, networkState: NetworkState, exceptionResolver: ExceptionResolver, ) : PageHolder( owner = owner, binding = binding, loader = loader, readerSettingsProducer = readerSettingsProducer, networkState = networkState, exceptionResolver = exceptionResolver, ) { private val isEven: Boolean get() = bindingAdapterPosition and 1 == 0 init { binding.ssiv.panLimit = SubsamplingScaleImageView.PAN_LIMIT_INSIDE } override fun onBind(data: ReaderPage) { super.onBind(data) (binding.textViewNumber.layoutParams as FrameLayout.LayoutParams) .gravity = (if (isEven) Gravity.START else Gravity.END) or Gravity.BOTTOM } override fun onReady() { with(binding.ssiv) { maxScale = 2f * maxOf( width / sWidth.toFloat(), height / sHeight.toFloat(), ) binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter() minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE setScaleAndCenter( minScale, PointF(if (isEven) 0f else sWidth.toFloat(), sHeight / 2f), ) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/doublepage/DoublePageLayoutManager.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.doublepage import android.content.Context import android.util.AttributeSet import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView class DoublePageLayoutManager( context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int, ) : LinearLayoutManager(context, attrs, defStyleAttr, defStyleRes) { override fun checkLayoutParams(lp: RecyclerView.LayoutParams?): Boolean { lp?.width = width / 2 return super.checkLayoutParams(lp) } override fun calculateExtraLayoutSpace(state: RecyclerView.State, extraLayoutSpace: IntArray) { val offscreenSpace = width / 2 extraLayoutSpace[0] = offscreenSpace extraLayoutSpace[1] = offscreenSpace } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/doublepage/DoublePageSnapHelper.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.doublepage import android.util.DisplayMetrics import android.view.View import android.view.animation.Interpolator import android.widget.Scroller import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.OrientationHelper import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.SmoothScroller.ScrollVectorProvider import androidx.recyclerview.widget.SnapHelper import org.koitharu.kotatsu.core.prefs.AppSettings import kotlin.math.abs import kotlin.math.absoluteValue import kotlin.math.max import kotlin.math.roundToInt import kotlin.math.sign class DoublePageSnapHelper(private val settings: AppSettings) : SnapHelper() { private lateinit var recyclerView: RecyclerView // Total number of items in a block of view in the RecyclerView private var blockSize = 2 // Maximum number of positions to move on a fling. private var maxPositionsToMove = 0 // Width of a RecyclerView item if orientation is horizontal; height of the item if vertical private var itemDimension = 0 // Maxim blocks to move during most vigorous fling. private val maxFlingBlocks = 2 // When snapping, used to determine direction of snap. private var priorFirstPosition = RecyclerView.NO_POSITION // Our private scroller private var scroller: Scroller? = null // Horizontal/vertical layout helper private lateinit var orientationHelper: OrientationHelper // LTR/RTL helper private lateinit var layoutDirectionHelper: LayoutDirectionHelper private val snapInterpolator = Interpolator { input -> var t = input t -= 1.0f t * t * t + 1.0f } @Throws(IllegalStateException::class) override fun attachToRecyclerView(target: RecyclerView?) { if (target != null) { recyclerView = target val layoutManager = recyclerView.layoutManager as LinearLayoutManager check(layoutManager.canScrollHorizontally()) { "RecyclerView must be scrollable" } orientationHelper = OrientationHelper.createHorizontalHelper(layoutManager) layoutDirectionHelper = LayoutDirectionHelper(recyclerView.layoutDirection) scroller = Scroller(target.context, snapInterpolator) initItemDimensionIfNeeded(layoutManager) } super.attachToRecyclerView(recyclerView) } override fun calculateDistanceToFinalSnap( layoutManager: RecyclerView.LayoutManager, targetView: View ): IntArray { val out = IntArray(2) if (layoutManager.canScrollHorizontally()) { out[0] = layoutDirectionHelper.getScrollToAlignView(targetView) } if (layoutManager.canScrollVertically()) { out[1] = layoutDirectionHelper.getScrollToAlignView(targetView) } return out } // We are flinging and need to know where we are heading. override fun findTargetSnapPosition( layoutManager: RecyclerView.LayoutManager, velocityX: Int, velocityY: Int ): Int { val lm = layoutManager as LinearLayoutManager initItemDimensionIfNeeded(layoutManager) scroller!!.fling(0, 0, velocityX, velocityY, Int.MIN_VALUE, Int.MAX_VALUE, Int.MIN_VALUE, Int.MAX_VALUE) if (velocityX != 0) { return layoutDirectionHelper .getPositionsToMove(lm, scroller!!.finalX, itemDimension) } return if (velocityY != 0) { layoutDirectionHelper .getPositionsToMove(lm, scroller!!.finalY, itemDimension) } else RecyclerView.NO_POSITION } // We have scrolled to the neighborhood where we will snap. Determine the snap position. override fun findSnapView(layoutManager: RecyclerView.LayoutManager): View? { // Snap to a view that is either 1) toward the bottom of the data and therefore on screen, // or, 2) toward the top of the data and may be off-screen. val snapPos: Int = calcTargetPosition(layoutManager as LinearLayoutManager) return if (snapPos == RecyclerView.NO_POSITION) null else layoutManager.findViewByPosition(snapPos) } // Does the heavy lifting for findSnapView. private fun calcTargetPosition(layoutManager: LinearLayoutManager): Int { val snapPos: Int val firstVisiblePos = layoutManager.findFirstVisibleItemPosition() if (firstVisiblePos == RecyclerView.NO_POSITION) { return RecyclerView.NO_POSITION } initItemDimensionIfNeeded(layoutManager) if (firstVisiblePos >= priorFirstPosition) { // Scrolling toward bottom of data val firstCompletePosition = layoutManager.findFirstCompletelyVisibleItemPosition() snapPos = if (firstCompletePosition != RecyclerView.NO_POSITION && firstCompletePosition % blockSize == 0 ) { firstCompletePosition } else { roundDownToBlockSize(firstVisiblePos + blockSize) } } else { // Scrolling toward top of data snapPos = roundDownToBlockSize(firstVisiblePos) // Check to see if target view exists. If it doesn't, force a smooth scroll. // SnapHelper only snaps to existing views and will not scroll to a non-existent one. // If limiting fling to single block, then the following is not needed since the // views are likely to be in the RecyclerView pool. if (layoutManager.findViewByPosition(snapPos) == null) { val toScroll: IntArray = layoutDirectionHelper.calculateDistanceToScroll(layoutManager, snapPos) recyclerView.smoothScrollBy(toScroll[0], toScroll[1], snapInterpolator) } } priorFirstPosition = firstVisiblePos return snapPos } private fun initItemDimensionIfNeeded(layoutManager: RecyclerView.LayoutManager) { if (itemDimension != 0) { return } val child: View = layoutManager.getChildAt(0) ?: return if (layoutManager.canScrollHorizontally()) { itemDimension = child.width blockSize = getSpanCount(layoutManager) * (recyclerView.width / itemDimension) } else if (layoutManager.canScrollVertically()) { itemDimension = child.height blockSize = getSpanCount(layoutManager) * (recyclerView.height / itemDimension) } maxPositionsToMove = blockSize * maxFlingBlocks } private fun getSpanCount(layoutManager: RecyclerView.LayoutManager): Int { return if (layoutManager is GridLayoutManager) layoutManager.spanCount else 1 } private fun roundDownToBlockSize(trialPosition: Int): Int { return trialPosition and 1.inv() } private fun roundUpToBlockSize(trialPosition: Int): Int { return roundDownToBlockSize(trialPosition + blockSize - 1) } override fun createScroller(layoutManager: RecyclerView.LayoutManager): RecyclerView.SmoothScroller? { return if (layoutManager !is ScrollVectorProvider) { null } else object : LinearSmoothScroller(recyclerView.context) { override fun onTargetFound(targetView: View, state: RecyclerView.State, action: Action) { val snapDistances = calculateDistanceToFinalSnap( recyclerView.layoutManager!!, targetView, ) val dx = snapDistances[0] val dy = snapDistances[1] val time = calculateTimeForDeceleration( max(abs(dx.toDouble()), abs(dy.toDouble())) .toInt(), ) if (time > 0) { action.update(dx, dy, time, snapInterpolator) } } override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics): Float { return 40f / displayMetrics.densityDpi } } } /* Helper class that handles calculations for LTR and RTL layouts. */ private inner class LayoutDirectionHelper(direction: Int) { // Is the layout an RTL one? private val isRTL = direction == View.LAYOUT_DIRECTION_RTL /* Calculate the amount of scroll needed to align the target view with the layout edge. */ fun getScrollToAlignView(targetView: View?): Int { return if (isRTL) { orientationHelper.getDecoratedEnd(targetView) - recyclerView.width } else { orientationHelper.getDecoratedStart(targetView) } } /** * Calculate the distance to final snap position when the view corresponding to the snap * position is not currently available. * * @param layoutManager LinearLayoutManager or descendant class * @param targetPos - Adapter position to snap to * @return int[2] {x-distance in pixels, y-distance in pixels} */ fun calculateDistanceToScroll(layoutManager: LinearLayoutManager, targetPos: Int): IntArray { val out = IntArray(2) val firstVisiblePos = layoutManager.findFirstVisibleItemPosition() if (layoutManager.canScrollHorizontally()) { if (targetPos <= firstVisiblePos) { // scrolling toward top of data if (isRTL) { val lastView = layoutManager.findViewByPosition(layoutManager.findLastVisibleItemPosition()) out[0] = (orientationHelper.getDecoratedEnd(lastView) + (firstVisiblePos - targetPos) * itemDimension) } else { val firstView = layoutManager.findViewByPosition(firstVisiblePos) out[0] = (orientationHelper.getDecoratedStart(firstView) - (firstVisiblePos - targetPos) * itemDimension) } } } if (layoutManager.canScrollVertically()) { if (targetPos <= firstVisiblePos) { // scrolling toward top of data val firstView = layoutManager.findViewByPosition(firstVisiblePos) out[1] = firstView!!.top - (firstVisiblePos - targetPos) * itemDimension } } return out } /* Calculate the number of positions to move in the RecyclerView given a scroll amount and the size of the items to be scrolled. Return integral multiple of mBlockSize not equal to zero. */ fun getPositionsToMove(llm: LinearLayoutManager, scroll: Int, itemSize: Int): Int { val sensitivity = settings.readerDoublePagesSensitivity.coerceIn(0f, 1f) * 2.5 var positionsToMove = (scroll.toDouble() / (itemSize * (2.5 - sensitivity))).roundToInt() // Apply a maximum threshold val maxPages = (4 * sensitivity).roundToInt().coerceAtLeast(1) if (positionsToMove.absoluteValue > maxPages) { positionsToMove = maxPages * positionsToMove.sign } // Apply a minimum threshold if (positionsToMove == 0 && scroll.absoluteValue > itemSize * 0.2) { positionsToMove = 1 * scroll.sign } val currentPosition = if (layoutDirectionHelper.isDirectionToBottom(scroll < 0)) { llm.findFirstVisibleItemPosition() } else { llm.findLastVisibleItemPosition() } val targetPos = currentPosition + positionsToMove * 2 return roundDownToBlockSize(targetPos) } fun isDirectionToBottom(velocityNegative: Boolean): Boolean { return if (isRTL) velocityNegative else !velocityNegative } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/doublepage/DoublePagesAdapter.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.doublepage import android.view.LayoutInflater import android.view.ViewGroup import androidx.lifecycle.LifecycleOwner import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.databinding.ItemPageBinding import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter class DoublePagesAdapter( private val lifecycleOwner: LifecycleOwner, loader: PageLoader, readerSettingsProducer: ReaderSettings.Producer, networkState: NetworkState, exceptionResolver: ExceptionResolver, ) : BaseReaderAdapter(loader, readerSettingsProducer, networkState, exceptionResolver) { override fun onCreateViewHolder( parent: ViewGroup, loader: PageLoader, readerSettingsProducer: ReaderSettings.Producer, networkState: NetworkState, exceptionResolver: ExceptionResolver, ) = DoublePageHolder( owner = lifecycleOwner, binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false), loader = loader, readerSettingsProducer = readerSettingsProducer, networkState = networkState, exceptionResolver = exceptionResolver, ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/doublepage/DoubleReaderFragment.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.doublepage import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup import android.view.animation.AccelerateDecelerateInterpolator import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.yield import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.list.lifecycle.RecyclerViewLifecycleDispatcher import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition import org.koitharu.kotatsu.databinding.FragmentReaderDoubleBinding import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter import org.koitharu.kotatsu.reader.ui.pager.BaseReaderFragment import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import javax.inject.Inject import kotlin.math.absoluteValue @AndroidEntryPoint open class DoubleReaderFragment : BaseReaderFragment() { @Inject lateinit var networkState: NetworkState @Inject lateinit var pageLoader: PageLoader @Inject lateinit var settings: AppSettings private var recyclerLifecycleDispatcher: RecyclerViewLifecycleDispatcher? = null override fun onCreateViewBinding( inflater: LayoutInflater, container: ViewGroup?, ) = FragmentReaderDoubleBinding.inflate(inflater, container, false) override fun onViewBindingCreated( binding: FragmentReaderDoubleBinding, savedInstanceState: Bundle?, ) { super.onViewBindingCreated(binding, savedInstanceState) with(binding.recyclerView) { adapter = readerAdapter recyclerLifecycleDispatcher = RecyclerViewLifecycleDispatcher().also { addOnScrollListener(it) } addOnScrollListener(PageScrollListener()) DoublePageSnapHelper(settings).attachToRecyclerView(this) } } override fun onDestroyView() { recyclerLifecycleDispatcher = null requireViewBinding().recyclerView.adapter = null super.onDestroyView() } override suspend fun onPagesChanged(pages: List, pendingState: ReaderState?) = coroutineScope { val items = launch { requireAdapter().setItems(pages) yield() viewBinding?.recyclerView?.let { rv -> recyclerLifecycleDispatcher?.invalidate(rv) } } if (pendingState != null) { var position = pages.indexOfFirst { it.chapterId == pendingState.chapterId && it.index == pendingState.page } items.join() if (position != -1) { position = position.toPagePosition() requireViewBinding().recyclerView.firstVisibleItemPosition = position notifyPageChanged(position, position + 1) } else { Snackbar.make(requireView(), R.string.not_found_404, Snackbar.LENGTH_SHORT) .show() } } else { items.join() } } override fun onCreateAdapter() = DoublePagesAdapter( lifecycleOwner = viewLifecycleOwner, loader = pageLoader, readerSettingsProducer = viewModel.readerSettingsProducer, networkState = networkState, exceptionResolver = exceptionResolver, ) override fun onZoomIn() { (viewBinding ?: return).recyclerView.visiblePageHolders() .forEach { it.onZoomIn() } } override fun onZoomOut() { (viewBinding ?: return).recyclerView.visiblePageHolders() .forEach { it.onZoomOut() } } override fun switchPageBy(delta: Int) { if (delta.absoluteValue > 1 || !isAnimationEnabled()) { switchPageTo(getCurrentItem() + delta + delta, false) return } val rv = viewBinding?.recyclerView ?: return val distance = rv.width * delta rv.smoothScrollBy(distance, 0, AccelerateDecelerateInterpolator()) } override fun switchPageTo(position: Int, smooth: Boolean) { val lm = viewBinding?.recyclerView?.layoutManager as? LinearLayoutManager ?: return val targetPosition = position.toPagePosition() lm.scrollToPositionWithOffset(targetPosition, 0) } override fun getCurrentState(): ReaderState? = viewBinding?.run { val adapter = recyclerView.adapter as? BaseReaderAdapter<*> val page = adapter?.getItemOrNull(getCurrentItem()) ?: return@run null ReaderState( chapterId = page.chapterId, page = page.index, scroll = 0, ) } protected open fun notifyPageChanged(lowerPos: Int, upperPos: Int) { viewModel.onCurrentPageChanged(lowerPos, upperPos) } private fun getCurrentItem() = (requireViewBinding().recyclerView.layoutManager as LinearLayoutManager) .findFirstCompletelyVisibleItemPosition().toPagePosition() private fun Int.toPagePosition() = this and 1.inv() private inner class PageScrollListener : RecyclerView.OnScrollListener() { private var firstPos = RecyclerView.NO_POSITION private var lastPos = RecyclerView.NO_POSITION override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { super.onScrolled(recyclerView, dx, dy) val lm = recyclerView.layoutManager as? LinearLayoutManager if (lm == null) { firstPos = RecyclerView.NO_POSITION lastPos = RecyclerView.NO_POSITION return } val newFirstPos = lm.findFirstVisibleItemPosition() val newLastPos = lm.findLastVisibleItemPosition() if (newFirstPos != firstPos || newLastPos != lastPos) { firstPos = newFirstPos lastPos = newLastPos notifyPageChanged(newFirstPos, newLastPos) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/doublepage/Utils.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.doublepage import androidx.core.view.children import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import org.koitharu.kotatsu.reader.ui.pager.standard.PageHolder fun RecyclerView.visiblePageHolders(): Sequence { val lm = layoutManager as? LinearLayoutManager ?: return emptySequence() return (lm.findFirstVisibleItemPosition()..lm.findLastVisibleItemPosition()).asSequence() .mapNotNull { findViewHolderForAdapterPosition(it) as? PageHolder } } fun RecyclerView.allPageHolders(): Sequence { return children.mapNotNull { findContainingViewHolder(it) as? PageHolder } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/doublereversed/ReversedDoubleReaderFragment.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.doublereversed import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.doublepage.DoubleReaderFragment class ReversedDoubleReaderFragment : DoubleReaderFragment() { override fun switchPageBy(delta: Int) { super.switchPageBy(-delta) } override fun switchPageTo(position: Int, smooth: Boolean) { super.switchPageTo(reversed(position), smooth) } override suspend fun onPagesChanged(pages: List, pendingState: ReaderState?) { super.onPagesChanged(pages.reversed(), pendingState) } override fun notifyPageChanged(lowerPos: Int, upperPos: Int) { viewModel.onCurrentPageChanged(reversed(upperPos), reversed(lowerPos)) } private fun reversed(position: Int): Int { return ((readerAdapter?.itemCount ?: 0) - position - 1).coerceAtLeast(0) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageAnimTransformer.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.reversed import android.view.View import androidx.viewpager2.widget.ViewPager2 class ReversedPageAnimTransformer : ViewPager2.PageTransformer { override fun transformPage(page: View, position: Float) = with(page) { translationX = -position * width pivotX = width.toFloat() pivotY = height / 2f cameraDistance = 20000f when { position < -1f || position > 1f -> { alpha = 0f rotationY = 0f translationZ = -1f } position <= 0f -> { alpha = 1f rotationY = 0f translationZ = 0f } position > 0f -> { alpha = 1f rotationY = 120 * position translationZ = 2f } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageHolder.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.reversed import android.graphics.PointF import android.view.Gravity import android.widget.FrameLayout import androidx.lifecycle.LifecycleOwner import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.databinding.ItemPageBinding import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.pager.standard.PageHolder class ReversedPageHolder( owner: LifecycleOwner, binding: ItemPageBinding, loader: PageLoader, readerSettingsProducer: ReaderSettings.Producer, networkState: NetworkState, exceptionResolver: ExceptionResolver, ) : PageHolder( owner = owner, binding = binding, loader = loader, readerSettingsProducer = readerSettingsProducer, networkState = networkState, exceptionResolver = exceptionResolver, ) { init { (binding.textViewNumber.layoutParams as FrameLayout.LayoutParams) .gravity = Gravity.START or Gravity.BOTTOM } override fun onReady() { with(binding.ssiv) { maxScale = 2f * maxOf( width / sWidth.toFloat(), height / sHeight.toFloat(), ) binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter() when (settings.zoomMode) { ZoomMode.FIT_CENTER -> { minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE resetScaleAndCenter() } ZoomMode.FIT_HEIGHT -> { minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM minScale = height / sHeight.toFloat() setScaleAndCenter( minScale, PointF(sWidth.toFloat(), sHeight / 2f), ) } ZoomMode.FIT_WIDTH -> { minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM minScale = width / sWidth.toFloat() setScaleAndCenter( minScale, PointF(sWidth / 2f, 0f), ) } ZoomMode.KEEP_START -> { minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE setScaleAndCenter( maxScale, PointF(sWidth.toFloat(), 0f), ) } } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPagesAdapter.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.reversed import android.view.LayoutInflater import android.view.ViewGroup import androidx.lifecycle.LifecycleOwner import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.databinding.ItemPageBinding import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter class ReversedPagesAdapter( private val lifecycleOwner: LifecycleOwner, loader: PageLoader, readerSettingsProducer: ReaderSettings.Producer, networkState: NetworkState, exceptionResolver: ExceptionResolver, ) : BaseReaderAdapter(loader, readerSettingsProducer, networkState, exceptionResolver) { override fun onCreateViewHolder( parent: ViewGroup, loader: PageLoader, readerSettingsProducer: ReaderSettings.Producer, networkState: NetworkState, exceptionResolver: ExceptionResolver, ) = ReversedPageHolder( owner = lifecycleOwner, binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false), loader = loader, readerSettingsProducer = readerSettingsProducer, networkState = networkState, exceptionResolver = exceptionResolver, ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedReaderFragment.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.reversed import androidx.viewpager2.widget.ViewPager2 import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.pager.BasePagerReaderFragment import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import javax.inject.Inject @AndroidEntryPoint class ReversedReaderFragment : BasePagerReaderFragment() { @Inject lateinit var settings: AppSettings override fun onCreateAdvancedTransformer(): ViewPager2.PageTransformer = ReversedPageAnimTransformer() override fun onCreateAdapter() = ReversedPagesAdapter( lifecycleOwner = viewLifecycleOwner, loader = pageLoader, readerSettingsProducer = viewModel.readerSettingsProducer, networkState = networkState, exceptionResolver = exceptionResolver, ) override fun onWheelScroll(axisValue: Float) { val value = if (settings.isReaderControlAlwaysLTR) -axisValue else axisValue super.onWheelScroll(value) } override fun switchPageBy(delta: Int) { super.switchPageBy(-delta) } override fun switchPageTo(position: Int, smooth: Boolean) { super.switchPageTo(reversed(position), smooth) } override suspend fun onPagesChanged(pages: List, pendingState: ReaderState?) { super.onPagesChanged(pages.reversed(), pendingState) } override fun notifyPageChanged(page: Int) { val pos = reversed(page) viewModel.onCurrentPageChanged(pos, pos) } private fun reversed(position: Int): Int { return ((readerAdapter?.itemCount ?: 0) - position - 1).coerceAtLeast(0) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/NoAnimPageTransformer.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.standard import android.view.View import androidx.viewpager2.widget.ViewPager2 class NoAnimPageTransformer( private val orientation: Int ) : ViewPager2.PageTransformer { override fun transformPage(page: View, position: Float) { page.translationX = when { orientation != ViewPager2.ORIENTATION_HORIZONTAL -> 0f position in -0.5f..0.5f -> -position * page.width.toFloat() position > 0 -> page.width.toFloat() else -> -page.width.toFloat() } page.translationY = when { orientation != ViewPager2.ORIENTATION_VERTICAL -> 0f position in -0.5f..0.5f -> -position * page.height.toFloat() position > 0 -> page.height.toFloat() else -> -page.height.toFloat() } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageAnimTransformer.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.standard import android.view.View import androidx.viewpager2.widget.ViewPager2 class PageAnimTransformer : ViewPager2.PageTransformer { override fun transformPage(page: View, position: Float) = with(page) { translationX = -position * width pivotX = 0f pivotY = height / 2f cameraDistance = 20000f when { position < -1f || position > 1f -> { alpha = 0f rotationY = 0f translationZ = -1f } position > 0f -> { alpha = 1f rotationY = 0f translationZ = 0f } position <= 0f -> { alpha = 1f rotationY = 120 * position translationZ = 2f } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.standard import android.annotation.SuppressLint import android.graphics.PointF import android.os.Build import android.view.Gravity import android.view.RoundedCorner import android.view.View import android.view.WindowInsets import android.view.animation.DecelerateInterpolator import android.widget.FrameLayout import androidx.annotation.RequiresApi import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.core.view.setMargins import androidx.core.view.updateLayoutParams import androidx.lifecycle.LifecycleOwner import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.ui.widgets.ZoomControl import org.koitharu.kotatsu.databinding.ItemPageBinding import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder import org.koitharu.kotatsu.reader.ui.pager.ReaderPage open class PageHolder( owner: LifecycleOwner, binding: ItemPageBinding, loader: PageLoader, readerSettingsProducer: ReaderSettings.Producer, networkState: NetworkState, exceptionResolver: ExceptionResolver, ) : BasePageHolder( binding = binding, loader = loader, readerSettingsProducer = readerSettingsProducer, networkState = networkState, exceptionResolver = exceptionResolver, lifecycleOwner = owner, ), ZoomControl.ZoomControlListener, OnApplyWindowInsetsListener { override val ssiv = binding.ssiv init { ViewCompat.setOnApplyWindowInsetsListener(binding.root, this) } override fun onApplyWindowInsets( v: View, insets: WindowInsetsCompat ): WindowInsetsCompat { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { insets.toWindowInsets()?.let { applyRoundedCorners(it) } } return insets } override fun onConfigChanged(settings: ReaderSettings) { super.onConfigChanged(settings) binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled } @SuppressLint("SetTextI18n") override fun onBind(data: ReaderPage) { super.onBind(data) binding.textViewNumber.text = (data.index + 1).toString() } override fun onReady() { binding.ssiv.maxScale = 2f * maxOf( binding.ssiv.width / binding.ssiv.sWidth.toFloat(), binding.ssiv.height / binding.ssiv.sHeight.toFloat(), ) binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter() when (settings.zoomMode) { ZoomMode.FIT_CENTER -> { binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE binding.ssiv.resetScaleAndCenter() } ZoomMode.FIT_HEIGHT -> { binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM binding.ssiv.minScale = binding.ssiv.height / binding.ssiv.sHeight.toFloat() binding.ssiv.setScaleAndCenter( binding.ssiv.minScale, PointF(0f, binding.ssiv.sHeight / 2f), ) } ZoomMode.FIT_WIDTH -> { binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM binding.ssiv.minScale = binding.ssiv.width / binding.ssiv.sWidth.toFloat() binding.ssiv.setScaleAndCenter( binding.ssiv.minScale, PointF(binding.ssiv.sWidth / 2f, 0f), ) } ZoomMode.KEEP_START -> { binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE binding.ssiv.setScaleAndCenter( binding.ssiv.maxScale, PointF(0f, 0f), ) } } } override fun onZoomIn() { scaleBy(1.2f) } override fun onZoomOut() { scaleBy(0.8f) } @SuppressLint("RtlHardcoded") @RequiresApi(Build.VERSION_CODES.S) protected open fun applyRoundedCorners(insets: WindowInsets) { binding.textViewNumber.updateLayoutParams { val baseMargin = context.resources.getDimensionPixelOffset(R.dimen.margin_small) val absoluteGravity = Gravity.getAbsoluteGravity(gravity, layoutDirection) val corner = when { absoluteGravity and Gravity.LEFT == Gravity.LEFT -> { insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT) } absoluteGravity and Gravity.RIGHT == Gravity.RIGHT -> { insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT) } else -> { null } } setMargins(baseMargin + (corner?.radius ?: 0)) } } private fun scaleBy(factor: Float) { val ssiv = binding.ssiv val center = ssiv.getCenter() ?: return val newScale = ssiv.scale * factor ssiv.animateScaleAndCenter(newScale, center)?.apply { withDuration(ssiv.resources.getInteger(android.R.integer.config_shortAnimTime).toLong()) withInterpolator(DecelerateInterpolator()) start() } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PagerEventSupplier.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.standard import android.view.KeyEvent import android.view.View import android.view.ViewGroup import androidx.core.view.children import androidx.viewpager2.widget.ViewPager2 import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import org.koitharu.kotatsu.core.util.ext.recyclerView class PagerEventSupplier(private val pager: ViewPager2) : View.OnKeyListener { fun attach() { pager.recyclerView?.setOnKeyListener(this) } override fun onKey(v: View?, keyCode: Int, event: KeyEvent?): Boolean { val rootView = pager.recyclerView?.findViewHolderForAdapterPosition(pager.currentItem)?.itemView as? ViewGroup ?: return false return rootView.children.firstNotNullOfOrNull { x -> x as? SubsamplingScaleImageView }?.dispatchKeyEvent(event) == true } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.standard import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.reader.ui.pager.BasePagerReaderFragment @AndroidEntryPoint class PagerReaderFragment : BasePagerReaderFragment() ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PagesAdapter.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.standard import android.view.LayoutInflater import android.view.ViewGroup import androidx.lifecycle.LifecycleOwner import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.databinding.ItemPageBinding import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter class PagesAdapter( private val lifecycleOwner: LifecycleOwner, loader: PageLoader, readerSettingsProducer: ReaderSettings.Producer, networkState: NetworkState, exceptionResolver: ExceptionResolver, ) : BaseReaderAdapter( loader = loader, readerSettingsProducer = readerSettingsProducer, networkState = networkState, exceptionResolver = exceptionResolver, ) { override fun onCreateViewHolder( parent: ViewGroup, loader: PageLoader, readerSettingsProducer: ReaderSettings.Producer, networkState: NetworkState, exceptionResolver: ExceptionResolver, ) = PageHolder( owner = lifecycleOwner, binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false), loader = loader, readerSettingsProducer = readerSettingsProducer, networkState = networkState, exceptionResolver = exceptionResolver, ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/vertical/VerticalPageAnimTransformer.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.vertical import android.view.View import androidx.viewpager2.widget.ViewPager2 class VerticalPageAnimTransformer : ViewPager2.PageTransformer { override fun transformPage(page: View, position: Float) = with(page) { translationY = -position * height pivotX = width / 2f pivotY = height * 0.2f cameraDistance = 20000f when { position < -1f || position > 1f -> { alpha = 0f rotationX = 0f translationZ = -1f } position > 0f -> { alpha = 1f rotationX = 0f translationZ = 0f } position <= 0f -> { alpha = 1f rotationX = -120 * position translationZ = 2f } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/vertical/VerticalReaderFragment.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.vertical import androidx.viewpager2.widget.ViewPager2 import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.reader.ui.pager.BasePagerReaderFragment @AndroidEntryPoint class VerticalReaderFragment : BasePagerReaderFragment() { override fun onInitPager(pager: ViewPager2) { super.onInitPager(pager) pager.orientation = ViewPager2.ORIENTATION_VERTICAL } override fun onCreateAdvancedTransformer(): ViewPager2.PageTransformer = VerticalPageAnimTransformer() } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/vm/PageState.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.vm import com.davemorrissey.labs.subscaleview.ImageSource sealed class PageState { data object Empty : PageState() data class Loading( val preview: ImageSource?, val progress: Int, ) : PageState() data class Loaded( val source: ImageSource, val isConverted: Boolean, ) : PageState() class Converting() : PageState() data class Shown( val source: ImageSource, val isConverted: Boolean, ) : PageState() data class Error( val error: Throwable, ) : PageState() fun isFinalState(): Boolean = this is Error || this is Shown } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/vm/PageViewModel.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.vm import android.graphics.Rect import android.net.Uri import androidx.annotation.WorkerThread import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener import com.davemorrissey.labs.subscaleview.ImageSource import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.plus import kotlinx.coroutines.withContext import okio.IOException import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.throttle import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings class PageViewModel( private val loader: PageLoader, val settingsProducer: ReaderSettings.Producer, private val networkState: NetworkState, private val exceptionResolver: ExceptionResolver, private val isWebtoon: Boolean, ) : DefaultOnImageEventListener { private val scope = loader.loaderScope + Dispatchers.Main.immediate private var job: Job? = null private var cachedBounds: Rect? = null val state = MutableStateFlow(PageState.Empty) fun isLoading() = job?.isActive == true fun onBind(page: MangaPage) { val prevJob = job job = scope.launch(Dispatchers.Default) { prevJob?.cancelAndJoin() doLoad(page, force = false) } } fun retry(page: MangaPage, isFromUser: Boolean) { val prevJob = job job = scope.launch { prevJob?.cancelAndJoin() val e = (state.value as? PageState.Error)?.error if (e != null && ExceptionResolver.canResolve(e)) { if (isFromUser) { exceptionResolver.resolve(e) } } withContext(Dispatchers.Default) { doLoad(page, force = true) } } } fun showErrorDetails(url: String?) { val e = (state.value as? PageState.Error)?.error ?: return exceptionResolver.showErrorDetails(e, url) } fun onRecycle() { state.value = PageState.Empty cachedBounds = null job?.cancel() } override fun onImageLoaded() { state.update { currentState -> if (currentState is PageState.Loaded) { PageState.Shown(currentState.source, currentState.isConverted) } else { currentState } } } override fun onImageLoadError(e: Throwable) { e.printStackTraceDebug() state.update { currentState -> if (currentState is PageState.Loaded) { val uri = (currentState.source as? ImageSource.Uri)?.uri if (!currentState.isConverted && uri != null && e is IOException) { tryConvert(uri, e) PageState.Converting() } else { PageState.Error(e) } } else { currentState } } } private fun tryConvert(uri: Uri, e: Exception) { val prevJob = job job = scope.launch(Dispatchers.Default) { prevJob?.join() state.value = PageState.Converting() try { val newUri = loader.convertBimap(uri) cachedBounds = if (settingsProducer.value.isPagesCropEnabled(isWebtoon)) { loader.getTrimmedBounds(newUri) } else { null } state.value = PageState.Loaded(newUri.toImageSource(cachedBounds), isConverted = true) } catch (ce: CancellationException) { throw ce } catch (e2: Throwable) { e2.printStackTrace() e.addSuppressed(e2) state.value = PageState.Error(e) } } } @WorkerThread private suspend fun doLoad(data: MangaPage, force: Boolean) = coroutineScope { state.value = PageState.Loading(null, -1) val previewJob = launch { val preview = loader.loadPreview(data) ?: return@launch state.update { if (it is PageState.Loading) it.copy(preview = preview) else it } } try { val task = loader.loadPageAsync(data, force) val progressObserver = observeProgress(this, task.progressAsFlow()) val uri = task.await() progressObserver.cancelAndJoin() previewJob.cancel() cachedBounds = if (settingsProducer.value.isPagesCropEnabled(isWebtoon)) { loader.getTrimmedBounds(uri) } else { null } state.value = PageState.Loaded(uri.toImageSource(cachedBounds), isConverted = false) } catch (e: CancellationException) { throw e } catch (e: Throwable) { e.printStackTraceDebug() state.value = PageState.Error(e) if (e is IOException && !networkState.value) { networkState.awaitForConnection() retry(data, isFromUser = false) } } } private fun observeProgress(scope: CoroutineScope, progress: Flow) = progress .throttle(250) .onEach { val progressValue = (100 * it).toInt() state.update { currentState -> if (currentState is PageState.Loading) { currentState.copy(progress = progressValue) } else { currentState } } }.launchIn(scope) private fun Uri.toImageSource(bounds: Rect?): ImageSource { val source = ImageSource.uri(this) return if (bounds != null) { source.region(bounds) } else { source } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonAdapter.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.webtoon import android.view.LayoutInflater import android.view.ViewGroup import androidx.lifecycle.LifecycleOwner import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter class WebtoonAdapter( private val lifecycleOwner: LifecycleOwner, loader: PageLoader, readerSettingsProducer: ReaderSettings.Producer, networkState: NetworkState, exceptionResolver: ExceptionResolver, ) : BaseReaderAdapter(loader, readerSettingsProducer, networkState, exceptionResolver) { override fun onCreateViewHolder( parent: ViewGroup, loader: PageLoader, readerSettingsProducer: ReaderSettings.Producer, networkState: NetworkState, exceptionResolver: ExceptionResolver, ) = WebtoonHolder( owner = lifecycleOwner, binding = ItemPageWebtoonBinding.inflate( LayoutInflater.from(parent.context), parent, false, ), loader = loader, readerSettingsProducer = readerSettingsProducer, networkState = networkState, exceptionResolver = exceptionResolver, ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonFrameLayout.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.webtoon import android.content.Context import android.util.AttributeSet import android.widget.FrameLayout import androidx.annotation.AttrRes import org.koitharu.kotatsu.R class WebtoonFrameLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0, ) : FrameLayout(context, attrs, defStyleAttr) { private var _target: WebtoonImageView? = null val target: WebtoonImageView get() = _target ?: findViewById(R.id.ssiv).also { _target = it } fun dispatchVerticalScroll(dy: Int): Int { if (dy == 0) { return 0 } val oldScroll = target.getScroll() target.scrollBy(dy) return target.getScroll() - oldScroll } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonGapsDecoration.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.webtoon import android.content.Context import android.graphics.Rect import android.view.View import androidx.recyclerview.widget.RecyclerView import org.koitharu.kotatsu.R class WebtoonGapsDecoration : RecyclerView.ItemDecoration() { private var gapSize = -1 override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { super.getItemOffsets(outRect, view, parent, state) val position = parent.getChildAdapterPosition(view) if (position > 0) { outRect.top = getGap(parent.context) } } private fun getGap(context: Context): Int { return if (gapSize == -1) { context.resources.getDimensionPixelOffset(R.dimen.webtoon_pages_gap).also { gapSize = it } } else { gapSize } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.webtoon import android.view.View import androidx.lifecycle.LifecycleOwner import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder class WebtoonHolder( owner: LifecycleOwner, binding: ItemPageWebtoonBinding, loader: PageLoader, readerSettingsProducer: ReaderSettings.Producer, networkState: NetworkState, exceptionResolver: ExceptionResolver, ) : BasePageHolder( binding = binding, loader = loader, readerSettingsProducer = readerSettingsProducer, networkState = networkState, exceptionResolver = exceptionResolver, lifecycleOwner = owner, ) { override val ssiv = binding.ssiv private var scrollToRestore = 0 init { bindingInfo.progressBar.setVisibilityAfterHide(View.GONE) } override fun onReady() { binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter() with(binding.ssiv) { scrollTo( when { scrollToRestore != 0 -> scrollToRestore itemView.top < 0 -> getScrollRange() else -> 0 }, ) scrollToRestore = 0 } } fun getScrollY() = binding.ssiv.getScroll() fun restoreScroll(scroll: Int) { if (binding.ssiv.isReady) { binding.ssiv.scrollTo(scroll) } else { scrollToRestore = scroll } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonImageView.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.webtoon import android.content.Context import android.graphics.Canvas import android.graphics.Paint import android.graphics.PointF import android.util.AttributeSet import androidx.core.view.ancestors import androidx.recyclerview.widget.RecyclerView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import org.koitharu.kotatsu.core.util.ext.resolveDp import kotlin.math.roundToInt class WebtoonImageView @JvmOverloads constructor( context: Context, attr: AttributeSet? = null, ) : SubsamplingScaleImageView(context, attr) { private val ct = PointF() private var scrollPos = 0 private var debugPaint: Paint? = null override fun onDraw(canvas: Canvas) { super.onDraw(canvas) if (isDebugDrawingEnabled) { drawDebug(canvas) } } fun scrollBy(delta: Int) { val maxScroll = getScrollRange() if (maxScroll == 0) { return } val newScroll = scrollPos + delta scrollToInternal(newScroll.coerceIn(0, maxScroll)) } fun scrollTo(y: Int) { val maxScroll = getScrollRange() if (maxScroll == 0) { scrollToInternal(0) return } scrollToInternal(y.coerceIn(0, maxScroll)) } fun getScroll() = scrollPos fun getScrollRange(): Int { if (!isReady) { return 0 } val totalHeight = (sHeight * width / sWidth.toFloat()).roundToInt() return (totalHeight - height).coerceAtLeast(0) } override fun recycle() { scrollPos = 0 super.recycle() } override fun getSuggestedMinimumHeight(): Int { var desiredHeight = super.getSuggestedMinimumHeight() if (sHeight == 0) { val parentHeight = parentHeight() if (desiredHeight < parentHeight) { desiredHeight = parentHeight } } return desiredHeight } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val widthSpecMode = MeasureSpec.getMode(widthMeasureSpec) val heightSpecMode = MeasureSpec.getMode(heightMeasureSpec) val parentWidth = MeasureSpec.getSize(widthMeasureSpec) val parentHeight = MeasureSpec.getSize(heightMeasureSpec) val resizeWidth = widthSpecMode != MeasureSpec.EXACTLY val resizeHeight = heightSpecMode != MeasureSpec.EXACTLY var desiredWidth = parentWidth var desiredHeight = parentHeight if (sWidth > 0 && sHeight > 0) { if (resizeWidth && resizeHeight) { desiredWidth = sWidth desiredHeight = sHeight } else if (resizeHeight) { desiredHeight = (sHeight.toDouble() / sWidth.toDouble() * desiredWidth).toInt() } else if (resizeWidth) { desiredWidth = (sWidth.toDouble() / sHeight.toDouble() * desiredHeight).toInt() } } desiredWidth = desiredWidth.coerceAtLeast(suggestedMinimumWidth) desiredHeight = desiredHeight.coerceAtLeast(suggestedMinimumHeight).coerceAtMost(parentHeight()) setMeasuredDimension(desiredWidth, desiredHeight) } override fun onDownSamplingChanged() { super.onDownSamplingChanged() if (isReady) { adjustScale() onImageEventListener.onReady() } } override fun onReady() { super.onReady() adjustScale() } private fun scrollToInternal(pos: Int) { minScale = width / sWidth.toFloat() maxScale = minScale scrollPos = pos ct.set(sWidth / 2f, (height / 2f + pos.toFloat()) / minScale) setScaleAndCenter(minScale, ct) } private fun adjustScale() { minScale = width / sWidth.toFloat() maxScale = minScale minimumScaleType = SCALE_TYPE_CUSTOM requestLayout() } private fun parentHeight(): Int { return ancestors.firstNotNullOfOrNull { it as? RecyclerView }?.height ?: 0 } private fun drawDebug(canvas: Canvas) { val paint = debugPaint ?: Paint(Paint.ANTI_ALIAS_FLAG).apply { color = android.graphics.Color.RED strokeWidth = context.resources.resolveDp(2f) textAlign = Paint.Align.LEFT textSize = context.resources.resolveDp(14f) debugPaint = this } paint.style = Paint.Style.STROKE canvas.drawRect(1f, 1f, width.toFloat() - 1f, height.toFloat() - 1f, paint) paint.style = Paint.Style.FILL canvas.drawText("${getScroll()} / ${getScrollRange()}", 100f, 100f, paint) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonLayoutManager.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.webtoon import android.content.Context import android.util.AttributeSet import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import kotlin.math.sign @Suppress("unused") class WebtoonLayoutManager : LinearLayoutManager { private var scrollDirection: Int = 0 constructor(context: Context) : super(context) constructor( context: Context, orientation: Int, reverseLayout: Boolean, ) : super(context, orientation, reverseLayout) constructor( context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int, ) : super(context, attrs, defStyleAttr, defStyleRes) override fun scrollVerticallyBy(dy: Int, recycler: RecyclerView.Recycler?, state: RecyclerView.State): Int { scrollDirection = dy.sign return super.scrollVerticallyBy(dy, recycler, state) } override fun calculateExtraLayoutSpace(state: RecyclerView.State, extraLayoutSpace: IntArray) { if (state.hasTargetScrollPosition()) { super.calculateExtraLayoutSpace(state, extraLayoutSpace) return } val pageSize = height extraLayoutSpace[0] = if (scrollDirection < 0) pageSize else 0 extraLayoutSpace[1] = if (scrollDirection < 0) 0 else pageSize } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.webtoon import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.ViewGroup.MarginLayoutParams import android.view.animation.DecelerateInterpolator import android.widget.TextView import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateLayoutParams import androidx.recyclerview.widget.RecyclerView import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch import kotlinx.coroutines.yield import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.ui.list.lifecycle.RecyclerViewLifecycleDispatcher import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.removeItemDecoration import org.koitharu.kotatsu.databinding.FragmentReaderWebtoonBinding import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter import org.koitharu.kotatsu.reader.ui.pager.BaseReaderFragment import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import javax.inject.Inject @AndroidEntryPoint class WebtoonReaderFragment : BaseReaderFragment(), WebtoonRecyclerView.OnWebtoonScrollListener, WebtoonRecyclerView.OnPullGestureListener { @Inject lateinit var networkState: NetworkState @Inject lateinit var pageLoader: PageLoader private val scrollInterpolator = DecelerateInterpolator() private var recyclerLifecycleDispatcher: RecyclerViewLifecycleDispatcher? = null private var canGoPrev = true private var canGoNext = true override fun onCreateViewBinding( inflater: LayoutInflater, container: ViewGroup?, ) = FragmentReaderWebtoonBinding.inflate(inflater, container, false) override fun onViewBindingCreated(binding: FragmentReaderWebtoonBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) with(binding.recyclerView) { setHasFixedSize(true) adapter = readerAdapter addOnPageScrollListener(this@WebtoonReaderFragment) recyclerLifecycleDispatcher = RecyclerViewLifecycleDispatcher().also { addOnScrollListener(it) } setOnPullGestureListener(this@WebtoonReaderFragment) } viewModel.isWebtoonZooEnabled.observe(viewLifecycleOwner) { binding.frame.isZoomEnable = it } viewModel.defaultWebtoonZoomOut.take(1).observe(viewLifecycleOwner) { binding.frame.zoom = 1f - it } viewModel.isWebtoonGapsEnabled.observe(viewLifecycleOwner) { val rv = binding.recyclerView rv.removeItemDecoration(WebtoonGapsDecoration::class.java) if (it) { rv.addItemDecoration(WebtoonGapsDecoration()) } } viewModel.readerSettingsProducer.observe(viewLifecycleOwner) { it.applyBackground(binding.root) } viewModel.isWebtoonPullGestureEnabled.observe(viewLifecycleOwner) { enabled -> binding.recyclerView.isPullGestureEnabled = enabled } viewModel.uiState.observe(viewLifecycleOwner) { state -> if (state != null) { canGoPrev = state.chapterIndex > 0 canGoNext = state.chapterIndex < state.chaptersTotal - 1 } else { canGoPrev = true canGoNext = true } } } override fun onDestroyView() { recyclerLifecycleDispatcher = null requireViewBinding().recyclerView.adapter = null super.onDestroyView() } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val offsetInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) viewBinding?.apply { feedbackTop.updateLayoutParams { topMargin = bottomMargin + offsetInsets.top } feedbackBottom.updateLayoutParams { bottomMargin = topMargin + offsetInsets.bottom } } return super.onApplyWindowInsets(v, insets) } override fun onCreateAdapter() = WebtoonAdapter( lifecycleOwner = viewLifecycleOwner, loader = pageLoader, readerSettingsProducer = viewModel.readerSettingsProducer, networkState = networkState, exceptionResolver = exceptionResolver, ) override fun onScrollChanged( recyclerView: WebtoonRecyclerView, dy: Int, firstVisiblePosition: Int, lastVisiblePosition: Int, ) { viewModel.onCurrentPageChanged(firstVisiblePosition, lastVisiblePosition) } override suspend fun onPagesChanged(pages: List, pendingState: ReaderState?) = coroutineScope { val setItems = launch { requireAdapter().setItems(pages) yield() viewBinding?.recyclerView?.let { rv -> recyclerLifecycleDispatcher?.invalidate(rv) } } if (pendingState != null) { val position = pages.indexOfFirst { it.chapterId == pendingState.chapterId && it.index == pendingState.page } setItems.join() if (position != -1) { with(requireViewBinding().recyclerView) { firstVisibleItemPosition = position post { (findViewHolderForAdapterPosition(position) as? WebtoonHolder) ?.restoreScroll(pendingState.scroll) } } viewModel.onCurrentPageChanged(position, position) } else { Snackbar.make(requireView(), R.string.not_found_404, Snackbar.LENGTH_SHORT) .show() } } else { setItems.join() } } override fun getCurrentState(): ReaderState? = viewBinding?.run { val currentItem = recyclerView.findCurrentPagePosition() val adapter = recyclerView.adapter as? BaseReaderAdapter<*> val page = adapter?.getItemOrNull(currentItem) ?: return@run null ReaderState( chapterId = page.chapterId, page = page.index, scroll = (recyclerView.findViewHolderForAdapterPosition(currentItem) as? WebtoonHolder)?.getScrollY() ?: 0, ) } override fun onZoomIn() { viewBinding?.frame?.onZoomIn() } override fun onZoomOut() { viewBinding?.frame?.onZoomOut() } override fun switchPageBy(delta: Int) { with(requireViewBinding().recyclerView) { if (isAnimationEnabled()) { smoothScrollBy(0, (height * 0.9).toInt() * delta, scrollInterpolator) } else { nestedScrollBy(0, (height * 0.9).toInt() * delta) } } } override fun switchPageTo(position: Int, smooth: Boolean) { requireViewBinding().recyclerView.firstVisibleItemPosition = position } override fun scrollBy(delta: Int, smooth: Boolean): Boolean { if (smooth && isAnimationEnabled()) { requireViewBinding().recyclerView.smoothScrollBy(0, delta, scrollInterpolator) } else { requireViewBinding().recyclerView.nestedScrollBy(0, delta) } return true } override fun onPullProgressTop(progress: Float) { val binding = viewBinding ?: return if (canGoPrev) { binding.feedbackTop.setFeedbackText(getString(R.string.pull_to_prev_chapter)) } else { binding.feedbackTop.setFeedbackText(getString(R.string.pull_top_no_prev)) } binding.feedbackTop.updateFeedback(progress) } override fun onPullProgressBottom(progress: Float) { val binding = viewBinding ?: return if (canGoNext) { binding.feedbackBottom.setFeedbackText(getString(R.string.pull_to_next_chapter)) } else { binding.feedbackBottom.setFeedbackText(getString(R.string.pull_bottom_no_next)) } binding.feedbackBottom.updateFeedback(progress) } override fun onPullTriggeredTop() { (viewBinding ?: return).feedbackTop.fadeOut() if (canGoPrev) { viewModel.switchChapterBy(-1) } } override fun onPullTriggeredBottom() { (viewBinding ?: return).feedbackBottom.fadeOut() if (canGoNext) { viewModel.switchChapterBy(1) } } override fun onPullCancelled() { viewBinding?.apply { feedbackTop.fadeOut() feedbackBottom.fadeOut() } } private fun RecyclerView.findCurrentPagePosition(): Int { val centerX = width / 2f val centerY = height - resources.getDimension(R.dimen.webtoon_pages_gap) if (centerY <= 0) { return RecyclerView.NO_POSITION } val view = findChildViewUnder(centerX, centerY) ?: return RecyclerView.NO_POSITION return getChildAdapterPosition(view) } private fun TextView.updateFeedback(progress: Float) { val clamped = progress.coerceIn(0f, 1.2f) this.alpha = clamped.coerceAtMost(1f) this.scaleX = 0.9f + 0.1f * clamped.coerceAtMost(1f) this.scaleY = this.scaleX } private fun TextView.fadeOut() { animate().alpha(0f).setDuration(150L).start() } private fun TextView.setFeedbackText(text: CharSequence) { if (this.alpha <= 0f && text.isNotEmpty()) { this.alpha = 0f this.text = text animate().alpha(1f).setDuration(120L).start() } else { this.text = text } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonRecyclerView.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.webtoon import android.content.Context import android.graphics.Canvas import android.util.AttributeSet import android.view.View import android.widget.EdgeEffect import androidx.core.view.ViewCompat.TYPE_TOUCH import androidx.core.view.forEach import androidx.core.view.isEmpty import androidx.core.view.isNotEmpty import androidx.core.view.iterator import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.EdgeEffectFactory.DIRECTION_BOTTOM import androidx.recyclerview.widget.RecyclerView.EdgeEffectFactory.DIRECTION_TOP import java.util.Collections import java.util.LinkedList import java.util.WeakHashMap class WebtoonRecyclerView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : RecyclerView(context, attrs, defStyleAttr) { private var onPageScrollListeners = LinkedList() private val scrollDispatcher = WebtoonScrollDispatcher() private val detachedViews = Collections.newSetFromMap(WeakHashMap()) private var isFixingScroll = false var isPullGestureEnabled: Boolean = false set(value) { if (field != value) { field = value setEdgeEffectFactory( if (value) { PullEffect.Factory() } else { EdgeEffectFactory() }, ) } } var pullThreshold: Float = 0.3f private var pullListener: OnPullGestureListener? = null fun setOnPullGestureListener(listener: OnPullGestureListener?) { pullListener = listener } override fun onChildDetachedFromWindow(child: View) { super.onChildDetachedFromWindow(child) detachedViews.add(child) } override fun onChildAttachedToWindow(child: View) { super.onChildAttachedToWindow(child) detachedViews.remove(child) } override fun startNestedScroll(axes: Int) = startNestedScroll(axes, TYPE_TOUCH) override fun startNestedScroll(axes: Int, type: Int): Boolean = isNotEmpty() override fun dispatchNestedPreScroll( dx: Int, dy: Int, consumed: IntArray?, offsetInWindow: IntArray? ) = dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, TYPE_TOUCH) override fun dispatchNestedPreScroll( dx: Int, dy: Int, consumed: IntArray?, offsetInWindow: IntArray?, type: Int ): Boolean { val consumedY = consumeVerticalScroll(dy) if (consumed != null) { consumed[0] = 0 consumed[1] = consumedY } notifyScrollChanged(dy) return consumedY != 0 || dy == 0 } private fun consumeVerticalScroll(dy: Int): Int { if (isEmpty()) { return 0 } when { dy > 0 -> { val child = getChildAt(0) as WebtoonFrameLayout var consumedByChild = child.dispatchVerticalScroll(dy) if (consumedByChild < dy) { if (childCount > 1) { val nextChild = getChildAt(1) as WebtoonFrameLayout val unconsumed = dy - consumedByChild - nextChild.top //will be consumed by scroll if (unconsumed > 0) { consumedByChild += nextChild.dispatchVerticalScroll(unconsumed) } } } return consumedByChild } dy < 0 -> { val child = getChildAt(childCount - 1) as WebtoonFrameLayout var consumedByChild = child.dispatchVerticalScroll(dy) if (consumedByChild > dy) { if (childCount > 1) { val nextChild = getChildAt(childCount - 2) as WebtoonFrameLayout val unconsumed = dy - consumedByChild + (height - nextChild.bottom) //will be consumed by scroll if (unconsumed < 0) { consumedByChild += nextChild.dispatchVerticalScroll(unconsumed) } } } return consumedByChild } } return 0 } fun addOnPageScrollListener(listener: OnWebtoonScrollListener) { onPageScrollListeners.add(listener) } fun removeOnPageScrollListener(listener: OnWebtoonScrollListener) { onPageScrollListeners.remove(listener) } private fun notifyScrollChanged(dy: Int) { val listeners = onPageScrollListeners if (listeners.isEmpty()) { return } scrollDispatcher.dispatchScroll(this, dy) } fun relayoutChildren() { forEach { child -> (child as WebtoonFrameLayout).target.requestLayout() } detachedViews.forEach { child -> (child as WebtoonFrameLayout).target.requestLayout() } } fun updateChildrenScroll() { if (isFixingScroll) { return } isFixingScroll = true for (child in this) { val ssiv = (child as WebtoonFrameLayout).target if (adjustScroll(child, ssiv)) { break } } isFixingScroll = false } private fun adjustScroll(child: View, ssiv: WebtoonImageView): Boolean = when { child.bottom < height && ssiv.getScroll() < ssiv.getScrollRange() -> { val distance = minOf(height - child.bottom, ssiv.getScrollRange() - ssiv.getScroll()) ssiv.scrollBy(distance) true } child.top > 0 && ssiv.getScroll() > 0 -> { val distance = minOf(child.top, ssiv.getScroll()) ssiv.scrollBy(-distance) true } else -> false } private class WebtoonScrollDispatcher { private var firstPos = NO_POSITION private var lastPos = NO_POSITION fun dispatchScroll(rv: WebtoonRecyclerView, dy: Int) { val lm = rv.layoutManager as? LinearLayoutManager if (lm == null) { firstPos = NO_POSITION lastPos = NO_POSITION return } val newFirstPos = lm.findFirstVisibleItemPosition() val newLastPos = lm.findLastVisibleItemPosition() if (newFirstPos != firstPos || newLastPos != lastPos) { firstPos = newFirstPos lastPos = newLastPos if (newFirstPos != NO_POSITION && newLastPos != NO_POSITION) { rv.onPageScrollListeners.forEach { it.onScrollChanged(rv, dy, newFirstPos, newLastPos) } } } } } private class PullEffect( view: RecyclerView, private val direction: Int, private val pullThreshold: Float, private val pullListener: OnPullGestureListener, ) : EdgeEffect(view.context) { private var pullProgressTop: Float = 0f private var pullProgressBottom: Float = 0f override fun onPull(deltaDistance: Float) { val sign = if (direction == DIRECTION_TOP) 1f else if (direction == DIRECTION_BOTTOM) 1f else 0f if (sign != 0f) onPull(deltaDistance, 0.5f) } override fun onPull(deltaDistance: Float, displacement: Float) { if (direction == DIRECTION_TOP) { pullProgressTop = (pullProgressTop + deltaDistance).coerceAtLeast(0f) pullListener.onPullProgressTop(pullProgressTop / pullThreshold) } else if (direction == DIRECTION_BOTTOM) { pullProgressBottom = (pullProgressBottom + deltaDistance).coerceAtLeast(0f) pullListener.onPullProgressBottom(pullProgressBottom / pullThreshold) } } override fun onRelease() { var triggered = false if (direction == DIRECTION_TOP) { if (pullProgressTop >= pullThreshold) { pullListener.onPullTriggeredTop() triggered = true } pullProgressTop = 0f pullListener.onPullProgressTop(0f) } else if (direction == DIRECTION_BOTTOM) { if (pullProgressBottom >= pullThreshold) { pullListener.onPullTriggeredBottom() triggered = true } pullProgressBottom = 0f pullListener.onPullProgressBottom(0f) } if (!triggered) { pullListener.onPullCancelled() } } override fun draw(canvas: Canvas?): Boolean = false class Factory : EdgeEffectFactory() { override fun createEdgeEffect(view: RecyclerView, direction: Int): EdgeEffect { val pullListener = (view as? WebtoonRecyclerView)?.pullListener return if (pullListener != null) { PullEffect(view, direction, view.pullThreshold, pullListener) } else { super.createEdgeEffect(view, direction) } } } } interface OnWebtoonScrollListener { fun onScrollChanged( recyclerView: WebtoonRecyclerView, dy: Int, firstVisiblePosition: Int, lastVisiblePosition: Int, ) } interface OnPullGestureListener { fun onPullProgressTop(progress: Float) fun onPullProgressBottom(progress: Float) fun onPullTriggeredTop() fun onPullTriggeredBottom() fun onPullCancelled() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonScalingFrame.kt ================================================ package org.koitharu.kotatsu.reader.ui.pager.webtoon import android.animation.ValueAnimator import android.content.Context import android.graphics.Matrix import android.graphics.Point import android.graphics.Rect import android.graphics.RectF import android.util.AttributeSet import android.view.GestureDetector import android.view.InputDevice import android.view.KeyEvent import android.view.MotionEvent import android.view.ScaleGestureDetector import android.view.ViewConfiguration import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.DecelerateInterpolator import android.widget.FrameLayout import android.widget.OverScroller import androidx.core.animation.doOnEnd import androidx.core.view.ViewConfigurationCompat import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.widgets.ZoomControl import org.koitharu.kotatsu.core.util.ext.getAnimationDuration import kotlin.math.roundToInt private const val MAX_SCALE = 2.5f private const val MIN_SCALE = 0.5f private const val FLING_RANGE = 20_000 class WebtoonScalingFrame @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyles: Int = 0, ) : FrameLayout(context, attrs, defStyles), ScaleGestureDetector.OnScaleGestureListener, ZoomControl.ZoomControlListener { private val scaleDetector = ScaleGestureDetector(context, this) private val gestureDetector = GestureDetector(context, GestureListener()) private val overScroller = OverScroller(context, AccelerateDecelerateInterpolator()) private val transformMatrix = Matrix() private val matrixValues = FloatArray(9) private val scale get() = matrixValues[Matrix.MSCALE_X] private val transX get() = halfWidth * (scale - 1f) + matrixValues[Matrix.MTRANS_X] private val transY get() = halfHeight * (scale - 1f) + matrixValues[Matrix.MTRANS_Y] private var halfWidth = 0f private var halfHeight = 0f private val translateBounds = RectF() private val targetHitRect = Rect() private var animator: ValueAnimator? = null private var pendingScroll = 0 var isZoomEnable = false set(value) { field = value if (scale != 1f) { scaleChild(1f, halfWidth, halfHeight) } } var zoom: Float get() = scale set(value) { if (value != scale) { scaleChild(value, halfWidth, halfHeight) onPostScale(invalidateLayout = true) } } init { syncMatrixValues() } override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { if (!isZoomEnable || ev == null) { return super.dispatchTouchEvent(ev) } if (ev.action == MotionEvent.ACTION_DOWN && overScroller.computeScrollOffset()) { overScroller.forceFinished(true) } val consumed = gestureDetector.onTouchEvent(ev) scaleDetector.onTouchEvent(ev) // Offset event to inside the child view if (scale < 1 && !targetHitRect.contains(ev.x.toInt(), ev.y.toInt())) { ev.offsetLocation(halfWidth - ev.x + targetHitRect.width() / 3, 0f) } return consumed || scaleDetector.isInProgress || super.dispatchTouchEvent(ev) } override fun onGenericMotionEvent(event: MotionEvent): Boolean { if (isZoomEnable && event.source and InputDevice.SOURCE_CLASS_POINTER != 0) { if (event.actionMasked == MotionEvent.ACTION_SCROLL) { val withCtrl = event.metaState and KeyEvent.META_CTRL_MASK != 0 if (withCtrl) { val axisValue = event.getAxisValue(MotionEvent.AXIS_VSCROLL) * ViewConfigurationCompat.getScaledVerticalScrollFactor( ViewConfiguration.get(context), context, ) val newScale = (scale + axisValue).coerceIn(MIN_SCALE, MAX_SCALE) scaleChild(newScale, event.x, event.y) return true } } } return super.onGenericMotionEvent(event) } override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { if (!isZoomEnable) { return super.onKeyDown(keyCode, event) } return when (keyCode) { KeyEvent.KEYCODE_ZOOM_IN, KeyEvent.KEYCODE_NUMPAD_ADD, KeyEvent.KEYCODE_PLUS -> { onZoomIn() true } KeyEvent.KEYCODE_ZOOM_OUT, KeyEvent.KEYCODE_NUMPAD_SUBTRACT, KeyEvent.KEYCODE_MINUS -> { onZoomOut() true } KeyEvent.KEYCODE_ESCAPE -> { smoothScaleTo(1f) true } else -> super.onKeyDown(keyCode, event) } } override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { return if (isZoomEnable) { keyCode == KeyEvent.KEYCODE_NUMPAD_ADD || keyCode == KeyEvent.KEYCODE_PLUS || keyCode == KeyEvent.KEYCODE_NUMPAD_SUBTRACT || keyCode == KeyEvent.KEYCODE_MINUS || keyCode == KeyEvent.KEYCODE_ZOOM_IN || keyCode == KeyEvent.KEYCODE_ZOOM_OUT || keyCode == KeyEvent.KEYCODE_ESCAPE || super.onKeyUp(keyCode, event) } else { super.onKeyUp(keyCode, event) } } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) halfWidth = w / 2f halfHeight = h / 2f } override fun onZoomIn() { smoothScaleTo(scale * 1.1f) } override fun onZoomOut() { smoothScaleTo(scale * 0.9f) } private fun invalidateTarget() { val targetChild = findTargetChild() adjustBounds() targetChild.run { if (!scale.isNaN()) { scaleX = scale scaleY = scale } translationX = transX translationY = transY if (pendingScroll != 0) { nestedScrollBy(0, pendingScroll) pendingScroll = 0 } } val newHeight = if (scale < 1f) (height / scale).toInt() else height if (newHeight != targetChild.height) { targetChild.layoutParams.height = newHeight targetChild.requestLayout() targetChild.relayoutChildren() } if (scale < 1) { targetChild.getHitRect(targetHitRect) } } private fun syncMatrixValues() { transformMatrix.getValues(matrixValues) } private fun adjustBounds() { syncMatrixValues() val dx = when { transX < translateBounds.left -> translateBounds.left - transX transX > translateBounds.right -> translateBounds.right - transX else -> 0f } val dy = when { transY < translateBounds.top -> translateBounds.top - transY transY > translateBounds.bottom -> translateBounds.bottom - transY else -> 0f } pendingScroll = if (scale > 1) (dy / scale).roundToInt() else 0 transformMatrix.postTranslate(dx, dy) syncMatrixValues() } private fun scaleChild( newScale: Float, focusX: Float, focusY: Float, ): Boolean { if (scale.isNaN() || scale == 0f) { return false } val factor = newScale / scale if (newScale > 1) { translateBounds.set( halfWidth * (1 - newScale), halfHeight * (1 - newScale), halfWidth * (newScale - 1), halfHeight * (newScale - 1), ) } else { translateBounds.set( 0f, halfHeight - halfHeight / newScale, 0f, halfHeight - halfHeight / newScale, ) } transformMatrix.postScale(factor, factor, focusX, focusY) invalidateTarget() return true } override fun onScale(detector: ScaleGestureDetector): Boolean { val newScale = (scale * detector.scaleFactor).coerceIn(MIN_SCALE, MAX_SCALE) return scaleChild(newScale, detector.focusX, detector.focusY) } override fun onScaleBegin(detector: ScaleGestureDetector): Boolean { animator?.cancel() animator = null return true } override fun onScaleEnd(p0: ScaleGestureDetector) { onPostScale(invalidateLayout = false) } private fun onPostScale(invalidateLayout: Boolean) { val target = findTargetChild() target.post { target.updateChildrenScroll() if (invalidateLayout) { target.requestLayout() } } } private fun smoothScaleTo(target: Float) { val newScale = target.coerceIn(MIN_SCALE, MAX_SCALE) animator?.cancel() animator = ValueAnimator.ofFloat(scale, newScale).apply { setDuration(context.getAnimationDuration(android.R.integer.config_shortAnimTime)) interpolator = DecelerateInterpolator() addUpdateListener { scaleChild(it.animatedValue as Float, halfWidth, halfHeight) } doOnEnd { onPostScale(invalidateLayout = false) } start() } } private fun findTargetChild() = getChildAt(0) as WebtoonRecyclerView private inner class GestureListener : GestureDetector.SimpleOnGestureListener(), Runnable { private val prevPos = Point() override fun onScroll( e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float, ): Boolean { if (scale <= 1f || scale.isNaN()) return false transformMatrix.postTranslate(-distanceX, -distanceY) invalidateTarget() return true } override fun onDoubleTap(e: MotionEvent): Boolean { val newScale = if (scale != 1f) 1f else MAX_SCALE * 0.8f ValueAnimator.ofFloat(scale, newScale).run { interpolator = AccelerateDecelerateInterpolator() duration = context.getAnimationDuration(R.integer.config_defaultAnimTime) addUpdateListener { scaleChild(it.animatedValue as Float, e.x, e.y) } start() } return true } override fun onFling( e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float, ): Boolean { if (scale <= 1 || scale.isNaN()) return false prevPos.set(transX.toInt(), transY.toInt()) overScroller.fling( prevPos.x, prevPos.y, velocityX.toInt(), velocityY.toInt(), translateBounds.left.toInt(), translateBounds.right.toInt(), translateBounds.top.toInt() - FLING_RANGE, translateBounds.bottom.toInt() + FLING_RANGE, ) postOnAnimation(this) return true } override fun run() { if (overScroller.computeScrollOffset()) { transformMatrix.postTranslate( overScroller.currX.toFloat() - prevPos.x, overScroller.currY.toFloat() - prevPos.y, ) prevPos.set(overScroller.currX, overScroller.currY) invalidateTarget() postOnAnimation(this) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/tapgrid/TapAction.kt ================================================ package org.koitharu.kotatsu.reader.ui.tapgrid import androidx.annotation.StringRes import org.koitharu.kotatsu.R enum class TapAction( @StringRes val nameStringResId: Int, val color: Int, ) { PAGE_NEXT(R.string.next_page, 0x8BFF00), PAGE_PREV(R.string.prev_page, 0xFF4700), CHAPTER_NEXT(R.string.next_chapter, 0x327E49), CHAPTER_PREV(R.string.prev_chapter, 0x7E1218), TOGGLE_UI(R.string.toggle_ui, 0x3D69C5), SHOW_MENU(R.string.show_menu, 0xAA1AC5), } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/tapgrid/TapGridDispatcher.kt ================================================ package org.koitharu.kotatsu.reader.ui.tapgrid import android.view.GestureDetector import android.view.MotionEvent import android.view.View import org.koitharu.kotatsu.reader.domain.TapGridArea import kotlin.math.roundToInt class TapGridDispatcher( private val rootView: View, private val listener: OnGridTouchListener, ) : GestureDetector.SimpleOnGestureListener() { private val detector = GestureDetector(rootView.context, this) private var isDispatching = false init { detector.setIsLongpressEnabled(true) detector.setOnDoubleTapListener(this) } fun dispatchTouchEvent(event: MotionEvent) { if (event.actionMasked == MotionEvent.ACTION_DOWN) { isDispatching = listener.onProcessTouch(event.rawX.toInt(), event.rawY.toInt()) } detector.onTouchEvent(event) } override fun onSingleTapConfirmed(event: MotionEvent): Boolean { if (!isDispatching) { return true } val area = getArea(event.rawX, event.rawY) ?: return false return listener.onGridTouch(area) } override fun onDoubleTapEvent(e: MotionEvent): Boolean { isDispatching = false // ignore long press after double tap return super.onDoubleTapEvent(e) } override fun onLongPress(event: MotionEvent) { if (isDispatching) { val area = getArea(event.rawX, event.rawY) ?: return listener.onGridLongTouch(area) } } private fun getArea(x: Float, y: Float): TapGridArea? { val width = rootView.width val height = rootView.height if (height <= 0 || width <= 0) { return null } val xIndex = (x * 2f / width).roundToInt() val yIndex = (y * 2f / height).roundToInt() val area = when (xIndex) { 0 -> when (yIndex) { // LEFT 0 -> TapGridArea.TOP_LEFT 1 -> TapGridArea.CENTER_LEFT 2 -> TapGridArea.BOTTOM_LEFT else -> null } 1 -> when (yIndex) { // CENTER 0 -> TapGridArea.TOP_CENTER 1 -> TapGridArea.CENTER 2 -> TapGridArea.BOTTOM_CENTER else -> null } 2 -> when (yIndex) { // RIGHT 0 -> TapGridArea.TOP_RIGHT 1 -> TapGridArea.CENTER_RIGHT 2 -> TapGridArea.BOTTOM_RIGHT else -> null } else -> null } assert(area != null) { "Invalid area ($xIndex, $yIndex)" } return area } interface OnGridTouchListener { fun onGridTouch(area: TapGridArea): Boolean fun onGridLongTouch(area: TapGridArea) fun onProcessTouch(rawX: Int, rawY: Int): Boolean } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/MangaSearchMenuProvider.kt ================================================ package org.koitharu.kotatsu.remotelist.ui import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.appcompat.widget.SearchView import androidx.core.view.MenuProvider import androidx.core.view.inputmethod.EditorInfoCompat import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.parsers.model.MangaListFilter class MangaSearchMenuProvider( private val filter: FilterCoordinator, private val viewModel: MangaListViewModel, ) : MenuProvider, MenuItem.OnActionExpandListener, SearchView.OnQueryTextListener { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.opt_search, menu) val menuItem = menu.findItem(R.id.action_search) menuItem.setOnActionExpandListener(this) val searchView = menuItem.actionView as SearchView searchView.setOnQueryTextListener(this) searchView.queryHint = menuItem.title } override fun onPrepareMenu(menu: Menu) { super.onPrepareMenu(menu) menu.findItem(R.id.action_search)?.isVisible = filter.capabilities.isSearchSupported } override fun onMenuItemSelected(menuItem: MenuItem): Boolean = false override fun onQueryTextSubmit(query: String?): Boolean { val snapshot = filter.snapshot() if (!query.isNullOrEmpty() && !filter.capabilities.isSearchWithFiltersSupported && snapshot.listFilter.hasNonSearchOptions()) { filter.set(MangaListFilter(query = query)) viewModel.onActionDone.call( ReversibleAction(R.string.filter_search_warning) { filter.set(snapshot.listFilter) }, ) } else { filter.setQuery(query) } return true } override fun onQueryTextChange(newText: String?): Boolean = false override fun onMenuItemActionExpand(item: MenuItem): Boolean { (item.actionView as? SearchView)?.run { post { adjustSearchView() } } return true } override fun onMenuItemActionCollapse(item: MenuItem): Boolean = true private fun SearchView.adjustSearchView() { imeOptions = if (viewModel.isIncognitoModeEnabled) { imeOptions or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING } else { imeOptions and EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING.inv() } setQuery(filter.query.value, false) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt ================================================ package org.koitharu.kotatsu.remotelist.ui import android.os.Bundle import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.core.view.MenuProvider import androidx.fragment.app.viewModels import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.drop import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.util.MenuInvalidator import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.getCauseUrl import org.koitharu.kotatsu.core.util.ext.isHttpUrl import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.search.domain.SearchKind @AndroidEntryPoint class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner, View.OnClickListener { override val viewModel by viewModels() override val filterCoordinator: FilterCoordinator get() = viewModel.filterCoordinator override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) addMenuProvider(RemoteListMenuProvider()) addMenuProvider(MangaSearchMenuProvider(filterCoordinator, viewModel)) viewModel.isRandomLoading.observe(viewLifecycleOwner, MenuInvalidator(requireActivity())) viewModel.onOpenManga.observeEvent(viewLifecycleOwner) { router.openDetails(it) } viewModel.onSourceBroken.observeEvent(viewLifecycleOwner) { showSourceBrokenWarning() } filterCoordinator.observe().distinctUntilChangedBy { it.listFilter.isEmpty() } .drop(1) .observe(viewLifecycleOwner) { activity?.invalidateMenu() } } override fun onScrolledToEnd() { viewModel.loadNextPage() } override fun onCreateActionMode( controller: ListSelectionController, menuInflater: MenuInflater, menu: Menu ): Boolean { menuInflater.inflate(R.menu.mode_remote, menu) return super.onCreateActionMode(controller, menuInflater, menu) } override fun onFilterClick(view: View?) { router.showFilterSheet() } override fun onEmptyActionClick() { if (filterCoordinator.isFilterApplied) { filterCoordinator.reset() } else { openInBrowser(null) // should never be called } } override fun onFooterButtonClick() { val filter = filterCoordinator.snapshot().listFilter when { !filter.query.isNullOrEmpty() -> router.openSearch(filter.query.orEmpty(), SearchKind.SIMPLE) !filter.author.isNullOrEmpty() -> router.openSearch(filter.author.orEmpty(), SearchKind.AUTHOR) filter.tags.size == 1 -> router.openSearch(filter.tags.singleOrNull()?.title.orEmpty(), SearchKind.TAG) } } override fun onSecondaryErrorActionClick(error: Throwable) { openInBrowser(error.getCauseUrl()) } override fun onClick(v: View?) = Unit // from Snackbar, do nothing private fun openInBrowser(url: String?) { if (url?.isHttpUrl() == true) { router.openBrowser( url = url, source = viewModel.source, title = viewModel.source.getTitle(requireContext()), ) } else { Snackbar.make(requireViewBinding().recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT) .show() } } private fun showSourceBrokenWarning() { val snackbar = Snackbar.make( viewBinding?.recyclerView ?: return, R.string.source_broken_warning, Snackbar.LENGTH_INDEFINITE, ) snackbar.setAction(R.string.got_it, this) snackbar.show() } private inner class RemoteListMenuProvider : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.opt_list_remote, menu) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { R.id.action_source_settings -> { router.openSourceSettings(viewModel.source) true } R.id.action_random -> { viewModel.openRandom() true } R.id.action_filter -> { onFilterClick(null) true } R.id.action_filter_reset -> { filterCoordinator.reset() true } else -> false } override fun onPrepareMenu(menu: Menu) { super.onPrepareMenu(menu) menu.findItem(R.id.action_random)?.isEnabled = !viewModel.isRandomLoading.value menu.findItem(R.id.action_filter_reset)?.isVisible = filterCoordinator.isFilterApplied } } companion object { const val ARG_SOURCE = "provider" fun newInstance(source: MangaSource) = RemoteListFragment().withArgs(1) { putString(ARG_SOURCE, source.name) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt ================================================ package org.koitharu.kotatsu.remotelist.ui import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.distinctById import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.getCauseUrl import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.list.ui.MangaListViewModel 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.toErrorFooter import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.util.sizeOrZero import javax.inject.Inject private const val FILTER_MIN_INTERVAL = 250L @HiltViewModel open class RemoteListViewModel @Inject constructor( savedStateHandle: SavedStateHandle, mangaRepositoryFactory: MangaRepository.Factory, final override val filterCoordinator: FilterCoordinator, settings: AppSettings, protected val mangaListMapper: MangaListMapper, private val exploreRepository: ExploreRepository, sourcesRepository: MangaSourcesRepository, mangaDataRepository: MangaDataRepository, @LocalStorageChanges localStorageChanges: SharedFlow ) : MangaListViewModel(settings, mangaDataRepository, localStorageChanges), FilterCoordinator.Owner { val source = MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE]) val isRandomLoading = MutableStateFlow(false) val onOpenManga = MutableEventFlow() val onSourceBroken = MutableEventFlow() protected val repository = mangaRepositoryFactory.create(source) private val mangaList = MutableStateFlow?>(null) private val hasNextPage = MutableStateFlow(false) private val listError = MutableStateFlow(null) private var loadingJob: Job? = null private var randomJob: Job? = null override val content = combine( mangaList.map { it?.skipNsfwIfNeeded() }, observeListModeWithTriggers(), listError, hasNextPage, ) { list, mode, error, hasNext -> buildList(list?.size?.plus(2) ?: 2) { when { list.isNullOrEmpty() && error != null -> add( error.toErrorState( canRetry = true, secondaryAction = if (error.getCauseUrl().isNullOrEmpty()) 0 else R.string.open_in_browser, ), ) list == null -> add(LoadingState) list.isEmpty() -> add(createEmptyState(canResetFilter = filterCoordinator.isFilterApplied)) else -> { mapMangaList(this, list, mode) when { error != null -> add(error.toErrorFooter()) hasNext -> add(LoadingFooter()) else -> getFooter()?.let(::add) } } } onBuildList(this) } }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState)) init { filterCoordinator.observe() .debounce(FILTER_MIN_INTERVAL) .onEach { filterState -> loadingJob?.cancelAndJoin() mangaList.value = null loadList(filterState, false) }.catch { error -> listError.value = error }.launchIn(viewModelScope) launchJob(Dispatchers.Default) { sourcesRepository.trackUsage(source) } if (source is MangaParserSource && source.isBroken) { // Just notify one. Will show reason in future onSourceBroken.call(Unit) } } override fun onRefresh() { loadList(filterCoordinator.snapshot(), append = false) } override fun onRetry() { loadList(filterCoordinator.snapshot(), append = !mangaList.value.isNullOrEmpty()) } fun loadNextPage() { if (hasNextPage.value && listError.value == null) { loadList(filterCoordinator.snapshot(), append = true) } } protected fun loadList(filterState: FilterCoordinator.Snapshot, append: Boolean): Job { loadingJob?.let { if (it.isActive) return it } return launchLoadingJob(Dispatchers.Default) { try { listError.value = null val list = repository.getList( offset = if (append) mangaList.value.sizeOrZero() else 0, order = filterState.sortOrder, filter = filterState.listFilter, ) val prevList = mangaList.value.orEmpty() if (!append) { mangaList.value = list.distinctById() } else if (list.isNotEmpty()) { mangaList.value = (prevList + list).distinctById() } hasNextPage.value = if (append) { prevList != mangaList.value } else { list.size > prevList.size || hasNextPage.value } } catch (e: CancellationException) { throw e } catch (e: Throwable) { e.printStackTraceDebug() listError.value = e if (!mangaList.value.isNullOrEmpty()) { errorEvent.call(e) } hasNextPage.value = false } }.also { loadingJob = it } } protected open fun createEmptyState(canResetFilter: Boolean) = EmptyState( icon = R.drawable.ic_empty_common, textPrimary = R.string.nothing_found, textSecondary = 0, actionStringRes = if (canResetFilter) R.string.reset_filter else 0, ) protected open suspend fun onBuildList(list: MutableList) = Unit protected open suspend fun mapMangaList( destination: MutableCollection, manga: Collection, mode: ListMode ) = mangaListMapper.toListModelList(destination, manga, mode) protected open fun getFooter(): ButtonFooter? { val filter = filterCoordinator.snapshot().listFilter val hasQuery = !filter.query.isNullOrEmpty() val hasAuthor = !filter.author.isNullOrEmpty() val isOneTag = filter.tags.size == 1 return if ((hasQuery xor isOneTag xor hasAuthor) && !(hasQuery && isOneTag && hasAuthor)) { ButtonFooter(R.string.global_search) } else { null } } fun openRandom() { if (randomJob?.isActive == true) { return } randomJob = launchLoadingJob(Dispatchers.Default) { isRandomLoading.value = true val manga = exploreRepository.findRandomManga(source, 16) onOpenManga.call(manga) isRandomLoading.value = false } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/ScrobblingModule.kt ================================================ package org.koitharu.kotatsu.scrobbling import android.content.Context 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 okhttp3.OkHttpClient import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.network.BaseHttpClient import org.koitharu.kotatsu.core.network.CurlLoggingInterceptor import org.koitharu.kotatsu.scrobbling.anilist.data.AniListAuthenticator import org.koitharu.kotatsu.scrobbling.anilist.data.AniListInterceptor import org.koitharu.kotatsu.scrobbling.anilist.domain.AniListScrobbler import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerType import org.koitharu.kotatsu.scrobbling.kitsu.data.KitsuAuthenticator import org.koitharu.kotatsu.scrobbling.kitsu.data.KitsuInterceptor import org.koitharu.kotatsu.scrobbling.kitsu.data.KitsuRepository import org.koitharu.kotatsu.scrobbling.kitsu.domain.KitsuScrobbler import org.koitharu.kotatsu.scrobbling.mal.data.MALAuthenticator import org.koitharu.kotatsu.scrobbling.mal.data.MALInterceptor import org.koitharu.kotatsu.scrobbling.mal.domain.MALScrobbler import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriAuthenticator import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriInterceptor import org.koitharu.kotatsu.scrobbling.shikimori.domain.ShikimoriScrobbler import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object ScrobblingModule { @Provides @Singleton @ScrobblerType(ScrobblerService.SHIKIMORI) fun provideShikimoriHttpClient( @BaseHttpClient baseHttpClient: OkHttpClient, authenticator: ShikimoriAuthenticator, @ScrobblerType(ScrobblerService.SHIKIMORI) storage: ScrobblerStorage, ): OkHttpClient = baseHttpClient.newBuilder().apply { authenticator(authenticator) addInterceptor(ShikimoriInterceptor(storage)) }.build() @Provides @Singleton @ScrobblerType(ScrobblerService.MAL) fun provideMALHttpClient( @BaseHttpClient baseHttpClient: OkHttpClient, authenticator: MALAuthenticator, @ScrobblerType(ScrobblerService.MAL) storage: ScrobblerStorage, ): OkHttpClient = baseHttpClient.newBuilder().apply { authenticator(authenticator) addInterceptor(MALInterceptor(storage)) }.build() @Provides @Singleton @ScrobblerType(ScrobblerService.ANILIST) fun provideAniListHttpClient( @BaseHttpClient baseHttpClient: OkHttpClient, authenticator: AniListAuthenticator, @ScrobblerType(ScrobblerService.ANILIST) storage: ScrobblerStorage, ): OkHttpClient = baseHttpClient.newBuilder().apply { authenticator(authenticator) addInterceptor(AniListInterceptor(storage)) }.build() @Provides @Singleton fun provideKitsuRepository( @ApplicationContext context: Context, @ScrobblerType(ScrobblerService.KITSU) storage: ScrobblerStorage, database: MangaDatabase, authenticator: KitsuAuthenticator, ): KitsuRepository { val okHttp = OkHttpClient.Builder().apply { authenticator(authenticator) addInterceptor(KitsuInterceptor(storage)) if (BuildConfig.DEBUG) { addInterceptor(CurlLoggingInterceptor()) } }.build() return KitsuRepository(context, okHttp, storage, database) } @Provides @Singleton @ScrobblerType(ScrobblerService.ANILIST) fun provideAniListStorage( @ApplicationContext context: Context, ): ScrobblerStorage = ScrobblerStorage(context, ScrobblerService.ANILIST) @Provides @Singleton @ScrobblerType(ScrobblerService.SHIKIMORI) fun provideShikimoriStorage( @ApplicationContext context: Context, ): ScrobblerStorage = ScrobblerStorage(context, ScrobblerService.SHIKIMORI) @Provides @Singleton @ScrobblerType(ScrobblerService.MAL) fun provideMALStorage( @ApplicationContext context: Context, ): ScrobblerStorage = ScrobblerStorage(context, ScrobblerService.MAL) @Provides @Singleton @ScrobblerType(ScrobblerService.KITSU) fun provideKitsuStorage( @ApplicationContext context: Context, ): ScrobblerStorage = ScrobblerStorage(context, ScrobblerService.KITSU) @Provides @ElementsIntoSet fun provideScrobblers( shikimoriScrobbler: ShikimoriScrobbler, aniListScrobbler: AniListScrobbler, malScrobbler: MALScrobbler, kitsuScrobbler: KitsuScrobbler ): Set<@JvmSuppressWildcards Scrobbler> = setOf(shikimoriScrobbler, aniListScrobbler, malScrobbler, kitsuScrobbler) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/anilist/data/AniListAuthenticator.kt ================================================ package org.koitharu.kotatsu.scrobbling.anilist.data import kotlinx.coroutines.runBlocking import okhttp3.Authenticator import okhttp3.Request import okhttp3.Response import okhttp3.Route import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerType import javax.inject.Inject import javax.inject.Provider class AniListAuthenticator @Inject constructor( @ScrobblerType(ScrobblerService.ANILIST) private val storage: ScrobblerStorage, private val repositoryProvider: Provider, ) : Authenticator { override fun authenticate(route: Route?, response: Response): Request? { val accessToken = storage.accessToken ?: return null if (!isRequestWithAccessToken(response)) { return null } synchronized(this) { val newAccessToken = storage.accessToken ?: return null if (accessToken != newAccessToken) { return newRequestWithAccessToken(response.request, newAccessToken) } val updatedAccessToken = refreshAccessToken() ?: return null return newRequestWithAccessToken(response.request, updatedAccessToken) } } private fun isRequestWithAccessToken(response: Response): Boolean { val header = response.request.header(CommonHeaders.AUTHORIZATION) return header?.startsWith("Bearer") == true } private fun newRequestWithAccessToken(request: Request, accessToken: String): Request { return request.newBuilder() .header(CommonHeaders.AUTHORIZATION, "Bearer $accessToken") .build() } private fun refreshAccessToken(): String? = runCatching { val repository = repositoryProvider.get() runBlocking { repository.authorize(null) } return storage.accessToken }.onFailure { it.printStackTraceDebug() }.getOrNull() } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/anilist/data/AniListInterceptor.kt ================================================ package org.koitharu.kotatsu.scrobbling.anilist.data import okhttp3.Interceptor import okhttp3.Response import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import java.net.HttpURLConnection private const val JSON = "application/json" class AniListInterceptor(private val storage: ScrobblerStorage) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val sourceRequest = chain.request() val request = sourceRequest.newBuilder() request.header(CommonHeaders.CONTENT_TYPE, JSON) request.header(CommonHeaders.ACCEPT, JSON) val isAuthRequest = sourceRequest.url.pathSegments.contains("oauth") if (!isAuthRequest) { storage.accessToken?.let { request.header(CommonHeaders.AUTHORIZATION, "Bearer $it") } } val response = chain.proceed(request.build()) if (!isAuthRequest && response.code == HttpURLConnection.HTTP_UNAUTHORIZED) { throw ScrobblerAuthRequiredException(ScrobblerService.ANILIST) } return response } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/anilist/data/AniListRepository.kt ================================================ package org.koitharu.kotatsu.scrobbling.anilist.data import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import okhttp3.FormBody import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONObject import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.parsers.exception.GraphQLException import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.json.getStringOrNull import org.koitharu.kotatsu.parsers.util.json.mapJSON import org.koitharu.kotatsu.parsers.util.parseJson import org.koitharu.kotatsu.parsers.util.toIntUp import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerMangaInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerType import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser import javax.inject.Inject import javax.inject.Singleton import kotlin.math.roundToInt private const val REDIRECT_URI = "kotatsu://anilist-auth" private const val BASE_URL = "https://anilist.co/api/v2/" private const val ENDPOINT = "https://graphql.anilist.co" private const val MANGA_PAGE_SIZE = 10 private const val REQUEST_QUERY = "query" private const val REQUEST_MUTATION = "mutation" private const val KEY_SCORE_FORMAT = "score_format" @Singleton class AniListRepository @Inject constructor( @ApplicationContext context: Context, @ScrobblerType(ScrobblerService.ANILIST) private val okHttp: OkHttpClient, @ScrobblerType(ScrobblerService.ANILIST) private val storage: ScrobblerStorage, private val db: MangaDatabase, ) : ScrobblerRepository { private val clientId = context.getString(R.string.anilist_clientId) private val clientSecret = context.getString(R.string.anilist_clientSecret) override val oauthUrl: String get() = "${BASE_URL}oauth/authorize?client_id=$clientId&" + "redirect_uri=${REDIRECT_URI}&response_type=code" override val isAuthorized: Boolean get() = storage.accessToken != null private val shrinkRegex = Regex("\\t+") override suspend fun authorize(code: String?) { val body = FormBody.Builder() body.add("client_id", clientId) body.add("client_secret", clientSecret) if (code != null) { body.add("grant_type", "authorization_code") body.add("redirect_uri", REDIRECT_URI) body.add("code", code) } else { body.add("grant_type", "refresh_token") body.add("refresh_token", checkNotNull(storage.refreshToken)) } val request = Request.Builder() .post(body.build()) .url("${BASE_URL}oauth/token") val response = okHttp.newCall(request.build()).await().parseJson() storage.accessToken = response.getString("access_token") storage.refreshToken = response.getString("refresh_token") } override suspend fun loadUser(): ScrobblerUser { val response = doRequest( REQUEST_QUERY, """ AniChartUser { user { id name avatar { medium } mediaListOptions { scoreFormat } } } """, ) val jo = response.getJSONObject("data").getJSONObject("AniChartUser").getJSONObject("user") storage[KEY_SCORE_FORMAT] = jo.getJSONObject("mediaListOptions").getString("scoreFormat") return AniListUser(jo).also { storage.user = it } } override val cachedUser: ScrobblerUser? get() { return storage.user } override suspend fun unregister(mangaId: Long) { return db.getScrobblingDao().delete(ScrobblerService.ANILIST.id, mangaId) } override fun logout() { storage.clear() } override suspend fun findManga(query: String, offset: Int): List { val page = (offset / MANGA_PAGE_SIZE.toFloat()).toIntUp() + 1 val response = doRequest( REQUEST_QUERY, """ Page(page: $page, perPage: ${MANGA_PAGE_SIZE}) { media(type: MANGA, sort: SEARCH_MATCH, search: ${JSONObject.quote(query)}) { id title { userPreferred native } coverImage { medium } siteUrl } } """, ) val data = response.getJSONObject("data").getJSONObject("Page").getJSONArray("media") return data.mapJSON { ScrobblerManga(it, query) } } override suspend fun createRate(mangaId: Long, scrobblerMangaId: Long) { val response = doRequest( REQUEST_MUTATION, """ SaveMediaListEntry(mediaId: $scrobblerMangaId) { id mediaId status notes score progress } """, ) saveRate(response.getJSONObject("data").getJSONObject("SaveMediaListEntry"), mangaId) } override suspend fun updateRate(rateId: Int, mangaId: Long, chapter: Int) { val response = doRequest( REQUEST_MUTATION, """ SaveMediaListEntry(id: $rateId, progress: $chapter) { id mediaId status notes score progress } """, ) saveRate(response.getJSONObject("data").getJSONObject("SaveMediaListEntry"), mangaId) } override suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) { val scoreRaw = (rating * 100f).roundToInt() val statusString = status?.let { ", status: $it" }.orEmpty() val notesString = comment?.let { ", notes: ${JSONObject.quote(it)}" }.orEmpty() val response = doRequest( REQUEST_MUTATION, """ SaveMediaListEntry(id: $rateId, scoreRaw: $scoreRaw$statusString$notesString) { id mediaId status notes score progress } """, ) saveRate(response.getJSONObject("data").getJSONObject("SaveMediaListEntry"), mangaId) } override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo { val response = doRequest( REQUEST_QUERY, """ Media(id: $id) { id title { userPreferred } coverImage { large } description siteUrl } """, ) return ScrobblerMangaInfo(response.getJSONObject("data").getJSONObject("Media")) } private suspend fun saveRate(json: JSONObject, mangaId: Long) { val scoreFormat = ScoreFormat.of(storage[KEY_SCORE_FORMAT]) val entity = ScrobblingEntity( scrobbler = ScrobblerService.ANILIST.id, id = json.getInt("id"), mangaId = mangaId, targetId = json.getLong("mediaId"), status = json.getString("status"), chapter = json.getInt("progress"), comment = json.getString("notes"), rating = scoreFormat.normalize(json.getDouble("score").toFloat()), ) db.getScrobblingDao().upsert(entity) } private fun ScrobblerManga(json: JSONObject, sourceTitle: String): ScrobblerManga { val title = json.getJSONObject("title") return ScrobblerManga( id = json.getLong("id"), name = title.getString("userPreferred"), altName = title.getStringOrNull("native"), cover = json.getJSONObject("coverImage").getString("medium"), url = json.getString("siteUrl"), isBestMatch = sourceTitle.let { title.keys().forEach { key -> if (title.getStringOrNull(key)?.equals(it, ignoreCase = true) == true) { return@let true } } false }, ) } private fun ScrobblerMangaInfo(json: JSONObject) = ScrobblerMangaInfo( id = json.getLong("id"), name = json.getJSONObject("title").getString("userPreferred"), cover = json.getJSONObject("coverImage").getString("large"), url = json.getString("siteUrl"), descriptionHtml = json.getString("description"), ) @Suppress("FunctionName") private fun AniListUser(json: JSONObject) = ScrobblerUser( id = json.getLong("id"), nickname = json.getString("name"), avatar = json.getJSONObject("avatar").getStringOrNull("medium"), service = ScrobblerService.ANILIST, ) private suspend fun doRequest(type: String, payload: String): JSONObject { val body = JSONObject() body.put("query", "$type { ${payload.shrink()} }") val mediaType = "application/json; charset=utf-8".toMediaType() val requestBody = body.toString().toRequestBody(mediaType) val request = Request.Builder() .post(requestBody) .url(ENDPOINT) val json = okHttp.newCall(request.build()).await().parseJson() json.optJSONArray("errors")?.let { if (it.length() != 0) { throw GraphQLException(it) } } return json } private fun String.shrink() = replace(shrinkRegex, " ") } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/anilist/data/ScoreFormat.kt ================================================ package org.koitharu.kotatsu.scrobbling.anilist.data import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug enum class ScoreFormat { POINT_100, POINT_10_DECIMAL, POINT_10, POINT_5, POINT_3; fun normalize(score: Float): Float = when (this) { POINT_100 -> score / 100f POINT_10_DECIMAL, POINT_10 -> score / 10f POINT_5 -> score / 5f POINT_3 -> score / 3f }.coerceIn(0f, 1f) companion object { fun of(rawValue: String?): ScoreFormat { rawValue ?: return POINT_10_DECIMAL return runCatching { valueOf(rawValue) } .onFailure { it.printStackTraceDebug() } .getOrDefault(POINT_10_DECIMAL) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/anilist/domain/AniListScrobbler.kt ================================================ package org.koitharu.kotatsu.scrobbling.anilist.domain import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus import javax.inject.Inject import javax.inject.Singleton @Singleton class AniListScrobbler @Inject constructor( private val repository: AniListRepository, db: MangaDatabase, mangaRepositoryFactory: MangaRepository.Factory, ) : Scrobbler(db, ScrobblerService.ANILIST, repository, mangaRepositoryFactory) { init { statuses[ScrobblingStatus.PLANNED] = "PLANNING" statuses[ScrobblingStatus.READING] = "CURRENT" statuses[ScrobblingStatus.RE_READING] = "REPEATING" statuses[ScrobblingStatus.COMPLETED] = "COMPLETED" statuses[ScrobblingStatus.ON_HOLD] = "PAUSED" statuses[ScrobblingStatus.DROPPED] = "DROPPED" } override suspend fun updateScrobblingInfo( mangaId: Long, rating: Float, status: ScrobblingStatus?, comment: String?, ) { val entity = db.getScrobblingDao().find(scrobblerService.id, mangaId) requireNotNull(entity) { "Scrobbling info for manga $mangaId not found" } repository.updateRate( rateId = entity.id, mangaId = entity.mangaId, rating = rating, status = statuses[status], comment = comment, ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/data/ScrobblerRepository.kt ================================================ package org.koitharu.kotatsu.scrobbling.common.data import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerMangaInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser interface ScrobblerRepository { val oauthUrl: String val isAuthorized: Boolean val cachedUser: ScrobblerUser? suspend fun authorize(code: String?) suspend fun loadUser(): ScrobblerUser fun logout() suspend fun unregister(mangaId: Long) suspend fun findManga(query: String, offset: Int): List suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo suspend fun createRate(mangaId: Long, scrobblerMangaId: Long) suspend fun updateRate(rateId: Int, mangaId: Long, chapter: Int) suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/data/ScrobblerStorage.kt ================================================ package org.koitharu.kotatsu.scrobbling.common.data import android.content.Context import androidx.core.content.edit import org.jsoup.internal.StringUtil.StringJoiner import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser private const val KEY_ACCESS_TOKEN = "access_token" private const val KEY_REFRESH_TOKEN = "refresh_token" private const val KEY_USER = "user" class ScrobblerStorage(context: Context, service: ScrobblerService) { private val prefs = context.getSharedPreferences(service.name, Context.MODE_PRIVATE) var accessToken: String? get() = prefs.getString(KEY_ACCESS_TOKEN, null) set(value) = prefs.edit { putString(KEY_ACCESS_TOKEN, value) } var refreshToken: String? get() = prefs.getString(KEY_REFRESH_TOKEN, null) set(value) = prefs.edit { putString(KEY_REFRESH_TOKEN, value) } var user: ScrobblerUser? get() = prefs.getString(KEY_USER, null)?.let { val lines = it.lines() if (lines.size != 4) { return@let null } ScrobblerUser( id = lines[0].toLong(), nickname = lines[1], avatar = lines[2].nullIfEmpty(), service = ScrobblerService.valueOf(lines[3]), ) } set(value) = prefs.edit { if (value == null) { remove(KEY_USER) return@edit } val str = StringJoiner("\n") .add(value.id) .add(value.nickname) .add(value.avatar.orEmpty()) .add(value.service.name) .complete() putString(KEY_USER, str) } operator fun get(key: String): String? = prefs.getString(key, null) operator fun set(key: String, value: String?) = prefs.edit { putString(key, value) } fun clear() = prefs.edit { clear() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/data/ScrobblingDao.kt ================================================ package org.koitharu.kotatsu.scrobbling.common.data import androidx.room.* import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.isActive @Dao abstract class ScrobblingDao { @Query("SELECT * FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId") abstract suspend fun find(scrobbler: Int, mangaId: Long): ScrobblingEntity? @Query("SELECT * FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId") abstract fun observe(scrobbler: Int, mangaId: Long): Flow @Query("SELECT * FROM scrobblings WHERE scrobbler = :scrobbler") abstract fun observe(scrobbler: Int): Flow> @Upsert abstract suspend fun upsert(entity: ScrobblingEntity) @Query("DELETE FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId") abstract suspend fun delete(scrobbler: Int, mangaId: Long) @Query("SELECT * FROM scrobblings ORDER BY scrobbler LIMIT :limit OFFSET :offset") protected abstract suspend fun findAll(offset: Int, limit: Int): List fun dumpEnabled(): Flow = flow { val window = 10 var offset = 0 while (currentCoroutineContext().isActive) { val list = findAll(offset, window) if (list.isEmpty()) { break } offset += window list.forEach { emit(it) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/data/ScrobblingEntity.kt ================================================ package org.koitharu.kotatsu.scrobbling.common.data import androidx.room.ColumnInfo import androidx.room.Entity @Entity( tableName = "scrobblings", primaryKeys = ["scrobbler", "id", "manga_id"], ) class ScrobblingEntity( @ColumnInfo(name = "scrobbler") val scrobbler: Int, @ColumnInfo(name = "id") val id: Int, @ColumnInfo(name = "manga_id") val mangaId: Long, @ColumnInfo(name = "target_id") val targetId: Long, @ColumnInfo(name = "status") val status: String?, @ColumnInfo(name = "chapter") val chapter: Int, @ColumnInfo(name = "comment") val comment: String?, @ColumnInfo(name = "rating") val rating: Float, ) { fun copy( status: String?, comment: String?, rating: Float, ) = 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/scrobbling/common/domain/Scrobbler.kt ================================================ package org.koitharu.kotatsu.scrobbling.common.domain import androidx.annotation.FloatRange import androidx.collection.LongSparseArray import androidx.collection.getOrElse import androidx.core.text.parseAsHtml import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.util.ext.findKeyByValue import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.sanitize import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.findById import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerMangaInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus import java.util.EnumMap abstract class Scrobbler( protected val db: MangaDatabase, val scrobblerService: ScrobblerService, private val repository: ScrobblerRepository, private val mangaRepositoryFactory: MangaRepository.Factory, ) { private val infoCache = LongSparseArray() protected val statuses = EnumMap(ScrobblingStatus::class.java) val user: Flow = flow { repository.cachedUser?.let { emit(it) } runCatchingCancellable { repository.loadUser() }.onSuccess { emit(it) }.onFailure { it.printStackTraceDebug() } } val isEnabled: Boolean get() = repository.isAuthorized suspend fun authorize(authCode: String): ScrobblerUser { repository.authorize(authCode) return repository.loadUser() } fun logout() { repository.logout() } suspend fun findManga(query: String, offset: Int): List { return repository.findManga(query, offset) } suspend fun linkManga(mangaId: Long, targetId: Long) { repository.createRate(mangaId, targetId) } suspend fun scrobble(manga: Manga, chapterId: Long) { var chapters = manga.chapters if (chapters.isNullOrEmpty()) { chapters = mangaRepositoryFactory.create(manga.source).getDetails(manga).chapters } requireNotNull(chapters) val chapter = checkNotNull(chapters.findById(chapterId)) { "Chapter $chapterId not found in this manga" } val number = if (chapter.number > 0f) { chapter.number.toInt() } else { chapters = chapters.filter { x -> x.branch == chapter.branch } chapters.indexOf(chapter) + 1 } val entity = db.getScrobblingDao().find(scrobblerService.id, manga.id) ?: return repository.updateRate(entity.id, entity.mangaId, number) } suspend fun getScrobblingInfoOrNull(mangaId: Long): ScrobblingInfo? { val entity = db.getScrobblingDao().find(scrobblerService.id, mangaId) ?: return null return entity.toScrobblingInfo() } abstract suspend fun updateScrobblingInfo( mangaId: Long, @FloatRange(from = 0.0, to = 1.0) rating: Float, status: ScrobblingStatus?, comment: String?, ) fun observeScrobblingInfo(mangaId: Long): Flow { return db.getScrobblingDao().observe(scrobblerService.id, mangaId) .map { it?.toScrobblingInfo() } } fun observeAllScrobblingInfo(): Flow> { return db.getScrobblingDao().observe(scrobblerService.id) .map { entities -> coroutineScope { entities.map { async { it.toScrobblingInfo() } }.awaitAll() }.filterNotNull() } } suspend fun unregisterScrobbling(mangaId: Long) { repository.unregister(mangaId) } protected suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo { return repository.getMangaInfo(id) } private suspend fun ScrobblingEntity.toScrobblingInfo(): ScrobblingInfo? { val mangaInfo = infoCache.getOrElse(targetId) { runCatchingCancellable { getMangaInfo(targetId) }.onFailure { it.printStackTraceDebug() }.onSuccess { infoCache.put(targetId, it) }.getOrNull() ?: return null } return ScrobblingInfo( scrobbler = scrobblerService, mangaId = mangaId, targetId = targetId, status = statuses.findKeyByValue(status), chapter = chapter, comment = comment, rating = rating, title = mangaInfo.name, coverUrl = mangaInfo.cover, description = mangaInfo.descriptionHtml.parseAsHtml().sanitize(), externalUrl = mangaInfo.url, ) } } suspend fun Scrobbler.tryScrobble(manga: Manga, chapterId: Long): Boolean { return runCatchingCancellable { scrobble(manga, chapterId) }.onFailure { it.printStackTraceDebug() }.isSuccess } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/ScrobblerAuthRequiredException.kt ================================================ package org.koitharu.kotatsu.scrobbling.common.domain import okio.IOException import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService class ScrobblerAuthRequiredException( val scrobbler: ScrobblerService, ) : IOException() ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/ScrobblerRepositoryMap.kt ================================================ package org.koitharu.kotatsu.scrobbling.common.domain import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.kitsu.data.KitsuRepository import org.koitharu.kotatsu.scrobbling.mal.data.MALRepository import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository import javax.inject.Inject import javax.inject.Provider class ScrobblerRepositoryMap @Inject constructor( private val shikimoriRepository: Provider, private val aniListRepository: Provider, private val malRepository: Provider, private val kitsuRepository: Provider, ) { operator fun get(scrobblerService: ScrobblerService): ScrobblerRepository = when (scrobblerService) { ScrobblerService.SHIKIMORI -> shikimoriRepository ScrobblerService.ANILIST -> aniListRepository ScrobblerService.MAL -> malRepository ScrobblerService.KITSU -> kitsuRepository }.get() } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblerManga.kt ================================================ package org.koitharu.kotatsu.scrobbling.common.domain.model import org.koitharu.kotatsu.list.ui.model.ListModel data class ScrobblerManga( val id: Long, val name: String, val altName: String?, val cover: String?, val url: String, val isBestMatch: Boolean, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is ScrobblerManga && other.id == id } override fun toString(): String { return "ScrobblerManga #$id \"$name\" $url" } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblerMangaInfo.kt ================================================ package org.koitharu.kotatsu.scrobbling.common.domain.model class ScrobblerMangaInfo( val id: Long, val name: String, val cover: String, val url: String, val descriptionHtml: String, ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblerService.kt ================================================ package org.koitharu.kotatsu.scrobbling.common.domain.model import androidx.annotation.DrawableRes import androidx.annotation.StringRes import org.koitharu.kotatsu.R enum class ScrobblerService( val id: Int, @StringRes val titleResId: Int, @DrawableRes val iconResId: Int, ) { SHIKIMORI(1, R.string.shikimori, R.drawable.ic_shikimori), ANILIST(2, R.string.anilist, R.drawable.ic_anilist), MAL(3, R.string.mal, R.drawable.ic_mal), KITSU(4, R.string.kitsu, R.drawable.ic_kitsu) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblerType.kt ================================================ package org.koitharu.kotatsu.scrobbling.common.domain.model import javax.inject.Qualifier @Qualifier annotation class ScrobblerType( val service: ScrobblerService ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblerUser.kt ================================================ package org.koitharu.kotatsu.scrobbling.common.domain.model data class ScrobblerUser( val id: Long, val nickname: String, val avatar: String?, val service: ScrobblerService, ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblingInfo.kt ================================================ package org.koitharu.kotatsu.scrobbling.common.domain.model import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.model.ListModel data class ScrobblingInfo( val scrobbler: ScrobblerService, val mangaId: Long, val targetId: Long, val status: ScrobblingStatus?, val chapter: Int, val comment: String?, val rating: Float, val title: String, val coverUrl: String, val description: CharSequence?, val externalUrl: String, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is ScrobblingInfo && other.scrobbler == scrobbler } override fun getChangePayload(previousState: ListModel): Any? = when { previousState !is ScrobblingInfo -> null previousState.status != status || previousState.rating != rating -> ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED else -> super.getChangePayload(previousState) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblingStatus.kt ================================================ package org.koitharu.kotatsu.scrobbling.common.domain.model import org.koitharu.kotatsu.list.ui.model.ListModel enum class ScrobblingStatus : ListModel { PLANNED, READING, RE_READING, COMPLETED, ON_HOLD, DROPPED; override fun areItemsTheSame(other: ListModel): Boolean { return other is ScrobblingStatus && other.ordinal == ordinal } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/ScrobblerAuthHelper.kt ================================================ package org.koitharu.kotatsu.scrobbling.common.ui import android.annotation.SuppressLint import android.content.Context import android.content.Intent import androidx.core.net.toUri import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerRepositoryMap import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser import org.koitharu.kotatsu.scrobbling.kitsu.ui.KitsuAuthActivity import javax.inject.Inject class ScrobblerAuthHelper @Inject constructor( private val repositoriesMap: ScrobblerRepositoryMap, ) { fun isAuthorized(scrobbler: ScrobblerService) = repositoriesMap[scrobbler].isAuthorized fun getCachedUser(scrobbler: ScrobblerService): ScrobblerUser? { return repositoriesMap[scrobbler].cachedUser } suspend fun getUser(scrobbler: ScrobblerService): ScrobblerUser { return repositoriesMap[scrobbler].loadUser() } @SuppressLint("UnsafeImplicitIntentLaunch") fun startAuth(context: Context, scrobbler: ScrobblerService) = runCatching { if (scrobbler == ScrobblerService.KITSU) { launchKitsuAuth(context) } else { val repository = repositoriesMap[scrobbler] val intent = Intent(Intent.ACTION_VIEW) intent.data = repository.oauthUrl.toUri() context.startActivity(intent) } } private fun launchKitsuAuth(context: Context) { context.startActivity(Intent(context, KitsuAuthActivity::class.java)) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt ================================================ package org.koitharu.kotatsu.scrobbling.common.ui.config import android.content.Intent import android.os.Bundle import android.view.View import androidx.activity.viewModels import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import com.google.android.material.dialog.MaterialAlertDialogBuilder 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.ui.BaseActivity 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.showOrHide import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.databinding.ActivityScrobblerConfigBinding import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.ui.config.adapter.ScrobblingMangaAdapter import androidx.appcompat.R as appcompatR @AndroidEntryPoint class ScrobblerConfigActivity : BaseActivity(), OnListItemClickListener, View.OnClickListener { private val viewModel: ScrobblerConfigViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityScrobblerConfigBinding.inflate(layoutInflater)) setTitle(viewModel.titleResId) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) val listAdapter = ScrobblingMangaAdapter(this) with(viewBinding.recyclerView) { adapter = listAdapter setHasFixedSize(true) val decoration = TypedListSpacingDecoration(context, false) addItemDecoration(decoration) } viewBinding.imageViewAvatar.setOnClickListener(this) viewModel.content.observe(this, listAdapter) viewModel.user.observe(this, this::onUserChanged) viewModel.isLoading.observe(this, this::onLoadingStateChanged) viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) viewModel.onLoggedOut.observeEvent(this) { finishAfterTransition() } processIntent(intent) } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) processIntent(intent) } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val barsInsets = insets.systemBarsInsets val basePadding = v.resources.getDimensionPixelOffset(R.dimen.list_spacing_normal) viewBinding.appbar.updatePadding( top = barsInsets.top, left = barsInsets.left, right = barsInsets.right, ) viewBinding.recyclerView.setPadding( barsInsets.left + basePadding, barsInsets.top + basePadding, barsInsets.right + basePadding, barsInsets.bottom + basePadding, ) return insets.consumeAllSystemBarsInsets() } override fun onItemClick(item: ScrobblingInfo, view: View) { router.openDetails(item.mangaId) } override fun onClick(v: View) { when (v.id) { R.id.imageView_avatar -> showUserDialog() } } private fun processIntent(intent: Intent) { if (intent.action == Intent.ACTION_VIEW) { val uri = intent.data ?: return val code = uri.getQueryParameter("code") if (!code.isNullOrEmpty()) { viewModel.onAuthCodeReceived(code) } } } private fun onUserChanged(user: ScrobblerUser?) { if (user == null) { viewBinding.imageViewAvatar.disposeImage() viewBinding.imageViewAvatar.setImageResource(appcompatR.drawable.abc_ic_menu_overflow_material) return } viewBinding.imageViewAvatar.setImageAsync(user.avatar) } private fun onLoadingStateChanged(isLoading: Boolean) { viewBinding.progressBar.showOrHide(isLoading) } private fun showUserDialog() { MaterialAlertDialogBuilder(this) .setTitle(title) .setMessage(getString(R.string.logged_in_as, viewModel.user.value?.nickname)) .setNegativeButton(R.string.close, null) .setPositiveButton(R.string.logout) { _, _ -> viewModel.logout() }.show() } companion object { const val HOST_SHIKIMORI_AUTH = "shikimori-auth" const val HOST_ANILIST_AUTH = "anilist-auth" const val HOST_MAL_AUTH = "mal-auth" const val HOST_KITSU_AUTH = "kitsu-auth" } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigViewModel.kt ================================================ package org.koitharu.kotatsu.scrobbling.common.ui.config import android.net.Uri import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.R 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.onFirst import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus import javax.inject.Inject @HiltViewModel class ScrobblerConfigViewModel @Inject constructor( savedStateHandle: SavedStateHandle, scrobblers: Set<@JvmSuppressWildcards Scrobbler>, ) : BaseViewModel() { private val scrobblerService = getScrobblerService(savedStateHandle) private val scrobbler = scrobblers.first { it.scrobblerService == scrobblerService } val titleResId = scrobbler.scrobblerService.titleResId val user = MutableStateFlow(null) val onLoggedOut = MutableEventFlow() val content = scrobbler.observeAllScrobblingInfo() .onStart { loadingCounter.increment() } .onFirst { loadingCounter.decrement() } .withErrorHandling() .map { buildContentList(it) } .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) init { scrobbler.user .onEach { user.value = it } .launchIn(viewModelScope + Dispatchers.Default) } fun onAuthCodeReceived(authCode: String) { launchLoadingJob(Dispatchers.Default) { val newUser = scrobbler.authorize(authCode) user.value = newUser } } fun logout() { launchLoadingJob(Dispatchers.Default) { scrobbler.logout() user.value = null onLoggedOut.call(Unit) } } private fun buildContentList(list: List): List { if (list.isEmpty()) { return listOf( EmptyState( icon = R.drawable.ic_empty_history, textPrimary = R.string.nothing_here, textSecondary = R.string.scrobbling_empty_hint, actionStringRes = 0, ), ) } val grouped = list.groupBy { it.status } val statuses = ScrobblingStatus.entries val result = ArrayList(list.size + statuses.size) for (st in statuses) { val subList = grouped[st] if (subList.isNullOrEmpty()) { continue } result.add(st) result.addAll(subList) } return result } private fun getScrobblerService( savedStateHandle: SavedStateHandle, ): ScrobblerService { val serviceId = savedStateHandle.get(AppRouter.KEY_ID) ?: 0 if (serviceId != 0) { return ScrobblerService.entries.first { it.id == serviceId } } val uri = savedStateHandle.require(AppRouter.KEY_DATA) return when (uri.host) { ScrobblerConfigActivity.HOST_SHIKIMORI_AUTH -> ScrobblerService.SHIKIMORI ScrobblerConfigActivity.HOST_ANILIST_AUTH -> ScrobblerService.ANILIST ScrobblerConfigActivity.HOST_MAL_AUTH -> ScrobblerService.MAL ScrobblerConfigActivity.HOST_KITSU_AUTH -> ScrobblerService.KITSU else -> error("Wrong scrobbler uri: $uri") } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingHeaderAD.kt ================================================ package org.koitharu.kotatsu.scrobbling.common.ui.config.adapter import androidx.core.view.isInvisible import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.databinding.ItemHeaderBinding import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus fun scrobblingHeaderAD() = adapterDelegateViewBinding( { inflater, parent -> ItemHeaderBinding.inflate(inflater, parent, false) }, ) { binding.buttonMore.isInvisible = true val strings = context.resources.getStringArray(R.array.scrobbling_statuses) bind { binding.textViewTitle.text = strings.getOrNull(item.ordinal) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAD.kt ================================================ package org.koitharu.kotatsu.scrobbling.common.ui.config.adapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.databinding.ItemScrobblingMangaBinding import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo fun scrobblingMangaAD( clickListener: OnListItemClickListener, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemScrobblingMangaBinding.inflate(layoutInflater, parent, false) }, ) { AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView) bind { binding.imageViewCover.setImageAsync(item.coverUrl, null) binding.textViewTitle.text = item.title binding.ratingBar.rating = item.rating * binding.ratingBar.numStars } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAdapter.kt ================================================ package org.koitharu.kotatsu.scrobbling.common.ui.config.adapter import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo class ScrobblingMangaAdapter( clickListener: OnListItemClickListener, ) : BaseListAdapter() { init { addDelegate(ListItemType.HEADER, scrobblingHeaderAD()) addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(null)) addDelegate(ListItemType.MANGA_SCROBBLING, scrobblingMangaAD(clickListener)) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorSheet.kt ================================================ package org.koitharu.kotatsu.scrobbling.common.ui.selector import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.appcompat.widget.SearchView import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import androidx.fragment.app.viewModels import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.RecyclerView.NO_ID import com.google.android.material.tabs.TabLayout import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.PaginationScrollListener import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.ui.util.CollapseActionViewCallback import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback import org.koitharu.kotatsu.core.util.ext.consume import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition 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.setProgressIcon import org.koitharu.kotatsu.core.util.ext.setTabsEnabled import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.databinding.SheetScrobblingSelectorBinding import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga import org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter.ScrobblerMangaSelectionDecoration import org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter.ScrobblerSelectorAdapter @AndroidEntryPoint class ScrobblingSelectorSheet : BaseAdaptiveSheet(), OnListItemClickListener, PaginationScrollListener.Callback, View.OnClickListener, MenuItem.OnActionExpandListener, SearchView.OnQueryTextListener, TabLayout.OnTabSelectedListener, ListStateHolderListener, AsyncListDiffer.ListListener { private var collapsibleActionViewCallback: CollapseActionViewCallback? = null private var paginationScrollListener: PaginationScrollListener? = null private val viewModel by viewModels() override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetScrobblingSelectorBinding { return SheetScrobblingSelectorBinding.inflate(inflater, container, false) } override fun onViewBindingCreated(binding: SheetScrobblingSelectorBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) disableFitToContents() val listAdapter = ScrobblerSelectorAdapter(this, this) listAdapter.addListListener(this) val decoration = ScrobblerMangaSelectionDecoration(binding.root.context) with(binding.recyclerView) { adapter = listAdapter addItemDecoration(decoration) addItemDecoration(TypedListSpacingDecoration(context, false)) addOnScrollListener( PaginationScrollListener(4, this@ScrobblingSelectorSheet).also { paginationScrollListener = it }, ) } binding.buttonDone.setOnClickListener(this) initOptionsMenu() initTabs() viewModel.content.observe(viewLifecycleOwner, listAdapter) viewModel.selectedItemId.observe(viewLifecycleOwner) { decoration.checkedItemId = it binding.recyclerView.invalidateItemDecorations() } viewModel.onError.observeEvent(viewLifecycleOwner, ::onError) viewModel.onClose.observeEvent(viewLifecycleOwner) { dismiss() } viewModel.isLoading.observe(viewLifecycleOwner) { isLoading -> binding.buttonDone.isEnabled = !isLoading if (isLoading) { binding.buttonDone.setProgressIcon() } else { binding.buttonDone.setIconResource(R.drawable.ic_check) } binding.tabs.setTabsEnabled(!isLoading) } viewModel.selectedScrobblerIndex.observe(viewLifecycleOwner) { index -> val tab = binding.tabs.getTabAt(index) if (tab != null && !tab.isSelected) { tab.select() } } } override fun onDestroyView() { super.onDestroyView() collapsibleActionViewCallback = null paginationScrollListener = null } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val typeMask = WindowInsetsCompat.Type.systemBars() val basePadding = v.resources.getDimensionPixelOffset(R.dimen.list_spacing_normal) viewBinding?.recyclerView?.updatePadding( bottom = basePadding + insets.getInsets(typeMask).bottom, ) return insets.consume(v, typeMask, bottom = true) } override fun onCurrentListChanged(previousList: MutableList, currentList: MutableList) { if (previousList.singleOrNull() is LoadingFooter) { val rv = viewBinding?.recyclerView ?: return val selectedId = viewModel.selectedItemId.value val target = if (selectedId == NO_ID) { 0 } else { currentList.indexOfFirst { it is ScrobblerManga && it.id == selectedId }.coerceAtLeast(0) } rv.post(RecyclerViewScrollCallback(rv, target, if (target == 0) 0 else rv.height / 3)) paginationScrollListener?.postInvalidate(rv) } } override fun onClick(v: View) { when (v.id) { R.id.button_done -> viewModel.onDoneClick() } } override fun onItemClick(item: ScrobblerManga, view: View) { viewModel.selectItem(item.id) } override fun onRetryClick(error: Throwable) { if (ExceptionResolver.canResolve(error)) { viewLifecycleScope.launch { if (exceptionResolver.resolve(error)) { viewModel.retry() } } } else { viewModel.retry() } } override fun onEmptyActionClick() { openSearch() } override fun onScrolledToEnd() { viewModel.loadNextPage() } override fun onMenuItemActionExpand(item: MenuItem): Boolean { setExpanded(isExpanded = true, isLocked = true) collapsibleActionViewCallback?.onMenuItemActionExpand(item) return true } override fun onMenuItemActionCollapse(item: MenuItem): Boolean { val searchView = (item.actionView as? SearchView) ?: return false searchView.setQuery("", false) searchView.post { setExpanded(isExpanded = false, isLocked = false) } collapsibleActionViewCallback?.onMenuItemActionCollapse(item) return true } override fun onQueryTextSubmit(query: String?): Boolean { if (query == null || query.length < 3) { return false } viewModel.search(query) requireViewBinding().toolbar.menu.findItem(R.id.action_search)?.collapseActionView() return true } override fun onQueryTextChange(newText: String?): Boolean = false override fun onTabSelected(tab: TabLayout.Tab) { viewModel.setScrobblerIndex(tab.position) } override fun onTabUnselected(tab: TabLayout.Tab?) = Unit override fun onTabReselected(tab: TabLayout.Tab?) { if (!isExpanded) { setExpanded(isExpanded = true, isLocked = behavior?.isDraggable == false) } requireViewBinding().recyclerView.firstVisibleItemPosition = 0 } private fun openSearch() { val menuItem = requireViewBinding().toolbar.menu.findItem(R.id.action_search) ?: return menuItem.expandActionView() } private fun onError(e: Throwable) { Toast.makeText(requireContext(), e.getDisplayMessage(resources), Toast.LENGTH_LONG).show() if (viewModel.isEmpty) { dismissAllowingStateLoss() } } private fun initOptionsMenu() { requireViewBinding().toolbar.inflateMenu(R.menu.opt_shiki_selector) val searchMenuItem = requireViewBinding().toolbar.menu.findItem(R.id.action_search) searchMenuItem.setOnActionExpandListener(this) val searchView = searchMenuItem.actionView as SearchView searchView.setOnQueryTextListener(this) searchView.setIconifiedByDefault(false) searchView.queryHint = searchMenuItem.title collapsibleActionViewCallback = CollapseActionViewCallback(searchMenuItem).also { onBackPressedDispatcher.addCallback(it) } } private fun initTabs() { val entries = viewModel.availableScrobblers val tabs = requireViewBinding().tabs val selectedId = arguments?.getInt(AppRouter.KEY_ID, -1) ?: -1 tabs.removeAllTabs() tabs.clearOnTabSelectedListeners() tabs.addOnTabSelectedListener(this) for (entry in entries) { val tab = tabs.newTab() tab.tag = entry.scrobblerService tab.setIcon(entry.scrobblerService.iconResId) tab.setText(entry.scrobblerService.titleResId) tabs.addTab(tab) if (entry.scrobblerService.id == selectedId) { tab.select() } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt ================================================ package org.koitharu.kotatsu.scrobbling.common.ui.selector import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import androidx.recyclerview.widget.RecyclerView.NO_ID import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job 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.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga 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.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.requireValue import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.list.domain.ReadingProgress 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.parsers.util.ifZero import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus import org.koitharu.kotatsu.scrobbling.common.ui.selector.model.ScrobblerHint import javax.inject.Inject @HiltViewModel class ScrobblingSelectorViewModel @Inject constructor( savedStateHandle: SavedStateHandle, scrobblers: Set<@JvmSuppressWildcards Scrobbler>, private val historyRepository: HistoryRepository, ) : BaseViewModel() { val manga = savedStateHandle.require(AppRouter.KEY_MANGA).manga val availableScrobblers = scrobblers.filter { it.isEnabled } val selectedScrobblerIndex = MutableStateFlow(0) private val scrobblerMangaList = MutableStateFlow>(emptyList()) private val hasNextPage = MutableStateFlow(true) private val listError = MutableStateFlow(null) private var loadingJob: Job? = null private var doneJob: Job? = null private var initJob: Job? = null private val currentScrobbler: Scrobbler get() = availableScrobblers[selectedScrobblerIndex.requireValue()] val content: StateFlow> = combine( scrobblerMangaList, listError, hasNextPage, ) { list, error, isHasNextPage -> if (list.isNotEmpty()) { if (isHasNextPage) { list + LoadingFooter() } else { list } } else { listOf( when { error != null -> errorHint(error) isHasNextPage -> LoadingFooter() else -> emptyResultsHint() }, ) } }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) val selectedItemId = MutableStateFlow(NO_ID) val onClose = MutableEventFlow() private val searchQuery = MutableStateFlow(manga.title) val isEmpty: Boolean get() = scrobblerMangaList.value.isEmpty() init { initialize() } fun search(query: String) { loadingJob?.cancel() searchQuery.value = query loadList(append = false) } fun selectItem(id: Long) { if (doneJob?.isActive == true) { return } selectedItemId.value = id } fun loadNextPage() { if (scrobblerMangaList.value.isNotEmpty() && hasNextPage.value) { loadList(append = true) } } fun retry() { loadingJob?.cancel() hasNextPage.value = true scrobblerMangaList.value = emptyList() loadList(append = false) } private fun loadList(append: Boolean) { if (loadingJob?.isActive == true) { return } loadingJob = launchJob(Dispatchers.Default) { listError.value = null val offset = if (append) scrobblerMangaList.value.size else 0 runCatchingCancellable { currentScrobbler.findManga(checkNotNull(searchQuery.value), offset) }.onSuccess { list -> val newList = (if (append) { scrobblerMangaList.value + list } else { list }).distinctBy { x -> x.id } val changed = newList != scrobblerMangaList.value scrobblerMangaList.value = newList hasNextPage.value = changed && newList.isNotEmpty() }.onFailure { error -> error.printStackTraceDebug() hasNextPage.value = false listError.value = error } } } fun onDoneClick() { if (doneJob?.isActive == true) { return } val targetId = selectedItemId.value if (targetId == NO_ID) { onClose.call(Unit) } doneJob = launchLoadingJob(Dispatchers.Default) { val prevInfo = currentScrobbler.getScrobblingInfoOrNull(manga.id) currentScrobbler.linkManga(manga.id, targetId) val history = historyRepository.getOne(manga) currentScrobbler.updateScrobblingInfo( mangaId = manga.id, rating = prevInfo?.rating ?: 0f, status = prevInfo?.status ?: when { history == null -> ScrobblingStatus.PLANNED ReadingProgress.isCompleted(history.percent) -> ScrobblingStatus.COMPLETED else -> ScrobblingStatus.READING }, comment = prevInfo?.comment, ) if (history != null) { currentScrobbler.scrobble( manga = manga, chapterId = history.chapterId, ) } onClose.call(Unit) } } fun setScrobblerIndex(index: Int) { if (index == selectedScrobblerIndex.value || index !in availableScrobblers.indices) return selectedScrobblerIndex.value = index initialize() } private fun initialize() { initJob?.cancel() loadingJob?.cancel() hasNextPage.value = true scrobblerMangaList.value = emptyList() initJob = launchJob(Dispatchers.Default) { try { val info = currentScrobbler.getScrobblingInfoOrNull(manga.id) if (info != null) { selectedItemId.value = info.targetId } } finally { loadList(append = false) } } } private fun emptyResultsHint() = ScrobblerHint( icon = R.drawable.ic_empty_history, textPrimary = R.string.nothing_found, textSecondary = R.string.text_search_holder_secondary, error = null, actionStringRes = R.string.search, ) private fun errorHint(e: Throwable): ScrobblerHint { val resolveAction = ExceptionResolver.getResolveStringId(e) return ScrobblerHint( icon = R.drawable.ic_error_large, textPrimary = R.string.error_occurred, error = e, textSecondary = if (resolveAction == 0) 0 else R.string.try_again, actionStringRes = resolveAction.ifZero { R.string.try_again }, ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblerHintAD.kt ================================================ package org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ItemEmptyHintBinding import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.scrobbling.common.ui.selector.model.ScrobblerHint fun scrobblerHintAD( listener: ListStateHolderListener, ) = adapterDelegateViewBinding( { inflater, parent -> ItemEmptyHintBinding.inflate(inflater, parent, false) }, ) { binding.buttonRetry.setOnClickListener { val e = item.error if (e != null) { listener.onRetryClick(e) } else { listener.onEmptyActionClick() } } bind { binding.icon.setImageResource(item.icon) binding.textPrimary.setText(item.textPrimary) if (item.error != null) { binding.textSecondary.textAndVisible = item.error?.getDisplayMessage(context.resources) } else { binding.textSecondary.setTextAndVisible(item.textSecondary) } binding.buttonRetry.setTextAndVisible(item.actionStringRes) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblerMangaSelectionDecoration.kt ================================================ package org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter import android.content.Context import android.graphics.Canvas import android.graphics.Paint import android.graphics.RectF import android.view.View import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.NO_ID import org.koitharu.kotatsu.core.util.ext.getItem import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga class ScrobblerMangaSelectionDecoration(context: Context) : MangaSelectionDecoration(context) { var checkedItemId: Long get() = if (selection.size == 1) { selection.first() } else { NO_ID } set(value) { clearSelection() if (value != NO_ID) { selection.add(value) } } override fun getItemId(parent: RecyclerView, child: View): Long { val holder = parent.getChildViewHolder(child) ?: return NO_ID val item = holder.getItem(ScrobblerManga::class.java) ?: return NO_ID return item.id } override fun onDrawForeground( canvas: Canvas, parent: RecyclerView, child: View, bounds: RectF, state: RecyclerView.State, ) { paint.color = strokeColor paint.style = Paint.Style.STROKE canvas.drawRoundRect(bounds, defaultRadius, defaultRadius, paint) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblerSelectorAdapter.kt ================================================ package org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener 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.scrobbling.common.domain.model.ScrobblerManga class ScrobblerSelectorAdapter( clickListener: OnListItemClickListener, stateHolderListener: ListStateHolderListener, ) : BaseListAdapter() { init { addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) addDelegate(ListItemType.MANGA_SCROBBLING, scrobblingMangaAD(clickListener)) addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD()) addDelegate(ListItemType.HINT_EMPTY, scrobblerHintAD(stateHolderListener)) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblingMangaAD.kt ================================================ package org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ItemMangaListBinding import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga fun scrobblingMangaAD( clickListener: OnListItemClickListener, ) = adapterDelegateViewBinding( { inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) }, ) { itemView.setOnClickListener { clickListener.onItemClick(item, it) } bind { binding.textViewTitle.text = item.name val endIcon = if (item.isBestMatch) R.drawable.ic_star_small else 0 binding.textViewTitle.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, endIcon, 0) binding.textViewSubtitle.textAndVisible = item.altName binding.imageViewCover.setImageAsync(item.cover, null) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/model/ScrobblerHint.kt ================================================ package org.koitharu.kotatsu.scrobbling.common.ui.selector.model import androidx.annotation.DrawableRes import androidx.annotation.StringRes import org.koitharu.kotatsu.list.ui.model.ListModel data class ScrobblerHint( @DrawableRes val icon: Int, @StringRes val textPrimary: Int, @StringRes val textSecondary: Int, val error: Throwable?, @StringRes val actionStringRes: Int, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is ScrobblerHint && other.textPrimary == textPrimary } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/data/DiscordRepository.kt ================================================ package org.koitharu.kotatsu.scrobbling.discord.data import android.content.Context import dagger.Reusable import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.internal.closeQuietly import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.network.BaseHttpClient import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.ensureSuccess import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.parseRaw import javax.inject.Inject private const val SCHEME_MP = "mp:" @Reusable class DiscordRepository @Inject constructor( @ApplicationContext context: Context, private val settings: AppSettings, @BaseHttpClient private val httpClient: OkHttpClient, ) { private val appId = context.getString(R.string.discord_app_id) suspend fun getMediaProxyUrl(url: String): String? { if (isMediaProxyUrl(url)) { return url } val token = checkNotNull(settings.discordToken) { "Discord token is missing" } val request = Request.Builder() .url("https://discord.com/api/v10/applications/${appId}/external-assets") .header(CommonHeaders.AUTHORIZATION, token) .post("{\"urls\":[\"${url}\"]}".toRequestBody("application/json".toMediaType())) .build() val body = httpClient.newCall(request).await().parseRaw() when (val json = Json.parseToJsonElement(body)) { is JsonObject -> throw RuntimeException(json.jsonObject["message"]?.jsonPrimitive?.content) is JsonArray -> { val externalAssetPath = json.firstOrNull() ?.jsonObject ?.get("external_asset_path") ?.toString() ?.replace("\"", "") return externalAssetPath?.let { SCHEME_MP + it } } else -> throw RuntimeException("Unexpected response: $json") } } fun isMediaProxyUrl(url: String) = url.startsWith(SCHEME_MP) suspend fun checkToken(token: String) { val request = Request.Builder() .url("https://discord.com/api/v10/users/@me") .header(CommonHeaders.AUTHORIZATION, token) .get() .build() httpClient.newCall(request).await().ensureSuccess().closeQuietly() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/ui/DiscordAuthActivity.kt ================================================ package org.koitharu.kotatsu.scrobbling.discord.ui import android.os.Bundle import android.view.MenuItem import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.browser.BaseBrowserActivity import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.parsers.model.MangaSource import javax.inject.Inject @AndroidEntryPoint class DiscordAuthActivity : BaseBrowserActivity(), DiscordTokenWebClient.Callback { @Inject lateinit var settings: AppSettings override fun onCreate2( savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository? ) { setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true) viewBinding.webView.settings.userAgentString = USER_AGENT viewBinding.webView.webViewClient = DiscordTokenWebClient(this) if (savedInstanceState == null) { viewBinding.webView.loadUrl(BASE_URL) } } override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { android.R.id.home -> { viewBinding.webView.stopLoading() finishAfterTransition() true } else -> super.onOptionsItemSelected(item) } override fun onTokenObtained(token: String) { settings.discordToken = token setResult(RESULT_OK) finish() } private companion object { const val BASE_URL = "https://discord.com/login" private const val USER_AGENT = "Mozilla/5.0 (Linux; Android 14; SM-S921U; Build/UP1A.231005.007) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.363" } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/ui/DiscordRpc.kt ================================================ package org.koitharu.kotatsu.scrobbling.discord.ui import android.content.Context import android.os.SystemClock import androidx.annotation.AnyThread import androidx.collection.ArrayMap import com.my.kizzyrpc.KizzyRPC import com.my.kizzyrpc.entities.presence.Activity import com.my.kizzyrpc.entities.presence.Assets import com.my.kizzyrpc.entities.presence.Metadata import com.my.kizzyrpc.entities.presence.Timestamps import dagger.hilt.android.ViewModelLifecycle import dagger.hilt.android.lifecycle.RetainedLifecycle import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.plus import okio.utf8Size import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.LocalizedAppContext import org.koitharu.kotatsu.core.model.appUrl import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.lifecycleScope import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState import org.koitharu.kotatsu.scrobbling.discord.data.DiscordRepository import java.util.Collections import javax.inject.Inject private const val STATUS_ONLINE = "online" private const val STATUS_IDLE = "idle" private const val BUTTON_TEXT_LIMIT = 32 private const val DEBOUNCE_TIMEOUT = 16_000L // 16 sec @ViewModelScoped class DiscordRpc @Inject constructor( @LocalizedAppContext private val context: Context, private val settings: AppSettings, private val repository: DiscordRepository, lifecycle: ViewModelLifecycle, ) : RetainedLifecycle.OnClearedListener { private val coroutineScope = lifecycle.lifecycleScope + Dispatchers.Default private val appId = context.getString(R.string.discord_app_id) private val appName = context.getString(R.string.app_name) private val appIcon = context.getString(R.string.app_icon_url) private val mpCache = Collections.synchronizedMap(ArrayMap()) private var lastUpdate = 0L private var rpc: KizzyRPC? = null private var rpcUpdateJob: Job? = null @Volatile private var lastActivity: Activity? = null init { lifecycle.addOnClearedListener(this) } override fun onCleared() { clearRpc() } fun clearRpc() = synchronized(this) { rpc?.closeRPC() rpc = null lastUpdate = 0L } fun setIdle() { lastActivity?.let { activity -> getRpc()?.updateRpcAsync(activity, idle = true) } } @AnyThread fun updateRpc(manga: Manga, state: ReaderUiState) { getRpc()?.run { if (settings.isDiscordRpcSkipNsfw && manga.isNsfw()) { clearRpc() return } updateRpcAsync( activity = Activity( applicationId = appId, name = appName, details = manga.title, state = context.getString(R.string.chapter_d_of_d, state.chapterNumber, state.chaptersTotal), type = 3, timestamps = Timestamps( start = lastActivity?.timestamps?.start ?: System.currentTimeMillis(), ), assets = Assets( largeImage = manga.coverUrl, largeText = context.getString(R.string.reading_s, manga.title), smallText = context.getString(R.string.discord_rpc_description), smallImage = appIcon, ), buttons = listOf( context.getString(R.string.read_on_s, appName), context.getString(R.string.read_on_s, manga.source.getTitle(context)), ), metadata = Metadata(listOf(manga.appUrl.toString(), manga.publicUrl)), ), idle = false, ) } } private fun KizzyRPC.updateRpcAsync(activity: Activity, idle: Boolean) { val prevJob = rpcUpdateJob rpcUpdateJob = coroutineScope.launch { prevJob?.cancelAndJoin() val debounceTime = lastUpdate + DEBOUNCE_TIMEOUT - SystemClock.elapsedRealtime() if (debounceTime > 0) { delay(debounceTime) } val hideButtons = activity.buttons?.any { it != null && it.utf8Size() > BUTTON_TEXT_LIMIT } ?: false val mappedActivity = activity.copy( assets = activity.assets?.let { it.copy( largeImage = it.largeImage?.toMediaProxyUrl(), smallImage = it.smallImage?.toMediaProxyUrl(), ) }, buttons = activity.buttons.takeUnless { hideButtons }, metadata = activity.metadata.takeUnless { hideButtons }, ) lastActivity = mappedActivity updateRPC( activity = mappedActivity, status = if (idle) STATUS_IDLE else STATUS_ONLINE, since = activity.timestamps?.start ?: System.currentTimeMillis(), ) lastUpdate = SystemClock.elapsedRealtime() } } suspend fun String.toMediaProxyUrl(): String? { if (repository.isMediaProxyUrl(this)) { return this } mpCache[this]?.let { return it } return runCatchingCancellable { repository.getMediaProxyUrl(this) }.onSuccess { url -> mpCache[this] = url }.onFailure { it.printStackTraceDebug() }.getOrNull() } private fun getRpc(): KizzyRPC? { rpc?.let { return it } return synchronized(this) { rpc?.let { return@synchronized it } if (settings.isDiscordRpcEnabled) { settings.discordToken?.let { KizzyRPC(it) } } else { null }.also { rpc = it } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/ui/DiscordTokenWebClient.kt ================================================ package org.koitharu.kotatsu.scrobbling.discord.ui import android.graphics.Bitmap import android.webkit.WebView import org.koitharu.kotatsu.browser.BrowserCallback import org.koitharu.kotatsu.browser.BrowserClient import org.koitharu.kotatsu.parsers.util.removeSurrounding class DiscordTokenWebClient(private val callback: Callback) : BrowserClient(callback, null) { override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { super.onPageStarted(view, url, favicon) if (view != null) { checkToken(view) } } private fun checkToken(view: WebView) { view.evaluateJavascript("window.localStorage.token") { result -> val token = result ?.replace("\\\"", "") ?.removeSurrounding('"') ?.takeUnless { it == "null" } if (!token.isNullOrEmpty()) { callback.onTokenObtained(token) } } } interface Callback : BrowserCallback { fun onTokenObtained(token: String) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/data/KitsuAuthenticator.kt ================================================ package org.koitharu.kotatsu.scrobbling.kitsu.data import kotlinx.coroutines.runBlocking import okhttp3.Authenticator import okhttp3.Request import okhttp3.Response import okhttp3.Route import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerType import javax.inject.Inject import javax.inject.Provider class KitsuAuthenticator @Inject constructor( @ScrobblerType(ScrobblerService.KITSU) private val storage: ScrobblerStorage, private val repositoryProvider: Provider, ) : Authenticator { override fun authenticate(route: Route?, response: Response): Request? { val accessToken = storage.accessToken ?: return null if (!isRequestWithAccessToken(response)) { return null } synchronized(this) { val newAccessToken = storage.accessToken ?: return null if (accessToken != newAccessToken) { return newRequestWithAccessToken(response.request, newAccessToken) } val updatedAccessToken = refreshAccessToken() ?: return null return newRequestWithAccessToken(response.request, updatedAccessToken) } } private fun isRequestWithAccessToken(response: Response): Boolean { val header = response.request.header(CommonHeaders.AUTHORIZATION) return header?.startsWith("Bearer") == true } private fun newRequestWithAccessToken(request: Request, accessToken: String): Request { return request.newBuilder() .header(CommonHeaders.AUTHORIZATION, "Bearer $accessToken") .build() } private fun refreshAccessToken(): String? = runCatching { val repository = repositoryProvider.get() runBlocking { repository.authorize(null) } return storage.accessToken }.onFailure { it.printStackTraceDebug() }.getOrNull() } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/data/KitsuInterceptor.kt ================================================ package org.koitharu.kotatsu.scrobbling.kitsu.data import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.Response import okhttp3.internal.closeQuietly import okio.IOException import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.parsers.util.mimeType import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.parsers.util.parseHtml import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import java.net.HttpURLConnection class KitsuInterceptor(private val storage: ScrobblerStorage) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val sourceRequest = chain.request() val request = sourceRequest.newBuilder() request.header(CommonHeaders.CONTENT_TYPE, VND_JSON) request.header(CommonHeaders.ACCEPT, VND_JSON) val isAuthRequest = sourceRequest.url.pathSegments.contains("oauth") if (!isAuthRequest) { storage.accessToken?.let { request.header(CommonHeaders.AUTHORIZATION, "Bearer $it") } } val response = chain.proceed(request.build()) if (!isAuthRequest && response.code == HttpURLConnection.HTTP_UNAUTHORIZED) { response.closeQuietly() throw ScrobblerAuthRequiredException(ScrobblerService.KITSU) } if (response.mimeType?.toMediaTypeOrNull()?.subtype == SUBTYPE_HTML) { val message = runCatchingCancellable { response.parseHtml().title().nullIfEmpty() }.onFailure { response.closeQuietly() }.getOrNull() ?: "Invalid response (${response.code})" throw IOException(message) } return response } companion object { const val VND_JSON = "application/vnd.api+json" const val SUBTYPE_HTML = "html" } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/data/KitsuRepository.kt ================================================ package org.koitharu.kotatsu.scrobbling.kitsu.data import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import okhttp3.FormBody import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okio.IOException import org.json.JSONObject import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.util.ext.parseJsonOrNull import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault import org.koitharu.kotatsu.parsers.util.json.getStringOrNull import org.koitharu.kotatsu.parsers.util.json.mapJSON import org.koitharu.kotatsu.parsers.util.parseJson import org.koitharu.kotatsu.parsers.util.urlEncoded import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerMangaInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser import org.koitharu.kotatsu.scrobbling.kitsu.data.KitsuInterceptor.Companion.VND_JSON private const val BASE_WEB_URL = "https://kitsu.app" class KitsuRepository( @ApplicationContext context: Context, private val okHttp: OkHttpClient, private val storage: ScrobblerStorage, private val db: MangaDatabase, ) : ScrobblerRepository { // not in use yet private val clientId = context.getString(R.string.kitsu_clientId) private val clientSecret = context.getString(R.string.kitsu_clientSecret) override val oauthUrl: String = "kotatsu+kitsu://auth" override val isAuthorized: Boolean get() = storage.accessToken != null override val cachedUser: ScrobblerUser? get() { return storage.user } override suspend fun authorize(code: String?) { val body = FormBody.Builder() if (code != null) { body.add("grant_type", "password") body.add("username", code.substringBefore(';')) body.add("password", code.substringAfter(';')) } else { body.add("grant_type", "refresh_token") body.add("refresh_token", checkNotNull(storage.refreshToken)) } val request = Request.Builder() .post(body.build()) .url("$BASE_WEB_URL/api/oauth/token") val response = okHttp.newCall(request.build()).await().parseJson() storage.accessToken = response.getString("access_token") storage.refreshToken = response.getString("refresh_token") } override suspend fun loadUser(): ScrobblerUser { val request = Request.Builder() .get() .url("$BASE_WEB_URL/api/edge/users?filter[self]=true") val response = okHttp.newCall(request.build()).await().parseJson() .getJSONArray("data") .getJSONObject(0) return ScrobblerUser( id = response.getAsLong("id"), nickname = response.getJSONObject("attributes").getString("name"), avatar = response.getJSONObject("attributes").optJSONObject("avatar")?.getStringOrNull("small"), service = ScrobblerService.KITSU, ).also { storage.user = it } } override fun logout() { storage.clear() } override suspend fun unregister(mangaId: Long) { return db.getScrobblingDao().delete(ScrobblerService.KITSU.id, mangaId) } override suspend fun findManga(query: String, offset: Int): List { val request = Request.Builder() .get() .url("$BASE_WEB_URL/api/edge/manga?page[limit]=20&page[offset]=$offset&filter[text]=${query.urlEncoded()}") val response = okHttp.newCall(request.build()).await().parseJson().ensureSuccess() return response.getJSONArray("data").mapJSON { jo -> val attrs = jo.getJSONObject("attributes") val titles = attrs.getJSONObject("titles").valuesToStringList() ScrobblerManga( id = jo.getAsLong("id"), name = titles.first(), altName = titles.drop(1).joinToString(), cover = attrs.getJSONObject("posterImage").getStringOrNull("small").orEmpty(), url = "$BASE_WEB_URL/manga/${attrs.getString("slug")}", isBestMatch = titles.any { it.equals(query, ignoreCase = true) } ) } } override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo { val request = Request.Builder() .get() .url("$BASE_WEB_URL/api/edge/manga/$id") val data = okHttp.newCall(request.build()).await().parseJson().ensureSuccess().getJSONObject("data") val attrs = data.getJSONObject("attributes") return ScrobblerMangaInfo( id = data.getAsLong("id"), name = attrs.getString("canonicalTitle"), cover = attrs.getJSONObject("posterImage").getString("medium"), url = "$BASE_WEB_URL/manga/${attrs.getString("slug")}", descriptionHtml = attrs.getString("description").replace("\\n", "
"), ) } override suspend fun createRate(mangaId: Long, scrobblerMangaId: Long) { findExistingRate(scrobblerMangaId)?.let { saveRate(it, mangaId) return } val user = cachedUser ?: loadUser() val payload = JSONObject() payload.putJO("data") { put("type", "libraryEntries") putJO("attributes") { put("status", "planned") // will be updated by next call put("progress", 0) } putJO("relationships") { putJO("manga") { putJO("data") { put("type", "manga") put("id", scrobblerMangaId) } } putJO("user") { putJO("data") { put("type", "users") put("id", user.id) } } } } val request = Request.Builder() .url("$BASE_WEB_URL/api/edge/library-entries?include=manga") .post(payload.toKitsuRequestBody()) val response = okHttp.newCall(request.build()).await().parseJson().ensureSuccess().getJSONObject("data") saveRate(response, mangaId) } override suspend fun updateRate(rateId: Int, mangaId: Long, chapter: Int) { val payload = JSONObject() payload.putJO("data") { put("type", "libraryEntries") put("id", rateId) putJO("attributes") { put("progress", chapter) } } val request = Request.Builder() .url("$BASE_WEB_URL/api/edge/library-entries/$rateId?include=manga") .patch(payload.toKitsuRequestBody()) val response = okHttp.newCall(request.build()).await().parseJson().ensureSuccess().getJSONObject("data") saveRate(response, mangaId) } override suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) { val payload = JSONObject() payload.putJO("data") { put("type", "libraryEntries") put("id", rateId) putJO("attributes") { put("status", status) put("ratingTwenty", (rating * 20).toInt().coerceIn(2, 20)) put("notes", comment) } } val request = Request.Builder() .url("$BASE_WEB_URL/api/edge/library-entries/$rateId?include=manga") .patch(payload.toKitsuRequestBody()) val response = okHttp.newCall(request.build()).await().parseJson().ensureSuccess().getJSONObject("data") saveRate(response, mangaId) } private fun JSONObject.valuesToStringList(): List { val result = ArrayList(length()) for (key in keys()) { result.add(getStringOrNull(key) ?: continue) } return result } private inline fun JSONObject.putJO(name: String, init: JSONObject.() -> Unit) { put(name, JSONObject().apply(init)) } private fun JSONObject.toKitsuRequestBody() = toString().toRequestBody(VND_JSON.toMediaType()) private suspend fun findExistingRate(scrobblerMangaId: Long): JSONObject? { val userId = (cachedUser ?: loadUser()).id val request = Request.Builder() .get() .url("$BASE_WEB_URL/api/edge/library-entries?filter[manga_id]=$scrobblerMangaId&filter[userId]=$userId&include=manga") val data = okHttp.newCall(request.build()).await().parseJsonOrNull()?.optJSONArray("data") ?: return null return data.optJSONObject(0) } private suspend fun saveRate(json: JSONObject, mangaId: Long) { val attrs = json.getJSONObject("attributes") val manga = json.getJSONObject("relationships").getJSONObject("manga").getJSONObject("data") val entity = ScrobblingEntity( scrobbler = ScrobblerService.KITSU.id, id = json.getInt("id"), mangaId = mangaId, targetId = manga.getAsLong("id"), status = attrs.getString("status"), chapter = attrs.getIntOrDefault("progress", 0), comment = attrs.getStringOrNull("notes"), rating = (attrs.getFloatOrDefault("ratingTwenty", 0f) / 20f).coerceIn(0f, 1f), ) db.getScrobblingDao().upsert(entity) } private fun JSONObject.ensureSuccess(): JSONObject { val error = optJSONArray("errors")?.optJSONObject(0) ?: return this val title = error.getString("title") val detail = error.getStringOrNull("detail") throw IOException("$title: $detail") } private fun JSONObject.getAsLong(name: String): Long = when (val rawValue = opt(name)) { is Long -> rawValue is Number -> rawValue.toLong() is String -> rawValue.toLong() else -> throw IllegalArgumentException("Value $rawValue at \"$name\" is not of type long") } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/domain/KitsuScrobbler.kt ================================================ package org.koitharu.kotatsu.scrobbling.kitsu.domain import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus import org.koitharu.kotatsu.scrobbling.kitsu.data.KitsuRepository import javax.inject.Inject class KitsuScrobbler @Inject constructor( private val repository: KitsuRepository, db: MangaDatabase, mangaRepositoryFactory: MangaRepository.Factory, ) : Scrobbler(db, ScrobblerService.KITSU, repository, mangaRepositoryFactory) { init { statuses[ScrobblingStatus.PLANNED] = "planned" statuses[ScrobblingStatus.READING] = "current" statuses[ScrobblingStatus.COMPLETED] = "completed" statuses[ScrobblingStatus.ON_HOLD] = "on_hold" statuses[ScrobblingStatus.DROPPED] = "dropped" } override suspend fun updateScrobblingInfo( mangaId: Long, rating: Float, status: ScrobblingStatus?, comment: String? ) { val entity = db.getScrobblingDao().find(scrobblerService.id, mangaId) requireNotNull(entity) { "Scrobbling info for manga $mangaId not found" } repository.updateRate( rateId = entity.id, mangaId = entity.mangaId, rating = rating, status = statuses[status], comment = comment, ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/ui/KitsuAuthActivity.kt ================================================ package org.koitharu.kotatsu.scrobbling.kitsu.ui import android.annotation.SuppressLint import android.content.Intent import android.os.Bundle import android.text.Editable import android.view.KeyEvent import android.view.View import android.view.ViewGroup.MarginLayoutParams import android.widget.TextView import androidx.core.net.toUri import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.util.DefaultTextWatcher import org.koitharu.kotatsu.core.util.ext.consume import org.koitharu.kotatsu.databinding.ActivityKitsuAuthBinding import org.koitharu.kotatsu.parsers.util.urlEncoded class KitsuAuthActivity : BaseActivity(), View.OnClickListener, DefaultTextWatcher, TextView.OnEditorActionListener { private val regexEmail = Regex("^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}$", RegexOption.IGNORE_CASE) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityKitsuAuthBinding.inflate(layoutInflater)) viewBinding.buttonCancel.setOnClickListener(this) viewBinding.buttonDone.setOnClickListener(this) viewBinding.editEmail.addTextChangedListener(this) viewBinding.editEmail.setOnEditorActionListener(this) viewBinding.editPassword.addTextChangedListener(this) viewBinding.editPassword.setOnEditorActionListener(this) } override fun onApplyWindowInsets( v: View, insets: WindowInsetsCompat ): WindowInsetsCompat { val typeMask = WindowInsetsCompat.Type.systemBars() val screenPadding = v.resources.getDimensionPixelOffset(R.dimen.screen_padding) val barsInsets = insets.getInsets(typeMask) viewBinding.root.updatePadding(top = barsInsets.top) viewBinding.dockedToolbarChild.updateLayoutParams { leftMargin = barsInsets.left rightMargin = barsInsets.right bottomMargin = barsInsets.bottom } viewBinding.layoutEmail.updateLayoutParams { leftMargin = barsInsets.left + screenPadding rightMargin = barsInsets.right + screenPadding } viewBinding.layoutPassword.updateLayoutParams { leftMargin = barsInsets.left + screenPadding rightMargin = barsInsets.right + screenPadding } return insets.consume(v, typeMask) } override fun onClick(v: View) { when (v.id) { R.id.button_cancel -> finish() R.id.button_done -> continueAuth() } } override fun onEditorAction( v: TextView, actionId: Int, event: KeyEvent? ): Boolean = when (v.id) { R.id.edit_email -> { viewBinding.editPassword.requestFocus() true } R.id.edit_password -> { if (viewBinding.buttonDone.isEnabled) { continueAuth() true } else { false } } else -> false } override fun afterTextChanged(s: Editable?) { val email = viewBinding.editEmail.text?.toString()?.trim() val password = viewBinding.editPassword.text?.toString()?.trim() viewBinding.buttonDone.isEnabled = !email.isNullOrEmpty() && !password.isNullOrEmpty() && regexEmail.matches(email) && password.length >= 3 } @SuppressLint("UnsafeImplicitIntentLaunch") private fun continueAuth() { val email = viewBinding.editEmail.text?.toString()?.trim().orEmpty() val password = viewBinding.editPassword.text?.toString()?.trim().orEmpty() val url = "kotatsu://kitsu-auth?code=" + "$email;$password".urlEncoded() val intent = Intent(Intent.ACTION_VIEW, url.toUri()) startActivity(intent) finishAfterTransition() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/mal/data/MALAuthenticator.kt ================================================ package org.koitharu.kotatsu.scrobbling.mal.data import kotlinx.coroutines.runBlocking import okhttp3.Authenticator import okhttp3.Request import okhttp3.Response import okhttp3.Route import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerType import javax.inject.Inject import javax.inject.Provider class MALAuthenticator @Inject constructor( @ScrobblerType(ScrobblerService.MAL) private val storage: ScrobblerStorage, private val repositoryProvider: Provider, ) : Authenticator { override fun authenticate(route: Route?, response: Response): Request? { val accessToken = storage.accessToken ?: return null if (!isRequestWithAccessToken(response)) { return null } synchronized(this) { val newAccessToken = storage.accessToken ?: return null if (accessToken != newAccessToken) { return newRequestWithAccessToken(response.request, newAccessToken) } val updatedAccessToken = refreshAccessToken() ?: return null return newRequestWithAccessToken(response.request, updatedAccessToken) } } private fun isRequestWithAccessToken(response: Response): Boolean { val header = response.request.header(CommonHeaders.AUTHORIZATION) return header?.startsWith("Bearer") == true } private fun newRequestWithAccessToken(request: Request, accessToken: String): Request { return request.newBuilder() .header(CommonHeaders.AUTHORIZATION, "Bearer $accessToken") .build() } private fun refreshAccessToken(): String? = runCatching { val repository = repositoryProvider.get() runBlocking { repository.authorize(null) } return storage.accessToken }.onFailure { it.printStackTraceDebug() }.getOrNull() } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/mal/data/MALInterceptor.kt ================================================ package org.koitharu.kotatsu.scrobbling.mal.data import okhttp3.Interceptor import okhttp3.Response import okio.IOException import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.parsers.util.mimeType import org.koitharu.kotatsu.parsers.util.parseHtml import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import java.net.HttpURLConnection private const val JSON = "application/json" private const val HTML = "text/html" class MALInterceptor(private val storage: ScrobblerStorage) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val sourceRequest = chain.request() val request = sourceRequest.newBuilder() request.header(CommonHeaders.CONTENT_TYPE, JSON) request.header(CommonHeaders.ACCEPT, JSON) val isAuthRequest = sourceRequest.url.pathSegments.contains("oauth") if (!isAuthRequest) { storage.accessToken?.let { request.header(CommonHeaders.AUTHORIZATION, "Bearer $it") } } val response = chain.proceed(request.build()) if (!isAuthRequest && response.code == HttpURLConnection.HTTP_UNAUTHORIZED) { throw ScrobblerAuthRequiredException(ScrobblerService.MAL) } if (response.mimeType == HTML) { throw IOException(response.parseHtml().title()) } return response } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/mal/data/MALRepository.kt ================================================ package org.koitharu.kotatsu.scrobbling.mal.data import android.content.Context import android.util.Base64 import dagger.hilt.android.qualifiers.ApplicationContext import okhttp3.FormBody import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request import org.json.JSONObject import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.json.getStringOrNull import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull import org.koitharu.kotatsu.parsers.util.parseJson import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerMangaInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerType import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser import java.security.SecureRandom import javax.inject.Inject import javax.inject.Singleton private const val REDIRECT_URI = "kotatsu://mal-auth" private const val BASE_WEB_URL = "https://myanimelist.net" private const val BASE_API_URL = "https://api.myanimelist.net/v2" @Singleton class MALRepository @Inject constructor( @ApplicationContext context: Context, @ScrobblerType(ScrobblerService.MAL) private val okHttp: OkHttpClient, @ScrobblerType(ScrobblerService.MAL) private val storage: ScrobblerStorage, private val db: MangaDatabase, ) : ScrobblerRepository { private val clientId = context.getString(R.string.mal_clientId) private val codeVerifier: String by lazy(::generateCodeVerifier) override val oauthUrl: String get() = "$BASE_WEB_URL/v1/oauth2/authorize?" + "response_type=code" + "&client_id=$clientId" + "&redirect_uri=$REDIRECT_URI" + "&code_challenge=$codeVerifier" + "&code_challenge_method=plain" override val isAuthorized: Boolean get() = storage.accessToken != null override val cachedUser: ScrobblerUser? get() { return storage.user } override suspend fun authorize(code: String?) { val body = FormBody.Builder() if (code != null) { body.add("client_id", clientId) body.add("grant_type", "authorization_code") body.add("code", code) body.add("redirect_uri", REDIRECT_URI) body.add("code_verifier", codeVerifier) } val request = Request.Builder() .post(body.build()) .url("${BASE_WEB_URL}/v1/oauth2/token") val response = okHttp.newCall(request.build()).await().parseJson() storage.accessToken = response.getString("access_token") storage.refreshToken = response.getString("refresh_token") } override suspend fun loadUser(): ScrobblerUser { val request = Request.Builder() .get() .url("${BASE_API_URL}/users/@me") val response = okHttp.newCall(request.build()).await().parseJson() return MALUser(response).also { storage.user = it } } override suspend fun unregister(mangaId: Long) { return db.getScrobblingDao().delete(ScrobblerService.MAL.id, mangaId) } override suspend fun findManga(query: String, offset: Int): List { val url = BASE_API_URL.toHttpUrl().newBuilder() .addPathSegment("manga") .addQueryParameter("offset", offset.toString()) .addQueryParameter("nsfw", "true") // WARNING! MAL API throws a 400 when the query is over 64 characters .addQueryParameter("q", query.take(64)) .build() val request = Request.Builder().url(url).get().build() val response = okHttp.newCall(request).await().parseJson() check(response.has("data")) { "Invalid response: \"$response\"" } val data = response.getJSONArray("data") return data.mapJSONNotNull { jsonToManga(it, query) } } override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo { val url = BASE_API_URL.toHttpUrl().newBuilder() .addPathSegment("manga") .addPathSegment(id.toString()) .addQueryParameter("fields", "synopsis") .build() val request = Request.Builder().url(url) val response = okHttp.newCall(request.build()).await().parseJson() return ScrobblerMangaInfo(response) } override suspend fun createRate(mangaId: Long, scrobblerMangaId: Long) { val body = FormBody.Builder() .add("status", "reading") .add("score", "0") val url = BASE_API_URL.toHttpUrl().newBuilder() .addPathSegment("manga") .addPathSegment(scrobblerMangaId.toString()) .addPathSegment("my_list_status") .addQueryParameter("fields", "synopsis") .build() val request = Request.Builder() .url(url) .put(body.build()) .build() val response = okHttp.newCall(request).await().parseJson() saveRate(response, mangaId, scrobblerMangaId) } override suspend fun updateRate(rateId: Int, mangaId: Long, chapter: Int) { val body = FormBody.Builder() .add("num_chapters_read", chapter.toString()) val url = BASE_API_URL.toHttpUrl().newBuilder() .addPathSegment("manga") .addPathSegment(rateId.toString()) .addPathSegment("my_list_status") .build() val request = Request.Builder() .url(url) .put(body.build()) .build() val response = okHttp.newCall(request).await().parseJson() saveRate(response, mangaId, rateId.toLong()) } override suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) { val body = FormBody.Builder() .add("status", status.toString()) .add("score", rating.toInt().toString()) .add("comments", comment.orEmpty()) val url = BASE_API_URL.toHttpUrl().newBuilder() .addPathSegment("manga") .addPathSegment(rateId.toString()) .addPathSegment("my_list_status") .build() val request = Request.Builder() .url(url) .put(body.build()) .build() val response = okHttp.newCall(request).await().parseJson() saveRate(response, mangaId, rateId.toLong()) } private suspend fun saveRate(json: JSONObject, mangaId: Long, scrobblerMangaId: Long) { val entity = ScrobblingEntity( scrobbler = ScrobblerService.MAL.id, id = scrobblerMangaId.toInt(), mangaId = mangaId, targetId = scrobblerMangaId, status = json.getString("status"), chapter = json.getInt("num_chapters_read"), comment = json.getString("comments"), rating = (json.getDouble("score").toFloat() / 10f).coerceIn(0f, 1f), ) db.getScrobblingDao().upsert(entity) } override fun logout() { storage.clear() } private fun jsonToManga(json: JSONObject, sourceTitle: String): ScrobblerManga { val node = json.getJSONObject("node") val title = node.getString("title") return ScrobblerManga( id = node.getLong("id"), name = title, altName = null, cover = node.optJSONObject("main_picture")?.getStringOrNull("large"), url = "$BASE_WEB_URL/manga/${node.getLong("id")}", isBestMatch = title.equals(sourceTitle, ignoreCase = true), ) } private fun ScrobblerMangaInfo(json: JSONObject) = ScrobblerMangaInfo( id = json.getLong("id"), name = json.getString("title"), cover = json.getJSONObject("main_picture").getString("large"), url = "$BASE_WEB_URL/manga/${json.getLong("id")}", descriptionHtml = json.getString("synopsis"), ) @Suppress("FunctionName") private fun MALUser(json: JSONObject) = ScrobblerUser( id = json.getLong("id"), nickname = json.getString("name"), avatar = json.getStringOrNull("picture"), service = ScrobblerService.MAL, ) private fun generateCodeVerifier(): String { val codeVerifier = ByteArray(50) SecureRandom().nextBytes(codeVerifier) return Base64.encodeToString(codeVerifier, Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/mal/domain/MALScrobbler.kt ================================================ package org.koitharu.kotatsu.scrobbling.mal.domain import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus import org.koitharu.kotatsu.scrobbling.mal.data.MALRepository import javax.inject.Inject import javax.inject.Singleton private const val RATING_MAX = 10f @Singleton class MALScrobbler @Inject constructor( private val repository: MALRepository, db: MangaDatabase, mangaRepositoryFactory: MangaRepository.Factory, ) : Scrobbler(db, ScrobblerService.MAL, repository, mangaRepositoryFactory) { init { statuses[ScrobblingStatus.PLANNED] = "plan_to_read" statuses[ScrobblingStatus.READING] = "reading" statuses[ScrobblingStatus.COMPLETED] = "completed" statuses[ScrobblingStatus.ON_HOLD] = "on_hold" statuses[ScrobblingStatus.DROPPED] = "dropped" } override suspend fun updateScrobblingInfo( mangaId: Long, rating: Float, status: ScrobblingStatus?, comment: String?, ) { val entity = db.getScrobblingDao().find(scrobblerService.id, mangaId) requireNotNull(entity) { "Scrobbling info for manga $mangaId not found" } repository.updateRate( rateId = entity.id, mangaId = entity.mangaId, rating = rating * RATING_MAX, status = statuses[status], comment = comment, ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriAuthenticator.kt ================================================ package org.koitharu.kotatsu.scrobbling.shikimori.data import kotlinx.coroutines.runBlocking import okhttp3.Authenticator import okhttp3.Request import okhttp3.Response import okhttp3.Route import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerType import javax.inject.Inject import javax.inject.Provider class ShikimoriAuthenticator @Inject constructor( @ScrobblerType(ScrobblerService.SHIKIMORI) private val storage: ScrobblerStorage, private val repositoryProvider: Provider, ) : Authenticator { override fun authenticate(route: Route?, response: Response): Request? { val accessToken = storage.accessToken ?: return null if (!isRequestWithAccessToken(response)) { return null } synchronized(this) { val newAccessToken = storage.accessToken ?: return null if (accessToken != newAccessToken) { return newRequestWithAccessToken(response.request, newAccessToken) } val updatedAccessToken = refreshAccessToken() ?: return null return newRequestWithAccessToken(response.request, updatedAccessToken) } } private fun isRequestWithAccessToken(response: Response): Boolean { val header = response.request.header(CommonHeaders.AUTHORIZATION) return header?.startsWith("Bearer") == true } private fun newRequestWithAccessToken(request: Request, accessToken: String): Request { return request.newBuilder() .header(CommonHeaders.AUTHORIZATION, "Bearer $accessToken") .build() } private fun refreshAccessToken(): String? = runCatching { val repository = repositoryProvider.get() runBlocking { repository.authorize(null) } return storage.accessToken }.onFailure { it.printStackTraceDebug() }.getOrNull() } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriInterceptor.kt ================================================ package org.koitharu.kotatsu.scrobbling.shikimori.data import okhttp3.Interceptor import okhttp3.Response import okio.IOException import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import java.net.HttpURLConnection private const val USER_AGENT_SHIKIMORI = "Kotatsu" class ShikimoriInterceptor(private val storage: ScrobblerStorage) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val sourceRequest = chain.request() val request = sourceRequest.newBuilder() request.header(CommonHeaders.USER_AGENT, USER_AGENT_SHIKIMORI) val isAuthRequest = sourceRequest.url.pathSegments.contains("oauth") if (!isAuthRequest) { storage.accessToken?.let { request.header(CommonHeaders.AUTHORIZATION, "Bearer $it") } } val response = chain.proceed(request.build()) if (!isAuthRequest && response.code == HttpURLConnection.HTTP_UNAUTHORIZED) { throw ScrobblerAuthRequiredException(ScrobblerService.SHIKIMORI) } if (!response.isSuccessful && !response.isRedirect) { throw IOException("${response.code} ${response.message}") } return response } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriRepository.kt ================================================ package org.koitharu.kotatsu.scrobbling.shikimori.data import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import okhttp3.FormBody import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request import org.json.JSONObject import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.util.ext.toRequestBody import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.json.getStringOrNull import org.koitharu.kotatsu.parsers.util.json.mapJSON import org.koitharu.kotatsu.parsers.util.parseJson import org.koitharu.kotatsu.parsers.util.parseJsonArray import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerMangaInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerType import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser import javax.inject.Inject import javax.inject.Singleton private const val DOMAIN = "shikimori.one" private const val REDIRECT_URI = "kotatsu://shikimori-auth" private const val BASE_URL = "https://$DOMAIN/" private const val MANGA_PAGE_SIZE = 10 @Singleton class ShikimoriRepository @Inject constructor( @ApplicationContext context: Context, @ScrobblerType(ScrobblerService.SHIKIMORI) private val okHttp: OkHttpClient, @ScrobblerType(ScrobblerService.SHIKIMORI) private val storage: ScrobblerStorage, private val db: MangaDatabase, ) : ScrobblerRepository { private val clientId = context.getString(R.string.shikimori_clientId) private val clientSecret = context.getString(R.string.shikimori_clientSecret) override val oauthUrl: String get() = "${BASE_URL}oauth/authorize?client_id=$clientId&" + "redirect_uri=$REDIRECT_URI&response_type=code&scope=" override val isAuthorized: Boolean get() = storage.accessToken != null override suspend fun authorize(code: String?) { val body = FormBody.Builder() body.add("client_id", clientId) body.add("client_secret", clientSecret) if (code != null) { body.add("grant_type", "authorization_code") body.add("redirect_uri", REDIRECT_URI) body.add("code", code) } else { body.add("grant_type", "refresh_token") body.add("refresh_token", checkNotNull(storage.refreshToken)) } val request = Request.Builder() .post(body.build()) .url("${BASE_URL}oauth/token") val response = okHttp.newCall(request.build()).await().parseJson() storage.accessToken = response.getString("access_token") storage.refreshToken = response.getString("refresh_token") } override suspend fun loadUser(): ScrobblerUser { val request = Request.Builder() .get() .url("${BASE_URL}api/users/whoami") val response = okHttp.newCall(request.build()).await().parseJson() return ShikimoriUser(response).also { storage.user = it } } override val cachedUser: ScrobblerUser? get() { return storage.user } override suspend fun unregister(mangaId: Long) { return db.getScrobblingDao().delete(ScrobblerService.SHIKIMORI.id, mangaId) } override fun logout() { storage.clear() } override suspend fun findManga(query: String, offset: Int): List { val page = offset / MANGA_PAGE_SIZE val pageOffset = offset % MANGA_PAGE_SIZE val url = BASE_URL.toHttpUrl().newBuilder() .addPathSegment("api") .addPathSegment("mangas") .addEncodedQueryParameter("page", (page + 1).toString()) .addEncodedQueryParameter("limit", MANGA_PAGE_SIZE.toString()) .addEncodedQueryParameter("censored", false.toString()) .addQueryParameter("search", query) .build() val request = Request.Builder().url(url).get().build() val response = okHttp.newCall(request).await().parseJsonArray() val list = response.mapJSON { ScrobblerManga(it, query) } return if (pageOffset != 0) list.drop(pageOffset) else list } override suspend fun createRate(mangaId: Long, scrobblerMangaId: Long) { val user = cachedUser ?: loadUser() val payload = JSONObject() payload.put( "user_rate", JSONObject().apply { put("target_id", scrobblerMangaId) put("target_type", "Manga") put("user_id", user.id) }, ) val url = BASE_URL.toHttpUrl().newBuilder() .addPathSegment("api") .addPathSegment("v2") .addPathSegment("user_rates") .build() val request = Request.Builder().url(url).post(payload.toRequestBody()).build() val response = okHttp.newCall(request).await().parseJson() saveRate(response, mangaId) } override suspend fun updateRate(rateId: Int, mangaId: Long, chapter: Int) { val payload = JSONObject() payload.put( "user_rate", JSONObject().apply { put("chapters", chapter) }, ) val url = BASE_URL.toHttpUrl().newBuilder() .addPathSegment("api") .addPathSegment("v2") .addPathSegment("user_rates") .addPathSegment(rateId.toString()) .build() val request = Request.Builder().url(url).patch(payload.toRequestBody()).build() val response = okHttp.newCall(request).await().parseJson() saveRate(response, mangaId) } override suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) { val payload = JSONObject() payload.put( "user_rate", JSONObject().apply { put("score", rating.toString()) if (comment != null) { put("text", comment) } if (status != null) { put("status", status) } }, ) val url = BASE_URL.toHttpUrl().newBuilder() .addPathSegment("api") .addPathSegment("v2") .addPathSegment("user_rates") .addPathSegment(rateId.toString()) .build() val request = Request.Builder().url(url).patch(payload.toRequestBody()).build() val response = okHttp.newCall(request).await().parseJson() saveRate(response, mangaId) } override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo { val request = Request.Builder() .get() .url("${BASE_URL}api/mangas/$id") val response = okHttp.newCall(request.build()).await().parseJson() return ScrobblerMangaInfo(response) } private suspend fun saveRate(json: JSONObject, mangaId: Long) { val entity = ScrobblingEntity( scrobbler = ScrobblerService.SHIKIMORI.id, id = json.getInt("id"), mangaId = mangaId, targetId = json.getLong("target_id"), status = json.getString("status"), chapter = json.getInt("chapters"), comment = json.getString("text"), rating = (json.getDouble("score").toFloat() / 10f).coerceIn(0f, 1f), ) db.getScrobblingDao().upsert(entity) } private fun ScrobblerManga(json: JSONObject, sourceTitle: String) = ScrobblerManga( id = json.getLong("id"), name = json.getString("name"), altName = json.getStringOrNull("russian"), cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl(DOMAIN), url = json.getString("url").toAbsoluteUrl(DOMAIN), isBestMatch = sourceTitle.equals(json.getString("name"), ignoreCase = true) || json.getStringOrNull("russian")?.equals(sourceTitle, ignoreCase = true) == true ) private fun ScrobblerMangaInfo(json: JSONObject) = ScrobblerMangaInfo( id = json.getLong("id"), name = json.getString("name"), cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl(DOMAIN), url = json.getString("url").toAbsoluteUrl(DOMAIN), descriptionHtml = json.getString("description_html"), ) @Suppress("FunctionName") private fun ShikimoriUser(json: JSONObject) = ScrobblerUser( id = json.getLong("id"), nickname = json.getString("nickname"), avatar = json.getStringOrNull("avatar"), service = ScrobblerService.SHIKIMORI, ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/shikimori/domain/ShikimoriScrobbler.kt ================================================ package org.koitharu.kotatsu.scrobbling.shikimori.domain import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository import javax.inject.Inject import javax.inject.Singleton private const val RATING_MAX = 10f @Singleton class ShikimoriScrobbler @Inject constructor( private val repository: ShikimoriRepository, db: MangaDatabase, mangaRepositoryFactory: MangaRepository.Factory, ) : Scrobbler(db, ScrobblerService.SHIKIMORI, repository, mangaRepositoryFactory) { init { statuses[ScrobblingStatus.PLANNED] = "planned" statuses[ScrobblingStatus.READING] = "watching" statuses[ScrobblingStatus.RE_READING] = "rewatching" statuses[ScrobblingStatus.COMPLETED] = "completed" statuses[ScrobblingStatus.ON_HOLD] = "on_hold" statuses[ScrobblingStatus.DROPPED] = "dropped" } override suspend fun updateScrobblingInfo( mangaId: Long, rating: Float, status: ScrobblingStatus?, comment: String?, ) { val entity = db.getScrobblingDao().find(scrobblerService.id, mangaId) requireNotNull(entity) { "Scrobbling info for manga $mangaId not found" } repository.updateRate( rateId = entity.id, mangaId = entity.mangaId, rating = rating * RATING_MAX, status = statuses[status], comment = comment, ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt ================================================ package org.koitharu.kotatsu.search.domain import android.app.SearchManager import android.content.Context import android.provider.SearchRecentSuggestions import dagger.Reusable import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toMangaTag import org.koitharu.kotatsu.core.db.entity.toMangaTagsList import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.util.levenshteinDistance import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider import javax.inject.Inject @Reusable class MangaSearchRepository @Inject constructor( private val db: MangaDatabase, private val sourcesRepository: MangaSourcesRepository, @ApplicationContext private val context: Context, private val recentSuggestions: SearchRecentSuggestions, private val settings: AppSettings, ) { suspend fun getMangaSuggestion(query: String, limit: Int, source: MangaSource?): List = when { query.isEmpty() -> db.getSuggestionDao().getTopManga(limit) source != null -> db.getMangaDao().searchByTitle("%$query%", source.name, limit) else -> db.getMangaDao().searchByTitle("%$query%", limit) }.let { if (settings.isNsfwContentDisabled) it.filterNot { x -> x.manga.isNsfw } else it }.map { it.toManga() }.sortedBy { x -> x.title.levenshteinDistance(query) } suspend fun getQuerySuggestion( query: String, limit: Int, ): List = withContext(Dispatchers.IO) { context.contentResolver.query( MangaSuggestionsProvider.QUERY_URI, arrayOf(SearchManager.SUGGEST_COLUMN_QUERY), "${SearchManager.SUGGEST_COLUMN_QUERY} LIKE ?", arrayOf("%$query%"), "date DESC", )?.use { cursor -> val count = minOf(cursor.count, limit) if (count == 0) { return@withContext emptyList() } val result = ArrayList(count) if (cursor.moveToFirst()) { val index = cursor.getColumnIndexOrThrow(SearchManager.SUGGEST_COLUMN_QUERY) do { result += cursor.getString(index) } while (currentCoroutineContext().isActive && cursor.moveToNext()) } result }.orEmpty() } suspend fun getQueryHintSuggestion( query: String, limit: Int, ): List { if (query.isEmpty()) { return emptyList() } val titles = db.getSuggestionDao().getTitles("$query%") if (titles.isEmpty()) { return emptyList() } return titles.shuffled().take(limit) } suspend fun getAuthorsSuggestion( query: String, limit: Int, ): List { if (query.isEmpty()) { return emptyList() } return db.getMangaDao().findAuthors("$query%", limit) } suspend fun getTagsSuggestion(query: String, limit: Int, source: MangaSource?): List { return when { query.isNotEmpty() && source != null -> db.getTagsDao() .findTags(source.name, "%$query%", limit) query.isNotEmpty() -> db.getTagsDao().findTags("%$query%", limit) source != null -> db.getTagsDao().findPopularTags(source.name, limit) else -> db.getTagsDao().findPopularTags(limit) }.toMangaTagsList() } suspend fun getTagsSuggestion(tags: Set): List { val ids = tags.mapToSet { it.toEntity().id } return if (ids.size == 1) { db.getTagsDao().findRelatedTags(ids.first()) } else { db.getTagsDao().findRelatedTags(ids) }.mapNotNull { x -> if (x.id in ids) null else x.toMangaTag() } } suspend fun getRareTags(source: MangaSource, limit: Int): List { return db.getTagsDao().findRareTags(source.name, limit).toMangaTagsList() } suspend fun getTopTags(source: MangaSource, limit: Int): List { return db.getTagsDao().findPopularTags(source.name, limit).toMangaTagsList() } suspend fun getSourcesSuggestion(limit: Int): List = sourcesRepository.getTopSources(limit) fun getSourcesSuggestion(query: String, limit: Int): List { if (query.length < 3) { return emptyList() } val skipNsfw = settings.isNsfwContentDisabled val sources = sourcesRepository.allMangaSources .filter { x -> (x.contentType != ContentType.HENTAI || !skipNsfw) && x.title.contains(query, ignoreCase = true) } return if (limit == 0) { sources } else { sources.take(limit) } } fun saveSearchQuery(query: String) { recentSuggestions.saveRecentQuery(query, null) } suspend fun clearSearchHistory(): Unit = withContext(Dispatchers.IO) { recentSuggestions.clearHistory() } suspend fun deleteSearchQuery(query: String) = withContext(Dispatchers.IO) { context.contentResolver.delete( MangaSuggestionsProvider.URI, "display1 = ?", arrayOf(query), ) } suspend fun getSearchHistoryCount(): Int = withContext(Dispatchers.IO) { context.contentResolver.query( MangaSuggestionsProvider.QUERY_URI, arrayOf(SearchManager.SUGGEST_COLUMN_QUERY), null, arrayOfNulls(1), null, )?.use { cursor -> cursor.count } ?: 0 } suspend fun getAuthors(source: MangaSource, limit: Int): List { return db.getMangaDao().findAuthorsBySource(source.name, limit) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/search/domain/SearchKind.kt ================================================ package org.koitharu.kotatsu.search.domain enum class SearchKind { SIMPLE, TITLE, AUTHOR, TAG } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/search/domain/SearchResults.kt ================================================ package org.koitharu.kotatsu.search.domain import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.SortOrder data class SearchResults( val listFilter: MangaListFilter, val sortOrder: SortOrder, val manga: List, ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/search/domain/SearchV2Helper.kt ================================================ package org.koitharu.kotatsu.search.domain import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.contains import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.almostEquals import org.koitharu.kotatsu.parsers.util.levenshteinDistance import org.koitharu.kotatsu.parsers.util.runCatchingCancellable private const val MATCH_THRESHOLD_DEFAULT = 0.2f class SearchV2Helper @AssistedInject constructor( @Assisted private val source: MangaSource, private val mangaRepositoryFactory: MangaRepository.Factory, private val dataRepository: MangaDataRepository, private val settings: AppSettings, ) { suspend operator fun invoke(query: String, kind: SearchKind): SearchResults? { if (settings.isNsfwContentDisabled && source.isNsfw()) { return null } val repository = mangaRepositoryFactory.create(source) val listFilter = repository.getFilter(query, kind) ?: return null val sortOrder = repository.getSortOrder(kind) val list = repository.getList(0, sortOrder, listFilter) if (list.isEmpty()) { return null } val result = list.toMutableList() result.postFilter(query, kind) result.sortByRelevance(query, kind) return SearchResults(listFilter = listFilter, sortOrder = sortOrder, manga = result) } private suspend fun MangaRepository.getFilter(query: String, kind: SearchKind): MangaListFilter? = when (kind) { SearchKind.SIMPLE, SearchKind.TITLE -> if (filterCapabilities.isSearchSupported) { MangaListFilter(query = query) } else { null } SearchKind.AUTHOR -> if (filterCapabilities.isAuthorSearchSupported) { MangaListFilter(author = query) } else if (filterCapabilities.isSearchSupported) { MangaListFilter(query = query) } else { null } SearchKind.TAG -> { val tags = this@SearchV2Helper.dataRepository.findTags(this.source) + runCatchingCancellable { this@getFilter.getFilterOptions().availableTags }.onFailure { e -> e.printStackTraceDebug() }.getOrDefault(emptySet()) val tag = tags.find { x -> x.title.equals(query, ignoreCase = true) } if (tag != null) { MangaListFilter(tags = setOf(tag)) } else { null } } } private fun MutableList.postFilter(query: String, kind: SearchKind) { if (settings.isNsfwContentDisabled) { removeAll { it.isNsfw() } } when (kind) { SearchKind.TITLE -> retainAll { m -> m.matches(query, MATCH_THRESHOLD_DEFAULT) } SearchKind.AUTHOR -> retainAll { m -> m.authors.isEmpty() || m.authors.contains(query, ignoreCase = true) } SearchKind.SIMPLE, // no filtering expected SearchKind.TAG -> Unit } } private fun MutableList.sortByRelevance(query: String, kind: SearchKind) { when (kind) { SearchKind.SIMPLE, SearchKind.TITLE -> sortBy { m -> minOf(m.title.levenshteinDistance(query), m.altTitle?.levenshteinDistance(query) ?: Int.MAX_VALUE) } SearchKind.AUTHOR -> sortByDescending { m -> m.authors.contains(query, ignoreCase = true) } SearchKind.TAG -> sortByDescending { m -> m.tags.any { tag -> tag.title.equals(query, ignoreCase = true) } } } } private fun MangaRepository.getSortOrder(kind: SearchKind): SortOrder { val preferred: SortOrder = when (kind) { SearchKind.SIMPLE, SearchKind.TITLE, SearchKind.AUTHOR -> SortOrder.RELEVANCE SearchKind.TAG -> SortOrder.POPULARITY } return if (preferred in sortOrders) { preferred } else { defaultSortOrder } } private fun Manga.matches(query: String, threshold: Float): Boolean { return matchesTitles(title, query, threshold) || matchesTitles(altTitle, query, threshold) } private fun matchesTitles(a: String?, b: String?, threshold: Float): Boolean { return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, threshold) } @AssistedFactory interface Factory { fun create(source: MangaSource): SearchV2Helper } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt ================================================ package org.koitharu.kotatsu.search.ui import android.os.Bundle import android.view.View import android.view.ViewGroup import androidx.core.graphics.drawable.toDrawable import androidx.core.os.bundleOf import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateLayoutParams import androidx.core.view.updatePaddingRelative import androidx.fragment.app.Fragment import androidx.fragment.app.commit import com.google.android.material.appbar.AppBarLayout import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.getSummary import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaListFilter import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.model.titleRes import org.koitharu.kotatsu.core.ui.util.FadingAppbarMediator import org.koitharu.kotatsu.core.util.ViewBadge import org.koitharu.kotatsu.core.util.ext.consumeSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.end import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.core.util.ext.start import org.koitharu.kotatsu.databinding.ActivityMangaListBinding import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.FilterHeaderFragment import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment import org.koitharu.kotatsu.list.ui.preview.PreviewFragment import org.koitharu.kotatsu.local.ui.LocalListFragment import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import kotlin.math.absoluteValue import com.google.android.material.R as materialR @AndroidEntryPoint class MangaListActivity : BaseActivity(), AppBarOwner, View.OnClickListener, FilterCoordinator.Owner, AppBarLayout.OnOffsetChangedListener { override val appBar: AppBarLayout get() = viewBinding.appbar override val filterCoordinator: FilterCoordinator get() = checkNotNull(findFilterOwner()) { "Cannot find FilterCoordinator.Owner fragment in ${supportFragmentManager.fragments}" }.filterCoordinator private lateinit var source: MangaSource override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityMangaListBinding.inflate(layoutInflater)) viewBinding.collapsingToolbarLayout?.let { collapsingToolbarLayout -> FadingAppbarMediator(viewBinding.appbar, collapsingToolbarLayout).bind() } val filter = intent.getParcelableExtraCompat(AppRouter.KEY_FILTER)?.filter val sortOrder = intent.getSerializableExtraCompat(AppRouter.KEY_SORT_ORDER) source = MangaSource(intent.getStringExtra(AppRouter.KEY_SOURCE)) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) if (viewBinding.containerFilterHeader != null) { viewBinding.appbar.addOnOffsetChangedListener(this) } viewBinding.buttonOrder?.setOnClickListener(this) title = source.getTitle(this) initList(source, filter, sortOrder) } override fun isNsfwContent(): Flow = flowOf(source.isNsfw()) override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) { val container = viewBinding.containerFilterHeader ?: return container.background = if (verticalOffset.absoluteValue < appBarLayout.totalScrollRange) { container.context.getThemeColor(materialR.attr.backgroundColor).toDrawable() } else { viewBinding.collapsingToolbarLayout?.contentScrim } } /** * Only for landscape */ override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val barsInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) viewBinding.cardSide?.updateLayoutParams { marginEnd = barsInsets.end(v) + resources.getDimensionPixelOffset(R.dimen.side_card_offset) topMargin = barsInsets.top + resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer_double) bottomMargin = barsInsets.bottom + resources.getDimensionPixelOffset(R.dimen.side_card_offset) } viewBinding.appbar.updatePaddingRelative( top = barsInsets.top, end = if (viewBinding.cardSide == null) barsInsets.end(v) else 0, start = barsInsets.start(v), ) return insets.consumeSystemBarsInsets(v, top = true, end = true) } override fun onClick(v: View) { when (v.id) { R.id.button_order -> router.showFilterSheet() } } fun showPreview(manga: Manga): Boolean = setSideFragment( PreviewFragment::class.java, bundleOf(AppRouter.KEY_MANGA to ParcelableManga(manga)), ) fun hidePreview() = setSideFragment(FilterSheetFragment::class.java, null) private fun initList(source: MangaSource, filter: MangaListFilter?, sortOrder: SortOrder?) { val fm = supportFragmentManager val existingFragment = fm.findFragmentById(R.id.container) if (existingFragment is FilterCoordinator.Owner) { initFilter(existingFragment) } else { fm.commit { setReorderingAllowed(true) val fragment = if (source == LocalMangaSource) { LocalListFragment() } else { RemoteListFragment.newInstance(source) } replace(R.id.container, fragment) runOnCommit { initFilter(fragment) } if (filter != null || sortOrder != null) { runOnCommit(ApplyFilterRunnable(fragment, filter, sortOrder)) } } } } private fun initFilter(filterOwner: FilterCoordinator.Owner) { if (viewBinding.containerSide != null) { if (supportFragmentManager.findFragmentById(R.id.container_side) == null) { setSideFragment(FilterSheetFragment::class.java, null) } } else if (viewBinding.containerFilterHeader != null) { if (supportFragmentManager.findFragmentById(R.id.container_filter_header) == null) { supportFragmentManager.commit { setReorderingAllowed(true) replace(R.id.container_filter_header, FilterHeaderFragment::class.java, null) } } } val filter = filterOwner.filterCoordinator val chipSort = viewBinding.buttonOrder if (chipSort != null) { val filterBadge = ViewBadge(chipSort, this) filterBadge.setMaxCharacterCount(0) filter.observe().observe(this) { snapshot -> chipSort.setTextAndVisible(snapshot.sortOrder.titleRes) filterBadge.counter = if (snapshot.listFilter.hasNonSearchOptions()) 1 else 0 } } else { filter.observe().map { it.listFilter.getSummary() }.flowOn(Dispatchers.Default) .observe(this) { supportActionBar?.subtitle = it } } } private fun findFilterOwner(): FilterCoordinator.Owner? { return supportFragmentManager.findFragmentById(R.id.container) as? FilterCoordinator.Owner } private fun setSideFragment(cls: Class, args: Bundle?) = if (viewBinding.containerSide != null) { supportFragmentManager.commit { setReorderingAllowed(true) replace(R.id.container_side, cls, args) } true } else { false } private class ApplyFilterRunnable( private val filterOwner: FilterCoordinator.Owner, private val filter: MangaListFilter?, private val sortOrder: SortOrder?, ) : Runnable { override fun run() { if (sortOrder != null) { filterOwner.filterCoordinator.setSortOrder(sortOrder) } if (filter != null) { filterOwner.filterCoordinator.setAdjusted(filter) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaSuggestionsProvider.kt ================================================ package org.koitharu.kotatsu.search.ui import android.app.SearchManager import android.content.ContentResolver import android.content.Context import android.content.SearchRecentSuggestionsProvider import android.net.Uri import android.provider.SearchRecentSuggestions import androidx.core.net.toUri import org.koitharu.kotatsu.BuildConfig class MangaSuggestionsProvider : SearchRecentSuggestionsProvider() { init { setupSuggestions(AUTHORITY, MODE) } companion object { private const val AUTHORITY = "${BuildConfig.APPLICATION_ID}.MangaSuggestionsProvider" private const val MODE = DATABASE_MODE_QUERIES fun createSuggestions(context: Context): SearchRecentSuggestions { return SearchRecentSuggestions(context, AUTHORITY, MODE) } val QUERY_URI: Uri = Uri.Builder() .scheme(ContentResolver.SCHEME_CONTENT) .authority(AUTHORITY) .appendPath(SearchManager.SUGGEST_URI_PATH_QUERY) .build() val URI: Uri = "content://$AUTHORITY/suggestions".toUri() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt ================================================ package org.koitharu.kotatsu.search.ui.multi import android.os.Bundle import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.activity.viewModels import androidx.appcompat.view.ActionMode import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding 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.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.widgets.TipView import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.invalidateNestedItemDecorations 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.ActivitySearchBinding import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration import org.koitharu.kotatsu.list.ui.adapter.MangaListListener import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.search.domain.SearchKind import org.koitharu.kotatsu.search.ui.multi.adapter.SearchAdapter import javax.inject.Inject @AndroidEntryPoint class SearchActivity : BaseActivity(), MangaListListener, ListSelectionController.Callback { @Inject lateinit var settings: AppSettings private val viewModel by viewModels() private lateinit var selectionController: ListSelectionController override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivitySearchBinding.inflate(layoutInflater)) title = when (viewModel.kind) { SearchKind.SIMPLE, SearchKind.TITLE -> viewModel.query SearchKind.AUTHOR -> getString( R.string.inline_preference_pattern, getString(R.string.author), viewModel.query, ) SearchKind.TAG -> getString(R.string.inline_preference_pattern, getString(R.string.genre), viewModel.query) } val itemClickListener = OnListItemClickListener { item, view -> if (item.listFilter == null) { router.openSearch(item.source, viewModel.query) } else { router.openList(item.source, item.listFilter, item.sortOrder) } } val sizeResolver = DynamicItemSizeResolver(resources, this, settings, adjustWidth = true) val selectionDecoration = MangaSelectionDecoration(this) selectionController = ListSelectionController( appCompatDelegate = delegate, decoration = selectionDecoration, registryOwner = this, callback = this, ) val adapter = SearchAdapter( listener = this, itemClickListener = itemClickListener, sizeResolver = sizeResolver, selectionDecoration = selectionDecoration, ) viewBinding.recyclerView.adapter = adapter viewBinding.recyclerView.setHasFixedSize(true) viewBinding.recyclerView.addItemDecoration(TypedListSpacingDecoration(this, true)) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) supportActionBar?.setSubtitle(R.string.search_results) addMenuProvider(SearchMenuProvider(this, viewModel)) viewModel.list.observe(this, adapter) viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val barsInsets = insets.systemBarsInsets viewBinding.toolbar.updatePadding( top = barsInsets.top, left = barsInsets.left, right = barsInsets.right, ) viewBinding.recyclerView.setPadding( left = barsInsets.left, top = 0, right = barsInsets.right, bottom = barsInsets.bottom, ) return insets.consumeAllSystemBarsInsets() } override fun onItemClick(item: MangaListModel, view: View) { if (!selectionController.onItemClick(item.id)) { router.openDetails(item.toMangaWithOverride()) } } override fun onItemLongClick(item: MangaListModel, view: View): Boolean { return selectionController.onItemLongClick(view, item.id) } override fun onItemContextClick(item: MangaListModel, view: View): Boolean { return selectionController.onItemContextClick(view, item.id) } override fun onReadClick(manga: Manga, view: View) { if (!selectionController.onItemClick(manga.id)) { router.openReader(manga) } } override fun onTagClick(manga: Manga, tag: MangaTag, view: View) { if (!selectionController.onItemClick(manga.id)) { router.openList(tag) } } override fun onRetryClick(error: Throwable) { viewModel.retry() } override fun onFilterOptionClick(option: ListFilterOption) = Unit override fun onFilterClick(view: View?) = Unit override fun onEmptyActionClick() = viewModel.continueSearch() override fun onListHeaderClick(item: ListHeader, view: View) = Unit override fun onFooterButtonClick() = viewModel.continueSearch() override fun onPrimaryButtonClick(tipView: TipView) = Unit override fun onSecondaryButtonClick(tipView: TipView) = Unit override fun onSelectionChanged(controller: ListSelectionController, count: Int) { viewBinding.recyclerView.invalidateNestedItemDecorations() } override fun onCreateActionMode( controller: ListSelectionController, menuInflater: MenuInflater, menu: Menu ): Boolean { menuInflater.inflate(R.menu.mode_remote, menu) return true } override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean { return when (item.itemId) { R.id.action_share -> { ShareHelper(this).shareMangaLinks(collectSelectedItems()) mode?.finish() true } R.id.action_favourite -> { router.showFavoriteDialog(collectSelectedItems()) mode?.finish() true } R.id.action_save -> { router.showDownloadDialog(collectSelectedItems(), viewBinding.recyclerView) mode?.finish() true } else -> false } } private fun collectSelectedItems(): Set { return viewModel.getItems(selectionController.peekCheckedIds()) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMenuProvider.kt ================================================ package org.koitharu.kotatsu.search.ui.multi import android.os.Build import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.core.view.MenuProvider import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.search.domain.SearchKind class SearchMenuProvider( private val activity: SearchActivity, private val viewModel: SearchViewModel, ) : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.opt_search_kind, menu) } override fun onPrepareMenu(menu: Menu) { super.onPrepareMenu(menu) menu.findItem( when (viewModel.kind) { SearchKind.SIMPLE -> R.id.action_kind_simple SearchKind.TITLE -> R.id.action_kind_title SearchKind.AUTHOR -> R.id.action_kind_author SearchKind.TAG -> R.id.action_kind_tag }, )?.isChecked = true } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { when (menuItem.itemId) { R.id.action_filter_pinned_only -> { menuItem.isChecked = !menuItem.isChecked viewModel.setPinnedOnly(menuItem.isChecked) return true } R.id.action_filter_hide_empty -> { menuItem.isChecked = !menuItem.isChecked viewModel.setHideEmpty(menuItem.isChecked) return true } } val newKind = when (menuItem.itemId) { R.id.action_kind_simple -> SearchKind.SIMPLE R.id.action_kind_title -> SearchKind.TITLE R.id.action_kind_author -> SearchKind.AUTHOR R.id.action_kind_tag -> SearchKind.TAG else -> return false } if (newKind != viewModel.kind) { activity.router.openSearch( query = viewModel.query, kind = newKind, ) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { activity.overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out, 0) } else { activity.overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) } activity.finishAfterTransition() } return true } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchResultsListModel.kt ================================================ package org.koitharu.kotatsu.search.ui.multi import android.content.Context import androidx.annotation.StringRes import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.SortOrder data class SearchResultsListModel( @StringRes val titleResId: Int, val source: MangaSource, val listFilter: MangaListFilter?, val sortOrder: SortOrder?, val list: List, val error: Throwable?, ) : ListModel { fun getTitle(context: Context): String = if (titleResId != 0) { context.getString(titleResId) } else { source.getTitle(context) } override fun areItemsTheSame(other: ListModel): Boolean { return other is SearchResultsListModel && source == other.source && titleResId == other.titleResId } override fun getChangePayload(previousState: ListModel): Any? { return if (previousState is SearchResultsListModel && previousState.list != list) { ListModelDiffCallback.PAYLOAD_NESTED_LIST_CHANGED } else { super.getChangePayload(previousState) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt ================================================ package org.koitharu.kotatsu.search.ui.multi import androidx.collection.ArraySet import androidx.collection.LongSet 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.dropWhile import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.plus import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.UnknownMangaSource import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.append import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.toLocale import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.history.data.HistoryRepository 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.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 @HiltViewModel class SearchViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val mangaListMapper: MangaListMapper, private val searchHelperFactory: SearchV2Helper.Factory, private val sourcesRepository: MangaSourcesRepository, private val historyRepository: HistoryRepository, private val favouritesRepository: FavouritesRepository, ) : BaseViewModel() { val query = savedStateHandle.get(AppRouter.KEY_QUERY).orEmpty() val kind = savedStateHandle.get(AppRouter.KEY_KIND) ?: SearchKind.SIMPLE private var includeDisabledSources = MutableStateFlow(false) private var pinnedOnly = MutableStateFlow(false) private var hideEmpty = MutableStateFlow(false) private val results = MutableStateFlow>(emptyList()) private var searchJob: Job? = null val list: StateFlow> = combine( results, isLoading.dropWhile { !it }, includeDisabledSources, hideEmpty, ) { list, loading, includeDisabled, hideEmptyVal -> val filteredList = if (hideEmptyVal) { list.filter { it.list.isNotEmpty() } } else { list } when { filteredList.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 -> filteredList + LoadingFooter() includeDisabled -> filteredList else -> filteredList + ButtonFooter(R.string.search_disabled_sources) } }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) init { doSearch() } fun getItems(ids: LongSet): Set { val snapshot = results.value val result = ArraySet(ids.size) snapshot.forEach { x -> for (item in x.list) { if (item.id in ids) { result.add(item.manga) } } } return result } fun retry() { searchJob?.cancel() results.value = emptyList() includeDisabledSources.value = false doSearch() } fun setPinnedOnly(value: Boolean) { if (pinnedOnly.value != value) { pinnedOnly.value = value retry() } } fun setHideEmpty(value: Boolean) { hideEmpty.value = value } fun continueSearch() { if (includeDisabledSources.value) { return } val prevJob = searchJob searchJob = launchLoadingJob(Dispatchers.Default) { includeDisabledSources.value = true prevJob?.join() val sources = if (pinnedOnly.value) { emptyList() } else { sourcesRepository.getDisabledSources() .sortedByDescending { it.priority() } } val semaphore = Semaphore(MAX_PARALLELISM) sources.map { source -> launch { semaphore.withPermit { appendResult(searchSource(source)) } } }.joinAll() } } private fun doSearch() { val prevJob = searchJob searchJob = launchLoadingJob(Dispatchers.Default) { prevJob?.cancelAndJoin() appendResult(searchHistory()) appendResult(searchFavorites()) appendResult(searchLocal()) val sources = if (pinnedOnly.value) { sourcesRepository.getPinnedSources().toList() } else { sourcesRepository.getEnabledSources() } val semaphore = Semaphore(MAX_PARALLELISM) sources.map { source -> launch { semaphore.withPermit { appendResult(searchSource(source)) } } }.joinAll() } } // impl private suspend fun searchSource(source: MangaSource): SearchResultsListModel? = runCatchingCancellable { val searchHelper = searchHelperFactory.create(source) searchHelper(query, kind) }.fold( onSuccess = { result -> if (result == null || result.manga.isEmpty()) { null } else { val list = mangaListMapper.toListModelList( manga = result.manga, mode = ListMode.GRID, ) SearchResultsListModel( titleResId = 0, source = source, list = list, error = null, listFilter = result.listFilter, sortOrder = result.sortOrder, ) } }, onFailure = { error -> error.printStackTraceDebug() if (source is MangaParserSource && source.isBroken) { null } else { SearchResultsListModel(0, source, null, null, emptyList(), error) } }, ) private suspend fun searchHistory(): SearchResultsListModel? = runCatchingCancellable { historyRepository.search(query, kind, Int.MAX_VALUE) }.fold( onSuccess = { result -> if (result.isNotEmpty()) { SearchResultsListModel( titleResId = R.string.history, source = UnknownMangaSource, list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID), error = null, listFilter = null, sortOrder = null, ) } else { null } }, onFailure = { error -> SearchResultsListModel( titleResId = R.string.history, source = UnknownMangaSource, list = emptyList(), error = error, listFilter = null, sortOrder = null, ) }, ) private suspend fun searchFavorites(): SearchResultsListModel? = runCatchingCancellable { favouritesRepository.search(query, kind, Int.MAX_VALUE) }.fold( onSuccess = { result -> if (result.isNotEmpty()) { SearchResultsListModel( titleResId = R.string.favourites, source = UnknownMangaSource, list = mangaListMapper.toListModelList( manga = result, mode = ListMode.GRID, flags = MangaListMapper.NO_FAVORITE, ), error = null, listFilter = null, sortOrder = null, ) } else { null } }, onFailure = { error -> SearchResultsListModel( titleResId = R.string.favourites, source = UnknownMangaSource, list = emptyList(), error = error, listFilter = null, sortOrder = null, ) }, ) private suspend fun searchLocal(): SearchResultsListModel? = runCatchingCancellable { searchHelperFactory.create(LocalMangaSource).invoke(query, kind) }.fold( onSuccess = { result -> if (!result?.manga.isNullOrEmpty()) { SearchResultsListModel( titleResId = 0, source = LocalMangaSource, list = mangaListMapper.toListModelList( manga = result.manga, mode = ListMode.GRID, flags = MangaListMapper.NO_SAVED, ), error = null, listFilter = result.listFilter, sortOrder = result.sortOrder, ) } else { null } }, onFailure = { error -> SearchResultsListModel( titleResId = 0, source = LocalMangaSource, list = emptyList(), error = error, listFilter = null, sortOrder = null, ) }, ) private fun appendResult(item: SearchResultsListModel?) { if (item != null) { results.append(item) } } private fun MangaSource.priority(): Int { var res = 0 if (this is MangaParserSource) { if (locale.toLocale() == Locale.getDefault()) res += 2 } return res } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchAdapter.kt ================================================ package org.koitharu.kotatsu.search.ui.multi.adapter import android.content.Context import androidx.recyclerview.widget.RecyclerView.RecycledViewPool 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.MangaSelectionDecoration import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.MangaListListener import org.koitharu.kotatsu.list.ui.adapter.buttonFooterAD import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD 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.list.ui.size.ItemSizeResolver import org.koitharu.kotatsu.search.ui.multi.SearchResultsListModel class SearchAdapter( listener: MangaListListener, itemClickListener: OnListItemClickListener, sizeResolver: ItemSizeResolver, selectionDecoration: MangaSelectionDecoration, ) : BaseListAdapter(), FastScroller.SectionIndexer { init { val pool = RecycledViewPool() addDelegate( ListItemType.MANGA_NESTED_GROUP, searchResultsAD( sharedPool = pool, sizeResolver = sizeResolver, selectionDecoration = selectionDecoration, listener = listener, itemClickListener = itemClickListener, ), ) addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD()) addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(listener)) addDelegate(ListItemType.STATE_ERROR, errorStateListAD(listener)) addDelegate(ListItemType.FOOTER_BUTTON, buttonFooterAD(listener)) } override fun getSectionText(context: Context, position: Int): CharSequence? { return (items.getOrNull(position) as? SearchResultsListModel)?.getTitle(context) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt ================================================ package org.koitharu.kotatsu.search.ui.multi.adapter import android.annotation.SuppressLint import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView.RecycledViewPool import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.UnknownMangaSource import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ItemListGroupBinding import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver import org.koitharu.kotatsu.search.ui.multi.SearchResultsListModel @SuppressLint("NotifyDataSetChanged") fun searchResultsAD( sharedPool: RecycledViewPool, sizeResolver: ItemSizeResolver, selectionDecoration: MangaSelectionDecoration, listener: OnListItemClickListener, itemClickListener: OnListItemClickListener, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemListGroupBinding.inflate(layoutInflater, parent, false) }, ) { binding.recyclerView.setRecycledViewPool(sharedPool) val adapter = ListDelegationAdapter(mangaGridItemAD(sizeResolver, listener)) binding.recyclerView.addItemDecoration(selectionDecoration) binding.recyclerView.adapter = adapter val spacing = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer) binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing, withBottomPadding = true)) val eventListener = AdapterDelegateClickListenerAdapter(this, itemClickListener) binding.buttonMore.setOnClickListener(eventListener) bind { binding.textViewTitle.text = item.getTitle(context) binding.buttonMore.isVisible = item.source !== UnknownMangaSource adapter.items = item.list adapter.notifyDataSetChanged() binding.recyclerView.isGone = item.list.isEmpty() binding.textViewError.textAndVisible = item.error?.getDisplayMessage(context.resources) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionItemCallback.kt ================================================ package org.koitharu.kotatsu.search.ui.suggestion import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import org.koitharu.kotatsu.core.util.ext.getItem import org.koitharu.kotatsu.search.ui.suggestion.adapter.SEARCH_SUGGESTION_ITEM_TYPE_QUERY import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem class SearchSuggestionItemCallback( private val listener: SuggestionItemListener, ) : ItemTouchHelper.Callback() { private val movementFlags = makeMovementFlags( 0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT, ) override fun getMovementFlags( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, ): Int = if (viewHolder.itemViewType == SEARCH_SUGGESTION_ITEM_TYPE_QUERY) { movementFlags } else { 0 } override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder, ): Boolean = false override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { val item = viewHolder.getItem(SearchSuggestionItem.RecentQuery::class.java) ?: return listener.onRemoveQuery(item.query) } interface SuggestionItemListener { fun onRemoveQuery(query: String) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt ================================================ package org.koitharu.kotatsu.search.ui.suggestion import android.text.TextWatcher import android.widget.TextView import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.search.domain.SearchKind interface SearchSuggestionListener : TextWatcher, TextView.OnEditorActionListener { fun onMangaClick(manga: Manga) fun onQueryClick(query: String, kind: SearchKind, submit: Boolean) fun onSourceToggle(source: MangaSource, isEnabled: Boolean) fun onSourceClick(source: MangaSource) fun onTagClick(tag: MangaTag) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListenerImpl.kt ================================================ package org.koitharu.kotatsu.search.ui.suggestion import android.text.Editable import android.view.KeyEvent import android.widget.TextView import androidx.core.net.toUri import com.google.android.material.search.SearchView import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.parser.MangaLinkResolver import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.search.domain.SearchKind class SearchSuggestionListenerImpl( private val router: AppRouter, private val searchView: SearchView, private val viewModel: SearchSuggestionViewModel, ) : SearchSuggestionListener { override fun onMangaClick(manga: Manga) { router.openDetails(manga) } override fun onQueryClick(query: String, kind: SearchKind, submit: Boolean) { if (submit && query.isNotEmpty()) { if (kind == SearchKind.SIMPLE && MangaLinkResolver.isValidLink(query)) { router.openDetails(query.toUri()) } else { router.openSearch(query, kind) if (kind != SearchKind.TAG) { viewModel.saveQuery(query) } } searchView.hide() } else { searchView.setText(query) } } override fun onTagClick(tag: MangaTag) { router.openSearch(tag.title, SearchKind.TAG) } override fun onSourceToggle(source: MangaSource, isEnabled: Boolean) { viewModel.onSourceToggle(source, isEnabled) } override fun onSourceClick(source: MangaSource) { router.openList(source, null, null) } override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit override fun afterTextChanged(s: Editable?) { viewModel.onQueryChanged(s?.toString().orEmpty()) } override fun onEditorAction( v: TextView?, actionId: Int, event: KeyEvent? ): Boolean { val query = v?.text?.toString() if (query.isNullOrEmpty()) { return false } onQueryClick(query, SearchKind.SIMPLE, true) return true } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionMenuProvider.kt ================================================ package org.koitharu.kotatsu.search.ui.suggestion import android.content.Context import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.activity.result.ActivityResultLauncher import androidx.core.view.MenuProvider import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.util.ext.resolve import org.koitharu.kotatsu.core.util.ext.tryLaunch class SearchSuggestionMenuProvider( private val context: Context, private val voiceInputLauncher: ActivityResultLauncher, private val viewModel: SearchSuggestionViewModel, ) : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.opt_search_suggestion, menu) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return when (menuItem.itemId) { R.id.action_clear -> { clearSearchHistory() true } R.id.action_voice_search -> { voiceInputLauncher.tryLaunch(context.getString(R.string.search_manga), null) } else -> false } } override fun onPrepareMenu(menu: Menu) { super.onPrepareMenu(menu) menu.findItem(R.id.action_voice_search)?.isVisible = voiceInputLauncher.resolve(context, null) != null } private fun clearSearchHistory() { buildAlertDialog(context, isCentered = true) { setTitle(R.string.clear_search_history) setIcon(R.drawable.ic_clear_all) setCancelable(true) setMessage(R.string.text_clear_search_history_prompt) setNegativeButton(android.R.string.cancel, null) setPositiveButton(R.string.clear) { _, _ -> viewModel.clearSearchHistory() } }.show() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt ================================================ package org.koitharu.kotatsu.search.ui.suggestion import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.SearchSuggestionType import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.search.domain.MangaSearchRepository import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem import javax.inject.Inject private const val DEBOUNCE_TIMEOUT = 300L private const val MAX_MANGA_ITEMS = 12 private const val MAX_QUERY_ITEMS = 16 private const val MAX_HINTS_ITEMS = 3 private const val MAX_AUTHORS_ITEMS = 2 private const val MAX_TAGS_ITEMS = 8 private const val MAX_SOURCES_ITEMS = 6 private const val MAX_SOURCES_TIPS_ITEMS = 2 @HiltViewModel class SearchSuggestionViewModel @Inject constructor( private val repository: MangaSearchRepository, private val settings: AppSettings, private val sourcesRepository: MangaSourcesRepository, ) : BaseViewModel() { private val query = MutableStateFlow("") private val invalidationTrigger = MutableStateFlow(0) val isIncognitoModeEnabled = settings.observeAsStateFlow( scope = viewModelScope + Dispatchers.Default, key = AppSettings.KEY_INCOGNITO_MODE, valueProducer = { isIncognitoModeEnabled }, ) val suggestion: Flow> = combine( query.debounce(DEBOUNCE_TIMEOUT), sourcesRepository.observeEnabledSources().map { it.mapToSet { x -> x.name } }, settings.observeAsFlow(AppSettings.KEY_SEARCH_SUGGESTION_TYPES) { searchSuggestionTypes }, invalidationTrigger, ) { a, b, c, _ -> Triple(a, b, c) }.mapLatest { (searchQuery, enabledSources, types) -> buildSearchSuggestion(searchQuery, enabledSources, types) }.distinctUntilChanged() .withErrorHandling() .flowOn(Dispatchers.Default) fun onQueryChanged(newQuery: String) { query.value = newQuery } fun saveQuery(query: String) { if (!settings.isIncognitoModeEnabled) { repository.saveSearchQuery(query) invalidationTrigger.value++ } } fun clearSearchHistory() { launchJob(Dispatchers.Default) { repository.clearSearchHistory() invalidationTrigger.value++ } } fun onSourceToggle(source: MangaSource, isEnabled: Boolean) { launchJob(Dispatchers.Default) { sourcesRepository.setSourcesEnabled(setOf(source), isEnabled) } } fun deleteQuery(query: String) { launchJob(Dispatchers.Default) { repository.deleteSearchQuery(query) invalidationTrigger.value++ } } private suspend fun buildSearchSuggestion( searchQuery: String, enabledSources: Set, types: Set, ): List = coroutineScope { listOfNotNull( if (SearchSuggestionType.GENRES in types) { async { getTags(searchQuery) } } else { null }, if (SearchSuggestionType.MANGA in types) { async { getManga(searchQuery) } } else { null }, if (SearchSuggestionType.QUERIES_RECENT in types) { async { getRecentQueries(searchQuery) } } else { null }, if (SearchSuggestionType.QUERIES_SUGGEST in types) { async { getQueryHints(searchQuery) } } else { null }, if (SearchSuggestionType.SOURCES in types) { async { getSources(searchQuery, enabledSources) } } else { null }, if (SearchSuggestionType.RECENT_SOURCES in types) { async { getRecentSources(searchQuery) } } else { null }, if (SearchSuggestionType.AUTHORS in types) { async { getAuthors(searchQuery) } } else { null }, ).flatMap { it.await() } } private suspend fun getAuthors(searchQuery: String): List = runCatchingCancellable { repository.getAuthorsSuggestion(searchQuery, MAX_AUTHORS_ITEMS) .map { SearchSuggestionItem.Author(it) } }.getOrElse { e -> e.printStackTraceDebug() listOf(SearchSuggestionItem.Text(0, e)) } private suspend fun getQueryHints(searchQuery: String): List = runCatchingCancellable { repository.getQueryHintSuggestion(searchQuery, MAX_HINTS_ITEMS) .map { SearchSuggestionItem.Hint(it) } }.getOrElse { e -> e.printStackTraceDebug() listOf(SearchSuggestionItem.Text(0, e)) } private suspend fun getRecentQueries(searchQuery: String): List = runCatchingCancellable { repository.getQuerySuggestion(searchQuery, MAX_QUERY_ITEMS) .map { SearchSuggestionItem.RecentQuery(it) } }.getOrElse { e -> e.printStackTraceDebug() listOf(SearchSuggestionItem.Text(0, e)) } private suspend fun getTags(searchQuery: String): List = runCatchingCancellable { val tags = repository.getTagsSuggestion(searchQuery, MAX_TAGS_ITEMS, null) if (tags.isEmpty()) { emptyList() } else { listOf(SearchSuggestionItem.Tags(mapTags(tags))) } }.getOrElse { e -> e.printStackTraceDebug() listOf(SearchSuggestionItem.Text(0, e)) } private suspend fun getManga(searchQuery: String): List = runCatchingCancellable { val manga = repository.getMangaSuggestion(searchQuery, MAX_MANGA_ITEMS, null) if (manga.isEmpty()) { emptyList() } else { listOf(SearchSuggestionItem.MangaList(manga)) } }.getOrElse { e -> e.printStackTraceDebug() listOf(SearchSuggestionItem.Text(0, e)) } private fun getSources(searchQuery: String, enabledSources: Set): List = runCatchingCancellable { repository.getSourcesSuggestion(searchQuery, MAX_SOURCES_ITEMS) .map { SearchSuggestionItem.Source(it, it.name in enabledSources) } }.getOrElse { e -> e.printStackTraceDebug() listOf(SearchSuggestionItem.Text(0, e)) } private suspend fun getRecentSources(searchQuery: String): List = if (searchQuery.isEmpty()) { runCatchingCancellable { repository.getSourcesSuggestion(MAX_SOURCES_TIPS_ITEMS) .map { SearchSuggestionItem.SourceTip(it) } }.getOrElse { e -> e.printStackTraceDebug() listOf(SearchSuggestionItem.Text(0, e)) } } else { emptyList() } private fun mapTags(tags: List): List = tags.map { tag -> ChipsView.ChipModel( title = tag.title, data = tag, ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAdapter.kt ================================================ package org.koitharu.kotatsu.search.ui.suggestion.adapter import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem const val SEARCH_SUGGESTION_ITEM_TYPE_QUERY = 0 class SearchSuggestionAdapter( listener: SearchSuggestionListener, ) : BaseListAdapter() { init { delegatesManager .addDelegate(SEARCH_SUGGESTION_ITEM_TYPE_QUERY, searchSuggestionQueryAD(listener)) .addDelegate(searchSuggestionSourceAD(listener)) .addDelegate(searchSuggestionSourceTipAD(listener)) .addDelegate(searchSuggestionTagsAD(listener)) .addDelegate(searchSuggestionMangaListAD(listener)) .addDelegate(searchSuggestionQueryHintAD(listener)) .addDelegate(searchSuggestionAuthorAD(listener)) .addDelegate(searchSuggestionTextAD()) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAuthorAD.kt ================================================ package org.koitharu.kotatsu.search.ui.suggestion.adapter import android.view.View import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.databinding.ItemSearchSuggestionQueryHintBinding import org.koitharu.kotatsu.search.domain.SearchKind import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem fun searchSuggestionAuthorAD( listener: SearchSuggestionListener, ) = adapterDelegateViewBinding( { inflater, parent -> ItemSearchSuggestionQueryHintBinding.inflate(inflater, parent, false) }, ) { val viewClickListener = View.OnClickListener { _ -> listener.onQueryClick(item.name, SearchKind.AUTHOR, true) } binding.root.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_user, 0, 0, 0) binding.root.setOnClickListener(viewClickListener) bind { binding.root.text = item.name } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryAD.kt ================================================ package org.koitharu.kotatsu.search.ui.suggestion.adapter import android.view.View import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.databinding.ItemSearchSuggestionQueryBinding import org.koitharu.kotatsu.search.domain.SearchKind import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem fun searchSuggestionQueryAD( listener: SearchSuggestionListener, ) = adapterDelegateViewBinding( { inflater, parent -> ItemSearchSuggestionQueryBinding.inflate(inflater, parent, false) }, ) { val viewClickListener = View.OnClickListener { v -> listener.onQueryClick(item.query, SearchKind.SIMPLE, v.id != R.id.button_complete) } binding.root.setOnClickListener(viewClickListener) binding.buttonComplete.setOnClickListener(viewClickListener) bind { binding.textViewTitle.text = item.query } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryHintAD.kt ================================================ package org.koitharu.kotatsu.search.ui.suggestion.adapter import android.view.View import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.databinding.ItemSearchSuggestionQueryHintBinding import org.koitharu.kotatsu.search.domain.SearchKind import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem fun searchSuggestionQueryHintAD( listener: SearchSuggestionListener, ) = adapterDelegateViewBinding( { inflater, parent -> ItemSearchSuggestionQueryHintBinding.inflate(inflater, parent, false) }, ) { val viewClickListener = View.OnClickListener { _ -> listener.onQueryClick(item.query, SearchKind.SIMPLE, true) } binding.root.setOnClickListener(viewClickListener) bind { binding.root.text = item.query } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceAD.kt ================================================ package org.koitharu.kotatsu.search.ui.suggestion.adapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.model.getSummary import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.databinding.ItemSearchSuggestionSourceBinding import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem fun searchSuggestionSourceAD( listener: SearchSuggestionListener, ) = adapterDelegateViewBinding( { inflater, parent -> ItemSearchSuggestionSourceBinding.inflate(inflater, parent, false) }, ) { binding.switchLocal.setOnCheckedChangeListener { _, isChecked -> listener.onSourceToggle(item.source, isChecked) } binding.root.setOnClickListener { listener.onSourceClick(item.source) } bind { binding.textViewTitle.text = item.source.getTitle(context) binding.textViewSubtitle.text = item.source.getSummary(context) binding.switchLocal.isChecked = item.isEnabled binding.imageViewCover.setImageAsync(item.source) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceTipAD.kt ================================================ package org.koitharu.kotatsu.search.ui.suggestion.adapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.model.getSummary import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.databinding.ItemSearchSuggestionSourceTipBinding import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem fun searchSuggestionSourceTipAD( listener: SearchSuggestionListener, ) = adapterDelegateViewBinding( { inflater, parent -> ItemSearchSuggestionSourceTipBinding.inflate(inflater, parent, false) }, ) { binding.root.setOnClickListener { listener.onSourceClick(item.source) } bind { binding.textViewTitle.text = item.source.getTitle(context) binding.textViewSubtitle.text = item.source.getSummary(context) binding.imageViewCover.setImageAsync(item.source) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionTagsAD.kt ================================================ package org.koitharu.kotatsu.search.ui.suggestion.adapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.databinding.ItemSearchSuggestionTagsBinding import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem fun searchSuggestionTagsAD( listener: SearchSuggestionListener, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemSearchSuggestionTagsBinding.inflate(layoutInflater, parent, false) }, ) { binding.chipsGenres.onChipClickListener = ChipsView.OnChipClickListener { _, data -> listener.onTagClick(data as? MangaTag ?: return@OnChipClickListener) } bind { binding.chipsGenres.setChips(item.tags) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionTextAD.kt ================================================ package org.koitharu.kotatsu.search.ui.suggestion.adapter import android.widget.TextView import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem fun searchSuggestionTextAD() = adapterDelegate( R.layout.item_search_suggestion_text, ) { bind { val tv = itemView as TextView val isError = item.error != null tv.setCompoundDrawablesRelativeWithIntrinsicBounds( if (isError) R.drawable.ic_error_small else 0, 0, 0, 0, ) if (item.textResId != 0) { tv.setText(item.textResId) } else { tv.text = item.error?.getDisplayMessage(tv.resources) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionsMangaListAD.kt ================================================ package org.koitharu.kotatsu.search.ui.suggestion.adapter import androidx.core.view.updatePadding import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback import org.koitharu.kotatsu.core.util.ext.setTooltipCompat import org.koitharu.kotatsu.databinding.ItemSearchSuggestionMangaGridBinding import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem fun searchSuggestionMangaListAD( listener: SearchSuggestionListener, ) = adapterDelegate(R.layout.item_search_suggestion_manga_list) { val adapter = AsyncListDifferDelegationAdapter( SuggestionMangaDiffCallback(), searchSuggestionMangaGridAD(listener), ) val recyclerView = itemView as RecyclerView recyclerView.adapter = adapter val spacing = context.resources.getDimensionPixelOffset(R.dimen.search_suggestions_manga_spacing) recyclerView.updatePadding( left = recyclerView.paddingLeft - spacing, right = recyclerView.paddingRight - spacing, ) recyclerView.addItemDecoration(SpacingItemDecoration(spacing, withBottomPadding = true)) val scrollResetCallback = RecyclerViewScrollCallback(recyclerView, 0, 0) bind { adapter.setItems(item.items, scrollResetCallback) } } private fun searchSuggestionMangaGridAD( listener: SearchSuggestionListener, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemSearchSuggestionMangaGridBinding.inflate(layoutInflater, parent, false) }, ) { itemView.setOnClickListener { listener.onMangaClick(item) } bind { itemView.setTooltipCompat(item.title) binding.imageViewCover.setImageAsync(item.coverUrl, item.source) binding.textViewTitle.text = item.title } } private class SuggestionMangaDiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: Manga, newItem: Manga): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame(oldItem: Manga, newItem: Manga): Boolean { return oldItem.title == newItem.title && oldItem.coverUrl == newItem.coverUrl } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/model/SearchSuggestionItem.kt ================================================ package org.koitharu.kotatsu.search.ui.suggestion.model import androidx.annotation.StringRes import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource sealed interface SearchSuggestionItem : ListModel { data class MangaList( val items: List, ) : SearchSuggestionItem { override fun areItemsTheSame(other: ListModel): Boolean { return other is MangaList } } data class RecentQuery( val query: String, ) : SearchSuggestionItem { override fun areItemsTheSame(other: ListModel): Boolean { return other is RecentQuery && query == other.query } } data class Hint( val query: String, ) : SearchSuggestionItem { override fun areItemsTheSame(other: ListModel): Boolean { return other is Hint && query == other.query } } data class Author( val name: String, ) : SearchSuggestionItem { override fun areItemsTheSame(other: ListModel): Boolean { return other is Author && name == other.name } } data class Source( val source: MangaSource, val isEnabled: Boolean, ) : SearchSuggestionItem { val isNsfw: Boolean get() = source.isNsfw() override fun areItemsTheSame(other: ListModel): Boolean { return other is Source && other.source.name == source.name } override fun getChangePayload(previousState: ListModel): Any? { if (previousState !is Source) { return super.getChangePayload(previousState) } return if (isEnabled != previousState.isEnabled) { ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED } else { null } } } data class SourceTip( val source: MangaSource, ) : SearchSuggestionItem { val isNsfw: Boolean get() = source.isNsfw() override fun areItemsTheSame(other: ListModel): Boolean { return other is SourceTip && other.source.name == source.name } } data class Tags( val tags: List, ) : SearchSuggestionItem { override fun areItemsTheSame(other: ListModel): Boolean { return other is Tags } override fun getChangePayload(previousState: ListModel): Any { return ListModelDiffCallback.PAYLOAD_NESTED_LIST_CHANGED } } data class Text( @StringRes val textResId: Int, val error: Throwable?, ) : SearchSuggestionItem { override fun areItemsTheSame(other: ListModel): Boolean = other is Text && textResId == other.textResId && error?.javaClass == other.error?.javaClass && error?.message == other.error?.message } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt ================================================ package org.koitharu.kotatsu.settings import android.content.Intent import android.content.SharedPreferences import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.Settings import android.view.View import androidx.appcompat.app.AppCompatDelegate import androidx.preference.ListPreference import androidx.preference.MultiSelectListPreference import androidx.preference.Preference import androidx.preference.TwoStatePreference import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy import org.koitharu.kotatsu.core.prefs.SearchSuggestionType import org.koitharu.kotatsu.core.prefs.TriStateOption import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle import org.koitharu.kotatsu.core.util.LocaleComparator import org.koitharu.kotatsu.core.util.ext.getLocalesConfig import org.koitharu.kotatsu.core.util.ext.postDelayed import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat import org.koitharu.kotatsu.core.util.ext.sortedWithSafe import org.koitharu.kotatsu.core.util.ext.toList import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.names import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity import org.koitharu.kotatsu.settings.utils.ActivityListPreference import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider import org.koitharu.kotatsu.settings.utils.PercentSummaryProvider import org.koitharu.kotatsu.settings.utils.SliderPreference import javax.inject.Inject @AndroidEntryPoint class AppearanceSettingsFragment : BasePreferenceFragment(R.string.appearance), SharedPreferences.OnSharedPreferenceChangeListener { @Inject lateinit var activityRecreationHandle: ActivityRecreationHandle @Inject lateinit var appShortcutManager: AppShortcutManager override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_appearance) findPreference(AppSettings.KEY_GRID_SIZE)?.summaryProvider = PercentSummaryProvider() findPreference(AppSettings.KEY_LIST_MODE)?.run { entryValues = ListMode.entries.names() setDefaultValueCompat(ListMode.GRID.name) } findPreference(AppSettings.KEY_PROGRESS_INDICATORS)?.run { entryValues = ProgressIndicatorMode.entries.names() setDefaultValueCompat(ProgressIndicatorMode.PERCENT_READ.name) } findPreference(AppSettings.KEY_APP_LOCALE)?.run { initLocalePicker(this) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { activityIntent = Intent( Settings.ACTION_APP_LOCALE_SETTINGS, Uri.fromParts("package", context.packageName, null), ) } summaryProvider = Preference.SummaryProvider { val locale = AppCompatDelegate.getApplicationLocales().get(0) locale?.getDisplayName(locale)?.toTitleCase(locale) ?: getString(R.string.follow_system) } setDefaultValueCompat("") } findPreference(AppSettings.KEY_MANGA_LIST_BADGES)?.run { summaryProvider = MultiSummaryProvider(R.string.none) } findPreference(AppSettings.KEY_SHORTCUTS)?.isVisible = appShortcutManager.isDynamicShortcutsAvailable() findPreference(AppSettings.KEY_PROTECT_APP) ?.isChecked = !settings.appPassword.isNullOrEmpty() findPreference(AppSettings.KEY_SCREENSHOTS_POLICY)?.run { entryValues = ScreenshotsPolicy.entries.names() setDefaultValueCompat(ScreenshotsPolicy.ALLOW.name) } findPreference(AppSettings.KEY_SEARCH_SUGGESTION_TYPES)?.let { pref -> pref.entryValues = SearchSuggestionType.entries.names() pref.entries = SearchSuggestionType.entries.map { pref.context.getString(it.titleResId) }.toTypedArray() pref.summaryProvider = MultiSummaryProvider(R.string.none) pref.values = settings.searchSuggestionTypes.mapToSet { it.name } } bindNavSummary() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) settings.subscribe(this) } override fun onDestroyView() { settings.unsubscribe(this) super.onDestroyView() } override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) { when (key) { AppSettings.KEY_THEME -> { AppCompatDelegate.setDefaultNightMode(settings.theme) } AppSettings.KEY_COLOR_THEME, AppSettings.KEY_THEME_AMOLED, -> { postRestart() } AppSettings.KEY_APP_LOCALE -> { AppCompatDelegate.setApplicationLocales(settings.appLocales) } AppSettings.KEY_NAV_MAIN -> { bindNavSummary() } AppSettings.KEY_APP_PASSWORD -> { findPreference(AppSettings.KEY_PROTECT_APP) ?.isChecked = !settings.appPassword.isNullOrEmpty() } } } override fun onPreferenceTreeClick(preference: Preference): Boolean { return when (preference.key) { AppSettings.KEY_PROTECT_APP -> { val pref = (preference as? TwoStatePreference ?: return false) if (pref.isChecked) { pref.isChecked = false startActivity(Intent(preference.context, ProtectSetupActivity::class.java)) } else { settings.appPassword = null } true } else -> super.onPreferenceTreeClick(preference) } } private fun postRestart() { viewLifecycleOwner.lifecycle.postDelayed(400) { activityRecreationHandle.recreateAll() } } private fun initLocalePicker(preference: ListPreference) { val locales = preference.context.getLocalesConfig() .toList() .sortedWithSafe(LocaleComparator()) preference.entries = Array(locales.size + 1) { i -> if (i == 0) { getString(R.string.follow_system) } else { val lc = locales[i - 1] lc.getDisplayName(lc).toTitleCase(lc) } } preference.entryValues = Array(locales.size + 1) { i -> if (i == 0) { "" } else { locales[i - 1].toLanguageTag() } } } private fun bindNavSummary() { val pref = findPreference(AppSettings.KEY_NAV_MAIN) ?: return pref.summary = settings.mainNavItems.joinToString { getString(it.title) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt ================================================ package org.koitharu.kotatsu.settings import android.content.Context import android.content.SharedPreferences import android.net.Uri import android.os.Bundle import android.view.View import androidx.documentfile.provider.DocumentFile import androidx.preference.ListPreference import androidx.preference.Preference import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R 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.prefs.DownloadFormat import org.koitharu.kotatsu.core.prefs.TriStateOption import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.resolveFile import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat import org.koitharu.kotatsu.core.util.ext.tryLaunch import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.parsers.util.names import org.koitharu.kotatsu.settings.utils.DozeHelper import javax.inject.Inject @AndroidEntryPoint class DownloadsSettingsFragment : BasePreferenceFragment(R.string.downloads), SharedPreferences.OnSharedPreferenceChangeListener { private val dozeHelper = DozeHelper(this) @Inject lateinit var storageManager: LocalStorageManager @Inject lateinit var downloadsScheduler: DownloadWorker.Scheduler private val pickFileTreeLauncher = OpenDocumentTreeHelper(this) { if (it != null) onDirectoryPicked(it) } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_downloads) findPreference(AppSettings.KEY_DOWNLOADS_FORMAT)?.run { entryValues = DownloadFormat.entries.names() setDefaultValueCompat(DownloadFormat.AUTOMATIC.name) } findPreference(AppSettings.KEY_DOWNLOADS_METERED_NETWORK)?.run { entryValues = TriStateOption.entries.names() setDefaultValueCompat(TriStateOption.ASK.name) } dozeHelper.updatePreference() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) findPreference(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName() findPreference(AppSettings.KEY_LOCAL_MANGA_DIRS)?.bindDirectoriesCount() findPreference(AppSettings.KEY_PAGES_SAVE_DIR)?.bindPagesDirectory() settings.subscribe(this) } override fun onDestroyView() { settings.unsubscribe(this) super.onDestroyView() } override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) { when (key) { AppSettings.KEY_LOCAL_STORAGE -> { findPreference(key)?.bindStorageName() } AppSettings.KEY_LOCAL_MANGA_DIRS -> { findPreference(key)?.bindDirectoriesCount() } AppSettings.KEY_DOWNLOADS_METERED_NETWORK -> { updateDownloadsConstraints() } AppSettings.KEY_PAGES_SAVE_DIR -> { findPreference(AppSettings.KEY_PAGES_SAVE_DIR)?.bindPagesDirectory() } } } override fun onPreferenceTreeClick(preference: Preference): Boolean { return when (preference.key) { AppSettings.KEY_LOCAL_STORAGE -> { router.showDirectorySelectDialog() true } AppSettings.KEY_LOCAL_MANGA_DIRS -> { router.openDirectoriesSettings() true } AppSettings.KEY_IGNORE_DOZE -> { dozeHelper.startIgnoreDoseActivity() } AppSettings.KEY_PAGES_SAVE_DIR -> { if (!pickFileTreeLauncher.tryLaunch(settings.getPagesSaveDir(preference.context)?.uri)) { Snackbar.make( requireView(), R.string.operation_not_supported, Snackbar.LENGTH_SHORT, ).show() } true } else -> super.onPreferenceTreeClick(preference) } } private fun onDirectoryPicked(uri: Uri) { storageManager.takePermissions(uri) val doc = DocumentFile.fromTreeUri(requireContext(), uri)?.takeIf { it.canWrite() } settings.setPagesSaveDir(doc?.uri) } private fun Preference.bindStorageName() { viewLifecycleScope.launch { val storage = storageManager.getDefaultWriteableDir() summary = if (storage != null) { storageManager.getDirectoryDisplayName(storage, isFullPath = true) } else { getString(R.string.not_available) } } } private fun Preference.bindDirectoriesCount() { viewLifecycleScope.launch { val dirs = storageManager.getReadableDirs().size summary = resources.getQuantityStringSafe(R.plurals.items, dirs, dirs) } } private fun Preference.bindPagesDirectory() { viewLifecycleScope.launch { val df = withContext(Dispatchers.IO) { settings.getPagesSaveDir(this@bindPagesDirectory.context) } summary = df?.getDisplayPath(this@bindPagesDirectory.context) ?: this@bindPagesDirectory.context.getString(androidx.preference.R.string.not_set) } } private fun updateDownloadsConstraints() { val preference = findPreference(AppSettings.KEY_DOWNLOADS_METERED_NETWORK) viewLifecycleScope.launch { try { preference?.isEnabled = false withContext(Dispatchers.Default) { val option = when (settings.allowDownloadOnMeteredNetwork) { TriStateOption.ENABLED -> true TriStateOption.ASK -> return@withContext TriStateOption.DISABLED -> false } downloadsScheduler.updateConstraints(option) } } catch (e: Exception) { e.printStackTraceDebug() } finally { preference?.isEnabled = true } } } private fun DocumentFile.getDisplayPath(context: Context): String { return uri.resolveFile(context)?.path ?: uri.toString() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/NotificationSettingsLegacyFragment.kt ================================================ package org.koitharu.kotatsu.settings import android.content.SharedPreferences import android.media.RingtoneManager import android.os.Bundle import android.view.View import androidx.preference.Preference import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.settings.utils.RingtonePickContract class NotificationSettingsLegacyFragment : BasePreferenceFragment(R.string.notifications), SharedPreferences.OnSharedPreferenceChangeListener { private val ringtonePickContract = registerForActivityResult( RingtonePickContract(R.string.notification_sound), ) { uri -> settings.notificationSound = uri ?: return@registerForActivityResult findPreference(AppSettings.KEY_NOTIFICATIONS_SOUND)?.run { summary = RingtoneManager.getRingtone(context, uri)?.getTitle(context) ?: getString(R.string.silent) } } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_notifications) findPreference(AppSettings.KEY_NOTIFICATIONS_SOUND)?.run { val uri = settings.notificationSound summary = RingtoneManager.getRingtone(context, uri)?.getTitle(context) ?: getString(R.string.silent) } updateInfo() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) settings.subscribe(this) } override fun onDestroyView() { settings.unsubscribe(this) super.onDestroyView() } override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) { when (key) { AppSettings.KEY_TRACKER_NOTIFICATIONS -> updateInfo() } } override fun onPreferenceTreeClick(preference: Preference): Boolean { return when (preference.key) { AppSettings.KEY_NOTIFICATIONS_SOUND -> { ringtonePickContract.launch(settings.notificationSound) true } else -> super.onPreferenceTreeClick(preference) } } private fun updateInfo() { findPreference(AppSettings.KEY_NOTIFICATIONS_INFO) ?.isVisible = !settings.isTrackerNotificationsEnabled } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/ProxySettingsFragment.kt ================================================ package org.koitharu.kotatsu.settings import android.content.SharedPreferences import android.os.Bundle import android.view.View import android.view.inputmethod.EditorInfo import androidx.preference.EditTextPreference import androidx.preference.Preference import androidx.preference.PreferenceCategory import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.network.BaseHttpClient import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.settings.utils.EditTextBindListener import org.koitharu.kotatsu.settings.utils.PasswordSummaryProvider import org.koitharu.kotatsu.settings.utils.validation.DomainValidator import org.koitharu.kotatsu.settings.utils.validation.PortNumberValidator import java.net.Proxy import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException @AndroidEntryPoint class ProxySettingsFragment : BasePreferenceFragment(R.string.proxy), SharedPreferences.OnSharedPreferenceChangeListener { private var testJob: Job? = null @Inject @BaseHttpClient lateinit var okHttpClient: OkHttpClient override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_proxy) @Suppress("UsePropertyAccessSyntax") findPreference(AppSettings.KEY_PROXY_ADDRESS)?.setOnBindEditTextListener( EditTextBindListener( inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_URI, hint = null, validator = DomainValidator(), ), ) @Suppress("UsePropertyAccessSyntax") findPreference(AppSettings.KEY_PROXY_PORT)?.setOnBindEditTextListener( EditTextBindListener( inputType = EditorInfo.TYPE_CLASS_NUMBER, hint = null, validator = PortNumberValidator(), ), ) findPreference(AppSettings.KEY_PROXY_PASSWORD)?.let { pref -> @Suppress("UsePropertyAccessSyntax") pref.setOnBindEditTextListener( EditTextBindListener( inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_PASSWORD, hint = null, validator = null, ), ) pref.summaryProvider = PasswordSummaryProvider() } updateDependencies() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) settings.subscribe(this) } override fun onDestroyView() { settings.unsubscribe(this) super.onDestroyView() } override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) { AppSettings.KEY_PROXY_TEST -> { testConnection() true } else -> super.onPreferenceTreeClick(preference) } override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { when (key) { AppSettings.KEY_PROXY_TYPE -> updateDependencies() } } private fun updateDependencies() { val isProxyEnabled = settings.proxyType != Proxy.Type.DIRECT findPreference(AppSettings.KEY_PROXY_ADDRESS)?.isEnabled = isProxyEnabled findPreference(AppSettings.KEY_PROXY_PORT)?.isEnabled = isProxyEnabled findPreference(AppSettings.KEY_PROXY_AUTH)?.isEnabled = isProxyEnabled findPreference(AppSettings.KEY_PROXY_LOGIN)?.isEnabled = isProxyEnabled findPreference(AppSettings.KEY_PROXY_PASSWORD)?.isEnabled = isProxyEnabled findPreference(AppSettings.KEY_PROXY_TEST)?.isEnabled = isProxyEnabled && testJob?.isActive != true } private fun testConnection() { testJob?.cancel() testJob = viewLifecycleScope.launch { val pref = findPreference(AppSettings.KEY_PROXY_TEST) pref?.run { setSummary(R.string.loading_) isEnabled = false } try { withContext(Dispatchers.Default) { val request = Request.Builder() .get() .url("http://neverssl.com") .build() okHttpClient.newCall(request).await().use { response -> check(response.isSuccessful) { response.message } } } showTestResult(null) } catch (e: CancellationException) { throw e } catch (e: Throwable) { e.printStackTraceDebug() showTestResult(e) } finally { pref?.run { isEnabled = true summary = null } } } } private fun showTestResult(error: Throwable?) { MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.proxy) .setMessage(error?.getDisplayMessage(resources) ?: getString(R.string.connection_ok)) .setPositiveButton(android.R.string.ok, null) .setCancelable(true) .show() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt ================================================ package org.koitharu.kotatsu.settings import android.content.SharedPreferences import android.content.pm.ActivityInfo import android.os.Bundle import android.view.View import androidx.preference.ListPreference import androidx.preference.MultiSelectListPreference import androidx.preference.Preference import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderAnimation import org.koitharu.kotatsu.core.prefs.ReaderBackground import org.koitharu.kotatsu.core.prefs.ReaderControl import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.names import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider import org.koitharu.kotatsu.settings.utils.PercentSummaryProvider import org.koitharu.kotatsu.settings.utils.SliderPreference @AndroidEntryPoint class ReaderSettingsFragment : BasePreferenceFragment(R.string.reader_settings), SharedPreferences.OnSharedPreferenceChangeListener { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_reader) findPreference(AppSettings.KEY_READER_MODE)?.run { entryValues = ReaderMode.entries.names() setDefaultValueCompat(ReaderMode.STANDARD.name) } findPreference(AppSettings.KEY_READER_ORIENTATION)?.run { entryValues = arrayOf( ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED.toString(), ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR.toString(), ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT.toString(), ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE.toString(), ) setDefaultValueCompat(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED.toString()) } findPreference(AppSettings.KEY_READER_CONTROLS)?.run { entryValues = ReaderControl.entries.names() setDefaultValueCompat(ReaderControl.DEFAULT.mapToSet { it.name }) summaryProvider = MultiSummaryProvider(R.string.none) } findPreference(AppSettings.KEY_READER_BACKGROUND)?.run { entryValues = ReaderBackground.entries.names() setDefaultValueCompat(ReaderBackground.DEFAULT.name) } findPreference(AppSettings.KEY_READER_ANIMATION)?.run { entryValues = ReaderAnimation.entries.names() setDefaultValueCompat(ReaderAnimation.DEFAULT.name) } findPreference(AppSettings.KEY_ZOOM_MODE)?.run { entryValues = ZoomMode.entries.names() setDefaultValueCompat(ZoomMode.FIT_CENTER.name) } findPreference(AppSettings.KEY_READER_CROP)?.run { summaryProvider = MultiSummaryProvider(R.string.disabled) } findPreference(AppSettings.KEY_WEBTOON_ZOOM_OUT)?.summaryProvider = PercentSummaryProvider() updateReaderModeDependency() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) settings.subscribe(this) } override fun onDestroyView() { settings.unsubscribe(this) super.onDestroyView() } override fun onPreferenceTreeClick(preference: Preference): Boolean { return when (preference.key) { AppSettings.KEY_READER_TAP_ACTIONS -> { router.openReaderTapGridSettings() true } else -> super.onPreferenceTreeClick(preference) } } override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { when (key) { AppSettings.KEY_READER_MODE -> updateReaderModeDependency() } } private fun updateReaderModeDependency() { findPreference(AppSettings.KEY_READER_MODE_DETECT)?.run { isEnabled = settings.defaultReaderMode != ReaderMode.WEBTOON } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/RootSettingsFragment.kt ================================================ package org.koitharu.kotatsu.settings import android.os.Bundle import android.view.View import androidx.annotation.StringRes import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.preference.Preference import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.settings.search.SettingsSearchMenuProvider import org.koitharu.kotatsu.settings.search.SettingsSearchViewModel @AndroidEntryPoint class RootSettingsFragment : BasePreferenceFragment(0) { private val viewModel: RootSettingsViewModel by viewModels() private val activityViewModel: SettingsSearchViewModel by activityViewModels() override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_root) addPreferencesFromResource(R.xml.pref_root_debug) bindPreferenceSummary("appearance", R.string.theme, R.string.list_mode, R.string.language) bindPreferenceSummary("reader", R.string.read_mode, R.string.scale_mode, R.string.switch_pages) bindPreferenceSummary("network", R.string.storage_usage, R.string.proxy, R.string.prefetch_content) bindPreferenceSummary("userdata", R.string.create_or_restore_backup, R.string.periodic_backups) bindPreferenceSummary("downloads", R.string.manga_save_location, R.string.downloads_wifi_only) bindPreferenceSummary("tracker", R.string.track_sources, R.string.notifications_settings) bindPreferenceSummary("services", R.string.suggestions, R.string.sync, R.string.tracking) findPreference("about")?.summary = getString(R.string.app_version, BuildConfig.VERSION_NAME) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) findPreference(AppSettings.KEY_REMOTE_SOURCES)?.let { pref -> val total = viewModel.totalSourcesCount viewModel.enabledSourcesCount.observe(viewLifecycleOwner) { pref.summary = if (it >= 0) { getString(R.string.enabled_d_of_d, it, total) } else { resources.getQuantityStringSafe(R.plurals.items, total, total) } } } addMenuProvider(SettingsSearchMenuProvider(activityViewModel)) } override fun setTitle(title: CharSequence?) { if (!resources.getBoolean(R.bool.is_tablet)) { super.setTitle(title) } } private fun bindPreferenceSummary(key: String, @StringRes vararg items: Int) { findPreference(key)?.summary = items.joinToString { getString(it) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/RootSettingsViewModel.kt ================================================ package org.koitharu.kotatsu.settings import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import javax.inject.Inject @HiltViewModel class RootSettingsViewModel @Inject constructor( sourcesRepository: MangaSourcesRepository, ) : BaseViewModel() { val totalSourcesCount = sourcesRepository.allMangaSources.size val enabledSourcesCount = sourcesRepository.observeEnabledSourcesCount() .withErrorHandling() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, -1) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/ServicesSettingsFragment.kt ================================================ package org.koitharu.kotatsu.settings import android.accounts.AccountManager import android.content.SharedPreferences import android.os.Bundle import android.view.View import androidx.preference.Preference import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper import org.koitharu.kotatsu.settings.utils.SplitSwitchPreference import org.koitharu.kotatsu.sync.domain.SyncController import javax.inject.Inject @AndroidEntryPoint class ServicesSettingsFragment : BasePreferenceFragment(R.string.services), SharedPreferences.OnSharedPreferenceChangeListener { @Inject lateinit var syncController: SyncController @Inject lateinit var scrobblerAuthHelper: ScrobblerAuthHelper override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_services) findPreference(AppSettings.KEY_STATS_ENABLED)?.let { it.onContainerClickListener = Preference.OnPreferenceClickListener { router.openStatistic() true } } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) bindSuggestionsSummary() bindStatsSummary() settings.subscribe(this) } override fun onDestroyView() { settings.unsubscribe(this) super.onDestroyView() } override fun onResume() { super.onResume() bindScrobblerSummary(AppSettings.KEY_SHIKIMORI, ScrobblerService.SHIKIMORI) bindScrobblerSummary(AppSettings.KEY_ANILIST, ScrobblerService.ANILIST) bindScrobblerSummary(AppSettings.KEY_MAL, ScrobblerService.MAL) bindScrobblerSummary(AppSettings.KEY_KITSU, ScrobblerService.KITSU) bindSyncSummary() } override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) { when (key) { AppSettings.KEY_SUGGESTIONS -> bindSuggestionsSummary() AppSettings.KEY_STATS_ENABLED -> bindStatsSummary() } } override fun onPreferenceTreeClick(preference: Preference): Boolean { return when (preference.key) { AppSettings.KEY_SHIKIMORI -> { handleScrobblerClick(ScrobblerService.SHIKIMORI) true } AppSettings.KEY_MAL -> { handleScrobblerClick(ScrobblerService.MAL) true } AppSettings.KEY_ANILIST -> { handleScrobblerClick(ScrobblerService.ANILIST) true } AppSettings.KEY_KITSU -> { handleScrobblerClick(ScrobblerService.KITSU) true } AppSettings.KEY_SYNC -> { val am = AccountManager.get(requireContext()) val accountType = getString(R.string.account_type_sync) val account = am.getAccountsByType(accountType).firstOrNull() if (account == null) { am.addAccount(accountType, accountType, null, null, requireActivity(), null, null) } else { if (!router.openSystemSyncSettings(account)) { Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() } } true } else -> super.onPreferenceTreeClick(preference) } } private fun bindScrobblerSummary( key: String, scrobblerService: ScrobblerService ) { val pref = findPreference(key) ?: return if (!scrobblerAuthHelper.isAuthorized(scrobblerService)) { pref.setSummary(R.string.disabled) return } val username = scrobblerAuthHelper.getCachedUser(scrobblerService)?.nickname if (username != null) { pref.summary = getString(R.string.logged_in_as, username) } else { pref.setSummary(R.string.loading_) viewLifecycleScope.launch { pref.summary = withContext(Dispatchers.Default) { runCatching { val user = scrobblerAuthHelper.getUser(scrobblerService) getString(R.string.logged_in_as, user.nickname) }.getOrElse { it.printStackTraceDebug() it.getDisplayMessage(resources) } } } } } private fun handleScrobblerClick(scrobblerService: ScrobblerService) { if (!scrobblerAuthHelper.isAuthorized(scrobblerService)) { confirmScrobblerAuth(scrobblerService) } else { router.openScrobblerSettings(scrobblerService) } } private fun bindSyncSummary() { viewLifecycleScope.launch { val account = withContext(Dispatchers.Default) { val type = getString(R.string.account_type_sync) AccountManager.get(requireContext()).getAccountsByType(type).firstOrNull() } findPreference(AppSettings.KEY_SYNC)?.run { summary = when { account == null -> getString(R.string.sync_title) syncController.isEnabled(account) -> account.name else -> getString(R.string.disabled) } } findPreference(AppSettings.KEY_SYNC_SETTINGS)?.isEnabled = account != null } } private fun bindSuggestionsSummary() { findPreference(AppSettings.KEY_SUGGESTIONS)?.setSummary( if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled, ) } private fun bindStatsSummary() { findPreference(AppSettings.KEY_STATS_ENABLED)?.setSummary( if (settings.isStatsEnabled) R.string.enabled else R.string.disabled, ) } private fun confirmScrobblerAuth(scrobblerService: ScrobblerService) { buildAlertDialog(context ?: return, isCentered = true) { setIcon(scrobblerService.iconResId) setTitle(scrobblerService.titleResId) setMessage(context.getString(R.string.scrobbler_auth_intro, context.getString(scrobblerService.titleResId))) setPositiveButton(R.string.sign_in) { _, _ -> scrobblerAuthHelper.startAuth(context, scrobblerService).onFailure { Snackbar.make(listView, it.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show() } } setNegativeButton(android.R.string.cancel, null) }.show() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt ================================================ package org.koitharu.kotatsu.settings import android.content.Intent import android.os.Bundle import android.view.View import android.view.ViewGroup import androidx.activity.viewModels import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePaddingRelative import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentFactory import androidx.fragment.app.FragmentTransaction import androidx.fragment.app.commit import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import com.google.android.material.appbar.AppBarLayout import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupSettingsFragment import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.util.ext.buildBundle import org.koitharu.kotatsu.core.util.ext.end import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.start import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ActivitySettingsBinding import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.settings.about.AboutSettingsFragment import org.koitharu.kotatsu.settings.discord.DiscordSettingsFragment import org.koitharu.kotatsu.settings.search.SettingsItem import org.koitharu.kotatsu.settings.search.SettingsSearchFragment import org.koitharu.kotatsu.settings.search.SettingsSearchViewModel import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment import org.koitharu.kotatsu.settings.sources.manage.SourcesManageFragment import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment import org.koitharu.kotatsu.settings.userdata.BackupsSettingsFragment @AndroidEntryPoint class SettingsActivity : BaseActivity(), PreferenceFragmentCompat.OnPreferenceStartFragmentCallback, AppBarOwner { override val appBar: AppBarLayout get() = viewBinding.appbar private val isMasterDetails get() = viewBinding.containerMaster != null private val viewModel: SettingsSearchViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivitySettingsBinding.inflate(layoutInflater)) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) val fm = supportFragmentManager val currentFragment = fm.findFragmentById(R.id.container) if (currentFragment == null || (isMasterDetails && currentFragment is RootSettingsFragment)) { openDefaultFragment() } if (isMasterDetails && fm.findFragmentById(R.id.container_master) == null) { supportFragmentManager.commit { setReorderingAllowed(true) replace(R.id.container_master, RootSettingsFragment()) } } viewModel.isSearchActive.observe(this, ::toggleSearchMode) viewModel.onNavigateToPreference.observeEvent(this, ::navigateToPreference) } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) val isTablet = viewBinding.containerMaster != null viewBinding.appbar.updatePaddingRelative( start = bars.start(v), top = bars.top, end = if (isTablet) 0 else bars.end(v), ) viewBinding.textViewHeader?.updateLayoutParams { marginEnd = bars.end(v) topMargin = bars.top } return insets } override fun onPreferenceStartFragment( caller: PreferenceFragmentCompat, pref: Preference, ): Boolean { val fragmentName = pref.fragment ?: return false openFragment( fragmentClass = FragmentFactory.loadFragmentClass(classLoader, fragmentName), args = pref.peekExtras(), isFromRoot = caller is RootSettingsFragment, ) return true } fun setSectionTitle(title: CharSequence?) { viewBinding.textViewHeader?.apply { textAndVisible = title } ?: setTitle(title ?: getString(R.string.settings)) } fun openFragment(fragmentClass: Class, args: Bundle?, isFromRoot: Boolean) { viewModel.discardSearch() val hasFragment = supportFragmentManager.findFragmentById(R.id.container) != null supportFragmentManager.commit { setReorderingAllowed(true) replace(R.id.container, fragmentClass, args) setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) if (!isMasterDetails || (hasFragment && !isFromRoot)) { addToBackStack(null) } } } private fun toggleSearchMode(isEnabled: Boolean) { viewBinding.containerSearch.isVisible = isEnabled val searchFragment = supportFragmentManager.findFragmentById(R.id.container_search) if (searchFragment != null) { if (!isEnabled) { invalidateOptionsMenu() supportFragmentManager.commit { setReorderingAllowed(true) remove(searchFragment) setTransition(FragmentTransaction.TRANSIT_FRAGMENT_CLOSE) } } } else if (isEnabled) { supportFragmentManager.commit { setReorderingAllowed(true) add(R.id.container_search, SettingsSearchFragment::class.java, null) setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) } } } private fun openDefaultFragment() { val fragment = when (intent?.action) { AppRouter.ACTION_READER -> ReaderSettingsFragment() AppRouter.ACTION_SUGGESTIONS -> SuggestionsSettingsFragment() AppRouter.ACTION_HISTORY -> BackupsSettingsFragment() AppRouter.ACTION_TRACKER -> TrackerSettingsFragment() AppRouter.ACTION_PERIODIC_BACKUP -> PeriodicalBackupSettingsFragment() AppRouter.ACTION_SOURCES -> SourcesSettingsFragment() AppRouter.ACTION_MANAGE_DISCORD -> DiscordSettingsFragment() AppRouter.ACTION_PROXY -> ProxySettingsFragment() AppRouter.ACTION_MANAGE_DOWNLOADS -> DownloadsSettingsFragment() AppRouter.ACTION_SOURCE -> SourceSettingsFragment.newInstance( MangaSource(intent.getStringExtra(AppRouter.KEY_SOURCE)), ) AppRouter.ACTION_MANAGE_SOURCES -> SourcesManageFragment() Intent.ACTION_VIEW -> { when (intent.data?.host) { HOST_ABOUT -> AboutSettingsFragment() HOST_SYNC_SETTINGS -> SyncSettingsFragment() else -> null } } else -> null } ?: if (isMasterDetails) AppearanceSettingsFragment() else RootSettingsFragment() supportFragmentManager.commit { setReorderingAllowed(true) replace(R.id.container, fragment) } } private fun navigateToPreference(item: SettingsItem) { val args = buildBundle(1) { putString(ARG_PREF_KEY, item.key) } openFragment(item.fragmentClass, args, true) } companion object { private const val HOST_ABOUT = "about" private const val HOST_SYNC_SETTINGS = "sync-settings" const val ARG_PREF_KEY = "pref_key" } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/StorageAndNetworkSettingsFragment.kt ================================================ package org.koitharu.kotatsu.settings import android.content.SharedPreferences import android.os.Bundle import android.view.View import androidx.fragment.app.viewModels import androidx.preference.ListPreference import androidx.preference.Preference import com.google.android.material.snackbar.Snackbar import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.network.DoHProvider 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.setDefaultValueCompat import org.koitharu.kotatsu.parsers.util.names import org.koitharu.kotatsu.settings.userdata.storage.StorageUsagePreference import java.net.Proxy class StorageAndNetworkSettingsFragment : BasePreferenceFragment(R.string.storage_and_network), SharedPreferences.OnSharedPreferenceChangeListener { private val viewModel by viewModels() override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_network_storage) findPreference(AppSettings.KEY_DOH)?.run { entryValues = DoHProvider.entries.names() setDefaultValueCompat(DoHProvider.NONE.name) } bindProxySummary() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this)) settings.subscribe(this) findPreference(AppSettings.KEY_STORAGE_USAGE)?.let { pref -> viewModel.storageUsage.observe(viewLifecycleOwner, pref) } } override fun onDestroyView() { settings.unsubscribe(this) super.onDestroyView() } override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) { when (key) { AppSettings.KEY_SSL_BYPASS -> { Snackbar.make(listView, R.string.settings_apply_restart_required, Snackbar.LENGTH_INDEFINITE).show() } AppSettings.KEY_PROXY_TYPE, AppSettings.KEY_PROXY_ADDRESS, AppSettings.KEY_PROXY_PORT -> { bindProxySummary() } } } private fun bindProxySummary() { findPreference(AppSettings.KEY_PROXY)?.run { val type = settings.proxyType val address = settings.proxyAddress val port = settings.proxyPort summary = when { type == Proxy.Type.DIRECT -> context.getString(R.string.disabled) address.isNullOrEmpty() || port == 0 -> context.getString(R.string.invalid_proxy_configuration) else -> "$address:$port" } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/StorageAndNetworkSettingsViewModel.kt ================================================ package org.koitharu.kotatsu.settings 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.flow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.settings.userdata.storage.StorageUsage import javax.inject.Inject @HiltViewModel class StorageAndNetworkSettingsViewModel @Inject constructor( private val storageManager: LocalStorageManager, ) : BaseViewModel() { val storageUsage: StateFlow = flow { emit(loadStorageUsage()) }.withErrorHandling() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(1000), null) private suspend fun loadStorageUsage(): StorageUsage { val pagesCacheSize = storageManager.computeCacheSize(CacheDir.PAGES) val otherCacheSize = storageManager.computeCacheSize() - pagesCacheSize val storageSize = storageManager.computeStorageSize() val availableSpace = storageManager.computeAvailableSize() val totalBytes = pagesCacheSize + otherCacheSize + storageSize + availableSpace return StorageUsage( savedManga = StorageUsage.Item( bytes = storageSize, percent = (storageSize.toDouble() / totalBytes).toFloat(), ), pagesCache = StorageUsage.Item( bytes = pagesCacheSize, percent = (pagesCacheSize.toDouble() / totalBytes).toFloat(), ), otherCache = StorageUsage.Item( bytes = otherCacheSize, percent = (otherCacheSize.toDouble() / totalBytes).toFloat(), ), available = StorageUsage.Item( bytes = availableSpace, percent = (availableSpace.toDouble() / totalBytes).toFloat(), ), ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/SuggestionsSettingsFragment.kt ================================================ package org.koitharu.kotatsu.settings import android.content.SharedPreferences import android.os.Bundle import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.settings.utils.MultiAutoCompleteTextViewPreference import org.koitharu.kotatsu.settings.utils.TagsAutoCompleteProvider import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker import javax.inject.Inject @AndroidEntryPoint class SuggestionsSettingsFragment : BasePreferenceFragment(R.string.suggestions), SharedPreferences.OnSharedPreferenceChangeListener { @Inject lateinit var repository: SuggestionRepository @Inject lateinit var tagsCompletionProvider: TagsAutoCompleteProvider @Inject lateinit var suggestionsScheduler: SuggestionsWorker.Scheduler override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) settings.subscribe(this) } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_suggestions) findPreference(AppSettings.KEY_SUGGESTIONS_EXCLUDE_TAGS)?.run { autoCompleteProvider = tagsCompletionProvider summaryProvider = MultiAutoCompleteTextViewPreference.SimpleSummaryProvider(summary) } } override fun onDestroy() { super.onDestroy() settings.unsubscribe(this) } override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { if (settings.isSuggestionsEnabled && (key == AppSettings.KEY_SUGGESTIONS || key == AppSettings.KEY_SUGGESTIONS_EXCLUDE_TAGS || key == AppSettings.KEY_SUGGESTIONS_EXCLUDE_NSFW) ) { updateSuggestions() } } private fun updateSuggestions() { lifecycleScope.launch(Dispatchers.Default) { suggestionsScheduler.startNow() } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/SyncSettingsFragment.kt ================================================ package org.koitharu.kotatsu.settings import android.os.Bundle import android.view.View import androidx.fragment.app.FragmentResultListener import androidx.preference.Preference import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.sync.data.SyncSettings import org.koitharu.kotatsu.sync.ui.SyncHostDialogFragment import javax.inject.Inject @AndroidEntryPoint class SyncSettingsFragment : BasePreferenceFragment(R.string.sync_settings), FragmentResultListener { @Inject lateinit var syncSettings: SyncSettings override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_sync) bindHostSummary() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) childFragmentManager.setFragmentResultListener(SyncHostDialogFragment.REQUEST_KEY, viewLifecycleOwner, this) } override fun onPreferenceTreeClick(preference: Preference): Boolean { return when (preference.key) { SyncSettings.KEY_SYNC_URL -> { SyncHostDialogFragment.show(childFragmentManager, null) true } else -> super.onPreferenceTreeClick(preference) } } override fun onFragmentResult(requestKey: String, result: Bundle) { bindHostSummary() } private fun bindHostSummary() { val preference = findPreference(SyncSettings.KEY_SYNC_URL) ?: return preference.summary = syncSettings.syncUrl } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt ================================================ package org.koitharu.kotatsu.settings.about import android.content.Intent import android.os.Bundle import android.view.View import androidx.annotation.StringRes import androidx.fragment.app.viewModels import androidx.preference.Preference import androidx.preference.SwitchPreferenceCompat import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.combine import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.github.AppVersion import org.koitharu.kotatsu.core.github.VersionId import org.koitharu.kotatsu.core.github.isStable import org.koitharu.kotatsu.core.nav.router 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 @AndroidEntryPoint class AboutSettingsFragment : BasePreferenceFragment(R.string.about) { private val viewModel by viewModels() override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_about) findPreference(AppSettings.KEY_APP_VERSION)?.run { title = getString(R.string.app_version, BuildConfig.VERSION_NAME) } findPreference(AppSettings.KEY_UPDATES_UNSTABLE)?.run { isEnabled = VersionId(BuildConfig.VERSION_NAME).isStable if (!isEnabled) isChecked = true } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) combine(viewModel.isUpdateSupported, viewModel.isLoading, ::Pair) .observe(viewLifecycleOwner) { (isUpdateSupported, isLoading) -> findPreference(AppSettings.KEY_UPDATES_UNSTABLE)?.isVisible = isUpdateSupported findPreference(AppSettings.KEY_APP_VERSION)?.isEnabled = isUpdateSupported && !isLoading } viewModel.onUpdateAvailable.observeEvent(viewLifecycleOwner, ::onUpdateAvailable) } override fun onPreferenceTreeClick(preference: Preference): Boolean { return when (preference.key) { AppSettings.KEY_APP_VERSION -> { viewModel.checkForUpdates() true } AppSettings.KEY_LINK_WEBLATE -> { openLink(R.string.url_weblate, preference.title) true } AppSettings.KEY_LINK_GITHUB -> { openLink(R.string.url_github, preference.title) true } AppSettings.KEY_LINK_MANUAL -> { openLink(R.string.url_user_manual, preference.title) true } AppSettings.KEY_LINK_TELEGRAM -> { if (!openLink(R.string.url_telegram, null)) { openLink(R.string.url_telegram_web, preference.title) } true } else -> super.onPreferenceTreeClick(preference) } } private fun onUpdateAvailable(version: AppVersion?) { if (version == null) { Snackbar.make(listView, R.string.no_update_available, Snackbar.LENGTH_SHORT).show() } else { startActivity(Intent(requireContext(), AppUpdateActivity::class.java)) } } private fun openLink( @StringRes url: Int, title: CharSequence? ): Boolean = if (router.openExternalBrowser(getString(url), title)) { true } else { Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() false } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsViewModel.kt ================================================ package org.koitharu.kotatsu.settings.about import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.stateIn import org.koitharu.kotatsu.core.github.AppUpdateRepository import org.koitharu.kotatsu.core.github.AppVersion import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call import javax.inject.Inject @HiltViewModel class AboutSettingsViewModel @Inject constructor( private val appUpdateRepository: AppUpdateRepository, ) : BaseViewModel() { val isUpdateSupported = flow { emit(appUpdateRepository.isUpdateSupported()) }.stateIn(viewModelScope, SharingStarted.Eagerly, false) val onUpdateAvailable = MutableEventFlow() fun checkForUpdates() { launchLoadingJob { val update = appUpdateRepository.fetchUpdate() onUpdateAvailable.call(update) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AppUpdateActivity.kt ================================================ package org.koitharu.kotatsu.settings.about import android.Manifest import android.app.DownloadManager import android.content.ActivityNotFoundException import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.Build import android.os.Bundle import android.view.View import android.view.ViewGroup.MarginLayoutParams import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.core.content.ContextCompat import androidx.core.text.buildSpannedString import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import io.noties.markwon.Markwon import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.combine import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.github.AppVersion import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.util.FileSize import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets 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.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.core.util.ext.showOrHide import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ActivityAppUpdateBinding @AndroidEntryPoint class AppUpdateActivity : BaseActivity(), View.OnClickListener { private val viewModel: AppUpdateViewModel by viewModels() private lateinit var downloadReceiver: UpdateDownloadReceiver private val permissionRequest = registerForActivityResult( ActivityResultContracts.RequestPermission(), ) { if (it) { viewModel.startDownload() } else { openInBrowser() } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityAppUpdateBinding.inflate(layoutInflater)) downloadReceiver = UpdateDownloadReceiver(viewModel) viewModel.nextVersion.observe(this, ::onNextVersionChanged) viewBinding.buttonCancel.setOnClickListener(this) viewBinding.buttonUpdate.setOnClickListener(this) ContextCompat.registerReceiver( this, downloadReceiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), ContextCompat.RECEIVER_EXPORTED, ) combine(viewModel.isLoading, viewModel.downloadProgress, ::Pair) .observe(this, ::onProgressChanged) viewModel.downloadState.observe(this, ::onDownloadStateChanged) viewModel.onError.observeEvent(this, ::onError) viewModel.onDownloadDone.observeEvent(this) { intent -> try { startActivity(intent) } catch (e: ActivityNotFoundException) { e.printStackTraceDebug() } } } override fun onDestroy() { unregisterReceiver(downloadReceiver) super.onDestroy() } override fun onApplyWindowInsets( v: View, insets: WindowInsetsCompat ): WindowInsetsCompat { val barsInsets = insets.systemBarsInsets viewBinding.root.updatePadding(top = barsInsets.top) viewBinding.dockedToolbarChild.updateLayoutParams { leftMargin = barsInsets.left rightMargin = barsInsets.right bottomMargin = barsInsets.bottom } viewBinding.scrollView.updatePadding( left = barsInsets.left, right = barsInsets.right, ) return insets.consumeAllSystemBarsInsets() } override fun onClick(v: View) { when (v.id) { R.id.button_cancel -> finishAfterTransition() R.id.button_update -> doUpdate() } } private suspend fun onNextVersionChanged(version: AppVersion?) { viewBinding.buttonUpdate.isEnabled = version != null && !viewModel.isLoading.value if (version == null) { viewBinding.textViewContent.setText(R.string.loading_) return } val markwon = Markwon.create(this) val message = withContext(Dispatchers.Default) { buildSpannedString { append(getString(R.string.new_version_s, version.name)) appendLine() append(getString(R.string.size_s, FileSize.BYTES.format(this@AppUpdateActivity, version.apkSize))) appendLine() appendLine() append(markwon.toMarkdown(version.description)) } } markwon.setParsedMarkdown(viewBinding.textViewContent, message) } private fun doUpdate() { viewModel.installIntent.value?.let { intent -> try { startActivity(intent) } catch (e: Exception) { onError(e) } return } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { permissionRequest.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) } else { viewModel.startDownload() } } private fun openInBrowser() { val latestVersion = viewModel.nextVersion.value ?: return if (!router.openExternalBrowser(latestVersion.url, getString(R.string.open_in_browser))) { Snackbar.make(viewBinding.scrollView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() } } private fun onProgressChanged(value: Pair) { val (isLoading, downloadProgress) = value val indicator = viewBinding.progressBar indicator.showOrHide(isLoading) indicator.isIndeterminate = downloadProgress <= 0f if (downloadProgress > 0f) { indicator.setProgressCompat((indicator.max * downloadProgress).toInt(), true) } viewBinding.buttonUpdate.isEnabled = !isLoading && viewModel.nextVersion.value != null } private fun onDownloadStateChanged(state: Int) { val message = when (state) { DownloadManager.STATUS_FAILED -> R.string.error_occurred DownloadManager.STATUS_PAUSED -> R.string.downloads_paused else -> 0 } viewBinding.textViewError.setTextAndVisible(message) } private fun onError(e: Throwable) { viewBinding.textViewError.textAndVisible = e.getDisplayMessage(resources) } private class UpdateDownloadReceiver( private val viewModel: AppUpdateViewModel, ) : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { when (intent.action) { DownloadManager.ACTION_DOWNLOAD_COMPLETE -> { viewModel.onDownloadComplete(intent) } } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AppUpdateViewModel.kt ================================================ package org.koitharu.kotatsu.settings.about import android.app.DownloadManager import android.content.Context import android.content.Intent import android.os.Environment import androidx.core.net.toUri import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.isActive import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.github.AppUpdateRepository 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.requireValue import javax.inject.Inject @HiltViewModel class AppUpdateViewModel @Inject constructor( private val repository: AppUpdateRepository, @ApplicationContext context: Context, ) : BaseViewModel() { val nextVersion = repository.observeAvailableUpdate() val downloadProgress = MutableStateFlow(-1f) val downloadState = MutableStateFlow(DownloadManager.STATUS_PENDING) val installIntent = MutableStateFlow(null) val onDownloadDone = MutableEventFlow() private val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager private val appName = context.getString(R.string.app_name) init { if (nextVersion.value == null) { launchLoadingJob(Dispatchers.Default) { repository.fetchUpdate() } } } fun startDownload() { launchLoadingJob(Dispatchers.Default) { val version = nextVersion.requireValue() val url = version.apkUrl.toUri() val request = DownloadManager.Request(url) .setTitle("$appName v${version.name}") .setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, url.lastPathSegment) .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) .setMimeType("application/vnd.android.package-archive") val downloadId = downloadManager.enqueue(request) observeDownload(downloadId) } } fun onDownloadComplete(intent: Intent) { launchLoadingJob(Dispatchers.Default) { val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L) if (downloadId == 0L) { return@launchLoadingJob } @Suppress("DEPRECATION") val installerIntent = Intent(Intent.ACTION_INSTALL_PACKAGE) installerIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) installerIntent.setDataAndType( downloadManager.getUriForDownloadedFile(downloadId), downloadManager.getMimeTypeForDownloadedFile(downloadId), ) installerIntent.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) installIntent.value = installerIntent onDownloadDone.call(installerIntent) } } private suspend fun observeDownload(id: Long) { val query = DownloadManager.Query() query.setFilterById(id) while (currentCoroutineContext().isActive) { downloadManager.query(query).use { cursor -> if (cursor.moveToFirst()) { val bytesDownloaded = cursor.getLong( cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR), ) val bytesTotal = cursor.getLong( cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES), ) downloadProgress.value = bytesDownloaded.toFloat() / bytesTotal val state = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)) downloadState.value = state if (state == DownloadManager.STATUS_SUCCESSFUL || state == DownloadManager.STATUS_FAILED) { return } } } delay(100) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/about/changelog/ChangelogFragment.kt ================================================ package org.koitharu.kotatsu.settings.about.changelog import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.viewModels import dagger.hilt.android.AndroidEntryPoint import io.noties.markwon.Markwon import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.DialogErrorObserver import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.util.ext.consumeAll import org.koitharu.kotatsu.core.util.ext.container import org.koitharu.kotatsu.core.util.ext.end import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.showOrHide import org.koitharu.kotatsu.core.util.ext.start import org.koitharu.kotatsu.databinding.FragmentChangelogBinding @AndroidEntryPoint class ChangelogFragment : BaseFragment() { private val viewModel: ChangelogViewModel by viewModels() override fun onCreateViewBinding( inflater: LayoutInflater, container: ViewGroup? ) = FragmentChangelogBinding.inflate(inflater, container, false) override fun onViewBindingCreated(binding: FragmentChangelogBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) val markwon = Markwon.create(binding.root.context) viewModel.isLoading.observe(viewLifecycleOwner) { binding.progressBar.showOrHide(it) } viewModel.onError.observeEvent(viewLifecycleOwner, DialogErrorObserver(binding.root, this)) viewModel.changelog.filterNotNull() .map { markwon.toMarkdown(it) } .flowOn(Dispatchers.Default) .observe(viewLifecycleOwner) { markwon.setParsedMarkdown(binding.textViewContent, it) } } override fun onResume() { super.onResume() activity?.setTitle(R.string.changelog) } override fun onApplyWindowInsets( v: View, insets: WindowInsetsCompat ): WindowInsetsCompat { val typeMask = WindowInsetsCompat.Type.systemBars() val barsInsets = insets.getInsets(typeMask) val isTablet = !resources.getBoolean(R.bool.is_tablet) val isMaster = container?.id == R.id.container_master val basePadding = resources.getDimensionPixelOffset(R.dimen.screen_padding) requireViewBinding().textViewContent.setPaddingRelative( basePadding + if (isTablet && !isMaster) 0 else barsInsets.start(v), basePadding, basePadding + if (isTablet && isMaster) 0 else barsInsets.end(v), basePadding + barsInsets.bottom, ) return insets.consumeAll(typeMask) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/about/changelog/ChangelogViewModel.kt ================================================ package org.koitharu.kotatsu.settings.about.changelog import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import org.jsoup.internal.StringUtil import org.koitharu.kotatsu.core.github.AppUpdateRepository import org.koitharu.kotatsu.core.ui.BaseViewModel import javax.inject.Inject @HiltViewModel class ChangelogViewModel @Inject constructor( private val appUpdateRepository: AppUpdateRepository, ) : BaseViewModel() { val changelog = MutableStateFlow(null) init { launchLoadingJob(Dispatchers.Default) { val versions = appUpdateRepository.getAvailableVersions() val stringJoiner = StringUtil.StringJoiner("\n\n\n") for (version in versions) { stringJoiner.add("# ") .append(version.name) .append("\n\n") .append(version.description) } changelog.value = stringJoiner.complete() } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/discord/DiscordSettingsFragment.kt ================================================ package org.koitharu.kotatsu.settings.discord import android.content.Intent import android.os.Bundle import android.view.View import android.view.inputmethod.EditorInfo import androidx.appcompat.app.AlertDialog import androidx.fragment.app.viewModels import androidx.preference.EditTextPreference import androidx.preference.EditTextPreferenceDialogFragmentCompat import androidx.preference.Preference import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R 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.withArgs import org.koitharu.kotatsu.scrobbling.discord.ui.DiscordAuthActivity @AndroidEntryPoint class DiscordSettingsFragment : BasePreferenceFragment(R.string.discord) { private val viewModel by viewModels() override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_discord) findPreference(AppSettings.KEY_DISCORD_TOKEN)?.let { pref -> pref.dialogMessage = pref.context.getString( R.string.discord_token_description, pref.context.getString(R.string.sign_in), ) pref.setOnBindEditTextListener { it.setHint(R.string.discord_token_hint) it.inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD } } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel.tokenState.observe(viewLifecycleOwner) { (state, token) -> bindTokenPreference(state, token) } } override fun onDisplayPreferenceDialog(preference: Preference) { if (preference is EditTextPreference && preference.key == AppSettings.KEY_DISCORD_TOKEN) { if (parentFragmentManager.findFragmentByTag(TokenDialogFragment.DIALOG_FRAGMENT_TAG) != null) { return } val f = TokenDialogFragment.newInstance(preference.key) @Suppress("DEPRECATION") f.setTargetFragment(this, 0) f.show(parentFragmentManager, TokenDialogFragment.DIALOG_FRAGMENT_TAG) return } super.onDisplayPreferenceDialog(preference) } private fun bindTokenPreference(state: TokenState, token: String?) { val pref = findPreference(AppSettings.KEY_DISCORD_TOKEN) ?: return when (state) { TokenState.EMPTY -> { pref.icon = null pref.setSummary(R.string.discord_token_summary) } TokenState.REQUIRED -> { pref.icon = getWarningIcon() pref.setSummary(R.string.discord_token_summary) } TokenState.INVALID -> { pref.icon = getWarningIcon() pref.summary = getString(R.string.invalid_token, token) } TokenState.VALID -> { pref.icon = null pref.summary = token } TokenState.CHECKING -> { pref.icon = null pref.setSummary(R.string.loading_) } } } class TokenDialogFragment : EditTextPreferenceDialogFragmentCompat() { override fun onPrepareDialogBuilder(builder: AlertDialog.Builder) { super.onPrepareDialogBuilder(builder) builder.setNeutralButton(R.string.sign_in) { _, _ -> openSignIn() } } private fun openSignIn() { activity?.run { startActivity(Intent(this, DiscordAuthActivity::class.java)) } } companion object { const val DIALOG_FRAGMENT_TAG: String = "androidx.preference.PreferenceFragment.DIALOG" fun newInstance(key: String) = TokenDialogFragment().withArgs(1) { putString(ARG_KEY, key) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/discord/DiscordSettingsViewModel.kt ================================================ package org.koitharu.kotatsu.settings.discord import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.isNetworkError import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.scrobbling.discord.data.DiscordRepository import javax.inject.Inject @HiltViewModel class DiscordSettingsViewModel @Inject constructor( private val settings: AppSettings, private val repository: DiscordRepository, ) : BaseViewModel() { val tokenState: StateFlow> = settings.observe( AppSettings.KEY_DISCORD_RPC, AppSettings.KEY_DISCORD_TOKEN, ).flatMapLatest { checkToken() }.stateIn( viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, TokenState.CHECKING to settings.discordToken, ) private fun checkToken(): Flow> = flow { val token = settings.discordToken if (!settings.isDiscordRpcEnabled) { emit( if (token == null) { TokenState.EMPTY to null } else { TokenState.VALID to token }, ) return@flow } if (token == null) { emit(TokenState.REQUIRED to null) return@flow } emit(TokenState.CHECKING to token) if (validateToken(token)) { emit(TokenState.VALID to token) } else { emit(TokenState.INVALID to token) } } private suspend fun validateToken(token: String) = runCatchingCancellable { repository.checkToken(token) }.fold( onSuccess = { true }, onFailure = { it.isNetworkError() }, ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/discord/TokenState.kt ================================================ package org.koitharu.kotatsu.settings.discord enum class TokenState { EMPTY, REQUIRED, INVALID, VALID, CHECKING } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/nav/NavConfigFragment.kt ================================================ package org.koitharu.kotatsu.settings.nav import android.content.DialogInterface import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.viewModels import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.NavItem import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.ui.dialog.setRecyclerViewList import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.container import org.koitharu.kotatsu.core.util.ext.end import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.start import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.settings.nav.adapter.navAddAD import org.koitharu.kotatsu.settings.nav.adapter.navAvailableAD import org.koitharu.kotatsu.settings.nav.adapter.navConfigAD @AndroidEntryPoint class NavConfigFragment : BaseFragment(), RecyclerViewOwner, OnListItemClickListener, View.OnClickListener { private var reorderHelper: ItemTouchHelper? = null private val viewModel by viewModels() override val recyclerView: RecyclerView? get() = viewBinding?.recyclerView override fun onCreateViewBinding( inflater: LayoutInflater, container: ViewGroup?, ): FragmentSettingsSourcesBinding { return FragmentSettingsSourcesBinding.inflate(inflater, container, false) } override fun onViewBindingCreated( binding: FragmentSettingsSourcesBinding, savedInstanceState: Bundle?, ) { super.onViewBindingCreated(binding, savedInstanceState) val navConfigAdapter = BaseListAdapter() .addDelegate(ListItemType.NAV_ITEM, navConfigAD(this)) .addDelegate(ListItemType.FOOTER_LOADING, navAddAD(this)) with(binding.recyclerView) { setHasFixedSize(true) adapter = navConfigAdapter reorderHelper = ItemTouchHelper(ReorderCallback()).also { it.attachToRecyclerView(this) } } viewModel.content.observe(viewLifecycleOwner, navConfigAdapter) } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val barsInsets = insets.systemBarsInsets val isTablet = !resources.getBoolean(R.bool.is_tablet) val isMaster = container?.id == R.id.container_master v.setPaddingRelative( if (isTablet && !isMaster) 0 else barsInsets.start(v), 0, if (isTablet && isMaster) 0 else barsInsets.end(v), barsInsets.bottom, ) return insets.consumeAllSystemBarsInsets() } override fun onResume() { super.onResume() activity?.setTitle(R.string.main_screen_sections) } override fun onDestroyView() { reorderHelper = null super.onDestroyView() } override fun onClick(v: View) { var dialog: DialogInterface? = null val listener = OnListItemClickListener { item, _ -> viewModel.addItem(item) dialog?.dismiss() } dialog = buildAlertDialog(v.context) { setTitle(R.string.add) setCancelable(true) setRecyclerViewList(viewModel.availableItems, navAvailableAD(listener)) setNegativeButton(android.R.string.cancel, null) }.apply { show() } } override fun onItemClick(item: NavItem, view: View) { viewModel.removeItem(item) } override fun onItemLongClick(item: NavItem, view: View): Boolean { val holder = viewBinding?.recyclerView?.findContainingViewHolder(view) ?: return false reorderHelper?.startDrag(holder) return true } private inner class ReorderCallback : ItemTouchHelper.SimpleCallback( ItemTouchHelper.DOWN or ItemTouchHelper.UP, 0, ) { override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder, ): Boolean = target.itemViewType == ListItemType.NAV_ITEM.ordinal override fun onMoved( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, fromPos: Int, target: RecyclerView.ViewHolder, toPos: Int, x: Int, y: Int, ) { super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y) viewModel.reorder(fromPos, toPos) } override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit override fun isLongPressDragEnabled() = false } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/nav/NavConfigViewModel.kt ================================================ package org.koitharu.kotatsu.settings.nav 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.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.NavItem import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.main.ui.MainActivity import org.koitharu.kotatsu.main.ui.MainNavigationDelegate import org.koitharu.kotatsu.parsers.util.move import org.koitharu.kotatsu.settings.nav.model.NavItemAddModel import org.koitharu.kotatsu.settings.nav.model.NavItemConfigModel import javax.inject.Inject @HiltViewModel class NavConfigViewModel @Inject constructor( private val settings: AppSettings, private val activityRecreationHandle: ActivityRecreationHandle, ) : BaseViewModel() { private val items = MutableStateFlow(settings.mainNavItems) val content: StateFlow> = items.map { snapshot -> buildList(snapshot.size + 1) { snapshot.mapTo(this) { NavItemConfigModel(it, getUnavailabilityHint(it)) } if (size < NavItem.entries.size) { add(NavItemAddModel(size < MainNavigationDelegate.MAX_ITEM_COUNT)) } } }.stateIn( viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), emptyList(), ) private var commitJob: Job? = null val availableItems get() = items.value.let { snapshot -> NavItem.entries.filterNot { x -> x in snapshot } } fun reorder(fromPos: Int, toPos: Int) { items.value = items.value.toMutableList().apply { move(fromPos, toPos) commit(this) } } fun addItem(item: NavItem) { items.value = items.value.plus(item).also { commit(it) } } fun removeItem(item: NavItem) { val newList = items.value.toMutableList() newList.remove(item) if (newList.isEmpty()) { newList.add(NavItem.EXPLORE) } items.value = newList commit(newList) } private fun commit(value: List) { val prevJob = commitJob commitJob = launchJob { prevJob?.cancelAndJoin() delay(500) settings.mainNavItems = value activityRecreationHandle.recreate(MainActivity::class.java) } } private fun getUnavailabilityHint(item: NavItem) = if (item.isAvailable(settings)) { 0 } else when (item) { NavItem.FEED -> R.string.check_for_new_chapters_disabled NavItem.SUGGESTIONS -> R.string.suggestions_unavailable_text else -> 0 } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/nav/adapter/NavConfigAD.kt ================================================ package org.koitharu.kotatsu.settings.nav.adapter import android.annotation.SuppressLint import android.view.MotionEvent import android.view.View import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.NavItem import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.databinding.ItemNavAvailableBinding import org.koitharu.kotatsu.databinding.ItemNavConfigBinding import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.settings.nav.model.NavItemAddModel import org.koitharu.kotatsu.settings.nav.model.NavItemConfigModel @SuppressLint("ClickableViewAccessibility") fun navConfigAD( clickListener: OnListItemClickListener, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemNavConfigBinding.inflate(layoutInflater, parent, false) }, ) { val eventListener = object : View.OnClickListener, View.OnTouchListener { override fun onClick(v: View) = clickListener.onItemClick(item.item, v) override fun onTouch(v: View?, event: MotionEvent): Boolean = event.actionMasked == MotionEvent.ACTION_DOWN && clickListener.onItemLongClick(item.item, itemView) } binding.imageViewRemove.setOnClickListener(eventListener) binding.imageViewReorder.setOnTouchListener(eventListener) bind { with(binding.textViewTitle) { isEnabled = item.disabledHintResId == 0 setText(item.item.title) setCompoundDrawablesRelativeWithIntrinsicBounds(item.item.icon, 0, 0, 0) } binding.textViewHint.setTextAndVisible(item.disabledHintResId) } } fun navAvailableAD( clickListener: OnListItemClickListener, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemNavAvailableBinding.inflate(layoutInflater, parent, false) }, ) { binding.root.setOnClickListener { v -> clickListener.onItemClick(item, v) } bind { with(binding.root) { setText(item.title) setCompoundDrawablesRelativeWithIntrinsicBounds(item.icon, 0, 0, 0) } } } fun navAddAD( clickListener: View.OnClickListener, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemNavAvailableBinding.inflate(layoutInflater, parent, false) }, ) { binding.root.setOnClickListener(clickListener) binding.root.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_add, 0, 0, 0) bind { with(binding.root) { setText(if (item.canAdd) R.string.add else R.string.items_limit_exceeded) isEnabled = item.canAdd } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/nav/model/NavItemAddModel.kt ================================================ package org.koitharu.kotatsu.settings.nav.model import org.koitharu.kotatsu.list.ui.model.ListModel data class NavItemAddModel( val canAdd: Boolean, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean = other is NavItemAddModel } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/nav/model/NavItemConfigModel.kt ================================================ package org.koitharu.kotatsu.settings.nav.model import androidx.annotation.StringRes import org.koitharu.kotatsu.core.prefs.NavItem import org.koitharu.kotatsu.list.ui.model.ListModel data class NavItemConfigModel( val item: NavItem, @StringRes val disabledHintResId: Int, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is NavItemConfigModel && other.item == item } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/override/OverrideConfigActivity.kt ================================================ package org.koitharu.kotatsu.settings.override import android.content.Intent import android.net.Uri import android.os.Bundle import android.view.View import androidx.activity.result.ActivityResultCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.filterNotNull import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.model.MangaOverride import org.koitharu.kotatsu.core.util.ext.consumeAll 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.tryLaunch import org.koitharu.kotatsu.databinding.ActivityOverrideEditBinding import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.picker.ui.PageImagePickContract import com.google.android.material.R as materialR @AndroidEntryPoint class OverrideConfigActivity : BaseActivity(), View.OnClickListener, ActivityResultCallback { private val viewModel: OverrideConfigViewModel by viewModels() private val pickCoverFileLauncher = registerForActivityResult(ActivityResultContracts.OpenDocument(), this) private val pickPageLauncher = registerForActivityResult(PageImagePickContract(), this) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityOverrideEditBinding.inflate(layoutInflater)) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true) viewBinding.buttonDone.setOnClickListener(this) viewBinding.buttonPickFile.setOnClickListener(this) viewBinding.buttonPickPage.setOnClickListener(this) viewBinding.buttonResetCover.setOnClickListener(this) viewBinding.layoutName.setEndIconOnClickListener(this) viewModel.data.filterNotNull().observe(this, ::onDataChanged) viewModel.onSaved.observeEvent(this) { onDataSaved() } viewModel.isLoading.observe(this, ::onLoadingStateChanged) viewModel.onError.observeEvent(this, ::onError) } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val typeMask = WindowInsetsCompat.Type.systemBars() val barsInsets = insets.getInsets(typeMask) viewBinding.root.setPadding( barsInsets.left, barsInsets.top, barsInsets.right, barsInsets.bottom, ) return insets.consumeAll(typeMask) } override fun onActivityResult(result: Uri?) { if (result != null) { if (result.host?.startsWith(packageName) != true) { contentResolver.takePersistableUriPermission(result, Intent.FLAG_GRANT_READ_URI_PERMISSION) } viewModel.updateCover(result.toString()) } } override fun onClick(v: View) { when (v.id) { R.id.button_done -> viewModel.save( title = viewBinding.editName.text?.toString()?.trim(), ) materialR.id.text_input_end_icon -> viewBinding.editName.text?.clear() R.id.button_reset_cover -> viewModel.updateCover(null) R.id.button_pick_file -> { if (!pickCoverFileLauncher.tryLaunch(arrayOf("image/*"))) { Snackbar.make( viewBinding.imageViewCover, R.string.operation_not_supported, Snackbar.LENGTH_SHORT, ).show() } } R.id.button_pick_page -> { val manga = viewModel.data.value?.first pickPageLauncher.launch(manga) } } } private fun onDataChanged(data: Pair) { val (manga, override) = data viewBinding.imageViewCover.setImageAsync(override.coverUrl.ifNullOrEmpty { manga.coverUrl }, manga) viewBinding.layoutName.placeholderText = manga.title if (viewBinding.editName.tag == null) { viewBinding.editName.setText(override.title) viewBinding.editName.tag = override.title } viewBinding.buttonResetCover.isEnabled = !override.coverUrl.isNullOrEmpty() } private fun onError(e: Throwable) { viewBinding.textViewError.text = e.getDisplayMessage(resources) viewBinding.textViewError.isVisible = true } private fun onLoadingStateChanged(isLoading: Boolean) { viewBinding.buttonDone.isEnabled = !isLoading viewBinding.editName.isEnabled = !isLoading if (isLoading) { viewBinding.textViewError.isVisible = false } } private fun onDataSaved() { setResult(RESULT_OK) finish() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/override/OverrideConfigViewModel.kt ================================================ package org.koitharu.kotatsu.settings.override import android.content.Context import androidx.core.net.toUri 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.withContext import okio.buffer import okio.sink import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.model.MangaOverride import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.isFileUri import org.koitharu.kotatsu.core.util.ext.openSource import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull import org.koitharu.kotatsu.core.util.ext.toUriOrNull import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.md5 import java.io.File import javax.inject.Inject private const val DIR_COVERS = "covers" @HiltViewModel class OverrideConfigViewModel @Inject constructor( savedStateHandle: SavedStateHandle, @ApplicationContext private val context: Context, private val dataRepository: MangaDataRepository, ) : BaseViewModel() { private val manga = savedStateHandle.require(AppRouter.KEY_MANGA).manga val data = MutableStateFlow?>(null) val onSaved = MutableEventFlow() init { launchLoadingJob(Dispatchers.Default) { data.value = manga to (dataRepository.getOverride(manga.id) ?: emptyOverride()) } } fun save(title: String?) { launchLoadingJob(Dispatchers.Default) { val override = checkNotNull(data.value).second.let { it.copy( title = title, coverUrl = it.coverUrl?.cachedFile(), ) } dataRepository.setOverride(manga, override) onSaved.call(Unit) } } fun updateCover(coverUri: String?) { val snapshot = data.value ?: return data.value = snapshot.first to snapshot.second.copy( coverUrl = coverUri, ) } private suspend fun String.cachedFile(): String { val uri = toUriOrNull() if (uri == null || uri.isFileUri()) { return this } val cacheDir = context.getExternalFilesDir(DIR_COVERS) ?: return this val cr = context.contentResolver val ext = cr.getType(uri)?.toMimeTypeOrNull()?.let { MimeTypes.getExtension(it) } val fileName = buildString { append(this@cachedFile.md5()) if (!ext.isNullOrEmpty()) { append('.') append(ext) } } return withContext(Dispatchers.IO) { val dest = File(cacheDir, fileName) cr.openSource(uri).use { source -> dest.sink().buffer().use { sink -> sink.writeAll(source) } } dest }.toUri().toString() } private fun emptyOverride() = MangaOverride(null, null, null) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt ================================================ package org.koitharu.kotatsu.settings.protect import android.content.pm.PackageManager import android.os.Bundle import android.text.Editable import android.view.KeyEvent import android.view.View import android.view.WindowManager import android.view.inputmethod.EditorInfo import android.widget.CompoundButton import android.widget.TextView import androidx.activity.viewModels import androidx.core.view.WindowInsetsCompat import androidx.core.view.isGone import androidx.core.view.isVisible import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.util.DefaultTextWatcher 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.ActivitySetupProtectBinding private const val MIN_PASSWORD_LENGTH = 4 @AndroidEntryPoint class ProtectSetupActivity : BaseActivity(), DefaultTextWatcher, View.OnClickListener, TextView.OnEditorActionListener, CompoundButton.OnCheckedChangeListener { private val viewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) setContentView(ActivitySetupProtectBinding.inflate(layoutInflater)) viewBinding.editPassword.addTextChangedListener(this) viewBinding.editPassword.setOnEditorActionListener(this) viewBinding.buttonNext.setOnClickListener(this) viewBinding.buttonCancel.setOnClickListener(this) viewBinding.switchBiometric.isChecked = viewModel.isBiometricEnabled viewBinding.switchBiometric.setOnCheckedChangeListener(this) viewModel.isSecondStep.observe(this, this::onStepChanged) viewModel.onPasswordSet.observeEvent(this) { finishAfterTransition() } viewModel.onPasswordMismatch.observeEvent(this) { viewBinding.editPassword.error = getString(R.string.passwords_mismatch) } viewModel.onClearText.observeEvent(this) { viewBinding.editPassword.text?.clear() } } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val barsInsets = insets.systemBarsInsets val basePadding = resources.getDimensionPixelOffset(R.dimen.screen_padding) viewBinding.root.setPadding( barsInsets.left + basePadding, barsInsets.top + basePadding, barsInsets.right + basePadding, barsInsets.bottom + basePadding, ) return insets.consumeAllSystemBarsInsets() } override fun onClick(v: View) { when (v.id) { R.id.button_cancel -> finish() R.id.button_next -> viewModel.onNextClick( password = viewBinding.editPassword.text?.toString() ?: return, ) } } override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { viewModel.setBiometricEnabled(isChecked) } override fun onEditorAction(v: TextView?, actionId: Int, event: KeyEvent?): Boolean { return if (actionId == EditorInfo.IME_ACTION_DONE && viewBinding.buttonNext.isEnabled) { viewBinding.buttonNext.performClick() true } else { false } } override fun afterTextChanged(s: Editable?) { viewBinding.editPassword.error = null val isEnoughLength = (s?.length ?: 0) >= MIN_PASSWORD_LENGTH viewBinding.buttonNext.isEnabled = isEnoughLength viewBinding.layoutPassword.isHelperTextEnabled = !isEnoughLength || viewModel.isSecondStep.value == true } private fun onStepChanged(isSecondStep: Boolean) { viewBinding.buttonCancel.isGone = isSecondStep viewBinding.switchBiometric.isVisible = isSecondStep && isBiometricAvailable() if (isSecondStep) { viewBinding.layoutPassword.helperText = getString(R.string.repeat_password) viewBinding.buttonNext.setText(R.string.confirm) } else { viewBinding.layoutPassword.helperText = getString(R.string.password_length_hint) viewBinding.buttonNext.setText(R.string.next) } } private fun isBiometricAvailable(): Boolean { return packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/protect/ProtectSetupViewModel.kt ================================================ package org.koitharu.kotatsu.settings.protect import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.prefs.AppSettings 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.parsers.util.isNumeric import org.koitharu.kotatsu.parsers.util.md5 import javax.inject.Inject @HiltViewModel class ProtectSetupViewModel @Inject constructor( private val settings: AppSettings, ) : BaseViewModel() { private val firstPassword = MutableStateFlow(null) val isSecondStep = firstPassword.map { it != null }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false) val onPasswordSet = MutableEventFlow() val onPasswordMismatch = MutableEventFlow() val onClearText = MutableEventFlow() val isBiometricEnabled get() = settings.isBiometricProtectionEnabled fun onNextClick(password: String) { if (firstPassword.value == null) { firstPassword.value = password onClearText.call(Unit) } else { if (firstPassword.value == password) { settings.appPassword = password.md5() settings.isAppPasswordNumeric = password.isNumeric() onPasswordSet.call(Unit) } else { onPasswordMismatch.call(Unit) } } } fun setBiometricEnabled(isEnabled: Boolean) { settings.isBiometricProtectionEnabled = isEnabled } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/reader/ReaderTapGridConfigActivity.kt ================================================ package org.koitharu.kotatsu.settings.reader import android.content.DialogInterface import android.graphics.drawable.Drawable import android.graphics.drawable.LayerDrawable import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.View import android.widget.TextView import androidx.activity.viewModels import androidx.core.graphics.ColorUtils import androidx.core.graphics.drawable.toDrawable import androidx.core.text.bold import androidx.core.text.buildSpannedString import androidx.core.view.WindowInsetsCompat import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.findKeyByValue import org.koitharu.kotatsu.core.util.ext.getThemeDrawable import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.databinding.ActivityReaderTapActionsBinding import org.koitharu.kotatsu.reader.domain.TapGridArea import org.koitharu.kotatsu.reader.ui.tapgrid.TapAction import java.util.EnumMap import androidx.appcompat.R as appcompatR @AndroidEntryPoint class ReaderTapGridConfigActivity : BaseActivity(), View.OnClickListener, View.OnLongClickListener { private val viewModel: ReaderTapGridConfigViewModel by viewModels() private val controls = EnumMap(TapGridArea::class.java) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityReaderTapActionsBinding.inflate(layoutInflater)) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) controls[TapGridArea.TOP_LEFT] = viewBinding.textViewTopLeft controls[TapGridArea.TOP_CENTER] = viewBinding.textViewTopCenter controls[TapGridArea.TOP_RIGHT] = viewBinding.textViewTopRight controls[TapGridArea.CENTER_LEFT] = viewBinding.textViewCenterLeft controls[TapGridArea.CENTER] = viewBinding.textViewCenter controls[TapGridArea.CENTER_RIGHT] = viewBinding.textViewCenterRight controls[TapGridArea.BOTTOM_LEFT] = viewBinding.textViewBottomLeft controls[TapGridArea.BOTTOM_CENTER] = viewBinding.textViewBottomCenter controls[TapGridArea.BOTTOM_RIGHT] = viewBinding.textViewBottomRight controls.forEach { (_, view) -> view.setOnClickListener(this) view.setOnLongClickListener(this) } viewModel.content.observe(this, ::onContentChanged) } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val barsInsets = insets.systemBarsInsets viewBinding.root.setPadding( barsInsets.left, barsInsets.top, barsInsets.right, barsInsets.bottom, ) return insets.consumeAllSystemBarsInsets() } override fun onCreateOptionsMenu(menu: Menu?): Boolean { menuInflater.inflate(R.menu.opt_tap_grid_config, menu) return super.onCreateOptionsMenu(menu) } override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { R.id.action_reset -> { confirmReset() true } R.id.action_disable_all -> { viewModel.disableAll() true } else -> super.onOptionsItemSelected(item) } } override fun onClick(v: View) { val area = controls.findKeyByValue(v) ?: return showActionSelector(area, isLongTap = false) } override fun onLongClick(v: View?): Boolean { val area = controls.findKeyByValue(v) ?: return false showActionSelector(area, isLongTap = true) return true } private fun onContentChanged(content: Map) { controls.forEach { (area, view) -> val actions = content[area] view.text = buildSpannedString { appendLine(getString(R.string.tap_action)) bold { appendLine(actions?.tapAction.getText()) } appendLine() appendLine(getString(R.string.long_tap_action)) bold { appendLine(actions?.longTapAction.getText()) } } view.background = createBackground(actions?.tapAction) } } // lint bug private fun TapAction?.getText(): String = if (this != null) { getString(nameStringResId) } else { getString(R.string.none) } private fun showActionSelector(area: TapGridArea, isLongTap: Boolean) { val selectedItem = viewModel.content.value[area]?.run { if (isLongTap) longTapAction else tapAction }?.ordinal ?: -1 val listener = DialogInterface.OnClickListener { dialog, which -> viewModel.setTapAction(area, isLongTap, TapAction.entries.getOrNull(which - 1)) dialog.dismiss() } val names = arrayOfNulls(TapAction.entries.size + 1) names[0] = getString(R.string.none) TapAction.entries.forEachIndexed { index, action -> names[index + 1] = getString(action.nameStringResId) } MaterialAlertDialogBuilder(this) .setSingleChoiceItems(names, selectedItem + 1, listener) .setTitle(if (isLongTap) R.string.long_tap_action else R.string.tap_action) .setIcon(R.drawable.ic_tap) .setNegativeButton(android.R.string.cancel, null) .show() } private fun confirmReset() { MaterialAlertDialogBuilder(this) .setTitle(R.string.reader_actions) .setMessage(R.string.config_reset_confirm) .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(R.string.reset) { _, _ -> viewModel.reset() }.show() } private fun createBackground(action: TapAction?): Drawable? { val ripple = getThemeDrawable(appcompatR.attr.selectableItemBackground) return if (action == null) { ripple } else { LayerDrawable(arrayOf(ripple, ColorUtils.setAlphaComponent(action.color, 40).toDrawable())) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/reader/ReaderTapGridConfigViewModel.kt ================================================ package org.koitharu.kotatsu.settings.reader import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.reader.data.TapGridSettings import org.koitharu.kotatsu.reader.domain.TapGridArea import org.koitharu.kotatsu.reader.ui.tapgrid.TapAction import java.util.EnumMap import javax.inject.Inject @HiltViewModel class ReaderTapGridConfigViewModel @Inject constructor( private val tapGridSettings: TapGridSettings, ) : BaseViewModel() { val content = tapGridSettings.observeChanges() .onStart { emit(null) } .map { getData() } .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyMap()) fun reset() { launchJob(Dispatchers.Default) { tapGridSettings.reset() } } fun disableAll() { launchJob(Dispatchers.Default) { tapGridSettings.disableAll() } } fun setTapAction(area: TapGridArea, isLongTap: Boolean, action: TapAction?) { launchJob(Dispatchers.Default) { tapGridSettings.setTapAction(area, isLongTap, action) } } private fun getData(): Map { val map = EnumMap(TapGridArea::class.java) for (area in TapGridArea.entries) { map[area] = TapActions( tapAction = tapGridSettings.getTapAction(area, isLongTap = false), longTapAction = tapGridSettings.getTapAction(area, isLongTap = true), ) } return map } data class TapActions( val tapAction: TapAction?, val longTapAction: TapAction?, ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsItem.kt ================================================ package org.koitharu.kotatsu.settings.search import androidx.preference.PreferenceFragmentCompat import org.koitharu.kotatsu.list.ui.model.ListModel data class SettingsItem( val key: String, val title: CharSequence, val breadcrumbs: List, val fragmentClass: Class, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is SettingsItem && other.key == key && other.fragmentClass == fragmentClass } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsItemAD.kt ================================================ package org.koitharu.kotatsu.settings.search import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ItemPreferenceBinding fun settingsItemAD( listener: OnListItemClickListener, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemPreferenceBinding.inflate(layoutInflater, parent, false) }, ) { AdapterDelegateClickListenerAdapter(this, listener).attach() val breadcrumbsSeparator = getString(R.string.breadcrumbs_separator) bind { binding.textViewTitle.text = item.title binding.textViewSummary.textAndVisible = item.breadcrumbs.joinToString(breadcrumbsSeparator) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchFragment.kt ================================================ package org.koitharu.kotatsu.settings.search import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.activityViewModels import androidx.recyclerview.widget.AsyncListDiffer.ListListener import androidx.recyclerview.widget.LinearLayoutManager import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.consumeAll import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.databinding.FragmentSearchSuggestionBinding import org.koitharu.kotatsu.list.ui.adapter.ListItemType @AndroidEntryPoint class SettingsSearchFragment : BaseFragment(), OnListItemClickListener, ListListener { private val viewModel: SettingsSearchViewModel by activityViewModels() override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentSearchSuggestionBinding { return FragmentSearchSuggestionBinding.inflate(inflater, container, false) } override fun onViewBindingCreated(binding: FragmentSearchSuggestionBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) val adapter = BaseListAdapter() .addDelegate(ListItemType.NAV_ITEM, settingsItemAD(this)) adapter.addListListener(this) binding.root.adapter = adapter binding.root.setHasFixedSize(true) viewModel.content.observe(viewLifecycleOwner, adapter) } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val type = WindowInsetsCompat.Type.ime() or WindowInsetsCompat.Type.systemBars() val barsInsets = insets.getInsets(type) v.setPadding( barsInsets.left, 0, barsInsets.right, barsInsets.bottom, ) return insets.consumeAll(type) } override fun onItemClick(item: SettingsItem, view: View) = viewModel.navigateToPreference(item) override fun onCurrentListChanged( previousList: List, currentList: List ) { if (currentList.size != previousList.size) { (viewBinding?.root?.layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset(0, 0) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchHelper.kt ================================================ package org.koitharu.kotatsu.settings.search import android.annotation.SuppressLint import android.content.Context import androidx.annotation.XmlRes import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager import androidx.preference.PreferenceScreen import androidx.preference.get import dagger.Reusable import org.koitharu.kotatsu.R import org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupSettingsFragment import org.koitharu.kotatsu.core.LocalizedAppContext import org.koitharu.kotatsu.settings.AppearanceSettingsFragment import org.koitharu.kotatsu.settings.DownloadsSettingsFragment import org.koitharu.kotatsu.settings.ProxySettingsFragment import org.koitharu.kotatsu.settings.ReaderSettingsFragment import org.koitharu.kotatsu.settings.ServicesSettingsFragment import org.koitharu.kotatsu.settings.StorageAndNetworkSettingsFragment import org.koitharu.kotatsu.settings.SuggestionsSettingsFragment import org.koitharu.kotatsu.settings.about.AboutSettingsFragment import org.koitharu.kotatsu.settings.discord.DiscordSettingsFragment import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment import org.koitharu.kotatsu.settings.userdata.BackupsSettingsFragment import org.koitharu.kotatsu.settings.userdata.storage.DataCleanupSettingsFragment import javax.inject.Inject @Reusable @SuppressLint("RestrictedApi") class SettingsSearchHelper @Inject constructor( @LocalizedAppContext private val context: Context, ) { fun inflatePreferences(): List { val preferenceManager = PreferenceManager(context) val result = ArrayList() preferenceManager.inflateTo(result, R.xml.pref_appearance, emptyList(), AppearanceSettingsFragment::class.java) preferenceManager.inflateTo(result, R.xml.pref_sources, emptyList(), SourcesSettingsFragment::class.java) preferenceManager.inflateTo(result, R.xml.pref_reader, emptyList(), ReaderSettingsFragment::class.java) preferenceManager.inflateTo( result, R.xml.pref_network_storage, emptyList(), StorageAndNetworkSettingsFragment::class.java, ) preferenceManager.inflateTo(result, R.xml.pref_backups, emptyList(), BackupsSettingsFragment::class.java) preferenceManager.inflateTo( result, R.xml.pref_data_cleanup, listOf(context.getString(R.string.storage_and_network)), DataCleanupSettingsFragment::class.java, ) preferenceManager.inflateTo(result, R.xml.pref_downloads, emptyList(), DownloadsSettingsFragment::class.java) preferenceManager.inflateTo(result, R.xml.pref_tracker, emptyList(), TrackerSettingsFragment::class.java) preferenceManager.inflateTo(result, R.xml.pref_services, emptyList(), ServicesSettingsFragment::class.java) preferenceManager.inflateTo(result, R.xml.pref_about, emptyList(), AboutSettingsFragment::class.java) preferenceManager.inflateTo( result, R.xml.pref_backup_periodic, listOf(context.getString(R.string.backup_restore)), PeriodicalBackupSettingsFragment::class.java, ) preferenceManager.inflateTo( result, R.xml.pref_proxy, listOf(context.getString(R.string.storage_and_network)), ProxySettingsFragment::class.java, ) preferenceManager.inflateTo( result, R.xml.pref_suggestions, listOf(context.getString(R.string.services)), SuggestionsSettingsFragment::class.java, ) preferenceManager.inflateTo( result, R.xml.pref_discord, listOf(context.getString(R.string.services)), DiscordSettingsFragment::class.java, ) preferenceManager.inflateTo( result, R.xml.pref_sources, listOf(), SourcesSettingsFragment::class.java, ) return result } private fun PreferenceManager.inflateTo( result: MutableList, @XmlRes resId: Int, breadcrumbs: List, fragmentClass: Class ) { val screen = inflateFromResource(context, resId, null) val screenTitle = screen.title?.toString() screen.inflateTo( result = result, breadcrumbs = if (screenTitle.isNullOrEmpty()) breadcrumbs else breadcrumbs + screenTitle, fragmentClass = fragmentClass, ) } private fun PreferenceScreen.inflateTo( result: MutableList, breadcrumbs: List, fragmentClass: Class ): Unit = repeat(preferenceCount) { i -> val pref = this[i] if (pref is PreferenceScreen) { val screenTitle = pref.title?.toString() pref.inflateTo( result = result, breadcrumbs = if (screenTitle.isNullOrEmpty()) breadcrumbs else breadcrumbs + screenTitle, fragmentClass = fragmentClass, ) } else { result.add( SettingsItem( key = pref.key ?: return@repeat, title = pref.title ?: return@repeat, breadcrumbs = breadcrumbs, fragmentClass = fragmentClass, ), ) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchMenuProvider.kt ================================================ package org.koitharu.kotatsu.settings.search import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.appcompat.widget.SearchView import androidx.core.view.MenuProvider import org.koitharu.kotatsu.R class SettingsSearchMenuProvider( private val viewModel: SettingsSearchViewModel, ) : MenuProvider, MenuItem.OnActionExpandListener, SearchView.OnQueryTextListener { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.opt_search, menu) val menuItem = menu.findItem(R.id.action_search) menuItem.setOnActionExpandListener(this) val searchView = menuItem.actionView as SearchView searchView.setOnQueryTextListener(this) searchView.queryHint = menuItem.title } override fun onPrepareMenu(menu: Menu) { super.onPrepareMenu(menu) if (viewModel.isSearchActive.value) { val menuItem = menu.findItem(R.id.action_search) menuItem.expandActionView() val searchView = menuItem.actionView as SearchView searchView.setQuery(viewModel.currentQuery, false) } } override fun onMenuItemSelected(menuItem: MenuItem): Boolean = false override fun onMenuItemActionExpand(item: MenuItem): Boolean { viewModel.startSearch() return true } override fun onMenuItemActionCollapse(item: MenuItem): Boolean { viewModel.discardSearch() return true } override fun onQueryTextSubmit(query: String?): Boolean { return true } override fun onQueryTextChange(newText: String?): Boolean { viewModel.onQueryChanged(newText.orEmpty()) return true } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchViewModel.kt ================================================ package org.koitharu.kotatsu.settings.search import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call import javax.inject.Inject @HiltViewModel class SettingsSearchViewModel @Inject constructor( private val searchHelper: SettingsSearchHelper, ) : BaseViewModel() { private val query = MutableStateFlow(null) private val allSettings by lazy { searchHelper.inflatePreferences() } val content = query.map { q -> if (q == null) { emptyList() } else { allSettings.filter { it.title.contains(q, ignoreCase = true) } } }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList()) val isSearchActive = query.map { it != null }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, query.value != null) val onNavigateToPreference = MutableEventFlow() val currentQuery: String get() = query.value.orEmpty() fun onQueryChanged(value: String) { if (query.value != null) { query.value = value } } fun discardSearch() { query.value = null } fun startSearch() { query.value = query.value.orEmpty() } fun navigateToPreference(item: SettingsItem) { discardSearch() onNavigateToPreference.call(item) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsExt.kt ================================================ package org.koitharu.kotatsu.settings.sources import android.view.inputmethod.EditorInfo import androidx.preference.EditTextPreference import androidx.preference.ListPreference import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreferenceCompat import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.parser.EmptyMangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.network.UserAgents import org.koitharu.kotatsu.parsers.util.mapToArray import org.koitharu.kotatsu.settings.utils.AutoCompleteTextViewPreference import org.koitharu.kotatsu.settings.utils.EditTextBindListener import org.koitharu.kotatsu.settings.utils.EditTextDefaultSummaryProvider import org.koitharu.kotatsu.settings.utils.validation.DomainValidator import org.koitharu.kotatsu.settings.utils.validation.HeaderValidator fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: MangaRepository) = when (repository) { is ParserMangaRepository -> addPreferencesFromParserRepository(repository) is EmptyMangaRepository -> addPreferencesFromEmptyRepository() else -> Unit } private fun PreferenceFragmentCompat.addPreferencesFromParserRepository(repository: ParserMangaRepository) { addPreferencesFromResource(R.xml.pref_source_parser) val configKeys = repository.getConfigKeys() val screen = preferenceScreen for (key in configKeys) { val preference: Preference = when (key) { is ConfigKey.Domain -> { val presetValues = key.presetValues if (presetValues.size <= 1) { EditTextPreference(screen.context) } else { AutoCompleteTextViewPreference(screen.context).apply { entries = presetValues.toStringArray() } }.apply { summaryProvider = EditTextDefaultSummaryProvider(key.defaultValue) setOnBindEditTextListener( EditTextBindListener( inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_URI, hint = key.defaultValue, validator = DomainValidator(), ), ) setTitle(R.string.domain) setDialogTitle(R.string.domain) } } is ConfigKey.UserAgent -> { AutoCompleteTextViewPreference(screen.context).apply { entries = arrayOf( UserAgents.FIREFOX_MOBILE, UserAgents.CHROME_MOBILE, UserAgents.FIREFOX_DESKTOP, UserAgents.CHROME_DESKTOP, ) summaryProvider = EditTextDefaultSummaryProvider(key.defaultValue) setOnBindEditTextListener( EditTextBindListener( inputType = EditorInfo.TYPE_CLASS_TEXT, hint = key.defaultValue, validator = HeaderValidator(), ), ) setTitle(R.string.user_agent) setDialogTitle(R.string.user_agent) } } is ConfigKey.ShowSuspiciousContent -> { SwitchPreferenceCompat(screen.context).apply { setDefaultValue(key.defaultValue) setTitle(R.string.show_suspicious_content) } } is ConfigKey.SplitByTranslations -> { SwitchPreferenceCompat(screen.context).apply { setDefaultValue(key.defaultValue) setTitle(R.string.split_by_translations) setSummary(R.string.split_by_translations_summary) } } is ConfigKey.PreferredImageServer -> { ListPreference(screen.context).apply { entries = key.presetValues.values.mapToArray { it ?: context.getString(R.string.automatic) } entryValues = key.presetValues.keys.mapToArray { it.orEmpty() } setDefaultValue(key.defaultValue.orEmpty()) setTitle(R.string.image_server) setDialogTitle(R.string.image_server) summaryProvider = ListPreference.SimpleSummaryProvider.getInstance() } } } preference.isIconSpaceReserved = false preference.key = key.key preference.order = 10 screen.addPreference(preference) } } private fun PreferenceFragmentCompat.addPreferencesFromEmptyRepository() { val preference = Preference(requireContext()) preference.setIcon(R.drawable.ic_alert_outline) preference.isPersistent = false preference.isSelectable = false preference.order = 200 preference.setSummary(R.string.unsupported_source) preferenceScreen.addPreference(preference) } private fun Array.toStringArray(): Array { return Array(size) { i -> this[i].orEmpty() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsFragment.kt ================================================ package org.koitharu.kotatsu.settings.sources import android.os.Bundle import android.view.View import androidx.appcompat.app.AlertDialog import androidx.fragment.app.viewModels import androidx.preference.EditTextPreference import androidx.preference.EditTextPreferenceDialogFragmentCompat import androidx.preference.Preference import androidx.preference.SwitchPreferenceCompat import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.filterNotNull 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.AppRouter import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.parser.EmptyMangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.parsers.model.MangaSource import java.io.File @AndroidEntryPoint class SourceSettingsFragment : BasePreferenceFragment(0), Preference.OnPreferenceChangeListener { private val viewModel: SourceSettingsViewModel by viewModels() override fun onResume() { super.onResume() context?.let { ctx -> setTitle(viewModel.source.getTitle(ctx)) } viewModel.onResume() } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { preferenceManager.sharedPreferencesName = viewModel.source.name.replace(File.separatorChar, '$') addPreferencesFromResource(R.xml.pref_source) addPreferencesFromRepository(viewModel.repository) val isValidSource = viewModel.repository !is EmptyMangaRepository findPreference(KEY_ENABLE)?.run { isVisible = isValidSource && !settings.isAllSourcesEnabled onPreferenceChangeListener = this@SourceSettingsFragment } findPreference(KEY_AUTH)?.run { val authProvider = (viewModel.repository as? ParserMangaRepository)?.getAuthProvider() isVisible = authProvider != null } findPreference(SourceSettings.KEY_SLOWDOWN)?.isVisible = isValidSource } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel.isAuthorized.filterNotNull().observe(viewLifecycleOwner) { isAuthorized -> findPreference(KEY_AUTH)?.isEnabled = !isAuthorized } viewModel.username.observe(viewLifecycleOwner) { username -> findPreference(KEY_AUTH)?.summary = username?.let { getString(R.string.logged_in_as, it) } } viewModel.onError.observeEvent( viewLifecycleOwner, SnackbarErrorObserver( listView, this, exceptionResolver, ) { viewModel.onResume() }, ) viewModel.isLoading.observe(viewLifecycleOwner) { isLoading -> findPreference(KEY_AUTH)?.isEnabled = !isLoading } viewModel.isEnabled.observe(viewLifecycleOwner) { enabled -> findPreference(KEY_ENABLE)?.isChecked = enabled } viewModel.browserUrl.observe(viewLifecycleOwner) { findPreference(AppSettings.KEY_OPEN_BROWSER)?.run { isVisible = it != null summary = it } } viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView)) } override fun onPreferenceTreeClick(preference: Preference): Boolean { return when (preference.key) { KEY_AUTH -> { router.openSourceAuth(viewModel.source) true } AppSettings.KEY_OPEN_BROWSER -> { router.openBrowser( url = viewModel.browserUrl.value ?: return false, source = viewModel.source, title = viewModel.source.getTitle(preference.context), ) true } AppSettings.KEY_COOKIES_CLEAR -> { viewModel.clearCookies() true } else -> super.onPreferenceTreeClick(preference) } } override fun onDisplayPreferenceDialog(preference: Preference) { if (preference.key == SourceSettings.KEY_DOMAIN) { if (parentFragmentManager.findFragmentByTag(DomainDialogFragment.DIALOG_FRAGMENT_TAG) != null) { return } val f = DomainDialogFragment.newInstance(preference.key) @Suppress("DEPRECATION") f.setTargetFragment(this, 0) f.show(parentFragmentManager, DomainDialogFragment.DIALOG_FRAGMENT_TAG) return } super.onDisplayPreferenceDialog(preference) } override fun onPreferenceChange(preference: Preference, newValue: Any?): Boolean { when (preference.key) { KEY_ENABLE -> viewModel.setEnabled(newValue == true) else -> return false } return true } class DomainDialogFragment : EditTextPreferenceDialogFragmentCompat() { override fun onPrepareDialogBuilder(builder: AlertDialog.Builder) { super.onPrepareDialogBuilder(builder) builder.setNeutralButton(R.string.reset) { _, _ -> resetValue() } } private fun resetValue() { val editTextPreference = preference as EditTextPreference if (editTextPreference.callChangeListener("")) { editTextPreference.text = "" } } companion object { const val DIALOG_FRAGMENT_TAG: String = "androidx.preference.PreferenceFragment.DIALOG" fun newInstance(key: String) = DomainDialogFragment().withArgs(1) { putString(ARG_KEY, key) } } } companion object { private const val KEY_AUTH = "auth" private const val KEY_ENABLE = "enable" fun newInstance(source: MangaSource) = SourceSettingsFragment().withArgs(1) { putString(AppRouter.KEY_SOURCE, source.name) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsViewModel.kt ================================================ package org.koitharu.kotatsu.settings.sources import android.content.SharedPreferences import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import okhttp3.HttpUrl import org.koitharu.kotatsu.R 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.CachingMangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.prefs.SourceSettings 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.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import javax.inject.Inject @HiltViewModel class SourceSettingsViewModel @Inject constructor( savedStateHandle: SavedStateHandle, mangaRepositoryFactory: MangaRepository.Factory, private val cookieJar: MutableCookieJar, private val mangaSourcesRepository: MangaSourcesRepository, ) : BaseViewModel(), SharedPreferences.OnSharedPreferenceChangeListener { val source = MangaSource(savedStateHandle.get(AppRouter.KEY_SOURCE)) val repository = mangaRepositoryFactory.create(source) val onActionDone = MutableEventFlow() val username = MutableStateFlow(null) val isAuthorized = MutableStateFlow(null) val browserUrl = MutableStateFlow(null) val isEnabled = mangaSourcesRepository.observeIsEnabled(source) private var usernameLoadJob: Job? = null init { when (repository) { is ParserMangaRepository -> { browserUrl.value = "https://${repository.domain}" repository.getConfig().subscribe(this) loadUsername(repository.getAuthProvider()) } } } override fun onCleared() { when (repository) { is ParserMangaRepository -> { repository.getConfig().unsubscribe(this) } } super.onCleared() } override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { if (repository is CachingMangaRepository) { if (key != SourceSettings.KEY_SLOWDOWN && key != SourceSettings.KEY_SORT_ORDER) { repository.invalidateCache() } } if (repository is ParserMangaRepository) { if (key == SourceSettings.KEY_DOMAIN) { browserUrl.value = "https://${repository.domain}" } } } fun onResume() { if (usernameLoadJob?.isActive != true && repository is ParserMangaRepository) { loadUsername(repository.getAuthProvider()) } } fun clearCookies() { if (repository !is ParserMangaRepository) return launchLoadingJob(Dispatchers.Default) { val url = HttpUrl.Builder() .scheme("https") .host(repository.domain) .build() cookieJar.removeCookies(url, null) onActionDone.call(ReversibleAction(R.string.cookies_cleared, null)) loadUsername(repository.getAuthProvider()) } } fun setEnabled(value: Boolean) { launchJob(Dispatchers.Default) { mangaSourcesRepository.setSourcesEnabled(setOf(source), value) } } private fun loadUsername(authProvider: MangaParserAuthProvider?) { launchLoadingJob(Dispatchers.Default) { try { username.value = null isAuthorized.value = null isAuthorized.value = authProvider?.isAuthorized() username.value = authProvider?.getUsername() } catch (_: AuthRequiredException) { } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt ================================================ package org.koitharu.kotatsu.settings.sources import android.content.SharedPreferences import android.os.Bundle import android.view.View import androidx.fragment.app.viewModels import androidx.preference.ListPreference import androidx.preference.Preference import androidx.preference.TwoStatePreference import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.TriStateOption import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat import org.koitharu.kotatsu.explore.data.SourcesSortOrder import org.koitharu.kotatsu.parsers.util.names @AndroidEntryPoint class SourcesSettingsFragment : BasePreferenceFragment(R.string.remote_sources), SharedPreferences.OnSharedPreferenceChangeListener { private val viewModel by viewModels() override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_sources) findPreference(AppSettings.KEY_SOURCES_ORDER)?.run { entryValues = SourcesSortOrder.entries.names() entries = SourcesSortOrder.entries.map { context.getString(it.titleResId) }.toTypedArray() setDefaultValueCompat(SourcesSortOrder.MANUAL.name) } findPreference(AppSettings.KEY_INCOGNITO_NSFW)?.run { entryValues = TriStateOption.entries.names() setDefaultValueCompat(TriStateOption.ASK.name) } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) findPreference(AppSettings.KEY_REMOTE_SOURCES)?.let { pref -> viewModel.enabledSourcesCount.observe(viewLifecycleOwner) { pref.summary = if (it >= 0) { resources.getQuantityStringSafe(R.plurals.items, it, it) } else { null } } } findPreference(AppSettings.KEY_SOURCES_CATALOG)?.let { pref -> viewModel.availableSourcesCount.observe(viewLifecycleOwner) { pref.summary = when { it == 0 -> getString(R.string.all_sources_enabled) it > 0 -> getString(R.string.available_d, it) else -> null } } } findPreference(AppSettings.KEY_HANDLE_LINKS)?.let { pref -> viewModel.isLinksEnabled.observe(viewLifecycleOwner) { pref.isChecked = it } } updateEnableAllDependencies() settings.subscribe(this) } override fun onDestroyView() { settings.unsubscribe(this) super.onDestroyView() } override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) { AppSettings.KEY_SOURCES_CATALOG -> { router.openSourcesCatalog() true } AppSettings.KEY_HANDLE_LINKS -> { viewModel.setLinksEnabled((preference as TwoStatePreference).isChecked) true } else -> super.onPreferenceTreeClick(preference) } override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { when (key) { AppSettings.KEY_SOURCES_ENABLED_ALL -> updateEnableAllDependencies() } } private fun updateEnableAllDependencies() { findPreference(AppSettings.KEY_SOURCES_CATALOG)?.isEnabled = !settings.isAllSourcesEnabled } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt ================================================ package org.koitharu.kotatsu.settings.sources import android.content.ComponentName import android.content.Context import android.content.pm.PackageManager import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DEFAULT import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DISABLED import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_ENABLED import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import javax.inject.Inject @HiltViewModel class SourcesSettingsViewModel @Inject constructor( sourcesRepository: MangaSourcesRepository, @ApplicationContext private val context: Context, ) : BaseViewModel() { private val linksHandlerActivity = ComponentName(context, "org.koitharu.kotatsu.details.ui.DetailsByLinkActivity") val enabledSourcesCount = sourcesRepository.observeEnabledSourcesCount() .withErrorHandling() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, -1) val availableSourcesCount = sourcesRepository.observeAvailableSourcesCount() .withErrorHandling() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, -1) val isLinksEnabled = MutableStateFlow(isLinksEnabled()) fun setLinksEnabled(isEnabled: Boolean) { context.packageManager.setComponentEnabledSetting( linksHandlerActivity, if (isEnabled) COMPONENT_ENABLED_STATE_ENABLED else COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP, ) isLinksEnabled.value = isLinksEnabled() } private fun isLinksEnabled(): Boolean { val state = context.packageManager.getComponentEnabledSetting(linksHandlerActivity) return state == COMPONENT_ENABLED_STATE_ENABLED || state == COMPONENT_ENABLED_STATE_DEFAULT } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt ================================================ package org.koitharu.kotatsu.settings.sources.adapter import org.koitharu.kotatsu.core.ui.ReorderableListAdapter import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem class SourceConfigAdapter( listener: SourceConfigListener, ) : ReorderableListAdapter() { init { with(delegatesManager) { addDelegate(sourceConfigItemDelegate2(listener)) addDelegate(sourceConfigEmptySearchDelegate()) addDelegate(sourceConfigTipDelegate(listener)) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt ================================================ package org.koitharu.kotatsu.settings.sources.adapter import android.view.View import androidx.appcompat.widget.PopupMenu import androidx.core.content.ContextCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.view.isGone import androidx.core.view.isVisible import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.getSummary import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.ui.list.OnTipCloseListener import org.koitharu.kotatsu.core.util.ext.drawableStart import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding import org.koitharu.kotatsu.databinding.ItemTipBinding import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem fun sourceConfigItemDelegate2( listener: SourceConfigListener, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemSourceConfigBinding.inflate( layoutInflater, parent, false, ) }, ) { val iconPinned = ContextCompat.getDrawable(context, R.drawable.ic_pin_small) val eventListener = View.OnClickListener { v -> when (v.id) { R.id.imageView_add -> listener.onItemEnabledChanged(item, true) R.id.imageView_remove -> listener.onItemEnabledChanged(item, false) R.id.imageView_menu -> showSourceMenu(v, item, listener) } } binding.imageViewRemove.setOnClickListener(eventListener) binding.imageViewAdd.setOnClickListener(eventListener) binding.imageViewMenu.setOnClickListener(eventListener) bind { binding.textViewTitle.text = item.source.getTitle(context) binding.imageViewAdd.isGone = item.isEnabled || !item.isAvailable binding.imageViewRemove.isVisible = item.isEnabled && item.isDisableAvailable binding.imageViewMenu.isVisible = item.isEnabled binding.textViewTitle.drawableStart = if (item.isPinned) iconPinned else null binding.textViewDescription.text = item.source.getSummary(context) binding.imageViewIcon.setImageAsync(item.source) } } fun sourceConfigTipDelegate( listener: OnTipCloseListener, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemTipBinding.inflate(layoutInflater, parent, false) }, ) { binding.buttonClose.setOnClickListener { listener.onCloseTip(item) } bind { binding.imageViewIcon.setImageResource(item.iconResId) binding.textView.setText(item.textResId) } } fun sourceConfigEmptySearchDelegate() = adapterDelegate( R.layout.item_sources_empty, ) { } private fun showSourceMenu( anchor: View, item: SourceConfigItem.SourceItem, listener: SourceConfigListener, ) { val menu = PopupMenu(anchor.context, anchor) menu.inflate(R.menu.popup_source_config) menu.menu.findItem(R.id.action_shortcut) ?.isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(anchor.context) menu.menu.findItem(R.id.action_pin)?.isVisible = item.isEnabled menu.menu.findItem(R.id.action_pin)?.isChecked = item.isPinned menu.menu.findItem(R.id.action_lift)?.isVisible = item.isDraggable menu.setOnMenuItemClickListener { when (it.itemId) { R.id.action_settings -> listener.onItemSettingsClick(item) R.id.action_lift -> listener.onItemLiftClick(item) R.id.action_shortcut -> listener.onItemShortcutClick(item) R.id.action_pin -> listener.onItemPinClick(item) } true } menu.show() } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigListener.kt ================================================ package org.koitharu.kotatsu.settings.sources.adapter import org.koitharu.kotatsu.core.ui.list.OnTipCloseListener import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem interface SourceConfigListener : OnTipCloseListener { fun onItemSettingsClick(item: SourceConfigItem.SourceItem) fun onItemLiftClick(item: SourceConfigItem.SourceItem) fun onItemShortcutClick(item: SourceConfigItem.SourceItem) fun onItemPinClick(item: SourceConfigItem.SourceItem) fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt ================================================ package org.koitharu.kotatsu.settings.sources.auth import android.content.Context import android.content.Intent import android.os.Bundle import android.view.MenuItem import android.widget.Toast 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.Job import kotlinx.coroutines.launch import org.koitharu.kotatsu.R import org.koitharu.kotatsu.browser.BaseBrowserActivity import org.koitharu.kotatsu.browser.BrowserCallback import org.koitharu.kotatsu.browser.BrowserClient import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.runCatchingCancellable @AndroidEntryPoint class SourceAuthActivity : BaseBrowserActivity(), BrowserCallback { private lateinit var authProvider: MangaParserAuthProvider private var authCheckJob: Job? = null override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) { if (repository == null) { finishAfterTransition() return } authProvider = repository.getAuthProvider() ?: run { Toast.makeText( this, getString(R.string.auth_not_supported_by, source.getTitle(this)), Toast.LENGTH_SHORT, ).show() finishAfterTransition() return } setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true) viewBinding.webView.webViewClient = BrowserClient(this, adBlock) lifecycleScope.launch { try { proxyProvider.applyWebViewConfig() } catch (e: Exception) { Snackbar.make(viewBinding.webView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show() } if (savedInstanceState == null) { val url = authProvider.authUrl onTitleChanged( source.getTitle(this@SourceAuthActivity), getString(R.string.loading_), ) viewBinding.webView.loadUrl(url) } } } override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { android.R.id.home -> { viewBinding.webView.stopLoading() setResult(RESULT_CANCELED) finishAfterTransition() true } else -> super.onOptionsItemSelected(item) } override fun onLoadingStateChanged(isLoading: Boolean) { super.onLoadingStateChanged(isLoading) if (isLoading) { return } val prevJob = authCheckJob authCheckJob = lifecycleScope.launch { prevJob?.join() val isAuthorized = runCatchingCancellable { authProvider.isAuthorized() }.getOrDefault(false) if (isAuthorized) { Toast.makeText(this@SourceAuthActivity, R.string.auth_complete, Toast.LENGTH_SHORT).show() setResult(RESULT_OK) finishAfterTransition() } } } class Contract : ActivityResultContract() { override fun createIntent(context: Context, input: MangaSource) = AppRouter.sourceAuthIntent(context, input) override fun parseResult(resultCode: Int, intent: Intent?) = resultCode == RESULT_OK } companion object { const val TAG = "SourceAuthActivity" } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogItem.kt ================================================ package org.koitharu.kotatsu.settings.sources.catalog import androidx.annotation.DrawableRes import androidx.annotation.StringRes import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.MangaParserSource sealed interface SourceCatalogItem : ListModel { data class Source( val source: MangaParserSource, ) : SourceCatalogItem { override fun areItemsTheSame(other: ListModel): Boolean { return other is Source && other.source == source } } data class Hint( @DrawableRes val icon: Int, @StringRes val title: Int, @StringRes val text: Int, ) : SourceCatalogItem { override fun areItemsTheSame(other: ListModel): Boolean { return other is Hint && other.title == title } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogItemAD.kt ================================================ package org.koitharu.kotatsu.settings.sources.catalog import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.core.view.updatePaddingRelative import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.getSummary import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.ui.image.FaviconDrawable import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.drawableStart import org.koitharu.kotatsu.core.util.ext.getThemeDimensionPixelOffset import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.databinding.ItemEmptyHintBinding import org.koitharu.kotatsu.databinding.ItemSourceCatalogBinding import org.koitharu.kotatsu.list.ui.model.ListModel import androidx.appcompat.R as appcompatR fun sourceCatalogItemSourceAD( listener: OnListItemClickListener ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemSourceCatalogBinding.inflate(layoutInflater, parent, false) }, ) { binding.imageViewAdd.setOnClickListener { v -> listener.onItemLongClick(item, v) } binding.root.setOnClickListener { v -> listener.onItemClick(item, v) } val basePadding = context.getThemeDimensionPixelOffset( appcompatR.attr.listPreferredItemPaddingEnd, binding.root.paddingStart, ) binding.root.updatePaddingRelative( end = (basePadding - context.resources.getDimensionPixelOffset(R.dimen.margin_small)).coerceAtLeast(0), ) bind { binding.textViewTitle.text = item.source.getTitle(context) binding.textViewDescription.text = item.source.getSummary(context) binding.textViewDescription.drawableStart = if (item.source.isBroken) { ContextCompat.getDrawable(context, R.drawable.ic_off_small) } else { null } FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name) binding.imageViewIcon.setImageAsync(item.source) } } fun sourceCatalogItemHintAD() = adapterDelegateViewBinding( { inflater, parent -> ItemEmptyHintBinding.inflate(inflater, parent, false) }, ) { binding.buttonRetry.isVisible = false bind { binding.icon.setImageAsync(item.icon) binding.textPrimary.setText(item.title) binding.textSecondary.setTextAndVisible(item.text) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogPage.kt ================================================ package org.koitharu.kotatsu.settings.sources.catalog import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.ContentType data class SourceCatalogPage( val type: ContentType, val items: List, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is SourceCatalogPage && other.type == type } override fun getChangePayload(previousState: ListModel): Any { return ListModelDiffCallback.PAYLOAD_NESTED_LIST_CHANGED } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt ================================================ package org.koitharu.kotatsu.settings.sources.catalog import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.View import androidx.activity.viewModels import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.SearchView import androidx.core.graphics.Insets import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import com.google.android.material.appbar.AppBarLayout import com.google.android.material.chip.Chip import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.combine import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.titleResId import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.util.FadingAppbarMediator import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView.ChipModel import org.koitharu.kotatsu.core.util.LocaleComparator import org.koitharu.kotatsu.core.util.ext.getDisplayName import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.toLocale import org.koitharu.kotatsu.databinding.ActivitySourcesCatalogBinding import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.parsers.model.ContentType @AndroidEntryPoint class SourcesCatalogActivity : BaseActivity(), OnListItemClickListener, AppBarOwner, MenuItem.OnActionExpandListener, ChipsView.OnChipClickListener { override val appBar: AppBarLayout get() = viewBinding.appbar private val viewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivitySourcesCatalogBinding.inflate(layoutInflater)) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) val sourcesAdapter = SourcesCatalogAdapter(this) with(viewBinding.recyclerView) { setHasFixedSize(true) addItemDecoration(TypedListSpacingDecoration(context, false)) adapter = sourcesAdapter } viewBinding.chipsFilter.onChipClickListener = this FadingAppbarMediator(viewBinding.appbar, viewBinding.toolbar).bind() viewModel.content.observe(this, sourcesAdapter) viewModel.onActionDone.observeEvent( this, ReversibleActionObserver(viewBinding.recyclerView), ) combine(viewModel.appliedFilter, viewModel.hasNewSources, viewModel.contentTypes, ::Triple).observe(this) { updateFilers(it.first, it.second, it.third) } addMenuProvider(SourcesCatalogMenuProvider(this, viewModel, this)) } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) viewBinding.recyclerView.updatePadding( left = bars.left, right = bars.right, bottom = bars.bottom, ) viewBinding.appbar.updatePadding( left = bars.left, right = bars.right, top = bars.top, ) return WindowInsetsCompat.Builder(insets) .setInsets(WindowInsetsCompat.Type.systemBars(), Insets.NONE) .build() } override fun onChipClick(chip: Chip, data: Any?) { when (data) { is ContentType -> viewModel.setContentType(data, !chip.isChecked) is Boolean -> viewModel.setNewOnly(!chip.isChecked) else -> showLocalesMenu(chip) } } override fun onItemClick(item: SourceCatalogItem.Source, view: View) { router.openList(item.source, null, null) } override fun onItemLongClick(item: SourceCatalogItem.Source, view: View): Boolean { viewModel.addSource(item.source) return false } override fun onMenuItemActionExpand(item: MenuItem): Boolean { val sq = (item.actionView as? SearchView)?.query?.trim()?.toString().orEmpty() viewModel.performSearch(sq) return true } override fun onMenuItemActionCollapse(item: MenuItem): Boolean { viewModel.performSearch(null) return true } private fun updateFilers( appliedFilter: SourcesCatalogFilter, hasNewSources: Boolean, contentTypes: List, ) { val chips = ArrayList(contentTypes.size + 2) chips += ChipModel( title = appliedFilter.locale?.toLocale().getDisplayName(this), icon = R.drawable.ic_language, isDropdown = true, ) if (hasNewSources) { chips += ChipModel( title = getString(R.string._new), icon = R.drawable.ic_updated, isChecked = appliedFilter.isNewOnly, data = true, ) } contentTypes.mapTo(chips) { type -> ChipModel( title = getString(type.titleResId), isChecked = type in appliedFilter.types, data = type, ) } viewBinding.chipsFilter.setChips(chips) } private fun showLocalesMenu(anchor: View) { val locales = viewModel.locales.mapTo(ArrayList(viewModel.locales.size)) { it to it?.toLocale() } locales.sortWith(compareBy(nullsFirst(LocaleComparator())) { it.second }) val menu = PopupMenu(this, anchor) for ((i, lc) in locales.withIndex()) { menu.menu.add(Menu.NONE, Menu.NONE, i, lc.second.getDisplayName(this)) } menu.setOnMenuItemClickListener { viewModel.setLocale(locales.getOrNull(it.order)?.first) true } menu.show() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogAdapter.kt ================================================ package org.koitharu.kotatsu.settings.sources.catalog import android.content.Context import org.koitharu.kotatsu.core.model.getTitle 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.ListItemType import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.model.ListModel class SourcesCatalogAdapter( listener: OnListItemClickListener, ) : BaseListAdapter(), FastScroller.SectionIndexer { init { addDelegate(ListItemType.CHAPTER_LIST, sourceCatalogItemSourceAD(listener)) addDelegate(ListItemType.HINT_EMPTY, sourceCatalogItemHintAD()) addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) } override fun getSectionText(context: Context, position: Int): CharSequence? { return (items.getOrNull(position) as? SourceCatalogItem.Source)?.source?.getTitle(context)?.take(1) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogFilter.kt ================================================ package org.koitharu.kotatsu.settings.sources.catalog import org.koitharu.kotatsu.parsers.model.ContentType data class SourcesCatalogFilter( val types: Set, val locale: String?, val isNewOnly: Boolean, ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogMenuProvider.kt ================================================ package org.koitharu.kotatsu.settings.sources.catalog import android.app.Activity import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.appcompat.widget.SearchView import androidx.core.view.MenuProvider import org.koitharu.kotatsu.R import org.koitharu.kotatsu.main.ui.owners.AppBarOwner class SourcesCatalogMenuProvider( private val activity: Activity, private val viewModel: SourcesCatalogViewModel, private val expandListener: MenuItem.OnActionExpandListener, ) : MenuProvider, MenuItem.OnActionExpandListener, SearchView.OnQueryTextListener { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.opt_sources_catalog, menu) val searchMenuItem = menu.findItem(R.id.action_search) searchMenuItem.setOnActionExpandListener(this) val searchView = searchMenuItem.actionView as SearchView searchView.setOnQueryTextListener(this) searchView.setIconifiedByDefault(false) searchView.queryHint = searchMenuItem.title } override fun onMenuItemSelected(menuItem: MenuItem): Boolean = false override fun onMenuItemActionExpand(item: MenuItem): Boolean { (activity as? AppBarOwner)?.appBar?.setExpanded(true, true) return expandListener.onMenuItemActionExpand(item) } override fun onMenuItemActionCollapse(item: MenuItem): Boolean { (item.actionView as SearchView).setQuery("", false) return expandListener.onMenuItemActionCollapse(item) } override fun onQueryTextSubmit(query: String?): Boolean = false override fun onQueryTextChange(newText: String?): Boolean { viewModel.performSearch(newText?.trim().orEmpty()) return true } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogViewModel.kt ================================================ package org.koitharu.kotatsu.settings.sources.catalog import androidx.annotation.WorkerThread import androidx.lifecycle.viewModelScope import androidx.room.invalidationTrackerFlow import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers 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.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.TABLE_SOURCES 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.mapSortedByCount import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.SourcesSortOrder import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.MangaSource import java.util.EnumSet import java.util.Locale import javax.inject.Inject @HiltViewModel class SourcesCatalogViewModel @Inject constructor( private val repository: MangaSourcesRepository, db: MangaDatabase, settings: AppSettings, ) : BaseViewModel() { val onActionDone = MutableEventFlow() val locales: Set = repository.allMangaSources.mapTo(HashSet()) { it.locale }.also { it.add(null) } private val searchQuery = MutableStateFlow(null) val appliedFilter = MutableStateFlow( SourcesCatalogFilter( types = emptySet(), locale = Locale.getDefault().language.takeIf { it in locales }, isNewOnly = false, ), ) val hasNewSources = repository.observeHasNewSources() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false) val contentTypes = MutableStateFlow>(emptyList()) val content: StateFlow> = combine( searchQuery, appliedFilter, db.invalidationTrackerFlow(TABLE_SOURCES), ) { q, f, _ -> buildSourcesList(f, q) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) init { repository.clearNewSourcesBadge() launchJob(Dispatchers.Default) { contentTypes.value = getContentTypes(settings.isNsfwContentDisabled) } } fun performSearch(query: String?) { searchQuery.value = query?.trim() } fun setLocale(value: String?) { appliedFilter.value = appliedFilter.value.copy(locale = value) } fun addSource(source: MangaSource) { launchJob(Dispatchers.Default) { val rollback = repository.setSourcesEnabled(setOf(source), true) onActionDone.call(ReversibleAction(R.string.source_enabled, rollback)) } } fun setContentType(value: ContentType, isAdd: Boolean) { val filter = appliedFilter.value val types = EnumSet.noneOf(ContentType::class.java) types.addAll(filter.types) if (isAdd) { types.add(value) } else { types.remove(value) } appliedFilter.value = filter.copy(types = types) } fun setNewOnly(value: Boolean) { appliedFilter.value = appliedFilter.value.copy(isNewOnly = value) } private suspend fun buildSourcesList(filter: SourcesCatalogFilter, query: String?): List { val sources = repository.queryParserSources( isDisabledOnly = true, isNewOnly = filter.isNewOnly, excludeBroken = false, types = filter.types, query = query, locale = filter.locale, sortOrder = SourcesSortOrder.ALPHABETIC, ) return if (sources.isEmpty()) { listOf( if (query == null) { SourceCatalogItem.Hint( icon = R.drawable.ic_empty_feed, title = R.string.no_manga_sources, text = R.string.no_manga_sources_catalog_text, ) } else { SourceCatalogItem.Hint( icon = R.drawable.ic_empty_feed, title = R.string.nothing_found, text = R.string.no_manga_sources_found, ) }, ) } else { sources.map { SourceCatalogItem.Source(source = it) } } } @WorkerThread private fun getContentTypes(isNsfwDisabled: Boolean): List { val result = repository.allMangaSources.mapSortedByCount { it.contentType } return if (isNsfwDisabled) { result.filterNot { it == ContentType.HENTAI } } else { result } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesListProducer.kt ================================================ package org.koitharu.kotatsu.settings.sources.manage import android.content.Context import androidx.room.InvalidationTracker import dagger.hilt.android.ViewModelLifecycle import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.LocalizedAppContext import org.koitharu.kotatsu.core.db.TABLE_SOURCES import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.model.unwrap import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.lifecycleScope import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.SourcesSortOrder import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem import javax.inject.Inject @ViewModelScoped class SourcesListProducer @Inject constructor( lifecycle: ViewModelLifecycle, @LocalizedAppContext private val context: Context, private val repository: MangaSourcesRepository, private val settings: AppSettings, ) : InvalidationTracker.Observer(TABLE_SOURCES) { private val scope = lifecycle.lifecycleScope private var query: String = "" val list = MutableStateFlow(emptyList()) private var job = scope.launch(Dispatchers.Default) { list.value = buildList() } init { settings.observeChanges() .filter { it == AppSettings.KEY_TIPS_CLOSED || it == AppSettings.KEY_DISABLE_NSFW } .flowOn(Dispatchers.Default) .onEach { onInvalidated(emptySet()) } .launchIn(scope) } override fun onInvalidated(tables: Set) { val prevJob = job job = scope.launch(Dispatchers.Default) { prevJob.cancelAndJoin() list.update { buildList() } } } fun setQuery(value: String) { this.query = value onInvalidated(emptySet()) } private suspend fun buildList(): List { val enabledSources = repository.getEnabledSources().filter { it.unwrap() is MangaParserSource } val pinned = repository.getPinnedSources().mapToSet { it.name } val isNsfwDisabled = settings.isNsfwContentDisabled val isReorderAvailable = settings.sourcesSortOrder == SourcesSortOrder.MANUAL val isDisableAvailable = !settings.isAllSourcesEnabled val withTip = isReorderAvailable && settings.isTipEnabled(TIP_REORDER) val enabledSet = enabledSources.toSet() if (query.isNotEmpty()) { return enabledSources.mapNotNull { if (!it.getTitle(context).contains(query, ignoreCase = true)) { return@mapNotNull null } SourceConfigItem.SourceItem( source = it, isEnabled = it in enabledSet, isDraggable = false, isAvailable = !isNsfwDisabled || !it.isNsfw(), isPinned = it.name in pinned, isDisableAvailable = isDisableAvailable, ) }.ifEmpty { listOf(SourceConfigItem.EmptySearchResult) } } val result = ArrayList(enabledSources.size + 1) if (enabledSources.isNotEmpty()) { if (withTip) { result += SourceConfigItem.Tip( TIP_REORDER, R.drawable.ic_tap_reorder, R.string.sources_reorder_tip, ) } enabledSources.mapTo(result) { SourceConfigItem.SourceItem( source = it, isEnabled = true, isDraggable = isReorderAvailable, isAvailable = false, isPinned = it.name in pinned, isDisableAvailable = isDisableAvailable, ) } } return result } companion object { const val TIP_REORDER = "src_reorder" } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt ================================================ package org.koitharu.kotatsu.settings.sources.manage 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 androidx.appcompat.widget.SearchView import androidx.core.view.MenuProvider import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.viewModels import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.container import org.koitharu.kotatsu.core.util.ext.end import org.koitharu.kotatsu.core.util.ext.getItem import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.start import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigAdapter import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem import javax.inject.Inject @AndroidEntryPoint class SourcesManageFragment : BaseFragment(), SourceConfigListener, RecyclerViewOwner { @Inject lateinit var settings: AppSettings @Inject lateinit var shortcutManager: AppShortcutManager private var reorderHelper: ItemTouchHelper? = null private var sourcesAdapter: SourceConfigAdapter? = null private val viewModel by viewModels() override val recyclerView: RecyclerView? get() = viewBinding?.recyclerView override fun onCreateViewBinding( inflater: LayoutInflater, container: ViewGroup?, ) = FragmentSettingsSourcesBinding.inflate(inflater, container, false) override fun onViewBindingCreated( binding: FragmentSettingsSourcesBinding, savedInstanceState: Bundle?, ) { super.onViewBindingCreated(binding, savedInstanceState) sourcesAdapter = SourceConfigAdapter(this) with(binding.recyclerView) { setHasFixedSize(true) adapter = sourcesAdapter reorderHelper = ItemTouchHelper(SourcesReorderCallback()).also { it.attachToRecyclerView(this) } } viewModel.content.observe(viewLifecycleOwner, checkNotNull(sourcesAdapter)) viewModel.onActionDone.observeEvent( viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView), ) addMenuProvider(SourcesMenuProvider()) } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val barsInsets = insets.systemBarsInsets val isTablet = !resources.getBoolean(R.bool.is_tablet) val isMaster = container?.id == R.id.container_master v.setPaddingRelative( if (isTablet && !isMaster) 0 else barsInsets.start(v), 0, if (isTablet && isMaster) 0 else barsInsets.end(v), barsInsets.bottom, ) return insets.consumeAllSystemBarsInsets() } override fun onResume() { super.onResume() activity?.setTitle(R.string.manage_sources) } override fun onDestroyView() { sourcesAdapter = null reorderHelper = null super.onDestroyView() } override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) { (activity as? SettingsActivity)?.openFragment( fragmentClass = SourceSettingsFragment::class.java, args = Bundle(1).apply { putString(AppRouter.KEY_SOURCE, item.source.name) }, isFromRoot = false, ) } override fun onItemLiftClick(item: SourceConfigItem.SourceItem) { viewModel.bringToTop(item.source) } override fun onItemShortcutClick(item: SourceConfigItem.SourceItem) { viewLifecycleScope.launch { shortcutManager.requestPinShortcut(item.source) } } override fun onItemPinClick(item: SourceConfigItem.SourceItem) { viewModel.setPinned(item.source, !item.isPinned) } override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) { viewModel.setEnabled(item.source, isEnabled) } override fun onCloseTip(tip: SourceConfigItem.Tip) { viewModel.onTipClosed(tip) } private inner class SourcesMenuProvider : MenuProvider, MenuItem.OnActionExpandListener, SearchView.OnQueryTextListener { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.opt_sources, menu) val searchMenuItem = menu.findItem(R.id.action_search) searchMenuItem.setOnActionExpandListener(this) val searchView = searchMenuItem.actionView as SearchView searchView.setOnQueryTextListener(this) searchView.setIconifiedByDefault(false) searchView.queryHint = searchMenuItem.title } override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { R.id.action_catalog -> { router.openSourcesCatalog() true } R.id.action_disable_all -> { viewModel.disableAll() true } R.id.action_no_nsfw -> { settings.isNsfwContentDisabled = !menuItem.isChecked true } else -> false } override fun onPrepareMenu(menu: Menu) { super.onPrepareMenu(menu) menu.findItem(R.id.action_no_nsfw).isChecked = settings.isNsfwContentDisabled menu.findItem(R.id.action_disable_all).isVisible = !settings.isAllSourcesEnabled menu.findItem(R.id.action_catalog).isVisible = !settings.isAllSourcesEnabled } override fun onMenuItemActionExpand(item: MenuItem): Boolean { (activity as? AppBarOwner)?.appBar?.setExpanded(false, true) return true } override fun onMenuItemActionCollapse(item: MenuItem): Boolean { (item.actionView as SearchView).setQuery("", false) return true } override fun onQueryTextSubmit(query: String?): Boolean = false override fun onQueryTextChange(newText: String?): Boolean { viewModel.performSearch(newText) return true } } private inner class SourcesReorderCallback : ItemTouchHelper.SimpleCallback( ItemTouchHelper.DOWN or ItemTouchHelper.UP, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT, ) { override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder, ): Boolean = viewHolder.itemViewType == target.itemViewType override fun onMoved( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, fromPos: Int, target: RecyclerView.ViewHolder, toPos: Int, x: Int, y: Int, ) { super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y) sourcesAdapter?.reorderItems(fromPos, toPos) } override fun canDropOver( recyclerView: RecyclerView, current: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder, ): Boolean = current.itemViewType == target.itemViewType && viewModel.canReorder( current.bindingAdapterPosition, target.bindingAdapterPosition, ) override fun getDragDirs( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, ): Int { val item = viewHolder.getItem(SourceConfigItem.SourceItem::class.java) return if (item != null && item.isDraggable) { super.getDragDirs(recyclerView, viewHolder) } else { 0 } } override fun getSwipeDirs( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, ): Int { val item = viewHolder.getItem(SourceConfigItem.Tip::class.java) return if (item != null) { super.getSwipeDirs(recyclerView, viewHolder) } else { 0 } } override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { val item = viewHolder.getItem(SourceConfigItem.Tip::class.java) if (item != null) { viewModel.onTipClosed(item) } } override fun isLongPressDragEnabled() = true override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { super.clearView(recyclerView, viewHolder) viewModel.saveSourcesOrder(sourcesAdapter?.items ?: return) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageViewModel.kt ================================================ package org.koitharu.kotatsu.settings.sources.manage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.removeObserverAsync 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.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.move import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem import javax.inject.Inject @HiltViewModel class SourcesManageViewModel @Inject constructor( private val database: MangaDatabase, private val settings: AppSettings, private val repository: MangaSourcesRepository, private val listProducer: SourcesListProducer, ) : BaseViewModel() { val content = listProducer.list val onActionDone = MutableEventFlow() private var commitJob: Job? = null init { launchJob(Dispatchers.Default) { database.invalidationTracker.addObserver(listProducer) } } override fun onCleared() { super.onCleared() database.invalidationTracker.removeObserverAsync(listProducer) } fun saveSourcesOrder(snapshot: List) { val prevJob = commitJob commitJob = launchJob(Dispatchers.Default) { prevJob?.cancelAndJoin() val newSourcesList = snapshot.mapNotNull { x -> if (x is SourceConfigItem.SourceItem && x.isDraggable) { x.source } else { null } } repository.setPositions(newSourcesList) } } fun canReorder(oldPos: Int, newPos: Int): Boolean { val snapshot = content.value val oldPosItem = snapshot.getOrNull(oldPos) as? SourceConfigItem.SourceItem ?: return false val newPosItem = snapshot.getOrNull(newPos) as? SourceConfigItem.SourceItem ?: return false return oldPosItem.isEnabled && newPosItem.isEnabled && oldPosItem.isPinned == newPosItem.isPinned } fun setEnabled(source: MangaSource, isEnabled: Boolean) { launchJob(Dispatchers.Default) { val rollback = repository.setSourcesEnabled(setOf(source), isEnabled) if (!isEnabled) { onActionDone.call(ReversibleAction(R.string.source_disabled, rollback)) } } } fun setPinned(source: MangaSource, isPinned: Boolean) { launchJob(Dispatchers.Default) { val rollback = repository.setIsPinned(setOf(source), isPinned) val message = if (isPinned) R.string.source_pinned else R.string.source_unpinned onActionDone.call(ReversibleAction(message, rollback)) } } fun bringToTop(source: MangaSource) { val snapshot = content.value launchJob(Dispatchers.Default) { var oldPos = -1 var newPos = -1 for ((i, x) in snapshot.withIndex()) { if (x !is SourceConfigItem.SourceItem) { continue } if (newPos == -1) { newPos = i } if (x.source == source) { oldPos = i break } } @Suppress("KotlinConstantConditions") if (oldPos != -1 && newPos != -1) { reorderSources(oldPos, newPos) val revert = ReversibleAction(R.string.moved_to_top) { reorderSources(newPos, oldPos) } commitJob?.join() onActionDone.call(revert) } } } fun disableAll() { launchJob(Dispatchers.Default) { repository.disableAllSources() } } fun performSearch(query: String?) { listProducer.setQuery(query?.trim().orEmpty()) } fun onTipClosed(item: SourceConfigItem.Tip) { launchJob(Dispatchers.Default) { settings.closeTip(item.key) } } private fun reorderSources(oldPos: Int, newPos: Int) { val snapshot = content.value.toMutableList() if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) { return } if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) { return } snapshot.move(oldPos, newPos) saveSourcesOrder(snapshot) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt ================================================ package org.koitharu.kotatsu.settings.sources.model import androidx.annotation.DrawableRes import androidx.annotation.StringRes import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.MangaSource sealed interface SourceConfigItem : ListModel { data class SourceItem( val source: MangaSource, val isEnabled: Boolean, val isDraggable: Boolean, val isAvailable: Boolean, val isPinned: Boolean, val isDisableAvailable: Boolean, ) : SourceConfigItem { val isNsfw: Boolean get() = source.isNsfw() override fun areItemsTheSame(other: ListModel): Boolean { return other is SourceItem && other.source == source } } data class Tip( val key: String, @DrawableRes val iconResId: Int, @StringRes val textResId: Int, ) : SourceConfigItem { override fun areItemsTheSame(other: ListModel): Boolean { return other is Tip && other.key == key } } data object EmptySearchResult : SourceConfigItem { override fun areItemsTheSame(other: ListModel): Boolean { return other is EmptySearchResult } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryAD.kt ================================================ package org.koitharu.kotatsu.settings.storage import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ItemStorageBinding fun directoryAD( clickListener: OnListItemClickListener, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemStorageBinding.inflate(layoutInflater, parent, false) }, ) { binding.root.setOnClickListener { v -> clickListener.onItemClick(item, v) } bind { binding.textViewTitle.text = item.title ?: getString(item.titleRes) binding.textViewSubtitle.textAndVisible = item.file?.absolutePath binding.imageViewIndicator.isChecked = item.isChecked } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryDiffCallback.kt ================================================ package org.koitharu.kotatsu.settings.storage import androidx.recyclerview.widget.DiffUtil.ItemCallback class DirectoryDiffCallback : ItemCallback() { override fun areItemsTheSame(oldItem: DirectoryModel, newItem: DirectoryModel): Boolean { return oldItem.file == newItem.file } override fun areContentsTheSame(oldItem: DirectoryModel, newItem: DirectoryModel): Boolean { return oldItem == newItem } override fun getChangePayload(oldItem: DirectoryModel, newItem: DirectoryModel): Any? { return if (oldItem.isChecked != newItem.isChecked) { Unit } else { super.getChangePayload(oldItem, newItem) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryModel.kt ================================================ package org.koitharu.kotatsu.settings.storage import androidx.annotation.StringRes import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.model.ListModel import java.io.File data class DirectoryModel( val title: String?, @StringRes val titleRes: Int, val file: File?, val isRemovable: Boolean, val isChecked: Boolean, val isAvailable: Boolean, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is DirectoryModel && other.file == file && other.title == title && other.titleRes == titleRes } override fun getChangePayload(previousState: ListModel): Any? { return if (previousState is DirectoryModel && previousState.isChecked != isChecked) { ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED } else { super.getChangePayload(previousState) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectDialog.kt ================================================ package org.koitharu.kotatsu.settings.storage import android.Manifest import android.content.Intent import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.viewModels import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.ToastErrorObserver import org.koitharu.kotatsu.core.os.OpenDocumentTreeHelper import org.koitharu.kotatsu.core.ui.AlertDialogFragment import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener 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.databinding.DialogDirectorySelectBinding @AndroidEntryPoint class MangaDirectorySelectDialog : AlertDialogFragment(), OnListItemClickListener { private val viewModel: MangaDirectorySelectViewModel by viewModels() private val pickFileTreeLauncher = OpenDocumentTreeHelper( activityResultCaller = this, flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION, ) { if (it != null) viewModel.onCustomDirectoryPicked(it) } private val permissionRequestLauncher = registerForActivityResult( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { RequestStorageManagerPermissionContract() } else { ActivityResultContracts.RequestPermission() }, ) { if (it) { viewModel.refresh() if (!pickFileTreeLauncher.tryLaunch(null)) { Toast.makeText( context ?: return@registerForActivityResult, R.string.operation_not_supported, Toast.LENGTH_SHORT, ).show() } } } override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): DialogDirectorySelectBinding { return DialogDirectorySelectBinding.inflate(inflater, container, false) } override fun onViewBindingCreated(binding: DialogDirectorySelectBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) val adapter = AsyncListDifferDelegationAdapter(DirectoryDiffCallback(), directoryAD(this)) binding.root.adapter = adapter viewModel.items.observe(viewLifecycleOwner) { adapter.items = it } viewModel.onDismissDialog.observeEvent(viewLifecycleOwner) { dismiss() } viewModel.onPickDirectory.observeEvent(viewLifecycleOwner) { pickCustomDirectory() } viewModel.onError.observeEvent(viewLifecycleOwner, ToastErrorObserver(binding.root, this)) } override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { return super.onBuildDialog(builder) .setCancelable(true) .setTitle(R.string.manga_save_location) .setNegativeButton(android.R.string.cancel, null) } override fun onItemClick(item: DirectoryModel, view: View) { viewModel.onItemClick(item) } private fun pickCustomDirectory() { if (!permissionRequestLauncher.tryLaunch(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { Toast.makeText(context ?: return, R.string.operation_not_supported, Toast.LENGTH_SHORT).show() } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectViewModel.kt ================================================ package org.koitharu.kotatsu.settings.storage import android.net.Uri import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings 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.isWriteable import org.koitharu.kotatsu.local.data.LocalStorageManager import javax.inject.Inject @HiltViewModel class MangaDirectorySelectViewModel @Inject constructor( private val storageManager: LocalStorageManager, private val settings: AppSettings, ) : BaseViewModel() { val items = MutableStateFlow(emptyList()) val onDismissDialog = MutableEventFlow() val onPickDirectory = MutableEventFlow() init { refresh() } fun onItemClick(item: DirectoryModel) { if (item.file != null) { settings.mangaStorageDir = item.file onDismissDialog.call(Unit) } else { onPickDirectory.call(Unit) } } fun onCustomDirectoryPicked(uri: Uri) { launchJob(Dispatchers.Default) { storageManager.takePermissions(uri) val dir = storageManager.resolveUri(uri) if (!dir.isWriteable()) { throw AccessDeniedException(dir) } if (dir !in storageManager.getApplicationStorageDirs()) { settings.mangaStorageDir = dir storageManager.setDirIsNoMedia(dir) } onDismissDialog.call(Unit) } } fun refresh() { launchJob(Dispatchers.Default) { val defaultValue = storageManager.getDefaultWriteableDir() val available = storageManager.getWriteableDirs() items.value = buildList(available.size + 1) { available.mapTo(this) { dir -> DirectoryModel( title = storageManager.getDirectoryDisplayName(dir, isFullPath = false), titleRes = 0, file = dir, isChecked = dir == defaultValue, isAvailable = true, isRemovable = false, ) } this += DirectoryModel( title = null, titleRes = R.string.pick_custom_directory, file = null, isChecked = false, isAvailable = true, isRemovable = false, ) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/RequestStorageManagerPermissionContract.kt ================================================ package org.koitharu.kotatsu.settings.storage import android.content.Context import android.content.Intent import android.os.Build import android.os.Environment import android.provider.Settings import androidx.activity.result.contract.ActivityResultContract import androidx.annotation.RequiresApi import androidx.core.net.toUri @RequiresApi(Build.VERSION_CODES.R) class RequestStorageManagerPermissionContract : ActivityResultContract() { override fun createIntent(context: Context, input: String): Intent { val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) intent.addCategory("android.intent.category.DEFAULT") intent.data = "package:${context.packageName}".toUri() return intent } override fun parseResult(resultCode: Int, intent: Intent?): Boolean { return Environment.isExternalStorageManager() } override fun getSynchronousResult(context: Context, input: String): SynchronousResult? { return if (Environment.isExternalStorageManager()) { SynchronousResult(true) } else { null } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/DirectoryConfigAD.kt ================================================ package org.koitharu.kotatsu.settings.storage.directories import android.view.View import androidx.core.content.ContextCompat import androidx.core.text.bold import androidx.core.text.buildSpannedString import androidx.core.text.color import androidx.core.view.isGone import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.FileSize import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.setTooltipCompat import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ItemStorageConfig2Binding fun directoryConfigAD( clickListener: OnListItemClickListener, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemStorageConfig2Binding.inflate(layoutInflater, parent, false) }, ) { binding.buttonRemove.setOnClickListener { v -> clickListener.onItemClick(item, v) } binding.buttonRemove.setTooltipCompat(binding.buttonRemove.contentDescription) bind { binding.textViewTitle.text = item.title binding.textViewSubtitle.text = item.path.absolutePath binding.buttonRemove.isGone = item.isAppPrivate binding.buttonRemove.isEnabled = !item.isDefault binding.spacer.visibility = if (item.isAppPrivate) { View.INVISIBLE } else { View.GONE } binding.textViewInfo.textAndVisible = buildSpannedString { if (item.isDefault) { bold { append(getString(R.string.download_default_directory)) } } if (!item.isAccessible) { if (isNotEmpty()) appendLine() color( context.getThemeColor( androidx.appcompat.R.attr.colorError, ContextCompat.getColor(context, R.color.common_red), ), ) { append(getString(R.string.no_write_permission_to_file)) } } if (item.isAppPrivate) { if (isNotEmpty()) appendLine() append(getString(R.string.private_app_directory_warning)) } } binding.indicatorSize.max = FileSize.BYTES.convert(item.available, FileSize.KILOBYTES).toInt() binding.indicatorSize.progress = FileSize.BYTES.convert(item.size, FileSize.KILOBYTES).toInt() binding.textViewSize.text = context.getString( R.string.available_pattern, FileSize.BYTES.format(context, item.available), ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/DirectoryConfigDiffCallback.kt ================================================ package org.koitharu.kotatsu.settings.storage.directories import androidx.recyclerview.widget.DiffUtil.ItemCallback class DirectoryConfigDiffCallback : ItemCallback() { override fun areItemsTheSame(oldItem: DirectoryConfigModel, newItem: DirectoryConfigModel): Boolean { return oldItem.path == newItem.path } override fun areContentsTheSame(oldItem: DirectoryConfigModel, newItem: DirectoryConfigModel): Boolean { return oldItem == newItem } override fun getChangePayload(oldItem: DirectoryConfigModel, newItem: DirectoryConfigModel): Any? { return if (oldItem.isDefault != newItem.isDefault) { Unit } else { super.getChangePayload(oldItem, newItem) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/DirectoryConfigModel.kt ================================================ package org.koitharu.kotatsu.settings.storage.directories import org.koitharu.kotatsu.list.ui.model.ListModel import java.io.File data class DirectoryConfigModel( val title: String, val path: File, val isDefault: Boolean, val isAppPrivate: Boolean, val isAccessible: Boolean, val size: Long, val available: Long, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is DirectoryConfigModel && path == other.path } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesActivity.kt ================================================ package org.koitharu.kotatsu.settings.storage.directories import android.Manifest import android.content.Intent import android.os.Build import android.os.Bundle import android.view.View import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import com.google.android.material.snackbar.Snackbar import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.os.OpenDocumentTreeHelper import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration 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.tryLaunch import org.koitharu.kotatsu.databinding.ActivityMangaDirectoriesBinding import org.koitharu.kotatsu.settings.storage.RequestStorageManagerPermissionContract @AndroidEntryPoint class MangaDirectoriesActivity : BaseActivity(), OnListItemClickListener, View.OnClickListener { private val viewModel: MangaDirectoriesViewModel by viewModels() private val pickFileTreeLauncher = OpenDocumentTreeHelper( activityResultCaller = this, flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION, ) { if (it != null) viewModel.onCustomDirectoryPicked(it) } private val permissionRequestLauncher = registerForActivityResult( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { RequestStorageManagerPermissionContract() } else { ActivityResultContracts.RequestPermission() }, ) { if (it) { viewModel.updateList() if (!pickFileTreeLauncher.tryLaunch(null)) { Snackbar.make( viewBinding.recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT, ).show() } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityMangaDirectoriesBinding.inflate(layoutInflater)) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) val adapter = AsyncListDifferDelegationAdapter(DirectoryConfigDiffCallback(), directoryConfigAD(this)) val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing_large) viewBinding.recyclerView.adapter = adapter viewBinding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing, withBottomPadding = false)) viewBinding.fabAdd.setOnClickListener(this) viewModel.items.observe(this) { adapter.items = it } viewModel.isLoading.observe(this) { viewBinding.progressBar.isVisible = it } viewModel.onError.observeEvent( this, SnackbarErrorObserver(viewBinding.root, null, exceptionResolver) { if (it) viewModel.updateList() }, ) } override fun onItemClick(item: DirectoryConfigModel, view: View) { viewModel.onRemoveClick(item.path) } override fun onClick(v: View?) { if (!permissionRequestLauncher.tryLaunch(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { Snackbar.make( viewBinding.recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT, ).show() } } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val barsInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) viewBinding.fabAdd.updateLayoutParams { rightMargin = topMargin + barsInsets.right leftMargin = topMargin + barsInsets.left bottomMargin = topMargin + barsInsets.bottom } viewBinding.appbar.updatePadding( left = barsInsets.left, right = barsInsets.right, top = barsInsets.top, ) viewBinding.recyclerView.updatePadding( left = barsInsets.left, right = barsInsets.right, bottom = barsInsets.bottom, ) return insets.consumeAllSystemBarsInsets() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesViewModel.kt ================================================ package org.koitharu.kotatsu.settings.storage.directories import android.net.Uri import android.os.StatFs import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.computeSize import org.koitharu.kotatsu.core.util.ext.isReadable import org.koitharu.kotatsu.core.util.ext.isWriteable import org.koitharu.kotatsu.local.data.LocalStorageManager import java.io.File import javax.inject.Inject @HiltViewModel class MangaDirectoriesViewModel @Inject constructor( private val storageManager: LocalStorageManager, private val settings: AppSettings, ) : BaseViewModel() { val items = MutableStateFlow(emptyList()) private var loadingJob: Job? = null init { loadList() } fun updateList() { loadList() } fun onCustomDirectoryPicked(uri: Uri) { launchLoadingJob(Dispatchers.Default) { loadingJob?.cancelAndJoin() storageManager.takePermissions(uri) val dir = storageManager.resolveUri(uri) if (!dir.canRead()) { throw AccessDeniedException(dir) } if (dir !in storageManager.getApplicationStorageDirs()) { settings.userSpecifiedMangaDirectories += dir loadList() } } } fun onRemoveClick(directory: File) { settings.userSpecifiedMangaDirectories -= directory if (settings.mangaStorageDir == directory) { settings.mangaStorageDir = null } loadList() } private fun loadList() { val prevJob = loadingJob loadingJob = launchJob(Dispatchers.Default) { prevJob?.cancelAndJoin() val downloadDir = storageManager.getDefaultWriteableDir() val applicationDirs = storageManager.getApplicationStorageDirs() val customDirs = settings.userSpecifiedMangaDirectories - applicationDirs items.value = buildList(applicationDirs.size + customDirs.size) { applicationDirs.mapTo(this) { dir -> dir.toDirectoryModel( isDefault = dir == downloadDir, isAppPrivate = true, ) } customDirs.mapTo(this) { dir -> dir.toDirectoryModel( isDefault = dir == downloadDir, isAppPrivate = false, ) } } } } private suspend fun File.toDirectoryModel( isDefault: Boolean, isAppPrivate: Boolean, ) = DirectoryConfigModel( title = storageManager.getDirectoryDisplayName(this, isFullPath = false), path = this, isDefault = isDefault, isAccessible = isReadable() && isWriteable(), isAppPrivate = isAppPrivate, size = computeSize(), available = StatFs(this.absolutePath).availableBytes, ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt ================================================ package org.koitharu.kotatsu.settings.tracker import android.content.ActivityNotFoundException import android.content.Intent import android.content.SharedPreferences import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.Settings import android.text.style.URLSpan import android.view.View import androidx.core.text.buildSpannedString import androidx.core.text.inSpans import androidx.fragment.app.viewModels import androidx.preference.ListPreference import androidx.preference.MultiSelectListPreference import androidx.preference.Preference import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.TrackerDownloadStrategy import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat import org.koitharu.kotatsu.parsers.util.names import org.koitharu.kotatsu.settings.utils.DozeHelper import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider import org.koitharu.kotatsu.tracker.ui.debug.TrackerDebugActivity import org.koitharu.kotatsu.tracker.work.TrackerNotificationHelper import javax.inject.Inject @AndroidEntryPoint class TrackerSettingsFragment : BasePreferenceFragment(R.string.check_for_new_chapters), SharedPreferences.OnSharedPreferenceChangeListener { private val viewModel by viewModels() private val dozeHelper = DozeHelper(this) @Inject lateinit var notificationHelper: TrackerNotificationHelper override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_tracker) findPreference(AppSettings.KEY_TRACK_SOURCES) ?.summaryProvider = MultiSummaryProvider(R.string.dont_check) val warningPreference = findPreference(AppSettings.KEY_TRACK_WARNING) if (warningPreference != null) { warningPreference.summary = buildSpannedString { append(getString(R.string.tracker_warning)) append(" ") inSpans(URLSpan("https://dontkillmyapp.com/")) { append(getString(R.string.read_more)) } } } findPreference(AppSettings.KEY_TRACKER_DOWNLOAD)?.run { entryValues = TrackerDownloadStrategy.entries.names() setDefaultValueCompat(TrackerDownloadStrategy.DISABLED.name) } dozeHelper.updatePreference() updateCategoriesEnabled() } override fun onResume() { super.onResume() updateNotificationsSummary() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) settings.subscribe(this) viewModel.categoriesCount.observe(viewLifecycleOwner, ::onCategoriesCountChanged) } override fun onDestroyView() { settings.unsubscribe(this) super.onDestroyView() } override fun onSharedPreferenceChanged(prefs: SharedPreferences, key: String?) { when (key) { AppSettings.KEY_TRACKER_NOTIFICATIONS -> updateNotificationsSummary() AppSettings.KEY_TRACK_SOURCES, AppSettings.KEY_TRACKER_ENABLED -> updateCategoriesEnabled() } } override fun onPreferenceTreeClick(preference: Preference): Boolean { return when (preference.key) { AppSettings.KEY_NOTIFICATIONS_SETTINGS -> when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> { val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) .putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName) startActivitySafe(intent) true } !notificationHelper.getAreNotificationsEnabled() -> { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) .setData(Uri.fromParts("package", requireContext().packageName, null)) startActivitySafe(intent) true } else -> super.onPreferenceTreeClick(preference) } AppSettings.KEY_TRACK_CATEGORIES -> { router.showTrackerCategoriesConfigSheet() true } AppSettings.KEY_IGNORE_DOZE -> { dozeHelper.startIgnoreDoseActivity() true } AppSettings.KEY_TRACKER_DEBUG -> { startActivity(Intent(preference.context, TrackerDebugActivity::class.java)) true } else -> super.onPreferenceTreeClick(preference) } } private fun updateNotificationsSummary() { val pref = findPreference(AppSettings.KEY_NOTIFICATIONS_SETTINGS) ?: return pref.setSummary( when { notificationHelper.getAreNotificationsEnabled() -> R.string.show_notification_new_chapters_on else -> R.string.show_notification_new_chapters_off }, ) } private fun updateCategoriesEnabled() { val pref = findPreference(AppSettings.KEY_TRACK_CATEGORIES) ?: return pref.isEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources } private fun onCategoriesCountChanged(count: IntArray?) { val pref = findPreference(AppSettings.KEY_TRACK_CATEGORIES) ?: return pref.summary = count?.let { getString(R.string.enabled_d_of_d, count[0], count[1]) } } private fun startActivitySafe(intent: Intent): Boolean = try { startActivity(intent) true } catch (_: ActivityNotFoundException) { Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() false } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsViewModel.kt ================================================ package org.koitharu.kotatsu.settings.tracker import androidx.room.InvalidationTracker import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import okio.Closeable import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES import org.koitharu.kotatsu.core.db.removeObserverAsync import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.tracker.domain.TrackingRepository import javax.inject.Inject @HiltViewModel class TrackerSettingsViewModel @Inject constructor( private val repository: TrackingRepository, private val database: MangaDatabase, ) : BaseViewModel() { val categoriesCount = MutableStateFlow(null) init { updateCategoriesCount() val databaseObserver = DatabaseObserver(this) addCloseable(databaseObserver) launchJob(Dispatchers.Default) { database.invalidationTracker.addObserver(databaseObserver) } } private fun updateCategoriesCount() { launchJob(Dispatchers.Default) { categoriesCount.value = repository.getCategoriesCount() } } private class DatabaseObserver(private var vm: TrackerSettingsViewModel?) : InvalidationTracker.Observer(arrayOf(TABLE_FAVOURITE_CATEGORIES)), Closeable { override fun onInvalidated(tables: Set) { vm?.updateCategoriesCount() } override fun close() { (vm ?: return).database.invalidationTracker.removeObserverAsync(this) vm = null } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigAdapter.kt ================================================ package org.koitharu.kotatsu.settings.tracker.categories import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener class TrackerCategoriesConfigAdapter( listener: OnListItemClickListener, ) : BaseListAdapter() { init { delegatesManager.addDelegate(trackerCategoryAD(listener)) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigSheet.kt ================================================ package org.koitharu.kotatsu.settings.tracker.categories import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import androidx.fragment.app.viewModels import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.util.ext.consume import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.databinding.SheetBaseBinding @AndroidEntryPoint class TrackerCategoriesConfigSheet : BaseAdaptiveSheet(), OnListItemClickListener { private val viewModel by viewModels() override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetBaseBinding { return SheetBaseBinding.inflate(inflater, container, false) } override fun onViewBindingCreated(binding: SheetBaseBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) binding.headerBar.setTitle(R.string.favourites_categories) val adapter = TrackerCategoriesConfigAdapter(this) binding.recyclerView.adapter = adapter viewModel.content.observe(viewLifecycleOwner, adapter) } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val typeMask = WindowInsetsCompat.Type.systemBars() viewBinding?.recyclerView?.updatePadding( bottom = insets.getInsets(typeMask).bottom, ) return insets.consume(v, typeMask, bottom = true) } override fun onItemClick(item: FavouriteCategory, view: View) { viewModel.toggleItem(item) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigViewModel.kt ================================================ package org.koitharu.kotatsu.settings.tracker.categories import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import javax.inject.Inject @HiltViewModel class TrackerCategoriesConfigViewModel @Inject constructor( private val favouritesRepository: FavouritesRepository, ) : BaseViewModel() { val content = favouritesRepository.observeCategories() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) private var updateJob: Job? = null fun toggleItem(category: FavouriteCategory) { val prevJob = updateJob updateJob = launchJob(Dispatchers.Default) { prevJob?.join() favouritesRepository.updateCategoryTracking(category.id, !category.isTrackingEnabled) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoryAD.kt ================================================ package org.koitharu.kotatsu.settings.tracker.categories import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.databinding.ItemCategoryCheckableMultipleBinding fun trackerCategoryAD( listener: OnListItemClickListener, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemCategoryCheckableMultipleBinding.inflate(layoutInflater, parent, false) }, ) { val eventListener = AdapterDelegateClickListenerAdapter(this, listener) itemView.setOnClickListener(eventListener) bind { binding.root.text = item.title binding.root.isChecked = item.isTrackingEnabled } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/BackupsSettingsFragment.kt ================================================ package org.koitharu.kotatsu.settings.userdata import android.net.Uri import android.os.Bundle import android.view.View import androidx.activity.result.ActivityResultCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.viewModels import androidx.preference.Preference import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.backups.domain.BackupUtils import org.koitharu.kotatsu.backups.ui.backup.BackupService import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.nav.router 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 @AndroidEntryPoint class BackupsSettingsFragment : BasePreferenceFragment(R.string.backup_restore), ActivityResultCallback { private val viewModel: BackupsSettingsViewModel by viewModels() private val backupSelectCall = registerForActivityResult( ActivityResultContracts.OpenDocument(), this, ) private val backupCreateCall = registerForActivityResult( ActivityResultContracts.CreateDocument("application/zip"), ) { uri -> if (uri != null) { if (!BackupService.start(requireContext(), uri)) { Snackbar.make( listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT, ).show() } } } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_backups) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) bindPeriodicalBackupSummary() viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this)) } override fun onPreferenceTreeClick(preference: Preference): Boolean { return when (preference.key) { AppSettings.KEY_BACKUP -> { if (!backupCreateCall.tryLaunch(BackupUtils.generateFileName(preference.context))) { Snackbar.make( listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT, ).show() } true } AppSettings.KEY_RESTORE -> { if (!backupSelectCall.tryLaunch(arrayOf("*/*"))) { Snackbar.make( listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT, ).show() } true } else -> super.onPreferenceTreeClick(preference) } } override fun onActivityResult(result: Uri?) { if (result != null) { router.showBackupRestoreDialog(result) } } private fun bindPeriodicalBackupSummary() { val preference = findPreference(AppSettings.KEY_BACKUP_PERIODICAL_ENABLED) ?: return val entries = resources.getStringArray(R.array.backup_frequency) val entryValues = resources.getStringArray(R.array.values_backup_frequency) viewModel.periodicalBackupFrequency.observe(viewLifecycleOwner) { freq -> preference.summary = if (freq == 0L) { getString(R.string.disabled) } else { val index = entryValues.indexOf(freq.toString()) entries.getOrNull(index) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/BackupsSettingsViewModel.kt ================================================ package org.koitharu.kotatsu.settings.userdata import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.ui.BaseViewModel import javax.inject.Inject @HiltViewModel class BackupsSettingsViewModel @Inject constructor( private val settings: AppSettings, ) : BaseViewModel() { val periodicalBackupFrequency = settings.observeAsFlow( key = AppSettings.KEY_BACKUP_PERIODICAL_ENABLED, valueProducer = { isPeriodicalBackupEnabled }, ).flatMapLatest { isEnabled -> if (isEnabled) { settings.observeAsFlow( key = AppSettings.KEY_BACKUP_PERIODICAL_FREQUENCY, valueProducer = { periodicalBackupFrequency }, ) } else { flowOf(0) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/DataCleanupSettingsFragment.kt ================================================ package org.koitharu.kotatsu.settings.userdata.storage import android.os.Bundle import android.view.View import androidx.fragment.app.viewModels import androidx.preference.Preference import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.StateFlow import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.util.FileSize import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.local.data.CacheDir @AndroidEntryPoint class DataCleanupSettingsFragment : BasePreferenceFragment(R.string.data_removal) { private val viewModel by viewModels() private val loadingPrefs = HashSet() override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_data_cleanup) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) findPreference(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.PAGES])) findPreference(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.THUMBS])) findPreference(AppSettings.KEY_HTTP_CACHE_CLEAR)?.bindBytesSizeSummary(viewModel.httpCacheSize) findPreference(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref -> viewModel.searchHistoryCount.observe(viewLifecycleOwner) { pref.summary = if (it < 0) { view.context.getString(R.string.loading_) } else { pref.context.resources.getQuantityStringSafe(R.plurals.items, it, it) } } } findPreference(AppSettings.KEY_UPDATES_FEED_CLEAR)?.let { pref -> viewModel.feedItemsCount.observe(viewLifecycleOwner) { pref.summary = if (it < 0) { view.context.getString(R.string.loading_) } else { pref.context.resources.getQuantityStringSafe(R.plurals.items, it, it) } } } findPreference(AppSettings.KEY_WEBVIEW_CLEAR)?.isVisible = viewModel.isBrowserDataCleanupEnabled viewModel.loadingKeys.observe(viewLifecycleOwner) { keys -> loadingPrefs.addAll(keys) loadingPrefs.forEach { prefKey -> findPreference(prefKey)?.isEnabled = prefKey !in keys } } viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this)) viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView)) viewModel.onChaptersCleanedUp.observeEvent(viewLifecycleOwner, ::onChaptersCleanedUp) } override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) { AppSettings.KEY_COOKIES_CLEAR -> { clearCookies() true } AppSettings.KEY_SEARCH_HISTORY_CLEAR -> { clearSearchHistory() true } AppSettings.KEY_PAGES_CACHE_CLEAR -> { viewModel.clearCache(preference.key, CacheDir.PAGES) true } AppSettings.KEY_THUMBS_CACHE_CLEAR -> { viewModel.clearCache(preference.key, CacheDir.THUMBS, CacheDir.FAVICONS) true } AppSettings.KEY_HTTP_CACHE_CLEAR -> { viewModel.clearHttpCache() true } AppSettings.KEY_CHAPTERS_CLEAR -> { cleanupChapters() true } AppSettings.KEY_WEBVIEW_CLEAR -> { viewModel.clearBrowserData() true } AppSettings.KEY_CLEAR_MANGA_DATA -> { viewModel.clearMangaData() true } AppSettings.KEY_UPDATES_FEED_CLEAR -> { viewModel.clearUpdatesFeed() true } else -> super.onPreferenceTreeClick(preference) } private fun onChaptersCleanedUp(result: Pair) { val c = context ?: return val text = if (result.first == 0 && result.second == 0L) { c.getString(R.string.no_chapters_deleted) } else { c.getString( R.string.chapters_deleted_pattern, c.resources.getQuantityStringSafe(R.plurals.chapters, result.first, result.first), FileSize.BYTES.format(c, result.second), ) } Snackbar.make(listView, text, Snackbar.LENGTH_SHORT).show() } private fun Preference.bindBytesSizeSummary(stateFlow: StateFlow) { stateFlow.observe(viewLifecycleOwner) { size -> summary = if (size < 0) { context.getString(R.string.computing_) } else { FileSize.BYTES.format(context, size) } } } private fun clearSearchHistory() { buildAlertDialog(context ?: return) { setTitle(R.string.clear_search_history) setMessage(R.string.text_clear_search_history_prompt) setNegativeButton(android.R.string.cancel, null) setPositiveButton(R.string.clear) { _, _ -> viewModel.clearSearchHistory() } }.show() } private fun clearCookies() { buildAlertDialog(context ?: return) { setTitle(R.string.clear_cookies) setMessage(R.string.text_clear_cookies_prompt) setNegativeButton(android.R.string.cancel, null) setPositiveButton(R.string.clear) { _, _ -> viewModel.clearCookies() } }.show() } private fun cleanupChapters() { buildAlertDialog(context ?: return) { setTitle(R.string.delete_read_chapters) setMessage(R.string.delete_read_chapters_prompt) setNegativeButton(android.R.string.cancel, null) setPositiveButton(R.string.delete) { _, _ -> viewModel.cleanupChapters() } }.show() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/DataCleanupSettingsViewModel.kt ================================================ package org.koitharu.kotatsu.settings.userdata.storage import android.annotation.SuppressLint import android.webkit.WebStorage import androidx.webkit.WebStorageCompat import androidx.webkit.WebViewFeature import coil3.ImageLoader import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.runInterruptible import okhttp3.Cache import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.parser.MangaDataRepository 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.local.data.CacheDir import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.domain.DeleteReadChaptersUseCase import org.koitharu.kotatsu.search.domain.MangaSearchRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository import java.util.EnumMap import javax.inject.Inject import javax.inject.Provider import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine @HiltViewModel class DataCleanupSettingsViewModel @Inject constructor( private val storageManager: LocalStorageManager, private val httpCache: Cache, private val searchRepository: MangaSearchRepository, private val trackingRepository: TrackingRepository, private val cookieJar: MutableCookieJar, private val deleteReadChaptersUseCase: DeleteReadChaptersUseCase, private val mangaDataRepositoryProvider: Provider, private val coil: ImageLoader, ) : BaseViewModel() { val onActionDone = MutableEventFlow() val loadingKeys = MutableStateFlow(emptySet()) val searchHistoryCount = MutableStateFlow(-1) val feedItemsCount = MutableStateFlow(-1) val httpCacheSize = MutableStateFlow(-1L) val cacheSizes = EnumMap>(CacheDir::class.java) val onChaptersCleanedUp = MutableEventFlow>() val isBrowserDataCleanupEnabled: Boolean get() = WebViewFeature.isFeatureSupported(WebViewFeature.DELETE_BROWSING_DATA) init { CacheDir.entries.forEach { cacheSizes[it] = MutableStateFlow(-1L) } launchJob(Dispatchers.Default) { searchHistoryCount.value = searchRepository.getSearchHistoryCount() } launchJob(Dispatchers.Default) { feedItemsCount.value = trackingRepository.getLogsCount() } CacheDir.entries.forEach { cache -> launchJob(Dispatchers.Default) { checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache) } } launchJob(Dispatchers.Default) { httpCacheSize.value = runInterruptible { httpCache.size() } } } fun clearCache(key: String, vararg caches: CacheDir) { launchJob(Dispatchers.Default) { try { loadingKeys.update { it + key } for (cache in caches) { storageManager.clearCache(cache) checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache) if (cache == CacheDir.THUMBS) { coil.memoryCache?.clear() } } } finally { loadingKeys.update { it - key } } } } fun clearHttpCache() { launchJob(Dispatchers.Default) { try { loadingKeys.update { it + AppSettings.KEY_HTTP_CACHE_CLEAR } val size = runInterruptible(Dispatchers.IO) { httpCache.evictAll() httpCache.size() } httpCacheSize.value = size } finally { loadingKeys.update { it - AppSettings.KEY_HTTP_CACHE_CLEAR } } } } fun clearSearchHistory() { launchJob(Dispatchers.Default) { searchRepository.clearSearchHistory() searchHistoryCount.value = searchRepository.getSearchHistoryCount() onActionDone.call(ReversibleAction(R.string.search_history_cleared, null)) } } fun clearCookies() { launchJob { cookieJar.clear() onActionDone.call(ReversibleAction(R.string.cookies_cleared, null)) } } @SuppressLint("RequiresFeature") fun clearBrowserData() { launchJob { try { loadingKeys.update { it + AppSettings.KEY_WEBVIEW_CLEAR } val storage = WebStorage.getInstance() suspendCoroutine { cont -> WebStorageCompat.deleteBrowsingData(storage) { cont.resume(Unit) } } onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null)) } finally { loadingKeys.update { it - AppSettings.KEY_WEBVIEW_CLEAR } } } } fun clearUpdatesFeed() { launchJob(Dispatchers.Default) { try { loadingKeys.update { it + AppSettings.KEY_UPDATES_FEED_CLEAR } trackingRepository.clearLogs() feedItemsCount.value = trackingRepository.getLogsCount() onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null)) } finally { loadingKeys.update { it - AppSettings.KEY_UPDATES_FEED_CLEAR } } } } fun clearMangaData() { launchJob(Dispatchers.Default) { try { loadingKeys.update { it + AppSettings.KEY_CLEAR_MANGA_DATA } trackingRepository.gc() val repository = mangaDataRepositoryProvider.get() repository.cleanupLocalManga() repository.cleanupDatabase() onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null)) } finally { loadingKeys.update { it - AppSettings.KEY_CLEAR_MANGA_DATA } } } } fun cleanupChapters() { launchJob(Dispatchers.Default) { try { loadingKeys.update { it + AppSettings.KEY_CHAPTERS_CLEAR } val oldSize = storageManager.computeStorageSize() val chaptersCount = deleteReadChaptersUseCase.invoke() val newSize = storageManager.computeStorageSize() onChaptersCleanedUp.call(chaptersCount to oldSize - newSize) } finally { loadingKeys.update { it - AppSettings.KEY_CHAPTERS_CLEAR } } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageUsage.kt ================================================ package org.koitharu.kotatsu.settings.userdata.storage data class StorageUsage( val savedManga: Item, val pagesCache: Item, val otherCache: Item, val available: Item, ) { data class Item( val bytes: Long, val percent: Float, ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageUsagePreference.kt ================================================ package org.koitharu.kotatsu.settings.userdata.storage import android.content.Context import android.content.res.ColorStateList import android.graphics.Color import android.util.AttributeSet import androidx.annotation.StringRes import androidx.core.widget.TextViewCompat import androidx.preference.Preference import androidx.preference.PreferenceViewHolder import kotlinx.coroutines.flow.FlowCollector import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView import org.koitharu.kotatsu.core.util.FileSize import org.koitharu.kotatsu.core.util.KotatsuColors import org.koitharu.kotatsu.databinding.PreferenceMemoryUsageBinding class StorageUsagePreference @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, ) : Preference(context, attrs), FlowCollector { private val labelPattern = context.getString(R.string.memory_usage_pattern) private var usage: StorageUsage? = null init { layoutResource = R.layout.preference_memory_usage isSelectable = false isPersistent = false } override fun onBindViewHolder(holder: PreferenceViewHolder) { super.onBindViewHolder(holder) val binding = PreferenceMemoryUsageBinding.bind(holder.itemView) val storageSegment = SegmentedBarView.Segment( usage?.savedManga?.percent ?: 0f, KotatsuColors.segmentColorRandom(context, Color.BLUE), ) val pagesSegment = SegmentedBarView.Segment( usage?.pagesCache?.percent ?: 0f, KotatsuColors.segmentColorRandom(context, Color.GREEN), ) val otherSegment = SegmentedBarView.Segment( usage?.otherCache?.percent ?: 0f, KotatsuColors.segmentColorRandom(context, Color.GRAY), ) with(binding) { bar.animateSegments(listOf(storageSegment, pagesSegment, otherSegment).filter { it.percent > 0f }) labelStorage.text = formatLabel(usage?.savedManga, R.string.saved_manga) labelPagesCache.text = formatLabel(usage?.pagesCache, R.string.pages_cache) labelOtherCache.text = formatLabel(usage?.otherCache, R.string.other_cache) labelAvailable.text = formatLabel(usage?.available, R.string.available, R.string.available) TextViewCompat.setCompoundDrawableTintList(labelStorage, ColorStateList.valueOf(storageSegment.color)) TextViewCompat.setCompoundDrawableTintList(labelPagesCache, ColorStateList.valueOf(pagesSegment.color)) TextViewCompat.setCompoundDrawableTintList(labelOtherCache, ColorStateList.valueOf(otherSegment.color)) } } override suspend fun emit(value: StorageUsage?) { usage = value notifyChanged() } private fun formatLabel( item: StorageUsage.Item?, @StringRes labelResId: Int, @StringRes emptyResId: Int = R.string.computing_, ): String { return if (item != null) { labelPattern.format( FileSize.BYTES.format(context, item.bytes), context.getString(labelResId), ) } else { context.getString(emptyResId) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/ActivityListPreference.kt ================================================ package org.koitharu.kotatsu.settings.utils import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.util.AttributeSet import androidx.preference.ListPreference import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug @Suppress("unused") class ActivityListPreference : ListPreference { var activityIntent: Intent? = null constructor( context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int ) : super(context, attrs, defStyleAttr, defStyleRes) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context) : super(context) override fun onClick() { val intent = activityIntent if (intent == null) { super.onClick() return } try { context.startActivity(intent) } catch (e: ActivityNotFoundException) { e.printStackTraceDebug() super.onClick() } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/AutoCompleteTextViewPreference.kt ================================================ package org.koitharu.kotatsu.settings.utils import android.content.Context import android.util.AttributeSet import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter import android.widget.AutoCompleteTextView import android.widget.EditText import androidx.annotation.ArrayRes import androidx.annotation.AttrRes import androidx.annotation.StyleRes import androidx.core.content.withStyledAttributes import androidx.preference.EditTextPreference import org.koitharu.kotatsu.R class AutoCompleteTextViewPreference @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = R.attr.autoCompleteTextViewPreferenceStyle, @StyleRes defStyleRes: Int = R.style.Preference_AutoCompleteTextView, ) : EditTextPreference(context, attrs, defStyleAttr, defStyleRes) { private val autoCompleteBindListener = AutoCompleteBindListener() var entries: Array = emptyArray() init { super.setOnBindEditTextListener(autoCompleteBindListener) context.withStyledAttributes(attrs, R.styleable.AutoCompleteTextViewPreference, defStyleAttr, defStyleRes) { val entriesId = getResourceId(R.styleable.AutoCompleteTextViewPreference_android_entries, 0) if (entriesId != 0) { setEntries(entriesId) } } } fun setEntries(@ArrayRes arrayResId: Int) { this.entries = context.resources.getStringArray(arrayResId) } fun setEntries(entries: Collection) { this.entries = entries.toTypedArray() } override fun setOnBindEditTextListener(onBindEditTextListener: OnBindEditTextListener?) { autoCompleteBindListener.delegate = onBindEditTextListener } private inner class AutoCompleteBindListener : OnBindEditTextListener { var delegate: OnBindEditTextListener? = null override fun onBindEditText(editText: EditText) { delegate?.onBindEditText(editText) if (editText !is AutoCompleteTextView || entries.isEmpty()) { return } editText.threshold = 0 editText.setAdapter(ArrayAdapter(editText.context, android.R.layout.simple_spinner_dropdown_item, entries)) (editText.parent as? ViewGroup)?.findViewById(R.id.dropdown)?.setOnClickListener { editText.showDropDown() } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/DozeHelper.kt ================================================ package org.koitharu.kotatsu.settings.utils import android.annotation.SuppressLint import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.os.PowerManager import android.provider.Settings import androidx.activity.result.contract.ActivityResultContracts import androidx.core.net.toUri import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import com.google.android.material.snackbar.Snackbar import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.powerManager @SuppressLint("BatteryLife") class DozeHelper( private val fragment: PreferenceFragmentCompat, ) { private val startForDozeResult = fragment.registerForActivityResult( ActivityResultContracts.StartActivityForResult(), ) { updatePreference() } fun updatePreference() { val preference = fragment.findPreference(AppSettings.KEY_IGNORE_DOZE) ?: return preference.isVisible = isDozeIgnoreAvailable() } fun startIgnoreDoseActivity(): Boolean { val context = fragment.context ?: return false val packageName = context.packageName val powerManager = context.powerManager ?: return false return if (!powerManager.isIgnoringBatteryOptimizations(packageName)) { try { val intent = Intent( Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, "package:$packageName".toUri(), ) startForDozeResult.launch(intent) true } catch (e: ActivityNotFoundException) { Snackbar.make(fragment.listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() false } } else { false } } private fun isDozeIgnoreAvailable(): Boolean { val context = fragment.context ?: return false val packageName = context.packageName val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager return !powerManager.isIgnoringBatteryOptimizations(packageName) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextBindListener.kt ================================================ package org.koitharu.kotatsu.settings.utils import android.widget.EditText import androidx.preference.EditTextPreference import org.koitharu.kotatsu.core.util.EditTextValidator class EditTextBindListener( private val inputType: Int, private val hint: String?, private val validator: EditTextValidator?, ) : EditTextPreference.OnBindEditTextListener { override fun onBindEditText(editText: EditText) { editText.inputType = inputType editText.hint = hint validator?.attachToEditText(editText) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextDefaultSummaryProvider.kt ================================================ package org.koitharu.kotatsu.settings.utils import androidx.preference.EditTextPreference import androidx.preference.Preference import org.koitharu.kotatsu.R import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty class EditTextDefaultSummaryProvider( private val defaultValue: String, ) : Preference.SummaryProvider { override fun provideSummary( preference: EditTextPreference, ): CharSequence = preference.text.ifNullOrEmpty { preference.context.getString(R.string.default_s, defaultValue) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextFallbackSummaryProvider.kt ================================================ package org.koitharu.kotatsu.settings.utils import androidx.annotation.StringRes import androidx.preference.EditTextPreference import androidx.preference.Preference import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty class EditTextFallbackSummaryProvider( @StringRes private val fallbackResId: Int, ) : Preference.SummaryProvider { override fun provideSummary( preference: EditTextPreference, ): CharSequence = preference.text.ifNullOrEmpty { preference.context.getString(fallbackResId) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/LinksPreference.kt ================================================ package org.koitharu.kotatsu.settings.utils import android.content.Context import android.util.AttributeSet import android.widget.TextView import androidx.core.text.method.LinkMovementMethodCompat import androidx.preference.Preference import androidx.preference.PreferenceViewHolder class LinksPreference @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = androidx.preference.R.attr.preferenceStyle, defStyleRes: Int = 0, ) : Preference(context, attrs, defStyleAttr, defStyleRes) { override fun onBindViewHolder(holder: PreferenceViewHolder) { super.onBindViewHolder(holder) val summaryView = holder.findViewById(android.R.id.summary) as TextView summaryView.movementMethod = LinkMovementMethodCompat.getInstance() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/MultiAutoCompleteTextViewPreference.kt ================================================ package org.koitharu.kotatsu.settings.utils import android.content.Context import android.util.AttributeSet import android.widget.ArrayAdapter import android.widget.EditText import android.widget.Filter import android.widget.MultiAutoCompleteTextView import androidx.annotation.AttrRes import androidx.annotation.MainThread import androidx.annotation.StyleRes import androidx.annotation.WorkerThread import androidx.preference.EditTextPreference import kotlinx.coroutines.runBlocking import org.koitharu.kotatsu.R import org.koitharu.kotatsu.parsers.util.replaceWith class MultiAutoCompleteTextViewPreference @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = R.attr.multiAutoCompleteTextViewPreferenceStyle, @StyleRes defStyleRes: Int = R.style.Preference_MultiAutoCompleteTextView, ) : EditTextPreference(context, attrs, defStyleAttr, defStyleRes) { private val autoCompleteBindListener = AutoCompleteBindListener() var autoCompleteProvider: AutoCompleteProvider? = null init { super.setOnBindEditTextListener(autoCompleteBindListener) } override fun setOnBindEditTextListener(onBindEditTextListener: OnBindEditTextListener?) { autoCompleteBindListener.delegate = onBindEditTextListener } private inner class AutoCompleteBindListener : OnBindEditTextListener { var delegate: OnBindEditTextListener? = null override fun onBindEditText(editText: EditText) { delegate?.onBindEditText(editText) if (editText !is MultiAutoCompleteTextView) { return } editText.setTokenizer(MultiAutoCompleteTextView.CommaTokenizer()) editText.setAdapter( autoCompleteProvider?.let { CompletionAdapter(editText.context, it, ArrayList()) } ) editText.threshold = 1 } } interface AutoCompleteProvider { suspend fun getSuggestions(query: String): List } class SimpleSummaryProvider( private val emptySummary: CharSequence?, ) : SummaryProvider { override fun provideSummary(preference: MultiAutoCompleteTextViewPreference): CharSequence? { return if (preference.text.isNullOrEmpty()) { emptySummary } else { preference.text?.trimEnd(' ', ',') } } } private class CompletionAdapter( context: Context, private val completionProvider: AutoCompleteProvider, private val dataset: MutableList, ) : ArrayAdapter(context, android.R.layout.simple_dropdown_item_1line, dataset) { override fun getFilter(): Filter { return CompletionFilter(this, completionProvider) } fun publishResults(results: List) { dataset.replaceWith(results) notifyDataSetChanged() } } private class CompletionFilter( private val adapter: CompletionAdapter, private val provider: AutoCompleteProvider, ) : Filter() { @WorkerThread override fun performFiltering(constraint: CharSequence?): FilterResults { val query = constraint?.toString().orEmpty() val suggestions = runBlocking { provider.getSuggestions(query) } return CompletionResults(suggestions) } @MainThread override fun publishResults(constraint: CharSequence?, results: FilterResults) { val completions = (results as CompletionResults).completions adapter.publishResults(completions) } private class CompletionResults( val completions: List, ) : FilterResults() { init { values = completions count = completions.size } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/MultiSummaryProvider.kt ================================================ package org.koitharu.kotatsu.settings.utils import androidx.annotation.StringRes import androidx.preference.MultiSelectListPreference import androidx.preference.Preference class MultiSummaryProvider(@StringRes private val emptySummaryId: Int) : Preference.SummaryProvider { override fun provideSummary(preference: MultiSelectListPreference): CharSequence { val values = preference.values return if (values.isEmpty()) { return preference.context.getString(emptySummaryId) } else { values.joinToString(", ") { preference.entries.getOrNull(preference.findIndexOfValue(it)) ?: preference.context.getString(androidx.preference.R.string.not_set) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/PasswordSummaryProvider.kt ================================================ package org.koitharu.kotatsu.settings.utils import android.text.TextUtils import androidx.preference.EditTextPreference import androidx.preference.Preference class PasswordSummaryProvider : Preference.SummaryProvider { private val delegate = EditTextPreference.SimpleSummaryProvider.getInstance() override fun provideSummary(preference: EditTextPreference): CharSequence? { val summary = delegate.provideSummary(preference) return if (summary != null && !TextUtils.isEmpty(preference.text)) { String(CharArray(summary.length) { '\u2022' }) } else { summary } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/PercentSummaryProvider.kt ================================================ package org.koitharu.kotatsu.settings.utils import androidx.preference.Preference import org.koitharu.kotatsu.R class PercentSummaryProvider : Preference.SummaryProvider { private var percentPattern: String? = null override fun provideSummary(preference: SliderPreference): CharSequence { val pattern = percentPattern ?: preference.context.getString(R.string.percent_string_pattern).also { percentPattern = it } return pattern.format(preference.value.toString()) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/RingtonePickContract.kt ================================================ package org.koitharu.kotatsu.settings.utils import android.content.Context import android.content.Intent import android.media.RingtoneManager import android.net.Uri import android.provider.Settings import androidx.activity.result.contract.ActivityResultContract import androidx.annotation.StringRes import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat class RingtonePickContract(@StringRes private val titleResId: Int) : ActivityResultContract() { override fun createIntent(context: Context, input: Uri?): Intent { val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER) intent.putExtra( RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION, ) intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true) intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true) intent.putExtra( RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, Settings.System.DEFAULT_NOTIFICATION_URI, ) if (titleResId != 0) { intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, context.getString(titleResId)) } intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, input) return intent } override fun parseResult(resultCode: Int, intent: Intent?): Uri? { return intent?.getParcelableExtraCompat(RingtoneManager.EXTRA_RINGTONE_PICKED_URI) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/SliderPreference.kt ================================================ package org.koitharu.kotatsu.settings.utils import android.content.Context import android.content.res.TypedArray import android.os.Parcel import android.os.Parcelable import android.util.AttributeSet import androidx.core.content.withStyledAttributes import androidx.customview.view.AbsSavedState import androidx.preference.Preference import androidx.preference.PreferenceViewHolder import com.google.android.material.slider.Slider import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.setValueRounded class SliderPreference @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = R.attr.sliderPreferenceStyle, defStyleRes: Int = R.style.Preference_Slider, ) : Preference(context, attrs, defStyleAttr, defStyleRes) { private var valueFrom: Int = 0 private var valueTo: Int = 100 private var stepSize: Int = 1 private var currentValue: Int = 0 private var isTickVisible: Boolean = false var value: Int get() = currentValue set(value) = setValueInternal(value, notifyChanged = true) private val sliderListener = Slider.OnChangeListener { _, value, fromUser -> if (fromUser) { syncValueInternal(value.toInt()) } } init { context.withStyledAttributes( attrs, R.styleable.SliderPreference, defStyleAttr, defStyleRes, ) { valueFrom = getFloat( R.styleable.SliderPreference_android_valueFrom, valueFrom.toFloat(), ).toInt() valueTo = getFloat(R.styleable.SliderPreference_android_valueTo, valueTo.toFloat()).toInt() stepSize = getFloat(R.styleable.SliderPreference_android_stepSize, stepSize.toFloat()).toInt() isTickVisible = getBoolean(R.styleable.SliderPreference_tickVisible, isTickVisible) if (getBoolean(R.styleable.SliderPreference_useSimpleSummaryProvider, false)) { summaryProvider = SimpleSummaryProvider } } } override fun onBindViewHolder(holder: PreferenceViewHolder) { super.onBindViewHolder(holder) val slider = holder.findViewById(R.id.slider) as? Slider ?: return slider.removeOnChangeListener(sliderListener) slider.addOnChangeListener(sliderListener) slider.valueFrom = valueFrom.toFloat() slider.valueTo = valueTo.toFloat() slider.stepSize = stepSize.toFloat() slider.isTickVisible = isTickVisible slider.setValueRounded(currentValue.toFloat()) slider.isEnabled = isEnabled } override fun onSetInitialValue(defaultValue: Any?) { value = getPersistedInt(defaultValue as? Int ?: 0) } override fun onGetDefaultValue(a: TypedArray, index: Int): Any { return a.getInt(index, 0) } override fun onSaveInstanceState(): Parcelable? { val superState = super.onSaveInstanceState() if (superState == null || isPersistent) { return superState } return SavedState( superState = superState, valueFrom = valueFrom, valueTo = valueTo, currentValue = currentValue, ) } override fun onRestoreInstanceState(state: Parcelable?) { if (state !is SavedState) { super.onRestoreInstanceState(state) return } super.onRestoreInstanceState(state.superState) valueFrom = state.valueFrom valueTo = state.valueTo currentValue = state.currentValue notifyChanged() } private fun setValueInternal(sliderValue: Int, notifyChanged: Boolean) { val newValue = sliderValue.coerceIn(valueFrom, valueTo) if (newValue != currentValue) { currentValue = newValue persistInt(newValue) if (notifyChanged) { notifyChanged() } } } private fun syncValueInternal(sliderValue: Int) { if (sliderValue != currentValue) { if (callChangeListener(sliderValue)) { setValueInternal(sliderValue, notifyChanged = true) } } } private object SimpleSummaryProvider : SummaryProvider { override fun provideSummary(preference: SliderPreference) = preference.value.toString() } private class SavedState : AbsSavedState { val valueFrom: Int val valueTo: Int val currentValue: Int constructor( superState: Parcelable, valueFrom: Int, valueTo: Int, currentValue: Int, ) : super(superState) { this.valueFrom = valueFrom this.valueTo = valueTo this.currentValue = currentValue } constructor(source: Parcel, classLoader: ClassLoader?) : super(source, classLoader) { valueFrom = source.readInt() valueTo = source.readInt() currentValue = source.readInt() } override fun writeToParcel(out: Parcel, flags: Int) { super.writeToParcel(out, flags) out.writeInt(valueFrom) out.writeInt(valueTo) out.writeInt(currentValue) } companion object { @Suppress("unused") @JvmField val CREATOR: Parcelable.Creator = object : Parcelable.Creator { override fun createFromParcel(`in`: Parcel) = SavedState(`in`, SavedState::class.java.classLoader) override fun newArray(size: Int): Array = arrayOfNulls(size) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/SplitSwitchPreference.kt ================================================ package org.koitharu.kotatsu.settings.utils import android.content.Context import android.util.AttributeSet import android.view.View import androidx.preference.PreferenceViewHolder import androidx.preference.SwitchPreferenceCompat import org.koitharu.kotatsu.R class SplitSwitchPreference @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = androidx.preference.R.attr.switchPreferenceCompatStyle, defStyleRes: Int = 0 ) : SwitchPreferenceCompat(context, attrs, defStyleAttr, defStyleRes) { init { layoutResource = R.layout.preference_split_switch } var onContainerClickListener: OnPreferenceClickListener? = null private val containerClickListener = View.OnClickListener { onContainerClickListener?.onPreferenceClick(this) } override fun onBindViewHolder(holder: PreferenceViewHolder) { super.onBindViewHolder(holder) holder.findViewById(R.id.press_container)?.setOnClickListener(containerClickListener) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/TagsAutoCompleteProvider.kt ================================================ package org.koitharu.kotatsu.settings.utils import javax.inject.Inject import org.koitharu.kotatsu.core.db.MangaDatabase class TagsAutoCompleteProvider @Inject constructor( private val db: MangaDatabase, ) : MultiAutoCompleteTextViewPreference.AutoCompleteProvider { override suspend fun getSuggestions(query: String): List { if (query.isEmpty()) { return emptyList() } val tags = db.getTagsDao().findTags(query = "$query%", limit = 6) val set = HashSet() val result = ArrayList(tags.size) for (tag in tags) { if (set.add(tag.title)) { result.add(tag.title) } } return result } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/ThemeChooserPreference.kt ================================================ package org.koitharu.kotatsu.settings.utils import android.content.Context import android.content.res.TypedArray import android.os.Build import android.os.Parcel import android.os.Parcelable import android.util.AttributeSet import android.view.LayoutInflater import android.view.View import android.view.ViewTreeObserver import android.widget.HorizontalScrollView import androidx.appcompat.view.ContextThemeWrapper import androidx.core.view.isEmpty import androidx.core.view.isVisible import androidx.core.view.updatePaddingRelative import androidx.customview.view.AbsSavedState import androidx.preference.Preference import androidx.preference.PreferenceViewHolder import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.ColorScheme import org.koitharu.kotatsu.databinding.ItemColorSchemeBinding import org.koitharu.kotatsu.databinding.PreferenceThemeBinding import java.lang.ref.WeakReference import com.google.android.material.R as materialR class ThemeChooserPreference @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = R.attr.themeChooserPreferenceStyle, defStyleRes: Int = R.style.Preference_ThemeChooser, ) : Preference(context, attrs, defStyleAttr, defStyleRes) { private val entries = ColorScheme.getAvailableList() private var currentValue: ColorScheme = ColorScheme.default private val lastScrollPosition = intArrayOf(-1) private val itemClickListener = View.OnClickListener { val tag = it.tag as? ColorScheme ?: return@OnClickListener setValueInternal(tag.name, true) } private var scrollPersistListener: ScrollPersistListener? = null var value: String get() = currentValue.name set(value) = setValueInternal(value, notifyChanged = true) override fun onBindViewHolder(holder: PreferenceViewHolder) { super.onBindViewHolder(holder) val binding = PreferenceThemeBinding.bind(holder.itemView) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { binding.scrollView.suppressLayout(true) binding.linear.suppressLayout(true) } binding.linear.removeAllViews() for (theme in entries) { val context = ContextThemeWrapper(context, theme.styleResId) val item = ItemColorSchemeBinding.inflate(LayoutInflater.from(context), binding.linear, false) if (binding.linear.isEmpty()) { item.root.updatePaddingRelative(start = 0) } val isSelected = theme == currentValue item.card.isChecked = isSelected item.card.strokeWidth = if (isSelected) context.resources.getDimensionPixelSize( materialR.dimen.m3_comp_outlined_card_outline_width, ) else 0 item.textViewTitle.setText(theme.titleResId) item.root.tag = theme item.card.tag = theme item.imageViewCheck.isVisible = theme == currentValue item.root.setOnClickListener(itemClickListener) item.card.setOnClickListener(itemClickListener) binding.linear.addView(item.root) if (isSelected) { item.root.requestFocus() } } if (lastScrollPosition[0] >= 0) { val scroller = Scroller(binding.scrollView, lastScrollPosition[0]) scroller.run() binding.scrollView.post(scroller) } binding.scrollView.viewTreeObserver.run { scrollPersistListener?.let { removeOnScrollChangedListener(it) } scrollPersistListener = ScrollPersistListener(WeakReference(binding.scrollView), lastScrollPosition) addOnScrollChangedListener(scrollPersistListener) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { binding.linear.suppressLayout(false) binding.scrollView.suppressLayout(false) } } override fun onSetInitialValue(defaultValue: Any?) { value = getPersistedString( when (defaultValue) { is String -> ColorScheme.safeValueOf(defaultValue) ?: ColorScheme.default is ColorScheme -> defaultValue else -> ColorScheme.default }.name, ) } override fun onGetDefaultValue(a: TypedArray, index: Int): Any { return a.getInt(index, 0) } override fun onSaveInstanceState(): Parcelable? { val superState = super.onSaveInstanceState() ?: return null return SavedState( superState = superState, scrollPosition = lastScrollPosition[0], ) } override fun onRestoreInstanceState(state: Parcelable?) { if (state !is SavedState) { super.onRestoreInstanceState(state) return } super.onRestoreInstanceState(state.superState) lastScrollPosition[0] = state.scrollPosition } private fun setValueInternal(enumName: String, notifyChanged: Boolean) { val newValue = ColorScheme.safeValueOf(enumName) ?: return if (newValue != currentValue) { currentValue = newValue persistString(newValue.name) if (notifyChanged) { notifyChanged() } } } private class SavedState : AbsSavedState { val scrollPosition: Int constructor( superState: Parcelable, scrollPosition: Int, ) : super(superState) { this.scrollPosition = scrollPosition } constructor(source: Parcel, classLoader: ClassLoader?) : super(source, classLoader) { scrollPosition = source.readInt() } override fun writeToParcel(out: Parcel, flags: Int) { super.writeToParcel(out, flags) out.writeInt(scrollPosition) } companion object { @Suppress("unused") @JvmField val CREATOR: Parcelable.Creator = object : Parcelable.Creator { override fun createFromParcel(`in`: Parcel) = SavedState(`in`, SavedState::class.java.classLoader) override fun newArray(size: Int): Array = arrayOfNulls(size) } } } private class ScrollPersistListener( private val scrollViewRef: WeakReference, private val lastScrollPosition: IntArray, ) : ViewTreeObserver.OnScrollChangedListener { override fun onScrollChanged() { val scrollView = scrollViewRef.get() ?: return lastScrollPosition[0] = scrollView.scrollX } } private class Scroller( private val scrollView: HorizontalScrollView, private val position: Int, ) : Runnable { override fun run() { scrollView.scrollTo(position, 0) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/validation/DomainValidator.kt ================================================ package org.koitharu.kotatsu.settings.utils.validation import okhttp3.HttpUrl import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.EditTextValidator class DomainValidator : EditTextValidator() { override fun validate(text: String): ValidationResult { val trimmed = text.trim() if (trimmed.isEmpty()) { return ValidationResult.Success } return if (!isValidDomain(trimmed)) { ValidationResult.Failed(context.getString(R.string.invalid_domain_message)) } else { ValidationResult.Success } } companion object { fun isValidDomain(value: String): Boolean = runCatching { require(value.isNotEmpty()) val parts = value.split(':') require(parts.size <= 2) val urlBuilder = HttpUrl.Builder() urlBuilder.host(parts.first()) if (parts.size == 2) { urlBuilder.port(parts[1].toInt()) } }.isSuccess } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/validation/HeaderValidator.kt ================================================ package org.koitharu.kotatsu.settings.utils.validation import okhttp3.Headers import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.util.EditTextValidator class HeaderValidator : EditTextValidator() { private val headers = Headers.Builder() override fun validate(text: String): ValidationResult { val trimmed = text.trim() if (trimmed.isEmpty()) { return ValidationResult.Success } return if (!validateImpl(trimmed)) { ValidationResult.Failed(context.getString(R.string.invalid_value_message)) } else { ValidationResult.Success } } private fun validateImpl(value: String): Boolean = runCatching { headers[CommonHeaders.USER_AGENT] = value }.isSuccess } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/validation/PortNumberValidator.kt ================================================ package org.koitharu.kotatsu.settings.utils.validation import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.EditTextValidator class PortNumberValidator : EditTextValidator() { override fun validate(text: String): ValidationResult { val trimmed = text.trim() if (trimmed.isEmpty()) { return ValidationResult.Success } return if (!checkCharacters(trimmed)) { ValidationResult.Failed(context.getString(R.string.invalid_port_number)) } else { ValidationResult.Success } } private fun checkCharacters(value: String): Boolean { val intValue = value.toIntOrNull() ?: return false return intValue in 1..65535 } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/validation/UrlValidator.kt ================================================ package org.koitharu.kotatsu.settings.utils.validation import android.webkit.URLUtil import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.EditTextValidator class UrlValidator : EditTextValidator() { override fun validate(text: String): ValidationResult { val trimmed = text.trim() if (trimmed.isEmpty()) { return ValidationResult.Success } return if (!isValidUrl(trimmed)) { ValidationResult.Failed(context.getString(R.string.invalid_server_address_message)) } else { ValidationResult.Success } } private fun isValidUrl(str: String): Boolean { return URLUtil.isValidUrl(str) || DomainValidator.isValidDomain(str) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/work/PeriodicWorkScheduler.kt ================================================ package org.koitharu.kotatsu.settings.work interface PeriodicWorkScheduler { suspend fun schedule() suspend fun unschedule() suspend fun isScheduled(): Boolean } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/settings/work/WorkScheduleManager.kt ================================================ package org.koitharu.kotatsu.settings.work import android.content.SharedPreferences import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker import org.koitharu.kotatsu.tracker.work.TrackWorker import javax.inject.Inject import javax.inject.Singleton @Singleton class WorkScheduleManager @Inject constructor( private val settings: AppSettings, private val suggestionScheduler: SuggestionsWorker.Scheduler, private val trackerScheduler: TrackWorker.Scheduler, ) : SharedPreferences.OnSharedPreferenceChangeListener { override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { when (key) { AppSettings.KEY_TRACKER_ENABLED, AppSettings.KEY_TRACKER_FREQUENCY, AppSettings.KEY_TRACKER_WIFI_ONLY -> updateWorker( scheduler = trackerScheduler, isEnabled = settings.isTrackerEnabled, force = key != AppSettings.KEY_TRACKER_ENABLED, ) AppSettings.KEY_SUGGESTIONS, AppSettings.KEY_SUGGESTIONS_WIFI_ONLY -> updateWorker( scheduler = suggestionScheduler, isEnabled = settings.isSuggestionsEnabled, force = key != AppSettings.KEY_SUGGESTIONS, ) } } fun init() { settings.subscribe(this) processLifecycleScope.launch(Dispatchers.Default) { updateWorkerImpl(trackerScheduler, settings.isTrackerEnabled, true) // always force due to adaptive interval updateWorkerImpl(suggestionScheduler, settings.isSuggestionsEnabled, false) } } private fun updateWorker(scheduler: PeriodicWorkScheduler, isEnabled: Boolean, force: Boolean) { processLifecycleScope.launch(Dispatchers.Default) { updateWorkerImpl(scheduler, isEnabled, force) } } private suspend fun updateWorkerImpl(scheduler: PeriodicWorkScheduler, isEnabled: Boolean, force: Boolean) { if (force || scheduler.isScheduled() != isEnabled) { if (isEnabled) { scheduler.schedule() } else { scheduler.unschedule() } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsDao.kt ================================================ package org.koitharu.kotatsu.stats.data import androidx.room.Dao import androidx.room.MapColumn import androidx.room.Query import androidx.room.RawQuery 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.core.db.entity.MangaEntity import kotlin.collections.forEach @Dao abstract class StatsDao { @Query("SELECT * FROM stats WHERE manga_id = :mangaId ORDER BY started_at") abstract suspend fun findAll(mangaId: Long): List @Query("SELECT IFNULL(SUM(pages),0) FROM stats WHERE manga_id = :mangaId") abstract suspend fun getReadPagesCount(mangaId: Long): Int @Query("SELECT IFNULL(SUM(duration)/SUM(pages), 0) FROM stats WHERE manga_id = :mangaId") abstract suspend fun getAverageTimePerPage(mangaId: Long): Long @Query("SELECT IFNULL(SUM(duration)/SUM(pages), 0) FROM stats") abstract suspend fun getAverageTimePerPage(): Long @Query("DELETE FROM stats") abstract suspend fun clear() @Query("SELECT COUNT(*) FROM stats WHERE manga_id = :mangaId") abstract fun observeRowCount(mangaId: Long): Flow @Upsert abstract suspend fun upsert(entity: StatsEntity) suspend fun getDurationStats( fromDate: Long, isNsfw: Boolean?, favouriteCategories: Set ): Map { val conditions = ArrayList() conditions.add("(SELECT deleted_at FROM history WHERE history.manga_id = stats.manga_id) = 0") conditions.add("stats.started_at >= $fromDate") if (favouriteCategories.isNotEmpty()) { val ids = favouriteCategories.joinToString(",") conditions.add("stats.manga_id IN (SELECT manga_id FROM favourites WHERE category_id IN ($ids))") } if (isNsfw != null) { val flag = if (isNsfw) 1 else 0 conditions.add("manga.nsfw = $flag") } val where = conditions.joinToString(separator = " AND ") val query = SimpleSQLiteQuery( "SELECT manga.*, SUM(duration) AS d FROM stats LEFT JOIN manga ON manga.manga_id = stats.manga_id WHERE $where GROUP BY manga.manga_id ORDER BY d DESC", ) return getDurationStatsImpl(query) } @RawQuery protected abstract suspend fun getDurationStatsImpl( query: SupportSQLiteQuery ): Map<@MapColumn("manga") MangaEntity, @MapColumn("d") Long> @Query("SELECT * FROM stats ORDER BY started_at LIMIT :limit OFFSET :offset") protected abstract suspend fun findAll(offset: Int, limit: Int): List fun dumpEnabled(): Flow = flow { val window = 10 var offset = 0 while (currentCoroutineContext().isActive) { val list = findAll(offset, window) if (list.isEmpty()) { break } offset += window list.forEach { emit(it) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsEntity.kt ================================================ package org.koitharu.kotatsu.stats.data import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import org.koitharu.kotatsu.history.data.HistoryEntity @Entity( tableName = "stats", primaryKeys = ["manga_id", "started_at"], foreignKeys = [ ForeignKey( entity = HistoryEntity::class, parentColumns = ["manga_id"], childColumns = ["manga_id"], onDelete = ForeignKey.CASCADE, ), ], ) data class StatsEntity( @ColumnInfo(name = "manga_id") val mangaId: Long, @ColumnInfo(name = "started_at") val startedAt: Long, @ColumnInfo(name = "duration") val duration: Long, @ColumnInfo(name = "pages") val pages: Int, ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsRepository.kt ================================================ package org.koitharu.kotatsu.stats.data import androidx.room.withTransaction import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.stats.domain.StatsPeriod import org.koitharu.kotatsu.stats.domain.StatsRecord import java.util.NavigableMap import java.util.TreeMap import java.util.concurrent.TimeUnit import javax.inject.Inject class StatsRepository @Inject constructor( private val settings: AppSettings, private val db: MangaDatabase, ) { suspend fun getReadingStats(period: StatsPeriod, categories: Set): List { val fromDate = if (period == StatsPeriod.ALL) { 0L } else { System.currentTimeMillis() - TimeUnit.DAYS.toMillis(period.days.toLong()) } val stats = db.getStatsDao().getDurationStats(fromDate, null, categories) val result = ArrayList(stats.size) var other = StatsRecord(null, 0) val total = stats.values.sum() for ((mangaEntity, duration) in stats) { val manga = mangaEntity.toManga(emptySet(), null) val percent = duration.toDouble() / total if (percent < 0.05) { other = other.copy(duration = other.duration + duration) } else { result += StatsRecord( manga = manga, duration = duration, ) } } if (other.duration != 0L) { result += other } return result } suspend fun getTimePerPage(mangaId: Long): Long = db.withTransaction { val dao = db.getStatsDao() val pages = dao.getReadPagesCount(mangaId) val time = if (pages >= 10) { dao.getAverageTimePerPage(mangaId) } else { dao.getAverageTimePerPage() } time } suspend fun getTotalPagesRead(mangaId: Long): Int { return db.getStatsDao().getReadPagesCount(mangaId) } suspend fun getMangaTimeline(mangaId: Long): NavigableMap { val entities = db.getStatsDao().findAll(mangaId) val map = TreeMap() for (e in entities) { map[e.startedAt] = e.pages } return map } suspend fun clearStats() { db.getStatsDao().clear() } fun observeHasStats(mangaId: Long): Flow = settings.observeAsFlow(AppSettings.KEY_STATS_ENABLED) { isStatsEnabled }.flatMapLatest { isEnabled -> if (isEnabled) { db.getStatsDao().observeRowCount(mangaId).map { it > 0 } } else { flowOf(false) } }.distinctUntilChanged() } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/stats/domain/StatsCollector.kt ================================================ package org.koitharu.kotatsu.stats.domain import androidx.collection.LongSparseArray import androidx.collection.set import dagger.hilt.android.ViewModelLifecycle import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.stats.data.StatsEntity import javax.inject.Inject @ViewModelScoped class StatsCollector @Inject constructor( private val db: MangaDatabase, private val settings: AppSettings, lifecycle: ViewModelLifecycle, ) { private val viewModelScope = RetainedLifecycleCoroutineScope(lifecycle) private val stats = LongSparseArray(1) @Synchronized fun onStateChanged(mangaId: Long, state: ReaderState) { if (!settings.isStatsEnabled) { return } val now = System.currentTimeMillis() val entry = stats[mangaId] if (entry == null) { stats[mangaId] = Entry( state = state, stats = StatsEntity( mangaId = mangaId, startedAt = now, duration = 0, pages = 0, ), ) return } val pagesDelta = if (entry.state.page != state.page || entry.state.chapterId != state.chapterId) 1 else 0 val newEntry = entry.copy( stats = StatsEntity( mangaId = mangaId, startedAt = entry.stats.startedAt, duration = now - entry.stats.startedAt, pages = entry.stats.pages + pagesDelta, ), ) stats[mangaId] = newEntry commit(newEntry.stats) } @Synchronized fun onPause(mangaId: Long) { stats.remove(mangaId) } private fun commit(entity: StatsEntity) { viewModelScope.launch(Dispatchers.Default) { runCatchingCancellable { db.getStatsDao().upsert(entity) }.onFailure { e -> e.printStackTraceDebug() } } } private data class Entry( val state: ReaderState, val stats: StatsEntity, ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/stats/domain/StatsPeriod.kt ================================================ package org.koitharu.kotatsu.stats.domain import androidx.annotation.StringRes import org.koitharu.kotatsu.R enum class StatsPeriod( @StringRes val titleResId: Int, val days: Int, ) { DAY(R.string.day, 1), WEEK(R.string.week, 7), MONTH(R.string.month, 30), MONTHS_3(R.string.three_months, 90), ALL(R.string.all_time, Int.MAX_VALUE), } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/stats/domain/StatsRecord.kt ================================================ package org.koitharu.kotatsu.stats.domain import org.koitharu.kotatsu.details.data.ReadingTime import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.Manga import java.util.concurrent.TimeUnit data class StatsRecord( val manga: Manga?, val duration: Long, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is StatsRecord && other.manga == manga } val time: ReadingTime init { val minutes = TimeUnit.MILLISECONDS.toMinutes(duration).toInt() time = ReadingTime( minutes = minutes % 60, hours = minutes / 60, isContinue = false, ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsAD.kt ================================================ package org.koitharu.kotatsu.stats.ui import android.content.res.ColorStateList import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.KotatsuColors import org.koitharu.kotatsu.databinding.ItemStatsBinding import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.stats.domain.StatsRecord fun statsAD( listener: OnListItemClickListener, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemStatsBinding.inflate(layoutInflater, parent, false) }, ) { binding.root.setOnClickListener { v -> listener.onItemClick(item.manga ?: return@setOnClickListener, v) } bind { binding.textViewTitle.text = item.manga?.title ?: getString(R.string.other_manga) binding.textViewSummary.text = item.time.format(context.resources) binding.imageViewBadge.imageTintList = ColorStateList.valueOf(KotatsuColors.ofManga(context, item.manga)) binding.root.isClickable = item.manga != null } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsActivity.kt ================================================ package org.koitharu.kotatsu.stats.ui import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.view.ViewStub import android.widget.CompoundButton import androidx.activity.viewModels import androidx.appcompat.widget.PopupMenu import androidx.core.graphics.Insets import androidx.core.view.WindowInsetsCompat import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePaddingRelative import androidx.recyclerview.widget.AsyncListDiffer import com.google.android.material.chip.Chip import com.google.android.material.chip.ChipDrawable import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.FavouriteCategory 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.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.util.KotatsuColors import org.koitharu.kotatsu.core.util.ext.end import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.core.util.ext.showOrHide import org.koitharu.kotatsu.core.util.ext.start import org.koitharu.kotatsu.databinding.ActivityStatsBinding import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.stats.domain.StatsPeriod import org.koitharu.kotatsu.stats.domain.StatsRecord import org.koitharu.kotatsu.stats.ui.views.PieChartView @AndroidEntryPoint class StatsActivity : BaseActivity(), OnListItemClickListener, PieChartView.OnSegmentClickListener, AsyncListDiffer.ListListener, ViewStub.OnInflateListener, View.OnClickListener, CompoundButton.OnCheckedChangeListener { private val viewModel: StatsViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityStatsBinding.inflate(layoutInflater)) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) val adapter = BaseListAdapter() .addDelegate(ListItemType.FEED, statsAD(this)) .addListListener(this) viewBinding.recyclerView.adapter = adapter viewBinding.chart.onSegmentClickListener = this viewBinding.stubEmpty.setOnInflateListener(this) viewBinding.chipPeriod.setOnClickListener(this) viewModel.isLoading.observe(this) { viewBinding.progressBar.showOrHide(it) } viewModel.period.observe(this) { viewBinding.chipPeriod.setText(it.titleResId) } viewModel.favoriteCategories.observe(this, ::createCategoriesChips) viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.recyclerView)) viewModel.readingStats.observe(this) { val sum = it.sumOf { it.duration } viewBinding.chart.setData( it.map { v -> PieChartView.Segment( value = (v.duration / 1000).toInt(), label = v.manga?.title ?: getString(R.string.other_manga), percent = (v.duration.toDouble() / sum).toFloat(), color = KotatsuColors.ofManga(this, v.manga), tag = v.manga, ) }, ) adapter.emit(it) } } override fun onApplyWindowInsets( v: View, insets: WindowInsetsCompat ): WindowInsetsCompat { val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) val isTablet = viewBinding.guidelineCenter != null viewBinding.appbar.updatePaddingRelative( start = bars.start(v), top = bars.top, end = if (isTablet) 0 else bars.end(v), ) val badgePadding = resources.getDimensionPixelOffset(R.dimen.list_spacing_large) viewBinding.scrollViewChips.updatePaddingRelative( start = badgePadding + if (isTablet) 0 else bars.start(v), end = badgePadding + bars.end(v), top = if (isTablet) bars.top else 0, ) viewBinding.recyclerView.updatePaddingRelative( start = if (isTablet) 0 else bars.start(v), end = bars.end(v), bottom = bars.bottom, ) viewBinding.chart.updateLayoutParams { val baseMargin = topMargin bottomMargin = if (isTablet) baseMargin + bars.bottom else baseMargin marginStart = baseMargin + bars.start(v) marginEnd = if (isTablet) baseMargin else baseMargin + bars.end(v) } return WindowInsetsCompat.Builder(insets) .setInsets(WindowInsetsCompat.Type.systemBars(), Insets.NONE) .build() } override fun onClick(v: View) { when (v.id) { R.id.chip_period -> showPeriodSelector() } } override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { val category = buttonView.tag as? FavouriteCategory ?: return viewModel.setCategoryChecked(category, isChecked) } override fun onItemClick(item: Manga, view: View) { router.showStatisticSheet(item) } override fun onSegmentClick(view: PieChartView, segment: PieChartView.Segment) { val manga = segment.tag as? Manga ?: return onItemClick(manga, view) } override fun onCreateOptionsMenu(menu: Menu?): Boolean { menuInflater.inflate(R.menu.opt_stats, menu) return super.onCreateOptionsMenu(menu) } override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { R.id.action_clear -> { showClearConfirmDialog() true } else -> super.onOptionsItemSelected(item) } } override fun onCurrentListChanged(previousList: MutableList, currentList: MutableList) { val isEmpty = currentList.isEmpty() with(viewBinding) { chart.isGone = isEmpty recyclerView.isGone = isEmpty stubEmpty.isVisible = isEmpty } } override fun onInflate(stub: ViewStub?, inflated: View) { val stubBinding = ItemEmptyStateBinding.bind(inflated) stubBinding.icon.setImageAsync(R.drawable.ic_empty_history) stubBinding.textPrimary.setText(R.string.text_empty_holder_primary) stubBinding.textSecondary.setTextAndVisible(R.string.empty_stats_text) stubBinding.buttonRetry.isVisible = false } private fun createCategoriesChips(categories: List) { val container = viewBinding.layoutChips if (container.childCount > 1) { // avoid duplication return } val checkedIds = viewModel.selectedCategories.value for (category in categories) { val chip = Chip(this) val drawable = ChipDrawable.createFromAttributes(this, null, 0, R.style.Widget_Kotatsu_Chip_Filter) chip.setChipDrawable(drawable) chip.text = category.title chip.tag = category chip.isChecked = category.id in checkedIds chip.setOnCheckedChangeListener(this) container.addView(chip) } } private fun showClearConfirmDialog() { buildAlertDialog(this, isCentered = true) { setMessage(R.string.clear_stats_confirm) setTitle(R.string.clear_stats) setIcon(R.drawable.ic_delete_all) setNegativeButton(android.R.string.cancel, null) setPositiveButton(R.string.clear) { _, _ -> viewModel.clearStats() } }.show() } private fun showPeriodSelector() { val menu = PopupMenu(this, viewBinding.chipPeriod) val selected = viewModel.period.value for ((i, branch) in StatsPeriod.entries.withIndex()) { val item = menu.menu.add(R.id.group_period, Menu.NONE, i, branch.titleResId) item.isCheckable = true item.isChecked = selected.ordinal == i } menu.menu.setGroupCheckable(R.id.group_period, true, true) menu.setOnMenuItemClickListener { StatsPeriod.entries.getOrNull(it.order)?.also { viewModel.period.value = it } != null } menu.show() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsViewModel.kt ================================================ package org.koitharu.kotatsu.stats.ui import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.take import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.FavouriteCategory 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.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.stats.data.StatsRepository import org.koitharu.kotatsu.stats.domain.StatsPeriod import org.koitharu.kotatsu.stats.domain.StatsRecord import javax.inject.Inject @HiltViewModel class StatsViewModel @Inject constructor( private val repository: StatsRepository, favouritesRepository: FavouritesRepository, ) : BaseViewModel() { val period = MutableStateFlow(StatsPeriod.WEEK) val onActionDone = MutableEventFlow() val selectedCategories = MutableStateFlow>(emptySet()) val favoriteCategories = favouritesRepository.observeCategories() .take(1) val readingStats = MutableStateFlow>(emptyList()) init { launchJob(Dispatchers.Default) { combine, Pair>>( period, selectedCategories, ::Pair, ).collectLatest { p -> readingStats.value = withLoading { repository.getReadingStats(p.first, p.second) } } } } fun setCategoryChecked(category: FavouriteCategory, checked: Boolean) { val snapshot = selectedCategories.value.toMutableSet() if (checked) { snapshot.add(category.id) } else { snapshot.remove(category.id) } selectedCategories.value = snapshot } fun clearStats() { launchLoadingJob(Dispatchers.Default) { repository.clearStats() readingStats.value = emptyList() onActionDone.call(ReversibleAction(R.string.stats_cleared, null)) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsSheet.kt ================================================ package org.koitharu.kotatsu.stats.ui.sheet import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.collection.IntList import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import androidx.fragment.app.viewModels import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.util.KotatsuColors import org.koitharu.kotatsu.core.util.ext.consume import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.SheetStatsMangaBinding import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.stats.ui.views.BarChartView @AndroidEntryPoint class MangaStatsSheet : BaseAdaptiveSheet(), View.OnClickListener { private val viewModel: MangaStatsViewModel by viewModels() override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetStatsMangaBinding { return SheetStatsMangaBinding.inflate(inflater, container, false) } override fun onViewBindingCreated(binding: SheetStatsMangaBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) binding.textViewTitle.text = viewModel.manga.title binding.chartView.barColor = KotatsuColors.ofManga(binding.root.context, viewModel.manga) viewModel.stats.observe(viewLifecycleOwner, ::onStatsChanged) viewModel.startDate.observe(viewLifecycleOwner) { binding.textViewStart.textAndVisible = it?.format(binding.root.context) } viewModel.totalPagesRead.observe(viewLifecycleOwner) { binding.textViewPages.text = getString(R.string.pages_read_s, it.format()) } binding.buttonOpen.setOnClickListener(this) } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val typeMask = WindowInsetsCompat.Type.systemBars() viewBinding?.scrollView?.updatePadding( bottom = insets.getInsets(typeMask).bottom, ) return insets.consume(v, typeMask, bottom = true) } override fun onClick(v: View) { router.openDetails(viewModel.manga) } private fun onStatsChanged(stats: IntList) { val chartView = viewBinding?.chartView ?: return if (stats.isEmpty()) { chartView.setData(emptyList()) return } val bars = ArrayList(stats.size) stats.forEach { pages -> bars.add( BarChartView.Bar( value = pages, label = pages.toString(), ), ) } chartView.setData(bars) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsViewModel.kt ================================================ package org.koitharu.kotatsu.stats.ui.sheet import androidx.collection.MutableIntList import androidx.collection.emptyIntList import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.model.DateTimeAgo import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.stats.data.StatsRepository import java.time.Instant import java.util.concurrent.TimeUnit import javax.inject.Inject @HiltViewModel class MangaStatsViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val repository: StatsRepository, ) : BaseViewModel() { val manga = savedStateHandle.require(AppRouter.KEY_MANGA).manga val stats = MutableStateFlow(emptyIntList()) val startDate = MutableStateFlow(null) val totalPagesRead = MutableStateFlow(0) init { launchLoadingJob(Dispatchers.Default) { val timeline = repository.getMangaTimeline(manga.id) if (timeline.isEmpty()) { startDate.value = null stats.value = emptyIntList() } else { val startDay = TimeUnit.MILLISECONDS.toDays(timeline.firstKey()) val endDay = TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis()) val res = MutableIntList((endDay - startDay).toInt() + 1) for (day in startDay..endDay) { val from = TimeUnit.DAYS.toMillis(day) val to = TimeUnit.DAYS.toMillis(day + 1) res.add(timeline.subMap(from, true, to, false).values.sum()) } stats.value = res startDate.value = calculateTimeAgo(Instant.ofEpochMilli(timeline.firstKey())) } } launchLoadingJob(Dispatchers.Default) { totalPagesRead.value = repository.getTotalPagesRead(manga.id) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/views/BarChartView.kt ================================================ package org.koitharu.kotatsu.stats.ui.views import android.content.Context import android.graphics.Canvas import android.graphics.DashPathEffect import android.graphics.Paint import android.graphics.RectF import android.util.AttributeSet import android.view.View import androidx.annotation.ColorInt import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.resolveDp import org.koitharu.kotatsu.parsers.util.replaceWith import org.koitharu.kotatsu.parsers.util.toIntUp import kotlin.math.roundToInt import kotlin.random.Random import androidx.appcompat.R as appcompatR import com.google.android.material.R as materialR class BarChartView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val rawData = ArrayList() private val bars = ArrayList() private var maxValue: Int = 0 private val minBarSpacing = context.resources.resolveDp(12f) private val minSpace = context.resources.resolveDp(20f) private val barWidth = context.resources.resolveDp(12f) private val outlineColor = context.getThemeColor(materialR.attr.colorOutline) private val dottedEffect = DashPathEffect( floatArrayOf( context.resources.resolveDp(6f), context.resources.resolveDp(6f), ), 0f, ) private val chartBounds = RectF() @ColorInt var barColor: Int = context.getThemeColor(appcompatR.attr.colorAccent) set(value) { field = value invalidate() } init { paint.strokeWidth = context.resources.resolveDp(1f) if (isInEditMode) { setData( List(Random.nextInt(20, 60)) { Bar( value = Random.nextInt(-20, 400).coerceAtLeast(0), label = it.toString(), ) }, ) } } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) if (bars.isEmpty() || chartBounds.isEmpty) { return } val spacing = (chartBounds.width() - (barWidth * bars.size.toFloat())) / (bars.size + 1).toFloat() // dashed horizontal lines paint.color = outlineColor paint.style = Paint.Style.STROKE canvas.drawLine(chartBounds.left, chartBounds.bottom, chartBounds.right, chartBounds.bottom, paint) paint.pathEffect = dottedEffect for (i in (0..maxValue).step(computeValueStep())) { val y = chartBounds.top + (chartBounds.height() * i / maxValue.toFloat()) canvas.drawLine(paddingLeft.toFloat(), y, (width - paddingLeft - paddingRight).toFloat(), y, paint) } // bottom line paint.color = outlineColor paint.style = Paint.Style.STROKE canvas.drawLine(chartBounds.left, chartBounds.bottom, chartBounds.right, chartBounds.bottom, paint) // bars paint.style = Paint.Style.FILL paint.color = barColor paint.pathEffect = null val corner = barWidth / 2f for ((i, bar) in bars.withIndex()) { if (bar.value == 0) { continue } val h = (chartBounds.height() * bar.value / maxValue.toFloat()).coerceAtLeast(barWidth) val x = spacing + i * (barWidth + spacing) + paddingLeft canvas.drawRoundRect(x, chartBounds.bottom - h, x + barWidth, chartBounds.bottom, corner, corner, paint) } } override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { super.onLayout(changed, left, top, right, bottom) invalidateBounds() } fun setData(value: List) { rawData.replaceWith(value) compressBars() invalidate() } private fun compressBars() { if (rawData.isEmpty() || width <= 0) { maxValue = 0 bars.clear() return } val fullWidth = rawData.size * (barWidth + minBarSpacing) + minBarSpacing val windowSize = (fullWidth / width.toFloat()).toIntUp() bars.replaceWith( rawData.chunked(windowSize) { it.average() }, ) maxValue = bars.maxOf { it.value } } private fun computeValueStep(): Int { val h = chartBounds.height() var step = 1 while (h / (maxValue / step).toFloat() <= minSpace) { step++ } return step } private fun invalidateBounds() { val inset = paint.strokeWidth chartBounds.set( paddingLeft.toFloat() + inset, paddingTop.toFloat() + inset, (width - paddingLeft - paddingRight).toFloat() - inset, (height - paddingTop - paddingBottom).toFloat() - inset, ) compressBars() } private fun Collection.average(): Bar { return when (size) { 0 -> Bar(0, "") 1 -> first() else -> Bar( value = (sumOf { it.value } / size.toFloat()).roundToInt(), label = "%s - %s".format(first().label, last().label), ) } } class Bar( val value: Int, val label: String, ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/views/PieChartView.kt ================================================ package org.koitharu.kotatsu.stats.ui.views import android.annotation.SuppressLint import android.content.Context import android.graphics.Canvas import android.graphics.Paint import android.graphics.RectF import android.util.AttributeSet import android.view.GestureDetector import android.view.MotionEvent import android.view.View import androidx.appcompat.widget.TooltipCompat import androidx.core.graphics.ColorUtils import androidx.core.view.PointerIconCompat import androidx.core.view.ViewCompat import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.resolveDp import org.koitharu.kotatsu.parsers.util.replaceWith import kotlin.math.atan2 import kotlin.math.sqrt class PieChartView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr), GestureDetector.OnGestureListener { private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val activePointerIcon = PointerIconCompat.getSystemIcon(context, PointerIconCompat.TYPE_HAND) private val segments = ArrayList() private val chartBounds = RectF() private val clearColor = context.getThemeColor(android.R.attr.colorBackground) private val touchDetector = GestureDetector(context, this) private var hoverSegment = -1 private var highlightedSegment = -1 var onSegmentClickListener: OnSegmentClickListener? = null init { touchDetector.setIsLongpressEnabled(false) paint.strokeWidth = context.resources.resolveDp(2f) } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) var angle = 0f for ((i, segment) in segments.withIndex()) { paint.color = segment.color if (i == highlightedSegment) { paint.color = ColorUtils.setAlphaComponent(paint.color, 180) } else if (i == hoverSegment) { paint.color = ColorUtils.setAlphaComponent(paint.color, 200) } paint.style = Paint.Style.FILL val sweepAngle = segment.percent * 360f canvas.drawArc( chartBounds, angle, sweepAngle, true, paint, ) paint.color = clearColor paint.style = Paint.Style.STROKE canvas.drawArc( chartBounds, angle, sweepAngle, true, paint, ) angle += sweepAngle } paint.style = Paint.Style.FILL paint.color = clearColor canvas.drawCircle(chartBounds.centerX(), chartBounds.centerY(), chartBounds.height() / 4f, paint) } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) val size = minOf(w, h).toFloat() val inset = paint.strokeWidth chartBounds.set(inset, inset, size - inset, size - inset) chartBounds.offset( (w - size) / 2f, (h - size) / 2f, ) } @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { if (event.actionMasked == MotionEvent.ACTION_CANCEL || event.actionMasked == MotionEvent.ACTION_UP) { highlightedSegment = -1 invalidate() } return super.onTouchEvent(event) || touchDetector.onTouchEvent(event) } override fun onDown(e: MotionEvent): Boolean { if (onSegmentClickListener == null) { return false } val segment = findSegmentIndex(e.x, e.y) if (segment != highlightedSegment) { highlightedSegment = segment invalidate() return true } else { return false } } override fun onShowPress(e: MotionEvent) = Unit override fun onSingleTapUp(e: MotionEvent): Boolean { onSegmentClickListener?.run { val segment = segments.getOrNull(findSegmentIndex(e.x, e.y)) if (segment != null) { onSegmentClick(this@PieChartView, segment) } } return true } override fun onScroll(e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean = false override fun onLongPress(e: MotionEvent) = Unit override fun onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean = false override fun onHoverEvent(event: MotionEvent): Boolean { val segment = when (event.actionMasked) { MotionEvent.ACTION_HOVER_ENTER, MotionEvent.ACTION_HOVER_MOVE -> findSegmentIndex(event.x, event.y) MotionEvent.ACTION_HOVER_EXIT -> -1 else -> hoverSegment } if (hoverSegment != segment) { hoverSegment = segment TooltipCompat.setTooltipText(this, segments.getOrNull(segment)?.label) ViewCompat.setPointerIcon(this, if (segment == -1) null else activePointerIcon) invalidate() } return super.onHoverEvent(event) || segment != -1 } fun setData(value: List) { segments.replaceWith(value) invalidate() } private fun findSegmentIndex(x: Float, y: Float): Int { val dy = (y - chartBounds.centerY()).toDouble() val dx = (x - chartBounds.centerX()).toDouble() val distance = sqrt(dx * dx + dy * dy).toFloat() if (distance < chartBounds.height() / 4f || distance > chartBounds.centerX()) { return -1 } var touchAngle = Math.toDegrees(atan2(dy, dx)).toFloat() if (touchAngle < 0) { touchAngle += 360 } var angle = 0f for ((i, segment) in segments.withIndex()) { val sweepAngle = segment.percent * 360f if (touchAngle in angle..(angle + sweepAngle)) { return i } angle += sweepAngle } return -1 } class Segment( val value: Int, val label: String, val percent: Float, val color: Int, val tag: Any?, ) interface OnSegmentClickListener { fun onSegmentClick(view: PieChartView, segment: Segment) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt ================================================ package org.koitharu.kotatsu.suggestions.data import android.database.DatabaseUtils.sqlEscapeString 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.Update import androidx.sqlite.db.SupportSQLiteQuery import kotlinx.coroutines.flow.Flow import org.koitharu.kotatsu.core.db.MangaQueryBuilder import org.koitharu.kotatsu.core.db.entity.MangaWithTags import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.list.domain.ListFilterOption @Dao abstract class SuggestionDao : MangaQueryBuilder.ConditionCallback { @Transaction @Query("SELECT * FROM suggestions ORDER BY relevance DESC") abstract fun observeAll(): Flow> fun observeAll( limit: Int, filterOptions: Collection ): Flow> = observeAllImpl( MangaQueryBuilder("suggestions", this) .filters(filterOptions) .orderBy("relevance DESC") .limit(limit) .build(), ) @Transaction @Query("SELECT manga.* FROM suggestions LEFT JOIN manga ON manga.manga_id = suggestions.manga_id ORDER BY relevance DESC LIMIT :limit") abstract suspend fun getTopManga(limit: Int): List @Transaction open suspend fun getRandom(limit: Int): List { val ids = getRandomIds(limit) return getByIds(ids) } @Query("SELECT COUNT(*) FROM suggestions") abstract suspend fun count(): Int @Query("SELECT manga.title FROM suggestions LEFT JOIN manga ON suggestions.manga_id = manga.manga_id WHERE manga.title LIKE :query") abstract suspend fun getTitles(query: String): List @Query("SELECT tags.* FROM suggestions LEFT JOIN tags ON (tag_id IN (SELECT tag_id FROM manga_tags WHERE manga_tags.manga_id = suggestions.manga_id)) GROUP BY tag_id ORDER BY COUNT(tags.tag_id) DESC LIMIT :limit") abstract suspend fun getTopTags(limit: Int): List @Query("SELECT manga.source AS count FROM suggestions LEFT JOIN manga ON manga.manga_id = suggestions.manga_id GROUP BY manga.source ORDER BY COUNT(manga.source) DESC LIMIT :limit") abstract suspend fun getTopSources(limit: Int): List @Insert(onConflict = OnConflictStrategy.IGNORE) abstract suspend fun insert(entity: SuggestionEntity): Long @Update abstract suspend fun update(entity: SuggestionEntity): Int @Query("DELETE FROM suggestions") abstract suspend fun deleteAll() @Transaction open suspend fun upsert(entity: SuggestionEntity) { if (update(entity) == 0) { insert(entity) } } @Query("SELECT * FROM manga WHERE manga_id IN (:ids)") protected abstract suspend fun getByIds(ids: LongArray): List @Query("SELECT manga_id FROM suggestions ORDER BY RANDOM() LIMIT :limit") protected abstract suspend fun getRandomIds(limit: Int): LongArray @Transaction @RawQuery(observedEntities = [SuggestionEntity::class]) protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow> override fun getCondition(option: ListFilterOption): String? = when (option) { ListFilterOption.Macro.NSFW -> "(SELECT nsfw FROM manga WHERE manga.manga_id = suggestions.manga_id) = 1" is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE manga_tags.manga_id = suggestions.manga_id AND tag_id = ${option.tagId})" is ListFilterOption.Source -> "(SELECT source FROM manga WHERE manga.manga_id = suggestions.manga_id) = ${ sqlEscapeString( option.mangaSource.name, ) }" else -> null } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/suggestions/data/SuggestionEntity.kt ================================================ package org.koitharu.kotatsu.suggestions.data import androidx.annotation.FloatRange import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.PrimaryKey import org.koitharu.kotatsu.core.db.entity.MangaEntity @Entity( tableName = "suggestions", foreignKeys = [ ForeignKey( entity = MangaEntity::class, parentColumns = ["manga_id"], childColumns = ["manga_id"], onDelete = ForeignKey.CASCADE ) ] ) class SuggestionEntity( @PrimaryKey(autoGenerate = false) @ColumnInfo(name = "manga_id", index = true) val mangaId: Long, @FloatRange(from = 0.0, to = 1.0) @ColumnInfo(name = "relevance") val relevance: Float, @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(), ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/suggestions/data/SuggestionWithManga.kt ================================================ package org.koitharu.kotatsu.suggestions.data import androidx.room.Embedded import androidx.room.Junction import androidx.room.Relation import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity import org.koitharu.kotatsu.core.db.entity.TagEntity data class SuggestionWithManga( @Embedded val suggestion: SuggestionEntity, @Relation( parentColumn = "manga_id", entityColumn = "manga_id" ) 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/suggestions/domain/MangaSuggestion.kt ================================================ package org.koitharu.kotatsu.suggestions.domain import androidx.annotation.FloatRange import org.koitharu.kotatsu.parsers.model.Manga data class MangaSuggestion( val manga: Manga, @FloatRange(from = 0.0, to = 1.0) val relevance: Float, ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt ================================================ package org.koitharu.kotatsu.suggestions.domain import androidx.room.withTransaction import kotlinx.coroutines.flow.Flow 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.db.entity.toMangaTagsList import org.koitharu.kotatsu.core.model.toMangaSources import org.koitharu.kotatsu.core.util.ext.mapItems import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.suggestions.data.SuggestionEntity import org.koitharu.kotatsu.suggestions.data.SuggestionWithManga import javax.inject.Inject class SuggestionRepository @Inject constructor( private val db: MangaDatabase, ) { fun observeAll(): Flow> { return db.getSuggestionDao().observeAll().mapItems { it.toManga() } } fun observeAll(limit: Int, filterOptions: Set): Flow> { return db.getSuggestionDao().observeAll(limit, filterOptions).mapItems { it.toManga() } } suspend fun getRandomList(limit: Int): List { return db.getSuggestionDao().getRandom(limit).map { it.toManga() } } suspend fun clear() { db.getSuggestionDao().deleteAll() } suspend fun isEmpty(): Boolean { return db.getSuggestionDao().count() == 0 } suspend fun getTopTags(limit: Int): List { return db.getSuggestionDao().getTopTags(limit) .toMangaTagsList() } suspend fun getTopSources(limit: Int): List { return db.getSuggestionDao().getTopSources(limit) .toMangaSources() } suspend fun replace(suggestions: Iterable) { db.withTransaction { db.getSuggestionDao().deleteAll() suggestions.forEach { (manga, relevance) -> val tags = manga.tags.toEntities() db.getTagsDao().upsert(tags) db.getMangaDao().upsert(manga.toEntity(), tags) db.getSuggestionDao().upsert( SuggestionEntity( mangaId = manga.id, relevance = relevance, createdAt = System.currentTimeMillis(), ), ) } } } private fun SuggestionWithManga.toManga() = manga.toManga(emptySet(), null) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionsListQuickFilter.kt ================================================ package org.koitharu.kotatsu.suggestions.domain import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.MangaListQuickFilter import javax.inject.Inject class SuggestionsListQuickFilter @Inject constructor( private val settings: AppSettings, private val suggestionRepository: SuggestionRepository, ) : MangaListQuickFilter(settings) { override suspend fun getAvailableFilterOptions(): List = buildList(6) { suggestionRepository.getTopTags(5).mapTo(this) { ListFilterOption.Tag(it) } if (!settings.isNsfwContentDisabled && !settings.isSuggestionsExcludeNsfw) { add(ListFilterOption.Macro.NSFW) add(ListFilterOption.SFW) } suggestionRepository.getTopSources(3).mapTo(this) { ListFilterOption.Source(it) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/TagsBlacklist.kt ================================================ package org.koitharu.kotatsu.suggestions.domain import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.util.almostEquals class TagsBlacklist( private val tags: Set, private val threshold: Float, ) { fun isNotEmpty() = tags.isNotEmpty() operator fun contains(manga: Manga): Boolean { if (tags.isEmpty()) { return false } for (mangaTag in manga.tags) { for (tagTitle in tags) { if (mangaTag.title.almostEquals(tagTitle, threshold)) { return true } } } return false } operator fun contains(tag: MangaTag): Boolean = tags.any { it.almostEquals(tag.title, threshold) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsActivity.kt ================================================ package org.koitharu.kotatsu.suggestions.ui import org.koitharu.kotatsu.core.ui.FragmentContainerActivity class SuggestionsActivity : FragmentContainerActivity(SuggestionsFragment::class.java) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt ================================================ package org.koitharu.kotatsu.suggestions.ui import android.os.Bundle import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.core.view.MenuProvider import androidx.fragment.app.viewModels import com.google.android.material.snackbar.Snackbar import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.list.ui.MangaListFragment class SuggestionsFragment : MangaListFragment() { override val viewModel by viewModels() override val isSwipeRefreshEnabled = false override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) addMenuProvider(SuggestionMenuProvider()) } override fun onScrolledToEnd() = Unit override fun onCreateActionMode( controller: ListSelectionController, menuInflater: MenuInflater, menu: Menu, ): Boolean { menuInflater.inflate(R.menu.mode_remote, menu) return super.onCreateActionMode(controller, menuInflater, menu) } private inner class SuggestionMenuProvider : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.opt_suggestions, menu) } override fun onPrepareMenu(menu: Menu) { super.onPrepareMenu(menu) menu.findItem(R.id.action_settings_suggestions)?.isVisible = menu.findItem(R.id.action_settings) == null } override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { R.id.action_update -> { viewModel.updateSuggestions() Snackbar.make( requireViewBinding().recyclerView, R.string.suggestions_updating, Snackbar.LENGTH_LONG, ).show() true } R.id.action_settings_suggestions -> { router.openSuggestionsSettings() true } else -> false } } companion object { @Deprecated( "", ReplaceWith( "SuggestionsFragment()", "org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment", ), ) fun newInstance() = SuggestionsFragment() } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt ================================================ package org.koitharu.kotatsu.suggestions.ui import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.util.ext.onFirst import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.list.domain.QuickFilterListener import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository import org.koitharu.kotatsu.suggestions.domain.SuggestionsListQuickFilter import javax.inject.Inject import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.domain.model.LocalManga import kotlinx.coroutines.flow.SharedFlow @HiltViewModel class SuggestionsViewModel @Inject constructor( repository: SuggestionRepository, settings: AppSettings, private val mangaListMapper: MangaListMapper, private val quickFilter: SuggestionsListQuickFilter, private val suggestionsScheduler: SuggestionsWorker.Scheduler, mangaDataRepository: MangaDataRepository, @LocalStorageChanges localStorageChanges: SharedFlow, ) : MangaListViewModel(settings, mangaDataRepository, localStorageChanges), QuickFilterListener by quickFilter { override val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE_SUGGESTIONS) { suggestionsListMode } .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.suggestionsListMode) override val content = combine( quickFilter.appliedOptions.combineWithSettings().flatMapLatest { repository.observeAll(0, it) }, quickFilter.appliedOptions, observeListModeWithTriggers(), ) { list, filters, mode -> when { list.isEmpty() -> if (filters.isEmpty()) { listOf( EmptyState( icon = R.drawable.ic_empty_common, textPrimary = R.string.nothing_found, textSecondary = R.string.text_suggestion_holder, actionStringRes = 0, ), ) } else { listOfNotNull( quickFilter.filterItem(filters), EmptyState( icon = R.drawable.ic_empty_common, textPrimary = R.string.nothing_found, textSecondary = R.string.text_empty_holder_secondary_filtered, actionStringRes = 0, ), ) } else -> buildList(list.size + 1) { quickFilter.filterItem(filters)?.let(::add) mangaListMapper.toListModelList(this, list, mode) } } }.onStart { loadingCounter.increment() }.onFirst { loadingCounter.decrement() }.catch { emit(listOf(it.toErrorState(canRetry = false))) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) override fun onRefresh() = Unit override fun onRetry() = Unit fun updateSuggestions() { launchJob(Dispatchers.Default) { suggestionsScheduler.startNow() } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt ================================================ package org.koitharu.kotatsu.suggestions.ui import android.Manifest import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo import android.os.Build import android.provider.Settings import androidx.annotation.FloatRange import androidx.annotation.RequiresPermission import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.PendingIntentCompat import androidx.core.text.HtmlCompat import androidx.core.text.bold import androidx.core.text.buildSpannedString import androidx.core.text.parseAsHtml import androidx.hilt.work.HiltWorker import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.CoroutineWorker import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ForegroundInfo import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OutOfQuotaPolicy import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.await import androidx.work.workDataOf import coil3.ImageLoader import coil3.request.ImageRequest import dagger.Reusable import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.CloudFlareException import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler import org.koitharu.kotatsu.core.model.distinctById import org.koitharu.kotatsu.core.model.getLocale import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.ReaderIntent import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.LocaleComparator import org.koitharu.kotatsu.core.util.ext.asArrayList import org.koitharu.kotatsu.core.util.ext.awaitUniqueWorkInfoByName import org.koitharu.kotatsu.core.util.ext.awaitWorkInfosByTag import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.core.util.ext.flatten import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.sanitize import org.koitharu.kotatsu.core.util.ext.takeMostFrequent import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull import org.koitharu.kotatsu.core.util.ext.trySetForeground import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.almostEquals import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.sizeOrZero import org.koitharu.kotatsu.settings.work.PeriodicWorkScheduler import org.koitharu.kotatsu.suggestions.domain.MangaSuggestion import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist import java.util.concurrent.TimeUnit import javax.inject.Inject import kotlin.math.pow import kotlin.random.Random import androidx.appcompat.R as appcompatR @HiltWorker class SuggestionsWorker @AssistedInject constructor( @Assisted appContext: Context, @Assisted params: WorkerParameters, private val coil: ImageLoader, private val suggestionRepository: SuggestionRepository, private val historyRepository: HistoryRepository, private val favouritesRepository: FavouritesRepository, private val appSettings: AppSettings, private val captchaHandler: CaptchaHandler, private val workManager: WorkManager, private val mangaRepositoryFactory: MangaRepository.Factory, private val sourcesRepository: MangaSourcesRepository, ) : CoroutineWorker(appContext, params) { private val notificationManager by lazy { NotificationManagerCompat.from(appContext) } override suspend fun doWork(): Result { trySetForeground() if (!appSettings.isSuggestionsEnabled) { suggestionRepository.clear() return Result.success() } val count = doWorkImpl() val outputData = workDataOf(DATA_COUNT to count) return Result.success(outputData) } override suspend fun getForegroundInfo(): ForegroundInfo { val title = applicationContext.getString(R.string.suggestions_updating) val channel = NotificationChannelCompat.Builder(WORKER_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW) .setName(title) .setShowBadge(true) .setVibrationEnabled(false) .setSound(null, null) .setLightsEnabled(true) .build() notificationManager.createNotificationChannel(channel) val notification = NotificationCompat.Builder(applicationContext, WORKER_CHANNEL_ID) .setContentTitle(title) .setContentIntent( PendingIntentCompat.getActivity( applicationContext, 0, AppRouter.suggestionsSettingsIntent(applicationContext), 0, false, ), ).addAction( appcompatR.drawable.abc_ic_clear_material, applicationContext.getString(android.R.string.cancel), workManager.createCancelPendingIntent(id), ) .setPriority(NotificationCompat.PRIORITY_MIN) .setCategory(NotificationCompat.CATEGORY_SERVICE) .setDefaults(0) .setOngoing(false) .setSilent(true) .setProgress(0, 0, true) .setSmallIcon(android.R.drawable.stat_notify_sync) .setForegroundServiceBehavior( if (TAG_ONESHOT in tags) { NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE } else { NotificationCompat.FOREGROUND_SERVICE_DEFERRED }, ) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val actionIntent = PendingIntentCompat.getActivity( applicationContext, SETTINGS_ACTION_CODE, Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) .putExtra(Settings.EXTRA_APP_PACKAGE, applicationContext.packageName) .putExtra(Settings.EXTRA_CHANNEL_ID, WORKER_CHANNEL_ID), 0, false, ) notification.addAction( R.drawable.ic_settings, applicationContext.getString(R.string.notifications_settings), actionIntent, ) } return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { ForegroundInfo(WORKER_NOTIFICATION_ID, notification.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) } else { ForegroundInfo(WORKER_NOTIFICATION_ID, notification.build()) } } private suspend fun doWorkImpl(): Int { val seed = ( historyRepository.getList(0, 20) + favouritesRepository.getLastManga(20) ).distinctById() val sources = getSources() if (seed.isEmpty() || sources.isEmpty()) { return 0 } val tagsBlacklist = TagsBlacklist(appSettings.suggestionsTagsBlacklist, TAG_EQ_THRESHOLD) val tags = seed.flatMap { it.tags.map { x -> x.title } }.takeMostFrequent(10) val semaphore = Semaphore(MAX_PARALLELISM) val producer = channelFlow { for (it in sources) { if (it.isNsfw() && (appSettings.isSuggestionsExcludeNsfw || appSettings.isNsfwContentDisabled)) { continue } launch { semaphore.withPermit { send(getList(it, tags, tagsBlacklist)) } } } } val suggestions = producer .flatten() .take(MAX_RAW_RESULTS) .map { manga -> MangaSuggestion( manga = manga, relevance = computeRelevance(manga.tags, tags), ) }.toList() .sortedBy { it.relevance } .take(MAX_RESULTS) suggestionRepository.replace(suggestions) if (appSettings.isSuggestionsNotificationAvailable && applicationContext.checkNotificationPermission(MANGA_CHANNEL_ID) ) { for (i in 0..3) { try { val manga = suggestions[Random.nextInt(0, suggestions.size / 3)] val details = mangaRepositoryFactory.create(manga.manga.source) .getDetails(manga.manga) if (details.chapters.isNullOrEmpty()) { continue } if (details.rating > 0 && details.rating < RATING_MIN) { continue } if (details.isNsfw() && (appSettings.isSuggestionsExcludeNsfw || appSettings.isNsfwContentDisabled)) { continue } if (details in tagsBlacklist) { continue } showNotification(details) break } catch (e: CancellationException) { throw e } catch (e: Exception) { e.printStackTraceDebug() } } } return suggestions.size } private suspend fun getSources(): List { if (appSettings.isSuggestionsIncludeDisabledSources) { val result = sourcesRepository.allMangaSources.toMutableList() result.addAll(sourcesRepository.getExternalSources()) result.shuffle() result.sortWith(compareBy(nullsLast(LocaleComparator())) { it.getLocale() }) return result } else { return sourcesRepository.getEnabledSources().shuffled() } } private suspend fun getList( source: MangaSource, tags: List, blacklist: TagsBlacklist, ): List = runCatchingCancellable { val repository = mangaRepositoryFactory.create(source) val availableOrders = repository.sortOrders val order = preferredSortOrders.first { it in availableOrders } val availableTags = repository.getFilterOptions().availableTags val tag = tags.firstNotNullOfOrNull { title -> availableTags.find { x -> x !in blacklist && x.title.almostEquals(title, TAG_EQ_THRESHOLD) } } val list = repository.getList( offset = 0, order = order, filter = MangaListFilter(tags = setOfNotNull(tag)), ).asArrayList() if (appSettings.isSuggestionsExcludeNsfw) { list.removeAll { it.isNsfw() } } if (blacklist.isNotEmpty()) { list.removeAll { manga -> manga in blacklist } } list.shuffle() list.take(MAX_SOURCE_RESULTS) }.onFailure { e -> if (e is CloudFlareException) { captchaHandler.handle(e) } e.printStackTraceDebug() }.getOrDefault(emptyList()) @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) private suspend fun showNotification(manga: Manga) { val channel = NotificationChannelCompat.Builder(MANGA_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT) .setName(applicationContext.getString(R.string.suggestions)) .setDescription(applicationContext.getString(R.string.suggestions_summary)) .setLightsEnabled(true) .setShowBadge(true) .build() notificationManager.createNotificationChannel(channel) val id = manga.url.hashCode() val title = applicationContext.getString(R.string.suggestion_manga, manga.title) val builder = NotificationCompat.Builder(applicationContext, MANGA_CHANNEL_ID) val tagsText = manga.tags.joinToString(", ") { it.title } with(builder) { setContentText(tagsText) setContentTitle(title) setGroup(GROUP_SUGGESTION) setLargeIcon( coil.execute( ImageRequest.Builder(applicationContext) .data(manga.coverUrl) .mangaSourceExtra(manga.source) .build(), ).toBitmapOrNull(), ) setSmallIcon(R.drawable.ic_stat_suggestion) val description = manga.description?.parseAsHtml(HtmlCompat.FROM_HTML_MODE_COMPACT)?.sanitize() if (!description.isNullOrBlank()) { val style = NotificationCompat.BigTextStyle() style.bigText( buildSpannedString { append(tagsText) val chaptersCount = manga.chapters.sizeOrZero() appendLine() bold { append( applicationContext.resources.getQuantityStringSafe( R.plurals.chapters, chaptersCount, chaptersCount, ), ) } appendLine() append(description) }, ) style.setBigContentTitle(title) setStyle(style) } val intent = AppRouter.detailsIntent(applicationContext, manga) setContentIntent( PendingIntentCompat.getActivity( applicationContext, id, intent, PendingIntent.FLAG_UPDATE_CURRENT, false, ), ) setAutoCancel(true) setCategory(NotificationCompat.CATEGORY_RECOMMENDATION) setVisibility(if (manga.isNsfw()) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PRIVATE) setShortcutId(manga.id.toString()) priority = NotificationCompat.PRIORITY_DEFAULT addAction( R.drawable.ic_read, applicationContext.getString(R.string.read), PendingIntentCompat.getActivity( applicationContext, id + 2, ReaderIntent.Builder(applicationContext).manga(manga).build().intent, 0, false, ), ) addAction( R.drawable.ic_suggestion, applicationContext.getString(R.string.more), PendingIntentCompat.getActivity( applicationContext, 0, AppRouter.suggestionsIntent(applicationContext), 0, false, ), ) } notificationManager.notify(TAG, id, builder.build()) } @FloatRange(from = 0.0, to = 1.0) private fun computeRelevance(mangaTags: Set, allTags: List): Float { val maxWeight = (allTags.size + allTags.size + 1 - mangaTags.size) * mangaTags.size / 2.0 val weight = mangaTags.sumOf { tag -> val index = allTags.inexactIndexOf(tag.title, TAG_EQ_THRESHOLD) if (index < 0) 0 else allTags.size - index } return (weight / maxWeight).pow(2.0).toFloat() } private fun Iterable.inexactIndexOf(element: String, threshold: Float): Int { forEachIndexed { i, t -> if (t.almostEquals(element, threshold)) { return i } } return -1 } @Reusable class Scheduler @Inject constructor( private val workManager: WorkManager, private val settings: AppSettings, ) : PeriodicWorkScheduler { override suspend fun schedule() { val request = PeriodicWorkRequestBuilder(6, TimeUnit.HOURS) .setConstraints(createConstraints()) .addTag(TAG) .setBackoffCriteria(BackoffPolicy.LINEAR, 1, TimeUnit.HOURS) .build() workManager .enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, request) .await() } override suspend fun unschedule() { workManager .cancelUniqueWork(TAG) .await() } override suspend fun isScheduled(): Boolean { return workManager .awaitUniqueWorkInfoByName(TAG) .any { !it.state.isFinished } } suspend fun startNow() { if (workManager.awaitWorkInfosByTag(TAG_ONESHOT).any { !it.state.isFinished }) { return } val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() val request = OneTimeWorkRequestBuilder() .setConstraints(constraints) .addTag(TAG_ONESHOT) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build() workManager.enqueue(request).await() } private fun createConstraints() = Constraints.Builder() .setRequiredNetworkType(if (settings.isSuggestionsWiFiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) .setRequiresBatteryNotLow(true) .build() } private companion object { const val TAG = "suggestions" const val TAG_ONESHOT = "suggestions_oneshot" const val DATA_COUNT = "count" const val WORKER_CHANNEL_ID = "suggestion_worker" const val MANGA_CHANNEL_ID = "suggestions" const val GROUP_SUGGESTION = "org.koitharu.kotatsu.SUGGESTIONS" const val WORKER_NOTIFICATION_ID = 36 const val MAX_RESULTS = 160 const val MAX_PARALLELISM = 3 const val MAX_SOURCE_RESULTS = 20 const val MAX_RAW_RESULTS = 280 const val TAG_EQ_THRESHOLD = 0.4f const val RATING_MIN = 0.5f const val SETTINGS_ACTION_CODE = 4 val preferredSortOrders = listOf( SortOrder.UPDATED, SortOrder.NEWEST, SortOrder.POPULARITY, SortOrder.RATING, ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncAuthApi.kt ================================================ package org.koitharu.kotatsu.sync.data import dagger.Reusable import okhttp3.OkHttpClient import okhttp3.Request import org.json.JSONObject import org.koitharu.kotatsu.core.exceptions.SyncApiException import org.koitharu.kotatsu.core.network.BaseHttpClient import org.koitharu.kotatsu.core.util.ext.toRequestBody import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.parseJson import org.koitharu.kotatsu.parsers.util.parseRaw import org.koitharu.kotatsu.parsers.util.removeSurrounding import javax.inject.Inject @Reusable class SyncAuthApi @Inject constructor( @BaseHttpClient private val okHttpClient: OkHttpClient, ) { suspend fun authenticate(syncURL: String, email: String, password: String): String { val body = JSONObject( mapOf("email" to email, "password" to password), ).toRequestBody() val request = Request.Builder() .url("$syncURL/auth") .post(body) .build() val response = okHttpClient.newCall(request).await() if (response.isSuccessful) { return response.parseJson().getString("token") } else { val code = response.code val message = response.parseRaw().removeSurrounding('"') throw SyncApiException(message, code) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncAuthenticator.kt ================================================ package org.koitharu.kotatsu.sync.data import android.accounts.Account import android.accounts.AccountManager import android.content.Context import kotlinx.coroutines.runBlocking import okhttp3.Authenticator import okhttp3.Request import okhttp3.Response import okhttp3.Route import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.network.CommonHeaders class SyncAuthenticator( context: Context, private val account: Account, private val syncSettings: SyncSettings, private val authApi: SyncAuthApi, ) : Authenticator { private val accountManager = AccountManager.get(context) private val tokenType = context.getString(R.string.account_type_sync) override fun authenticate(route: Route?, response: Response): Request? { val newToken = tryRefreshToken() ?: return null accountManager.setAuthToken(account, tokenType, newToken) return response.request.newBuilder() .header(CommonHeaders.AUTHORIZATION, "Bearer $newToken") .build() } private fun tryRefreshToken() = runCatching { runBlocking { authApi.authenticate( syncSettings.syncUrl, account.name, accountManager.getPassword(account), ) } }.getOrNull() } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncInterceptor.kt ================================================ package org.koitharu.kotatsu.sync.data import android.accounts.Account import android.accounts.AccountManager import android.content.Context import okhttp3.Interceptor import okhttp3.Response import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.db.DATABASE_VERSION import org.koitharu.kotatsu.core.network.CommonHeaders class SyncInterceptor( context: Context, private val account: Account, ) : Interceptor { private val accountManager = AccountManager.get(context) private val tokenType = context.getString(R.string.account_type_sync) override fun intercept(chain: Interceptor.Chain): Response { val token = accountManager.peekAuthToken(account, tokenType) val requestBuilder = chain.request().newBuilder() if (token != null) { requestBuilder.header(CommonHeaders.AUTHORIZATION, "Bearer $token") } requestBuilder.header("X-App-Version", BuildConfig.VERSION_CODE.toString()) requestBuilder.header("X-Db-Version", DATABASE_VERSION.toString()) return chain.proceed(requestBuilder.build()) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncSettings.kt ================================================ package org.koitharu.kotatsu.sync.data import android.accounts.Account import android.accounts.AccountManager import android.content.Context import androidx.annotation.WorkerThread import dagger.hilt.android.qualifiers.ApplicationContext import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.isHttpUrl import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import javax.inject.Inject class SyncSettings( context: Context, private val account: Account?, ) { @Inject constructor(@ApplicationContext context: Context) : this( context, AccountManager.get(context)?.getAccountsByType( context.getString(R.string.account_type_sync), )?.firstOrNull(), ) private val accountManager = AccountManager.get(context) private val defaultSyncUrl = context.resources.getStringArray(R.array.sync_url_list).first() @get:WorkerThread @set:WorkerThread var syncUrl: String get() = account?.let { accountManager.getUserData(it, KEY_SYNC_URL)?.withHttpSchema() }.ifNullOrEmpty { defaultSyncUrl } set(value) { account?.let { accountManager.setUserData(it, KEY_SYNC_URL, value) } } companion object { private fun String.withHttpSchema(): String = if (isHttpUrl()) { this } else { "http://$this" } const val KEY_SYNC_URL = "host" } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/FavouriteCategorySyncDto.kt ================================================ package org.koitharu.kotatsu.sync.data.model import android.database.Cursor import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.koitharu.kotatsu.core.util.ext.buildContentValues import org.koitharu.kotatsu.core.util.ext.getBoolean @Serializable data class FavouriteCategorySyncDto( @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, @SerialName("track") val track: Boolean, @SerialName("show_in_lib") val isVisibleInLibrary: Boolean, @SerialName("deleted_at") val deletedAt: Long, ) { constructor(cursor: Cursor) : this( categoryId = cursor.getInt(cursor.getColumnIndexOrThrow("category_id")), createdAt = cursor.getLong(cursor.getColumnIndexOrThrow("created_at")), sortKey = cursor.getInt(cursor.getColumnIndexOrThrow("sort_key")), title = cursor.getString(cursor.getColumnIndexOrThrow("title")), order = cursor.getString(cursor.getColumnIndexOrThrow("order")), track = cursor.getBoolean(cursor.getColumnIndexOrThrow("track")), isVisibleInLibrary = cursor.getBoolean(cursor.getColumnIndexOrThrow("show_in_lib")), deletedAt = cursor.getLong(cursor.getColumnIndexOrThrow("deleted_at")), ) fun toContentValues() = buildContentValues(8) { put("category_id", categoryId) put("created_at", createdAt) put("sort_key", sortKey) put("title", title) put("`order`", order) put("track", track) put("show_in_lib", isVisibleInLibrary) put("deleted_at", deletedAt) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/FavouriteSyncDto.kt ================================================ package org.koitharu.kotatsu.sync.data.model import android.database.Cursor import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.koitharu.kotatsu.core.util.ext.buildContentValues import org.koitharu.kotatsu.core.util.ext.getBoolean @Serializable data class FavouriteSyncDto( @SerialName("manga_id") val mangaId: Long, @SerialName("manga") val manga: MangaSyncDto, @SerialName("category_id") val categoryId: Int, @SerialName("sort_key") val sortKey: Int, @SerialName("pinned") val pinned: Boolean, @SerialName("created_at") val createdAt: Long, @SerialName("deleted_at") var deletedAt: Long, ) { constructor(cursor: Cursor, manga: MangaSyncDto) : this( mangaId = cursor.getLong(cursor.getColumnIndexOrThrow("manga_id")), manga = manga, categoryId = cursor.getInt(cursor.getColumnIndexOrThrow("category_id")), sortKey = cursor.getInt(cursor.getColumnIndexOrThrow("sort_key")), pinned = cursor.getBoolean(cursor.getColumnIndexOrThrow("pinned")), createdAt = cursor.getLong(cursor.getColumnIndexOrThrow("created_at")), deletedAt = cursor.getLong(cursor.getColumnIndexOrThrow("deleted_at")), ) fun toContentValues() = buildContentValues(6) { put("manga_id", mangaId) put("category_id", categoryId) put("sort_key", sortKey) put("pinned", pinned) put("created_at", createdAt) put("deleted_at", deletedAt) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/HistorySyncDto.kt ================================================ package org.koitharu.kotatsu.sync.data.model import android.database.Cursor import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.koitharu.kotatsu.core.util.ext.buildContentValues @Serializable data class HistorySyncDto( @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, @SerialName("deleted_at") val deletedAt: Long, @SerialName("chapters") val chaptersCount: Int, @SerialName("manga") val manga: MangaSyncDto, ) { constructor(cursor: Cursor, manga: MangaSyncDto) : this( mangaId = cursor.getLong(cursor.getColumnIndexOrThrow("manga_id")), createdAt = cursor.getLong(cursor.getColumnIndexOrThrow("created_at")), updatedAt = cursor.getLong(cursor.getColumnIndexOrThrow("updated_at")), chapterId = cursor.getLong(cursor.getColumnIndexOrThrow("chapter_id")), page = cursor.getInt(cursor.getColumnIndexOrThrow("page")), scroll = cursor.getFloat(cursor.getColumnIndexOrThrow("scroll")), percent = cursor.getFloat(cursor.getColumnIndexOrThrow("percent")), deletedAt = cursor.getLong(cursor.getColumnIndexOrThrow("deleted_at")), chaptersCount = cursor.getInt(cursor.getColumnIndexOrThrow("chapters")), manga = manga, ) fun toContentValues() = buildContentValues(9) { put("manga_id", mangaId) put("created_at", createdAt) put("updated_at", updatedAt) put("chapter_id", chapterId) put("page", page) put("scroll", scroll) put("percent", percent) put("deleted_at", deletedAt) put("chapters", chaptersCount) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/MangaSyncDto.kt ================================================ package org.koitharu.kotatsu.sync.data.model import android.database.Cursor import androidx.core.database.getStringOrNull import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.koitharu.kotatsu.core.util.ext.buildContentValues @Serializable data class MangaSyncDto( @SerialName("manga_id") val id: Long, @SerialName("title") val title: String, @SerialName("alt_title") val altTitle: String?, @SerialName("url") val url: String, @SerialName("public_url") val publicUrl: String, @SerialName("rating") val rating: Float, @SerialName("content_rating") val contentRating: String?, @SerialName("cover_url") val coverUrl: String, @SerialName("large_cover_url") val largeCoverUrl: String?, @SerialName("tags") val tags: Set, @SerialName("state") val state: String?, @SerialName("author") val author: String?, @SerialName("source") val source: String, ) { constructor(cursor: Cursor, tags: Set) : this( id = cursor.getLong(cursor.getColumnIndexOrThrow("manga_id")), title = cursor.getString(cursor.getColumnIndexOrThrow("title")), altTitle = cursor.getStringOrNull(cursor.getColumnIndexOrThrow("alt_title")), url = cursor.getString(cursor.getColumnIndexOrThrow("url")), publicUrl = cursor.getString(cursor.getColumnIndexOrThrow("public_url")), rating = cursor.getFloat(cursor.getColumnIndexOrThrow("rating")), contentRating = cursor.getStringOrNull(cursor.getColumnIndexOrThrow("content_rating")), coverUrl = cursor.getString(cursor.getColumnIndexOrThrow("cover_url")), largeCoverUrl = cursor.getStringOrNull(cursor.getColumnIndexOrThrow("large_cover_url")), tags = tags, state = cursor.getStringOrNull(cursor.getColumnIndexOrThrow("state")), author = cursor.getStringOrNull(cursor.getColumnIndexOrThrow("author")), source = cursor.getString(cursor.getColumnIndexOrThrow("source")), ) fun toContentValues() = buildContentValues(12) { put("manga_id", id) put("title", title) put("alt_title", altTitle) put("url", url) put("public_url", publicUrl) put("rating", rating) put("content_rating", contentRating) put("cover_url", coverUrl) put("large_cover_url", largeCoverUrl) put("state", state) put("author", author) put("source", source) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/MangaTagSyncDto.kt ================================================ package org.koitharu.kotatsu.sync.data.model import android.database.Cursor import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.koitharu.kotatsu.core.util.ext.buildContentValues @Serializable data class MangaTagSyncDto( @SerialName("tag_id") val id: Long, @SerialName("title") val title: String, @SerialName("key") val key: String, @SerialName("source") val source: String, ) { constructor(cursor: Cursor) : this( id = cursor.getLong(cursor.getColumnIndexOrThrow("tag_id")), title = cursor.getString(cursor.getColumnIndexOrThrow("title")), key = cursor.getString(cursor.getColumnIndexOrThrow("key")), source = cursor.getString(cursor.getColumnIndexOrThrow("source")), ) fun toContentValues() = buildContentValues(4) { put("tag_id", id) put("title", title) put("key", key) put("source", source) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/SyncDto.kt ================================================ package org.koitharu.kotatsu.sync.data.model import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class SyncDto( @SerialName("history") val history: List? = null, @SerialName("categories") val categories: List? = null, @SerialName("favourites") val favourites: List? = null, @SerialName("timestamp") val timestamp: Long, ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/sync/domain/SyncAuthResult.kt ================================================ package org.koitharu.kotatsu.sync.domain data class SyncAuthResult( val syncURL: String, val email: String, val password: String, val token: String, ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/sync/domain/SyncController.kt ================================================ package org.koitharu.kotatsu.sync.domain import android.accounts.Account import android.accounts.AccountManager import android.content.ContentResolver import android.content.ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE import android.content.Context import android.os.Bundle import androidx.room.InvalidationTracker import androidx.room.withTransaction import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow 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.db.MangaDatabase 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 org.koitharu.kotatsu.core.util.ext.processLifecycleScope import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Provider import javax.inject.Singleton @Singleton class SyncController @Inject constructor( @ApplicationContext context: Context, private val dbProvider: Provider, ) : InvalidationTracker.Observer(arrayOf(TABLE_HISTORY, TABLE_FAVOURITES, TABLE_FAVOURITE_CATEGORIES)) { private val authorityHistory = context.getString(R.string.sync_authority_history) private val authorityFavourites = context.getString(R.string.sync_authority_favourites) private val am = AccountManager.get(context) private val accountType = context.getString(R.string.account_type_sync) private val mutex = Mutex() private val defaultGcPeriod = TimeUnit.DAYS.toMillis(2) // gc period if sync disabled override fun onInvalidated(tables: Set) { val favourites = (TABLE_FAVOURITES in tables || TABLE_FAVOURITE_CATEGORIES in tables) && !isSyncActiveOrPending(authorityFavourites) val history = TABLE_HISTORY in tables && !isSyncActiveOrPending(authorityHistory) if (favourites || history) { requestSync(favourites, history) } } fun isEnabled(account: Account): Boolean { return ContentResolver.getMasterSyncAutomatically() && (ContentResolver.getSyncAutomatically( account, authorityFavourites, ) || ContentResolver.getSyncAutomatically( account, authorityHistory, )) } fun getLastSync(account: Account, authority: String): Long { val key = "last_sync_" + authority.substringAfterLast('.') val rawValue = am.getUserData(account, key) ?: return 0L return rawValue.toLongOrNull() ?: 0L } fun observeSyncStatus(): Flow = callbackFlow { val handle = ContentResolver.addStatusChangeListener(SYNC_OBSERVER_TYPE_ACTIVE) { which -> trySendBlocking(which and SYNC_OBSERVER_TYPE_ACTIVE != 0) } awaitClose { ContentResolver.removeStatusChangeListener(handle) } } suspend fun requestFullSync() = withContext(Dispatchers.Default) { requestSyncImpl(favourites = true, history = true) } private fun requestSync(favourites: Boolean, history: Boolean) = processLifecycleScope.launch(Dispatchers.Default) { requestSyncImpl(favourites = favourites, history = history) } private suspend fun requestSyncImpl(favourites: Boolean, history: Boolean) = mutex.withLock { if (!favourites && !history) { return } val db = dbProvider.get() val account = peekAccount() if (account == null || !ContentResolver.getMasterSyncAutomatically()) { db.gc(favourites, history) return } var gcHistory = false var gcFavourites = false if (favourites) { if (ContentResolver.getSyncAutomatically(account, authorityFavourites)) { ContentResolver.requestSync(account, authorityFavourites, Bundle.EMPTY) } else { gcFavourites = true } } if (history) { if (ContentResolver.getSyncAutomatically(account, authorityHistory)) { ContentResolver.requestSync(account, authorityHistory, Bundle.EMPTY) } else { gcHistory = true } } if (gcHistory || gcFavourites) { db.gc(gcFavourites, gcHistory) } } private fun peekAccount(): Account? { return am.getAccountsByType(accountType).firstOrNull() } private suspend fun MangaDatabase.gc(favourites: Boolean, history: Boolean) = withTransaction { val deletedAt = System.currentTimeMillis() - defaultGcPeriod if (history) { getHistoryDao().gc(deletedAt) } if (favourites) { getFavouritesDao().gc(deletedAt) getFavouriteCategoriesDao().gc(deletedAt) } } private fun isSyncActiveOrPending(authority: String): Boolean { val account = peekAccount() ?: return false return ContentResolver.isSyncActive(account, authority) || ContentResolver.isSyncPending(account, authority) } companion object { @JvmStatic fun setLastSync(context: Context, account: Account, authority: String, time: Long) { val key = "last_sync_" + authority.substringAfterLast('.') val am = AccountManager.get(context) am.setUserData(account, key, time.toString()) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/sync/domain/SyncHelper.kt ================================================ package org.koitharu.kotatsu.sync.domain import android.accounts.Account import android.content.ContentProviderClient import android.content.ContentProviderOperation import android.content.ContentProviderResult import android.content.Context import android.content.OperationApplicationException import android.content.SyncResult import android.content.SyncStats import android.database.Cursor import android.util.Log import androidx.annotation.WorkerThread import androidx.core.net.toUri import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import okio.IOException import org.jetbrains.annotations.Blocking import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R 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 org.koitharu.kotatsu.core.db.TABLE_MANGA import org.koitharu.kotatsu.core.db.TABLE_MANGA_TAGS import org.koitharu.kotatsu.core.db.TABLE_TAGS import org.koitharu.kotatsu.core.network.BaseHttpClient import org.koitharu.kotatsu.core.util.ext.buildContentValues import org.koitharu.kotatsu.core.util.ext.map import org.koitharu.kotatsu.core.util.ext.mapToSet import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.sync.data.SyncAuthApi import org.koitharu.kotatsu.sync.data.SyncAuthenticator import org.koitharu.kotatsu.sync.data.SyncInterceptor import org.koitharu.kotatsu.sync.data.SyncSettings import org.koitharu.kotatsu.sync.data.model.FavouriteCategorySyncDto import org.koitharu.kotatsu.sync.data.model.FavouriteSyncDto import org.koitharu.kotatsu.sync.data.model.HistorySyncDto import org.koitharu.kotatsu.sync.data.model.MangaSyncDto import org.koitharu.kotatsu.sync.data.model.MangaTagSyncDto import org.koitharu.kotatsu.sync.data.model.SyncDto import java.net.HttpURLConnection import java.util.concurrent.TimeUnit class SyncHelper @AssistedInject constructor( @ApplicationContext context: Context, @BaseHttpClient baseHttpClient: OkHttpClient, @Assisted private val account: Account, @Assisted private val provider: ContentProviderClient, private val settings: SyncSettings, ) { private val authorityHistory = context.getString(R.string.sync_authority_history) private val authorityFavourites = context.getString(R.string.sync_authority_favourites) private val mediaTypeJson = "application/json".toMediaType() private val httpClient = baseHttpClient.newBuilder() .authenticator(SyncAuthenticator(context, account, settings, SyncAuthApi(OkHttpClient()))) .addInterceptor(SyncInterceptor(context, account)) .build() private val baseUrl: String by lazy { settings.syncUrl } private val defaultGcPeriod: Long // gc period if sync enabled get() = TimeUnit.DAYS.toMillis(4) @WorkerThread fun syncFavourites(stats: SyncStats) { val payload = Json.encodeToString( SyncDto( history = null, favourites = getFavourites(), categories = getFavouriteCategories(), timestamp = System.currentTimeMillis(), ), ) val request = Request.Builder() .url("$baseUrl/resource/$TABLE_FAVOURITES") .post(payload.toRequestBody(mediaTypeJson)) .build() val response = httpClient.newCall(request).execute().parseDtoOrNull() response?.categories?.let { categories -> val categoriesResult = upsertFavouriteCategories(categories) stats.numDeletes += categoriesResult.firstOrNull()?.count?.toLong() ?: 0L stats.numInserts += categoriesResult.drop(1).sumOf { it.count?.toLong() ?: 0L } } response?.favourites?.let { favourites -> val favouritesResult = upsertFavourites(favourites) stats.numDeletes += favouritesResult.firstOrNull()?.count?.toLong() ?: 0L stats.numInserts += favouritesResult.drop(1).sumOf { it.count?.toLong() ?: 0L } stats.numEntries += stats.numInserts + stats.numDeletes } gcFavourites() } @Blocking @WorkerThread fun syncHistory(stats: SyncStats) { val payload = Json.encodeToString( SyncDto( history = getHistory(), favourites = null, categories = null, timestamp = System.currentTimeMillis(), ), ) val request = Request.Builder() .url("$baseUrl/resource/$TABLE_HISTORY") .post(payload.toRequestBody(mediaTypeJson)) .build() val response = httpClient.newCall(request).execute().parseDtoOrNull() response?.history?.let { history -> val result = upsertHistory(history) stats.numDeletes += result.firstOrNull()?.count?.toLong() ?: 0L stats.numInserts += result.drop(1).sumOf { it.count?.toLong() ?: 0L } stats.numEntries += stats.numInserts + stats.numDeletes } gcHistory() } fun onError(e: Throwable) { e.printStackTraceDebug() } fun onSyncComplete(result: SyncResult) { if (BuildConfig.DEBUG) { Log.i("Sync", "Sync finished: ${result.toDebugString()}") } } private fun upsertHistory(history: List): Array { val uri = uri(authorityHistory, TABLE_HISTORY) val operations = ArrayList() history.mapTo(operations) { operations.addAll(upsertManga(it.manga, authorityHistory)) ContentProviderOperation.newInsert(uri) .withValues(it.toContentValues()) .build() } return provider.applyBatch(operations) } private fun upsertFavouriteCategories(categories: List): Array { val uri = uri(authorityFavourites, TABLE_FAVOURITE_CATEGORIES) val operations = ArrayList() categories.mapTo(operations) { ContentProviderOperation.newInsert(uri) .withValues(it.toContentValues()) .build() } return provider.applyBatch(operations) } private fun upsertFavourites(favourites: List): Array { val uri = uri(authorityFavourites, TABLE_FAVOURITES) val operations = ArrayList() favourites.mapTo(operations) { operations.addAll(upsertManga(it.manga, authorityFavourites)) ContentProviderOperation.newInsert(uri) .withValues(it.toContentValues()) .build() } return provider.applyBatch(operations) } private fun upsertManga(manga: MangaSyncDto, authority: String): List { val tags = manga.tags val result = ArrayList(tags.size * 2 + 1) for (tag in tags) { result += ContentProviderOperation.newInsert(uri(authority, TABLE_TAGS)) .withValues(tag.toContentValues()) .build() result += ContentProviderOperation.newInsert(uri(authority, TABLE_MANGA_TAGS)) .withValues( buildContentValues(2) { put("manga_id", manga.id) put("tag_id", tag.id) }, ).build() } result.add( 0, ContentProviderOperation.newInsert(uri(authority, TABLE_MANGA)) .withValues(manga.toContentValues()) .build(), ) return result } private fun getHistory(): List { return provider.query(authorityHistory, TABLE_HISTORY).use { cursor -> val result = ArrayList(cursor.count) if (cursor.moveToFirst()) { do { val mangaId = cursor.getLong(cursor.getColumnIndexOrThrow("manga_id")) result.add(HistorySyncDto(cursor, getManga(authorityHistory, mangaId))) } while (cursor.moveToNext()) } result } } private fun getFavourites(): List { return provider.query(authorityFavourites, TABLE_FAVOURITES).map { cursor -> val manga = getManga(authorityFavourites, cursor.getLong(cursor.getColumnIndexOrThrow("manga_id"))) FavouriteSyncDto(cursor, manga) } } private fun getFavouriteCategories(): List = provider.query(authorityFavourites, TABLE_FAVOURITE_CATEGORIES).map { cursor -> FavouriteCategorySyncDto(cursor) } private fun getManga(authority: String, id: Long): MangaSyncDto { val tags = requireNotNull( provider.query( uri(authority, TABLE_MANGA_TAGS), arrayOf("tag_id"), "manga_id = ?", arrayOf(id.toString()), null, )?.mapToSet { val tagId = it.getLong(it.getColumnIndexOrThrow("tag_id")) getTag(authority, tagId) }, ) return requireNotNull( provider.query( uri(authority, TABLE_MANGA), null, "manga_id = ?", arrayOf(id.toString()), null, )?.use { cursor -> cursor.moveToFirst() MangaSyncDto(cursor, tags) }, ) } private fun getTag(authority: String, tagId: Long): MangaTagSyncDto = requireNotNull( provider.query( uri(authority, TABLE_TAGS), null, "tag_id = ?", arrayOf(tagId.toString()), null, )?.use { cursor -> if (cursor.moveToFirst()) { MangaTagSyncDto(cursor) } else { null } }, ) private fun gcFavourites() { val deletedAt = System.currentTimeMillis() - defaultGcPeriod val selection = "deleted_at != 0 AND deleted_at < ?" val args = arrayOf(deletedAt.toString()) provider.delete(uri(authorityFavourites, TABLE_FAVOURITES), selection, args) provider.delete(uri(authorityFavourites, TABLE_FAVOURITE_CATEGORIES), selection, args) } private fun gcHistory() { val deletedAt = System.currentTimeMillis() - defaultGcPeriod val selection = "deleted_at != 0 AND deleted_at < ?" val args = arrayOf(deletedAt.toString()) provider.delete(uri(authorityHistory, TABLE_HISTORY), selection, args) } private fun ContentProviderClient.query(authority: String, table: String): Cursor { val uri = uri(authority, table) return query(uri, null, null, null, null) ?: throw OperationApplicationException("Query failed: $uri") } private fun uri(authority: String, table: String) = "content://$authority/$table".toUri() private fun Response.parseDtoOrNull(): SyncDto? = use { when { !isSuccessful -> throw IOException(body.string()) code == HttpURLConnection.HTTP_NO_CONTENT -> null else -> Json.decodeFromString(body.string()) } } @AssistedFactory interface Factory { fun create( account: Account, contentProviderClient: ContentProviderClient, ): SyncHelper } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAccountAuthenticator.kt ================================================ package org.koitharu.kotatsu.sync.ui import android.accounts.AbstractAccountAuthenticator import android.accounts.Account import android.accounts.AccountAuthenticatorResponse import android.accounts.AccountManager import android.content.Context import android.content.Intent import android.os.Bundle import android.text.TextUtils class SyncAccountAuthenticator(private val context: Context) : AbstractAccountAuthenticator(context) { override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?): Bundle? = null override fun addAccount( response: AccountAuthenticatorResponse?, accountType: String?, authTokenType: String?, requiredFeatures: Array?, options: Bundle?, ): Bundle { val intent = Intent(context, SyncAuthActivity::class.java) intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response) val bundle = Bundle() if (options != null) { bundle.putAll(options) } bundle.putParcelable(AccountManager.KEY_INTENT, intent) return bundle } override fun confirmCredentials( response: AccountAuthenticatorResponse?, account: Account?, options: Bundle?, ): Bundle? = null override fun getAuthToken( response: AccountAuthenticatorResponse?, account: Account, authTokenType: String?, options: Bundle?, ): Bundle { val result = Bundle() val am = AccountManager.get(context.applicationContext) val authToken = am.peekAuthToken(account, authTokenType) if (!TextUtils.isEmpty(authToken)) { result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name) result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type) result.putString(AccountManager.KEY_AUTHTOKEN, authToken) } else { val intent = Intent(context, SyncAuthActivity::class.java) intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response) val bundle = Bundle() if (options != null) { bundle.putAll(options) } bundle.putParcelable(AccountManager.KEY_INTENT, intent) } return result } override fun getAuthTokenLabel(authTokenType: String?): String? = null override fun updateCredentials( response: AccountAuthenticatorResponse?, account: Account?, authTokenType: String?, options: Bundle?, ): Bundle? = null override fun hasFeatures( response: AccountAuthenticatorResponse?, account: Account?, features: Array?, ): Bundle? = null } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAdapterEntryPoint.kt ================================================ package org.koitharu.kotatsu.sync.ui import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import org.koitharu.kotatsu.sync.domain.SyncHelper @EntryPoint @InstallIn(SingletonComponent::class) interface SyncAdapterEntryPoint { val syncHelperFactory: SyncHelper.Factory } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthActivity.kt ================================================ package org.koitharu.kotatsu.sync.ui import android.accounts.Account import android.accounts.AccountAuthenticatorResponse import android.accounts.AccountManager import android.os.Bundle import android.text.Editable import android.view.View import android.view.ViewGroup.MarginLayoutParams import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.activity.viewModels import androidx.core.view.WindowInsetsCompat import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.fragment.app.FragmentResultListener import androidx.transition.TransitionManager import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.transition.MaterialSharedAxis import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.util.DefaultTextWatcher import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat 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.ActivitySyncAuthBinding import org.koitharu.kotatsu.sync.data.SyncSettings import org.koitharu.kotatsu.sync.domain.SyncAuthResult private const val PAGE_EMAIL = 0 private const val PAGE_PASSWORD = 1 private const val PASSWORD_MIN_LENGTH = 4 @AndroidEntryPoint class SyncAuthActivity : BaseActivity(), View.OnClickListener, FragmentResultListener, DefaultTextWatcher { private var accountAuthenticatorResponse: AccountAuthenticatorResponse? = null private var resultBundle: Bundle? = null private val pageBackCallback = PageBackCallback() private val regexEmail = Regex("^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}$", RegexOption.IGNORE_CASE) private val viewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivitySyncAuthBinding.inflate(layoutInflater)) accountAuthenticatorResponse = intent.getParcelableExtraCompat(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE) accountAuthenticatorResponse?.onRequestContinued() viewBinding.buttonNext.setOnClickListener(this) viewBinding.buttonBack.setOnClickListener(this) viewBinding.buttonCancel.setOnClickListener(this) viewBinding.buttonDone.setOnClickListener(this) viewBinding.buttonSettings.setOnClickListener(this) viewBinding.editEmail.addTextChangedListener(this) viewBinding.editPassword.addTextChangedListener(this) onBackPressedDispatcher.addCallback(pageBackCallback) viewModel.onTokenObtained.observeEvent(this, ::onTokenReceived) viewModel.onError.observeEvent(this, ::onError) viewModel.isLoading.observe(this, ::onLoadingStateChanged) viewModel.onAccountAlreadyExists.observeEvent(this) { onAccountAlreadyExists() } supportFragmentManager.setFragmentResultListener(SyncHostDialogFragment.REQUEST_KEY, this, this) if (savedInstanceState == null) { setPage(PAGE_EMAIL) } else { pageBackCallback.update() } } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val barsInsets = insets.systemBarsInsets viewBinding.root.updatePadding(top = barsInsets.top) viewBinding.dockedToolbarChild.updateLayoutParams { leftMargin = barsInsets.left rightMargin = barsInsets.right bottomMargin = barsInsets.bottom } val basePadding = viewBinding.layoutContent.paddingBottom viewBinding.layoutContent.updatePadding( left = barsInsets.left + basePadding, right = barsInsets.right + basePadding, ) return insets.consumeAllSystemBarsInsets() } override fun onClick(v: View) { when (v.id) { R.id.button_cancel -> { setResult(RESULT_CANCELED) finish() } R.id.button_next -> { setPage(PAGE_PASSWORD) viewBinding.editPassword.requestFocus() } R.id.button_back -> { setPage(PAGE_EMAIL) viewBinding.editEmail.requestFocus() } R.id.button_done -> { viewModel.obtainToken( email = viewBinding.editEmail.text.toString().trim(), password = viewBinding.editPassword.text.toString(), ) } R.id.button_settings -> { SyncHostDialogFragment.show(supportFragmentManager, viewModel.syncURL.value) } } } override fun onFragmentResult(requestKey: String, result: Bundle) { val syncURL = result.getString(SyncHostDialogFragment.KEY_SYNC_URL) ?: return viewModel.syncURL.value = syncURL } override fun finish() { accountAuthenticatorResponse?.let { response -> resultBundle?.also { response.onResult(it) } ?: response.onError(AccountManager.ERROR_CODE_CANCELED, getString(R.string.canceled)) } super.finish() } override fun afterTextChanged(s: Editable?) { val isLoading = viewModel.isLoading.value val email = viewBinding.editEmail.text?.trim()?.toString() val password = viewBinding.editPassword.text?.toString() viewBinding.buttonNext.isEnabled = !isLoading && !email.isNullOrEmpty() && regexEmail.matches(email) viewBinding.buttonDone.isEnabled = !isLoading && password != null && password.length >= PASSWORD_MIN_LENGTH } private fun onLoadingStateChanged(isLoading: Boolean) { with(viewBinding) { progressBar.isInvisible = !isLoading editEmail.isEnabled = !isLoading editPassword.isEnabled = !isLoading } afterTextChanged(null) pageBackCallback.update() } private fun setPage(page: Int) { with(viewBinding) { val currentPage = if (layoutEmail.isVisible) PAGE_EMAIL else PAGE_PASSWORD if (currentPage != page) { val transition = MaterialSharedAxis(MaterialSharedAxis.X, page > currentPage) TransitionManager.beginDelayedTransition(layoutContent, transition) } buttonNext.isVisible = page == PAGE_EMAIL buttonBack.isVisible = page == PAGE_PASSWORD buttonSettings.isVisible = page == PAGE_EMAIL buttonDone.isVisible = page == PAGE_PASSWORD buttonCancel.isVisible = page == PAGE_EMAIL layoutEmail.isVisible = page == PAGE_EMAIL layoutPassword.isVisible = page == PAGE_PASSWORD } pageBackCallback.update() } private fun onError(error: Throwable) { MaterialAlertDialogBuilder(this) .setTitle(R.string.error) .setMessage(error.getDisplayMessage(resources)) .setNegativeButton(R.string.close, null) .show() } private fun onTokenReceived(authResult: SyncAuthResult) { val am = AccountManager.get(this) val account = Account(authResult.email, getString(R.string.account_type_sync)) val userdata = Bundle(1) userdata.putString(SyncSettings.KEY_SYNC_URL, authResult.syncURL) val result = Bundle() if (am.addAccountExplicitly(account, authResult.password, userdata)) { result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name) result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type) result.putString(AccountManager.KEY_AUTHTOKEN, authResult.token) am.setAuthToken(account, account.type, authResult.token) } else { result.putString(AccountManager.KEY_ERROR_MESSAGE, getString(R.string.account_already_exists)) } resultBundle = result setResult(RESULT_OK) finish() } private fun onAccountAlreadyExists() { Toast.makeText(this, R.string.account_already_exists, Toast.LENGTH_SHORT) .show() accountAuthenticatorResponse?.onError( AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION, getString(R.string.account_already_exists), ) super.finishAfterTransition() } private inner class PageBackCallback : OnBackPressedCallback(false) { override fun handleOnBackPressed() { setPage(PAGE_EMAIL) viewBinding.editEmail.requestFocus() update() } fun update() { isEnabled = !viewBinding.progressBar.isVisible && viewBinding.editPassword.isVisible } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthViewModel.kt ================================================ package org.koitharu.kotatsu.sync.ui import android.accounts.AccountManager import android.content.Context 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.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.sync.data.SyncAuthApi import org.koitharu.kotatsu.sync.domain.SyncAuthResult import javax.inject.Inject @HiltViewModel class SyncAuthViewModel @Inject constructor( @ApplicationContext context: Context, private val api: SyncAuthApi, ) : BaseViewModel() { val onAccountAlreadyExists = MutableEventFlow() val onTokenObtained = MutableEventFlow() val syncURL = MutableStateFlow(context.resources.getStringArray(R.array.sync_url_list).first()) init { launchJob(Dispatchers.Default) { val am = AccountManager.get(context) val accounts = am.getAccountsByType(context.getString(R.string.account_type_sync)) if (accounts.isNotEmpty()) { onAccountAlreadyExists.call(Unit) } } } fun obtainToken(email: String, password: String) { val urlValue = syncURL.value launchLoadingJob(Dispatchers.Default) { val token = api.authenticate(urlValue, email, password) val result = SyncAuthResult(syncURL.value, email, password, token) onTokenObtained.call(result) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthenticatorService.kt ================================================ package org.koitharu.kotatsu.sync.ui import android.app.Service import android.content.Intent import android.os.IBinder class SyncAuthenticatorService : Service() { private lateinit var authenticator: SyncAccountAuthenticator override fun onCreate() { super.onCreate() authenticator = SyncAccountAuthenticator(this) } override fun onBind(intent: Intent?): IBinder? { return authenticator.iBinder } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncHostDialogFragment.kt ================================================ package org.koitharu.kotatsu.sync.ui import android.content.DialogInterface import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup import android.view.ViewGroup.MarginLayoutParams import android.widget.ArrayAdapter import androidx.core.os.bundleOf import androidx.core.view.updateLayoutParams import androidx.fragment.app.FragmentManager 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.isHttpUrl import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.PreferenceDialogAutocompletetextviewBinding import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.settings.utils.validation.UrlValidator import org.koitharu.kotatsu.sync.data.SyncSettings import javax.inject.Inject @AndroidEntryPoint class SyncHostDialogFragment : AlertDialogFragment(), DialogInterface.OnClickListener { @Inject lateinit var syncSettings: SyncSettings override fun onCreateViewBinding( inflater: LayoutInflater, container: ViewGroup? ) = PreferenceDialogAutocompletetextviewBinding.inflate(inflater, container, false) override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { return super.onBuildDialog(builder) .setPositiveButton(android.R.string.ok, this) .setNegativeButton(android.R.string.cancel, this) .setCancelable(false) .setTitle(R.string.server_address) } override fun onViewBindingCreated( binding: PreferenceDialogAutocompletetextviewBinding, savedInstanceState: Bundle? ) { super.onViewBindingCreated(binding, savedInstanceState) binding.message.updateLayoutParams { topMargin = binding.root.resources.getDimensionPixelOffset(R.dimen.screen_padding) bottomMargin = topMargin } binding.message.setText(R.string.sync_host_description) val entries = binding.root.resources.getStringArray(R.array.sync_url_list) val editText = binding.edit editText.setText(arguments?.getString(KEY_SYNC_URL).ifNullOrEmpty { syncSettings.syncUrl }) editText.threshold = 0 editText.setAdapter(ArrayAdapter(binding.root.context, android.R.layout.simple_spinner_dropdown_item, entries)) binding.dropdown.setOnClickListener { editText.showDropDown() } UrlValidator().attachToEditText(editText) } override fun onClick(dialog: DialogInterface, which: Int) { when (which) { DialogInterface.BUTTON_POSITIVE -> { val result = requireViewBinding().edit.text?.toString().orEmpty() var scheme = "" if (!result.isHttpUrl()) { scheme = "http://" } syncSettings.syncUrl = "$scheme$result" parentFragmentManager.setFragmentResult(REQUEST_KEY, bundleOf(KEY_SYNC_URL to "$scheme$result")) } } dialog.dismiss() } companion object { private const val TAG = "SyncHostDialogFragment" const val REQUEST_KEY = "host" const val KEY_SYNC_URL = "host" fun show(fm: FragmentManager, syncURL: String?) = SyncHostDialogFragment().withArgs(1) { putString(KEY_SYNC_URL, syncURL) }.show(fm, TAG) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncProvider.kt ================================================ package org.koitharu.kotatsu.sync.ui import android.content.ContentProvider import android.content.ContentProviderOperation import android.content.ContentProviderResult import android.content.ContentValues import android.database.Cursor import android.database.sqlite.SQLiteDatabase import android.net.Uri import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteQueryBuilder import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent import org.koitharu.kotatsu.core.db.* import java.util.concurrent.Callable abstract class SyncProvider : ContentProvider() { private val entryPoint by lazy { EntryPointAccessors.fromApplication(checkNotNull(context), SyncProviderEntryPoint::class.java) } private val database by lazy { entryPoint.database } private val supportedTables = setOf( TABLE_FAVOURITES, TABLE_MANGA, TABLE_TAGS, TABLE_FAVOURITE_CATEGORIES, TABLE_HISTORY, TABLE_MANGA_TAGS, ) override fun onCreate(): Boolean { return true } override fun query( uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?, ): Cursor? { val tableName = getTableName(uri) ?: return null val sqlQuery = SupportSQLiteQueryBuilder.builder(tableName) .columns(projection) .selection(selection, selectionArgs) .orderBy(sortOrder) .create() return database.openHelper.readableDatabase.query(sqlQuery) } override fun getType(uri: Uri): String? { return getTableName(uri)?.let { "vnd.android.cursor.dir/" } } override fun insert(uri: Uri, values: ContentValues?): Uri? { val table = getTableName(uri) if (values == null || table == null) { return null } val db = database.openHelper.writableDatabase if (db.insert(table, SQLiteDatabase.CONFLICT_IGNORE, values) < 0) { db.update(table, values) } return uri } override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int { val table = getTableName(uri) ?: return 0 return database.openHelper.writableDatabase.delete(table, selection, selectionArgs) } override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int { val table = getTableName(uri) if (values == null || table == null) { return 0 } return database.openHelper.writableDatabase .update(table, SQLiteDatabase.CONFLICT_IGNORE, values, selection, selectionArgs) } override fun applyBatch(operations: ArrayList): Array { return runAtomicTransaction { super.applyBatch(operations) } } override fun bulkInsert(uri: Uri, values: Array): Int { return runAtomicTransaction { super.bulkInsert(uri, values) } } private fun getTableName(uri: Uri): String? { return uri.pathSegments.singleOrNull()?.takeIf { it in supportedTables } } private fun runAtomicTransaction(callable: Callable): R { return synchronized(database) { database.runInTransaction(callable) } } private fun SupportSQLiteDatabase.update(table: String, values: ContentValues) { val keys = when (table) { TABLE_TAGS -> listOf("tag_id") TABLE_MANGA_TAGS -> listOf("tag_id", "manga_id") TABLE_MANGA -> listOf("manga_id") TABLE_FAVOURITES -> listOf("manga_id", "category_id") TABLE_FAVOURITE_CATEGORIES -> listOf("category_id") TABLE_HISTORY -> listOf("manga_id") else -> throw IllegalArgumentException("Update for $table is not supported") } val whereClause = keys.joinToString(" AND ") { "`$it` = ?" } val whereArgs = Array(keys.size) { i -> values.get("`${keys[i]}`") ?: values.get(keys[i]) } this.update(table, SQLiteDatabase.CONFLICT_IGNORE, values, whereClause, whereArgs) } @EntryPoint @InstallIn(SingletonComponent::class) interface SyncProviderEntryPoint { val database: MangaDatabase } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncAdapter.kt ================================================ package org.koitharu.kotatsu.sync.ui.favourites import android.accounts.Account import android.content.AbstractThreadedSyncAdapter import android.content.ContentProviderClient import android.content.Context import android.content.SyncResult import android.os.Bundle import dagger.hilt.android.EntryPointAccessors import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.onError import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.sync.domain.SyncController import org.koitharu.kotatsu.sync.ui.SyncAdapterEntryPoint class FavouritesSyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context, true) { override fun onPerformSync( account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult, ) { if (!context.resources.getBoolean(R.bool.is_sync_enabled)) { return } val entryPoint = EntryPointAccessors.fromApplication(context, SyncAdapterEntryPoint::class.java) val syncHelper = entryPoint.syncHelperFactory.create(account, provider) runCatchingCancellable { syncHelper.syncFavourites(syncResult.stats) SyncController.setLastSync(context, account, authority, System.currentTimeMillis()) }.onFailure { e -> syncResult.onError(e) syncHelper.onError(e) } syncHelper.onSyncComplete(syncResult) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncProvider.kt ================================================ package org.koitharu.kotatsu.sync.ui.favourites import org.koitharu.kotatsu.sync.ui.SyncProvider class FavouritesSyncProvider : SyncProvider() ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncService.kt ================================================ package org.koitharu.kotatsu.sync.ui.favourites import android.app.Service import android.content.Intent import android.os.IBinder class FavouritesSyncService : Service() { private lateinit var syncAdapter: FavouritesSyncAdapter override fun onCreate() { super.onCreate() syncAdapter = FavouritesSyncAdapter(applicationContext) } override fun onBind(intent: Intent?): IBinder { return syncAdapter.syncAdapterBinder } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/history/HistorySyncAdapter.kt ================================================ package org.koitharu.kotatsu.sync.ui.history import android.accounts.Account import android.content.AbstractThreadedSyncAdapter import android.content.ContentProviderClient import android.content.Context import android.content.SyncResult import android.os.Bundle import dagger.hilt.android.EntryPointAccessors import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.onError import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.sync.domain.SyncController import org.koitharu.kotatsu.sync.ui.SyncAdapterEntryPoint class HistorySyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context, true) { override fun onPerformSync( account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult, ) { if (!context.resources.getBoolean(R.bool.is_sync_enabled)) { return } val entryPoint = EntryPointAccessors.fromApplication(context, SyncAdapterEntryPoint::class.java) val syncHelper = entryPoint.syncHelperFactory.create(account, provider) runCatchingCancellable { syncHelper.syncHistory(syncResult.stats) SyncController.setLastSync(context, account, authority, System.currentTimeMillis()) }.onFailure { e -> syncResult.onError(e) syncHelper.onError(e) } syncHelper.onSyncComplete(syncResult) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/history/HistorySyncProvider.kt ================================================ package org.koitharu.kotatsu.sync.ui.history import org.koitharu.kotatsu.sync.ui.SyncProvider class HistorySyncProvider : SyncProvider() ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/history/HistorySyncService.kt ================================================ package org.koitharu.kotatsu.sync.ui.history import android.app.Service import android.content.Intent import android.os.IBinder class HistorySyncService : Service() { private lateinit var syncAdapter: HistorySyncAdapter override fun onCreate() { super.onCreate() syncAdapter = HistorySyncAdapter(applicationContext) } override fun onBind(intent: Intent?): IBinder { return syncAdapter.syncAdapterBinder } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/EntityMapping.kt ================================================ package org.koitharu.kotatsu.tracker.data import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem import java.time.Instant fun TrackLogWithManga.toTrackingLogItem(): TrackingLogItem { val chaptersList = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() } return TrackingLogItem( id = trackLog.id, chapters = chaptersList, manga = manga.toManga(tags.toMangaTags(), null), createdAt = Instant.ofEpochMilli(trackLog.createdAt), isNew = trackLog.isUnread, ) } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/MangaWithTrack.kt ================================================ package org.koitharu.kotatsu.tracker.data import androidx.room.Embedded import androidx.room.Junction import androidx.room.Relation import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity import org.koitharu.kotatsu.core.db.entity.TagEntity class MangaWithTrack( @Embedded val track: TrackEntity, @Relation( parentColumn = "manga_id", entityColumn = "manga_id", ) 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/tracker/data/TrackEntity.kt ================================================ package org.koitharu.kotatsu.tracker.data import androidx.annotation.IntDef import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.PrimaryKey import org.koitharu.kotatsu.core.db.entity.MangaEntity @Entity( tableName = "tracks", foreignKeys = [ ForeignKey( entity = MangaEntity::class, parentColumns = ["manga_id"], childColumns = ["manga_id"], onDelete = ForeignKey.CASCADE, ), ], ) class TrackEntity( @PrimaryKey(autoGenerate = false) @ColumnInfo(name = "manga_id") val mangaId: Long, @ColumnInfo(name = "last_chapter_id") val lastChapterId: Long, @ColumnInfo(name = "chapters_new") val newChapters: Int, @ColumnInfo(name = "last_check_time") val lastCheckTime: Long, @ColumnInfo(name = "last_chapter_date") val lastChapterDate: Long, @TrackerResult @ColumnInfo(name = "last_result") val lastResult: Int, @ColumnInfo(name = "last_error") val lastError: String?, ) { @IntDef(RESULT_NONE, RESULT_HAS_UPDATE, RESULT_NO_UPDATE, RESULT_FAILED, RESULT_EXTERNAL_MODIFICATION) @Retention(AnnotationRetention.SOURCE) annotation class TrackerResult companion object { const val RESULT_NONE = 0 const val RESULT_HAS_UPDATE = 1 const val RESULT_NO_UPDATE = 2 const val RESULT_FAILED = 3 const val RESULT_EXTERNAL_MODIFICATION = 4 fun create(mangaId: Long) = TrackEntity( mangaId = mangaId, lastChapterId = 0L, newChapters = 0, lastCheckTime = 0L, lastChapterDate = 0, lastResult = RESULT_NONE, lastError = null, ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TrackLogEntity.kt ================================================ package org.koitharu.kotatsu.tracker.data import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.PrimaryKey import org.koitharu.kotatsu.core.db.entity.MangaEntity @Entity( tableName = "track_logs", foreignKeys = [ ForeignKey( entity = MangaEntity::class, parentColumns = ["manga_id"], childColumns = ["manga_id"], onDelete = ForeignKey.CASCADE, ), ], ) class TrackLogEntity( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0L, @ColumnInfo(name = "manga_id", index = true) val mangaId: Long, @ColumnInfo(name = "chapters") val chapters: String, @ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "unread") val isUnread: Boolean, ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TrackLogWithManga.kt ================================================ package org.koitharu.kotatsu.tracker.data import androidx.room.Embedded import androidx.room.Junction import androidx.room.Relation import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity import org.koitharu.kotatsu.core.db.entity.TagEntity class TrackLogWithManga( @Embedded val trackLog: TrackLogEntity, @Relation( parentColumn = "manga_id", entityColumn = "manga_id" ) 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/tracker/data/TrackWithManga.kt ================================================ package org.koitharu.kotatsu.tracker.data import androidx.room.Embedded import androidx.room.Relation import org.koitharu.kotatsu.core.db.entity.MangaEntity class TrackWithManga( @Embedded val track: TrackEntity, @Relation( parentColumn = "manga_id", entityColumn = "manga_id", ) val manga: MangaEntity, ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TracksDao.kt ================================================ package org.koitharu.kotatsu.tracker.data import androidx.room.Dao import androidx.room.Query import androidx.room.RawQuery import androidx.room.Transaction import androidx.room.Upsert import androidx.sqlite.db.SupportSQLiteQuery import kotlinx.coroutines.flow.Flow import org.koitharu.kotatsu.core.db.MangaQueryBuilder import org.koitharu.kotatsu.list.domain.ListFilterOption @Dao abstract class TracksDao : MangaQueryBuilder.ConditionCallback { @Transaction @Query("SELECT * FROM tracks ORDER BY last_check_time ASC LIMIT :limit OFFSET :offset") abstract suspend fun findAll(offset: Int, limit: Int): List @Transaction @Query("SELECT * FROM tracks ORDER BY last_check_time DESC") abstract fun observeAll(): Flow> @Query("SELECT manga_id FROM tracks") abstract suspend fun findAllIds(): LongArray @Query("SELECT * FROM tracks WHERE manga_id = :mangaId") abstract suspend fun find(mangaId: Long): TrackEntity? @Query("SELECT IFNULL(chapters_new,0) FROM tracks WHERE manga_id = :mangaId") abstract suspend fun findNewChapters(mangaId: Long): Int @Query("SELECT COUNT(*) FROM tracks") abstract suspend fun getTracksCount(): Int @Query("SELECT COUNT(*) FROM tracks WHERE chapters_new > 0") abstract fun observeUpdateMangaCount(): Flow @Query("SELECT IFNULL(chapters_new, 0) FROM tracks WHERE manga_id = :mangaId") abstract fun observeNewChapters(mangaId: Long): Flow @Transaction @Query("SELECT * FROM tracks WHERE chapters_new > 0 ORDER BY last_chapter_date DESC") abstract fun observeUpdatedManga(): Flow> fun observeUpdatedManga( limit: Int, filterOptions: Set, ): Flow> = observeMangaImpl( MangaQueryBuilder("tracks", this) .where("chapters_new > 0") .filters(filterOptions) .limit(limit) .orderBy("last_chapter_date DESC") .build(), ) @Query("DELETE FROM tracks") abstract suspend fun clear() @Query("UPDATE tracks SET chapters_new = 0") abstract suspend fun clearCounters() @Query("UPDATE tracks SET chapters_new = 0 WHERE manga_id = :mangaId") abstract suspend fun clearCounter(mangaId: Long) @Query("DELETE FROM tracks WHERE manga_id = :mangaId") abstract suspend fun delete(mangaId: Long) @Query("DELETE FROM tracks WHERE manga_id NOT IN (SELECT manga_id FROM history WHERE history.deleted_at = 0 UNION SELECT manga_id FROM favourites WHERE favourites.deleted_at = 0 AND category_id IN (SELECT category_id FROM favourite_categories WHERE favourite_categories.deleted_at = 0 AND track = 1))") abstract suspend fun gc() @Upsert abstract suspend fun upsert(entity: TrackEntity) @Transaction @RawQuery(observedEntities = [TrackEntity::class]) protected abstract fun observeMangaImpl(query: SupportSQLiteQuery): Flow> override fun getCondition(option: ListFilterOption): String? = when (option) { ListFilterOption.Macro.FAVORITE -> "EXISTS(SELECT * FROM favourites WHERE favourites.manga_id = tracks.manga_id)" is ListFilterOption.Favorite -> "EXISTS(SELECT * FROM favourites WHERE favourites.manga_id = tracks.manga_id AND favourites.category_id = ${option.category.id})" is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE manga_tags.manga_id = tracks.manga_id AND tag_id = ${option.tagId})" ListFilterOption.Macro.NSFW -> "(SELECT nsfw FROM manga WHERE manga.manga_id = tracks.manga_id) = 1" else -> null } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/CheckNewChaptersUseCase.kt ================================================ package org.koitharu.kotatsu.tracker.domain import android.util.Log import coil3.request.CachePolicy import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.model.getPreferredBranch import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.parser.CachingMangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.util.MultiMutex import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.toInstantOrNull import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.findById import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.tracker.domain.model.MangaTracking import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates import java.time.Instant import javax.inject.Inject import javax.inject.Singleton @Singleton class CheckNewChaptersUseCase @Inject constructor( private val repository: TrackingRepository, private val historyRepository: HistoryRepository, private val mangaRepositoryFactory: MangaRepository.Factory, private val localMangaRepository: LocalMangaRepository, ) { private val mutex = MultiMutex() suspend operator fun invoke(manga: Manga): MangaUpdates = mutex.withLock(manga.id) { repository.updateTracks() val tracking = repository.getTrackOrNull(manga) ?: return@withLock MangaUpdates.Failure( manga = manga, error = null, ) invokeImpl(tracking) } suspend operator fun invoke(track: MangaTracking): MangaUpdates = mutex.withLock(track.manga.id) { invokeImpl(track) } suspend operator fun invoke(manga: Manga, currentChapterId: Long) = mutex.withLock(manga.id) { runCatchingCancellable { repository.updateTracks() val details = getFullManga(manga) val track = repository.getTrackOrNull(manga) ?: return@withLock val branch = checkNotNull(details.chapters?.findById(currentChapterId)).branch val chapters = details.getChapters(branch) val chapterIndex = chapters.indexOfFirst { x -> x.id == currentChapterId } val lastNewChapterIndex = chapters.size - track.newChapters val lastChapter = chapters.lastOrNull() val tracking = MangaTracking( manga = details, lastChapterId = lastChapter?.id ?: 0L, lastCheck = Instant.now(), lastChapterDate = lastChapter?.uploadDate?.toInstantOrNull() ?: track.lastChapterDate, newChapters = when { track.newChapters == 0 -> 0 chapterIndex < 0 -> track.newChapters chapterIndex >= lastNewChapterIndex -> chapters.lastIndex - chapterIndex else -> track.newChapters }, ) repository.mergeWith(tracking) }.onFailure { e -> e.printStackTraceDebug() }.isSuccess } private suspend fun invokeImpl(track: MangaTracking): MangaUpdates = runCatchingCancellable { val details = getFullManga(track.manga) compare(track, details, getBranch(details, track.lastChapterId)) }.getOrElse { error -> MangaUpdates.Failure( manga = track.manga, error = error, ) }.also { updates -> repository.saveUpdates(updates) } private suspend fun getBranch(manga: Manga, trackChapterId: Long): String? { historyRepository.getOne(manga)?.let { manga.chapters?.findById(it.chapterId) }?.let { return it.branch } manga.chapters?.findById(trackChapterId)?.let { return it.branch } // fallback return manga.getPreferredBranch(null) } private suspend fun getFullManga(manga: Manga): Manga = when { manga.isLocal -> fetchDetails( requireNotNull(localMangaRepository.getRemoteManga(manga)) { "Local manga is not supported" }, ) manga.chapters.isNullOrEmpty() -> fetchDetails(manga) else -> manga } private suspend fun fetchDetails(manga: Manga): Manga { val repo = mangaRepositoryFactory.create(manga.source) return if (repo is CachingMangaRepository) { repo.getDetails(manga, CachePolicy.WRITE_ONLY) } else { repo.getDetails(manga) } } /** * The main functionality of tracker: check new chapters in [manga] comparing to the [track] */ private fun compare(track: MangaTracking, manga: Manga, branch: String?): MangaUpdates.Success { if (track.isEmpty()) { // first check or manga was empty on last check return MangaUpdates.Success(manga, branch, emptyList(), isValid = false) } val chapters = requireNotNull(manga.getChapters(branch)) if (BuildConfig.DEBUG && chapters.findById(track.lastChapterId) == null) { Log.e("Tracker", "Chapter ${track.lastChapterId} not found") } val newChapters = chapters.takeLastWhile { x -> x.id != track.lastChapterId } return when { newChapters.isEmpty() -> { MangaUpdates.Success( manga = manga, branch = branch, newChapters = emptyList(), isValid = chapters.lastOrNull()?.id == track.lastChapterId, ) } newChapters.size == chapters.size -> { MangaUpdates.Success(manga, branch, emptyList(), isValid = false) } else -> { MangaUpdates.Success(manga, branch, newChapters, isValid = true) } } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/GetTracksUseCase.kt ================================================ package org.koitharu.kotatsu.tracker.domain import org.koitharu.kotatsu.tracker.domain.model.MangaTracking import javax.inject.Inject class GetTracksUseCase @Inject constructor( private val repository: TrackingRepository, ) { suspend operator fun invoke(limit: Int): List { repository.updateTracks() return repository.getTracks(offset = 0, limit = limit) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt ================================================ package org.koitharu.kotatsu.tracker.domain import androidx.annotation.VisibleForTesting import androidx.room.withTransaction import dagger.Reusable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.onStart import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.mapItems import org.koitharu.kotatsu.core.util.ext.toInstantOrNull import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.ifZero import org.koitharu.kotatsu.tracker.data.TrackEntity import org.koitharu.kotatsu.tracker.data.TrackLogEntity import org.koitharu.kotatsu.tracker.data.toTrackingLogItem import org.koitharu.kotatsu.tracker.domain.model.MangaTracking import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject private const val NO_ID = 0L private const val MAX_LOG_SIZE = 120 @Reusable class TrackingRepository @Inject constructor( private val db: MangaDatabase, private val settings: AppSettings, private val progressUpdateUseCase: ProgressUpdateUseCase, ) { private var isGcCalled = AtomicBoolean(false) suspend fun getNewChaptersCount(mangaId: Long): Int { return db.getTracksDao().findNewChapters(mangaId) } fun observeNewChaptersCount(mangaId: Long): Flow { return db.getTracksDao().observeNewChapters(mangaId) } @Deprecated("") fun observeUpdatedMangaCount(): Flow { return db.getTracksDao().observeUpdateMangaCount() .onStart { gcIfNotCalled() } } fun observeUnreadUpdatesCount(): Flow { return db.getTrackLogsDao().observeUnreadCount() } fun observeUpdatedManga(limit: Int, filterOptions: Set): Flow> { return db.getTracksDao().observeUpdatedManga(limit, filterOptions) .mapItems { MangaTracking( manga = it.manga.toManga(it.tags.toMangaTags(), null), lastChapterId = it.track.lastChapterId, lastCheck = it.track.lastCheckTime.toInstantOrNull(), lastChapterDate = it.track.lastChapterDate.toInstantOrNull(), newChapters = it.track.newChapters, ) }.distinctUntilChanged() .onStart { gcIfNotCalled() } } suspend fun getTracks(offset: Int, limit: Int): List { return db.getTracksDao().findAll(offset = offset, limit = limit).map { MangaTracking( manga = it.manga.toManga(emptySet(), null), lastChapterId = it.track.lastChapterId, lastCheck = it.track.lastCheckTime.toInstantOrNull(), lastChapterDate = it.track.lastChapterDate.toInstantOrNull(), newChapters = it.track.newChapters, ) } } @Deprecated("") suspend fun getTrack(manga: Manga): MangaTracking { return getTrackOrNull(manga) ?: MangaTracking( manga = manga, lastChapterId = NO_ID, lastCheck = null, lastChapterDate = null, newChapters = 0, ) } suspend fun getTrackOrNull(manga: Manga): MangaTracking? { val track = db.getTracksDao().find(manga.id) ?: return null return MangaTracking( manga = manga, lastChapterId = track.lastChapterId, lastCheck = track.lastCheckTime.toInstantOrNull(), lastChapterDate = track.lastChapterDate.toInstantOrNull(), newChapters = track.newChapters, ) } @VisibleForTesting suspend fun deleteTrack(mangaId: Long) { db.getTracksDao().delete(mangaId) } fun observeTrackingLog(limit: Int, filterOptions: Set): Flow> { return db.getTrackLogsDao().observeAll(limit, filterOptions) .mapItems { it.toTrackingLogItem() } .onStart { gcIfNotCalled() } } suspend fun getLogsCount() = db.getTrackLogsDao().count() suspend fun clearLogs() = db.getTrackLogsDao().clear() suspend fun clearCounters() = db.getTracksDao().clearCounters() suspend fun markAsRead(trackLogId: Long) = db.getTrackLogsDao().markAsRead(trackLogId) suspend fun gc() = db.withTransaction { db.getTracksDao().gc() db.getTrackLogsDao().run { gc() trim(MAX_LOG_SIZE) } } suspend fun saveUpdates(updates: MangaUpdates) { db.withTransaction { val track = getOrCreateTrack(updates.manga.id).mergeWith(updates) db.getTracksDao().upsert(track) if (updates is MangaUpdates.Success && updates.isValid && updates.newChapters.isNotEmpty()) { progressUpdateUseCase(updates.manga) val logEntity = TrackLogEntity( mangaId = updates.manga.id, chapters = updates.newChapters.joinToString("\n") { x -> x.name }, createdAt = System.currentTimeMillis(), isUnread = true, ) db.getTrackLogsDao().insert(logEntity) } } } suspend fun clearUpdates(ids: Collection) { when { ids.isEmpty() -> return ids.size == 1 -> db.getTracksDao().clearCounter(ids.single()) else -> db.withTransaction { for (id in ids) { db.getTracksDao().clearCounter(id) } } } } suspend fun mergeWith(tracking: MangaTracking) { val entity = TrackEntity( mangaId = tracking.manga.id, lastChapterId = tracking.lastChapterId, newChapters = tracking.newChapters, lastCheckTime = tracking.lastCheck?.toEpochMilli() ?: 0L, lastChapterDate = tracking.lastChapterDate?.toEpochMilli() ?: 0L, lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION, lastError = null, ) db.getTracksDao().upsert(entity) } suspend fun getCategoriesCount(): IntArray { val categories = db.getFavouriteCategoriesDao().findAll() return intArrayOf( categories.count { it.track }, categories.size, ) } suspend fun updateTracks() = db.withTransaction { val dao = db.getTracksDao() dao.gc() val ids = dao.findAllIds().toMutableSet() val size = ids.size // history if (AppSettings.TRACK_HISTORY in settings.trackSources) { val historyIds = db.getHistoryDao().findAllIds() for (mangaId in historyIds) { if (!ids.remove(mangaId)) { dao.upsert(TrackEntity.create(mangaId)) } } } // favorites if (AppSettings.TRACK_FAVOURITES in settings.trackSources) { val favoritesIds = db.getFavouritesDao().findIdsWithTrack() for (mangaId in favoritesIds) { if (!ids.remove(mangaId)) { dao.upsert(TrackEntity.create(mangaId)) } } } // remove unused for (mangaId in ids) { dao.delete(mangaId) } size - ids.size } private suspend fun getOrCreateTrack(mangaId: Long): TrackEntity { return db.getTracksDao().find(mangaId) ?: TrackEntity.create(mangaId) } private fun TrackEntity.mergeWith(updates: MangaUpdates): TrackEntity { return when (updates) { is MangaUpdates.Failure -> TrackEntity( mangaId = mangaId, lastChapterId = lastChapterId, newChapters = newChapters, lastCheckTime = System.currentTimeMillis(), lastChapterDate = lastChapterDate, lastResult = TrackEntity.RESULT_FAILED, lastError = updates.error?.toString(), ) is MangaUpdates.Success -> TrackEntity( mangaId = mangaId, lastChapterId = updates.manga.getChapters(updates.branch).lastOrNull()?.id ?: NO_ID, newChapters = if (updates.isValid) newChapters + updates.newChapters.size else 0, lastCheckTime = System.currentTimeMillis(), lastChapterDate = updates.lastChapterDate().ifZero { lastChapterDate }, lastResult = if (updates.isNotEmpty()) TrackEntity.RESULT_HAS_UPDATE else TrackEntity.RESULT_NO_UPDATE, lastError = null, ) } } private suspend fun gcIfNotCalled() { if (isGcCalled.compareAndSet(false, true)) { gc() } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/UpdatesListQuickFilter.kt ================================================ package org.koitharu.kotatsu.tracker.domain import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.MangaListQuickFilter import javax.inject.Inject class UpdatesListQuickFilter @Inject constructor( private val favouritesRepository: FavouritesRepository, settings: AppSettings, ) : MangaListQuickFilter(settings) { override suspend fun getAvailableFilterOptions(): List = favouritesRepository.getMostUpdatedCategories( limit = 4, ).map { ListFilterOption.Favorite(it) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/model/MangaTracking.kt ================================================ package org.koitharu.kotatsu.tracker.domain.model import org.koitharu.kotatsu.parsers.model.Manga import java.time.Instant data class MangaTracking( val manga: Manga, val lastChapterId: Long, val lastCheck: Instant?, val lastChapterDate: Instant?, val newChapters: Int, ) { fun isEmpty(): Boolean { return lastChapterId == 0L } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/model/MangaUpdates.kt ================================================ package org.koitharu.kotatsu.tracker.domain.model import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.util.ifZero sealed interface MangaUpdates { val manga: Manga data class Success( override val manga: Manga, val branch: String?, val newChapters: List, val isValid: Boolean, ) : MangaUpdates { fun isNotEmpty() = newChapters.isNotEmpty() fun lastChapterDate(): Long { val lastChapter = newChapters.lastOrNull() return lastChapter?.uploadDate?.ifZero { System.currentTimeMillis() } ?: (manga.chapters?.lastOrNull()?.uploadDate ?: 0L) } } data class Failure( override val manga: Manga, val error: Throwable?, ) : MangaUpdates { fun shouldRetry() = error is TooManyRequestExceptions } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/model/TrackingLogItem.kt ================================================ package org.koitharu.kotatsu.tracker.domain.model import org.koitharu.kotatsu.parsers.model.Manga import java.time.Instant data class TrackingLogItem( val id: Long, val manga: Manga, val chapters: List, val createdAt: Instant, val isNew: Boolean, ) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackDebugAD.kt ================================================ package org.koitharu.kotatsu.tracker.ui.debug import android.graphics.Color import android.text.format.DateUtils import androidx.core.content.ContextCompat import androidx.core.text.bold import androidx.core.text.buildSpannedString import androidx.core.text.color import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.drawableStart import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.databinding.ItemTrackDebugBinding import org.koitharu.kotatsu.tracker.data.TrackEntity import androidx.appcompat.R as appcompatR fun trackDebugAD( clickListener: OnListItemClickListener, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemTrackDebugBinding.inflate(layoutInflater, parent, false) }, ) { val indicatorNew = ContextCompat.getDrawable(context, R.drawable.ic_new) itemView.setOnClickListener { v -> clickListener.onItemClick(item, v) } bind { binding.imageViewCover.setImageAsync(item.manga.coverUrl, item.manga) binding.textViewTitle.text = item.manga.title binding.textViewSummary.text = buildSpannedString { append( item.lastCheckTime?.let { DateUtils.getRelativeDateTimeString( context, it.toEpochMilli(), DateUtils.MINUTE_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0, ) } ?: getString(R.string.never), ) if (item.lastResult == TrackEntity.RESULT_FAILED) { append(" - ") bold { color(context.getThemeColor(appcompatR.attr.colorError, Color.RED)) { append(item.lastError ?: getString(R.string.error)) } } } } binding.textViewTitle.drawableStart = if (item.newChapters > 0) { indicatorNew } else { null } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackDebugItem.kt ================================================ package org.koitharu.kotatsu.tracker.ui.debug import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.Manga import java.time.Instant data class TrackDebugItem( val manga: Manga, val lastChapterId: Long, val newChapters: Int, val lastCheckTime: Instant?, val lastChapterDate: Instant?, val lastResult: Int, val lastError: String?, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is TrackDebugItem && other.manga.id == manga.id } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugActivity.kt ================================================ package org.koitharu.kotatsu.tracker.ui.debug import android.os.Bundle import android.view.View import androidx.activity.viewModels import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import dagger.hilt.android.AndroidEntryPoint 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.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.systemBarsInsets import org.koitharu.kotatsu.databinding.ActivityTrackerDebugBinding import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration @AndroidEntryPoint class TrackerDebugActivity : BaseActivity(), OnListItemClickListener { private val viewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityTrackerDebugBinding.inflate(layoutInflater)) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) val tracksAdapter = BaseListAdapter() .addDelegate(ListItemType.FEED, trackDebugAD(this)) with(viewBinding.recyclerView) { setHasFixedSize(true) adapter = tracksAdapter addItemDecoration(TypedListSpacingDecoration(context, false)) } viewModel.content.observe(this, tracksAdapter) } 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: TrackDebugItem, view: View) { router.openDetails(item.manga) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugViewModel.kt ================================================ package org.koitharu.kotatsu.tracker.ui.debug import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.toInstantOrNull import org.koitharu.kotatsu.tracker.data.TrackWithManga import javax.inject.Inject @HiltViewModel class TrackerDebugViewModel @Inject constructor( db: MangaDatabase ) : BaseViewModel() { val content = db.getTracksDao().observeAll() .map { it.toUiList() } .withErrorHandling() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) private fun List.toUiList(): List = map { TrackDebugItem( manga = it.manga.toManga(emptySet(), null), lastChapterId = it.track.lastChapterId, newChapters = it.track.newChapters, lastCheckTime = it.track.lastCheckTime.toInstantOrNull(), lastChapterDate = it.track.lastChapterDate.toInstantOrNull(), lastResult = it.track.lastResult, lastError = it.track.lastError, ) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt ================================================ package org.koitharu.kotatsu.tracker.ui.feed import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.viewModels import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.drop 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.ui.BaseFragment import org.koitharu.kotatsu.core.ui.list.PaginationScrollListener import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper import org.koitharu.kotatsu.core.ui.util.MenuInvalidator import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.ui.widgets.TipView import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.consumeAll import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.ui.adapter.MangaListListener import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.tracker.ui.feed.adapter.FeedAdapter import javax.inject.Inject @AndroidEntryPoint class FeedFragment : BaseFragment(), PaginationScrollListener.Callback, RecyclerViewOwner, MangaListListener, SwipeRefreshLayout.OnRefreshListener { @Inject lateinit var coil: ImageLoader private val viewModel by viewModels() override val recyclerView: RecyclerView? get() = viewBinding?.recyclerView override fun onCreateViewBinding( inflater: LayoutInflater, container: ViewGroup?, ) = FragmentListBinding.inflate(inflater, container, false) override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) val sizeResolver = StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width)) val feedAdapter = FeedAdapter(this, sizeResolver) { item, v -> viewModel.onItemClick(item) router.openDetails(item.toMangaWithOverride()) } with(binding.recyclerView) { val paddingVertical = resources.getDimensionPixelSize(R.dimen.list_spacing_normal) setPadding(0, paddingVertical, 0, paddingVertical) layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) adapter = feedAdapter setHasFixedSize(true) addOnScrollListener(PaginationScrollListener(4, this@FeedFragment)) addItemDecoration(TypedListSpacingDecoration(context, true)) RecyclerScrollKeeper(this).attach() } binding.swipeRefreshLayout.setOnRefreshListener(this) addMenuProvider(FeedMenuProvider(binding.recyclerView, viewModel)) viewModel.isHeaderEnabled.drop(1).observe(viewLifecycleOwner, MenuInvalidator(requireActivity())) viewModel.content.observe(viewLifecycleOwner, feedAdapter) viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) viewModel.isRunning.observe(viewLifecycleOwner, this::onIsTrackerRunningChanged) } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val typeMask = WindowInsetsCompat.Type.systemBars() val barsInsets = insets.getInsets(typeMask) val paddingVertical = resources.getDimensionPixelSize(R.dimen.list_spacing_normal) viewBinding?.recyclerView?.setPadding( left = barsInsets.left, top = paddingVertical, right = barsInsets.right, bottom = barsInsets.bottom + paddingVertical, ) return insets.consumeAll(typeMask) } override fun onRefresh() { viewModel.update() } override fun onFilterOptionClick(option: ListFilterOption) = viewModel.toggleFilterOption(option) override fun onRetryClick(error: Throwable) = Unit override fun onFilterClick(view: View?) = Unit override fun onEmptyActionClick() = Unit override fun onPrimaryButtonClick(tipView: TipView) = Unit override fun onSecondaryButtonClick(tipView: TipView) = Unit override fun onListHeaderClick(item: ListHeader, view: View) { router.openMangaUpdates() } private fun onIsTrackerRunningChanged(isRunning: Boolean) { requireViewBinding().swipeRefreshLayout.isRefreshing = isRunning } override fun onScrolledToEnd() { viewModel.requestMoreItems() } override fun onItemClick(item: MangaListModel, view: View) { router.openDetails(item.toMangaWithOverride()) } override fun onReadClick(manga: Manga, view: View) = Unit override fun onTagClick(manga: Manga, tag: MangaTag, view: View) = Unit } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedMenuProvider.kt ================================================ package org.koitharu.kotatsu.tracker.ui.feed import android.content.Context import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.core.view.MenuProvider import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.dialog.RememberCheckListener import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.ui.dialog.setCheckbox class FeedMenuProvider( private val snackbarHost: View, private val viewModel: FeedViewModel, ) : MenuProvider { private val context: Context get() = snackbarHost.context override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.opt_feed, menu) } override fun onPrepareMenu(menu: Menu) { super.onPrepareMenu(menu) menu.findItem(R.id.action_show_updated)?.isChecked = viewModel.isHeaderEnabled.value } override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { R.id.action_update -> { viewModel.update() true } R.id.action_show_updated -> { viewModel.setHeaderEnabled(!menuItem.isChecked) true } R.id.action_clear_feed -> { val checkListener = RememberCheckListener(true) buildAlertDialog(context, isCentered = true) { setIcon(R.drawable.ic_clear_all) setTitle(R.string.clear_updates_feed) setMessage(R.string.text_clear_updates_feed_prompt) setNegativeButton(android.R.string.cancel, null) setCheckbox(R.string.clear_new_chapters_counters, true, checkListener) setPositiveButton(R.string.clear) { _, _ -> viewModel.clearFeed(checkListener.isChecked) } }.show() true } else -> false } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedViewModel.kt ================================================ package org.koitharu.kotatsu.tracker.ui.feed import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.model.DateTimeAgo import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.list.domain.QuickFilterListener 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.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.UpdatesListQuickFilter import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem import org.koitharu.kotatsu.tracker.ui.feed.model.FeedItem import org.koitharu.kotatsu.tracker.ui.feed.model.UpdatedMangaHeader import org.koitharu.kotatsu.tracker.work.TrackWorker import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject private const val PAGE_SIZE = 20 @HiltViewModel class FeedViewModel @Inject constructor( private val settings: AppSettings, private val repository: TrackingRepository, private val scheduler: TrackWorker.Scheduler, private val mangaListMapper: MangaListMapper, private val quickFilter: UpdatesListQuickFilter, ) : BaseViewModel(), QuickFilterListener by quickFilter { private val limit = MutableStateFlow(PAGE_SIZE) private val isReady = AtomicBoolean(false) val isRunning = scheduler.observeIsRunning() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false) val isHeaderEnabled = settings.observeAsStateFlow( scope = viewModelScope + Dispatchers.Default, key = AppSettings.KEY_FEED_HEADER, valueProducer = { isFeedHeaderVisible }, ) val onActionDone = MutableEventFlow() @Suppress("USELESS_CAST") val content = combine( observeHeader(), quickFilter.appliedOptions, combine(limit, quickFilter.appliedOptions.combineWithSettings(), ::Pair) .flatMapLatest { repository.observeTrackingLog(it.first, it.second) }, ) { header, filters, list -> val result = ArrayList((list.size * 1.4).toInt().coerceAtLeast(3)) quickFilter.filterItem(filters)?.let(result::add) if (header != null) { result += header } if (list.isEmpty()) { result += EmptyState( icon = R.drawable.ic_empty_feed, textPrimary = R.string.text_empty_holder_primary, textSecondary = R.string.text_feed_holder, actionStringRes = 0, ) } else { isReady.set(true) list.mapListTo(result) } result as List }.catch { e -> emit(listOf(e.toErrorState(canRetry = false))) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) init { launchJob(Dispatchers.Default) { repository.gc() } } fun clearFeed(clearCounters: Boolean) { launchLoadingJob(Dispatchers.Default) { repository.clearLogs() if (clearCounters) { repository.clearCounters() } onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null)) } } fun requestMoreItems() { if (isReady.compareAndSet(true, false)) { limit.value += PAGE_SIZE } } fun update() { scheduler.startNow() } fun setHeaderEnabled(value: Boolean) { settings.isFeedHeaderVisible = value } fun onItemClick(item: FeedItem) { launchJob(Dispatchers.Default, CoroutineStart.ATOMIC) { repository.markAsRead(item.id) } } private suspend fun List.mapListTo(destination: MutableList) { var prevDate: DateTimeAgo? = null for (item in this) { val date = calculateTimeAgo(item.createdAt) if (prevDate != date) { destination += if (date != null) { ListHeader(date) } else { ListHeader(R.string.unknown) } } prevDate = date destination += mangaListMapper.toFeedItem(item) } } private fun observeHeader() = isHeaderEnabled.flatMapLatest { hasHeader -> if (hasHeader) { quickFilter.appliedOptions.combineWithSettings().flatMapLatest { repository.observeUpdatedManga(10, it) }.map { mangaList -> if (mangaList.isEmpty()) { null } else { UpdatedMangaHeader( mangaList.map { mangaListMapper.toListModel(it.manga, ListMode.GRID) }, ) } } } else { flowOf(null) } } private fun Flow>.combineWithSettings(): Flow> = combine( settings.observeAsFlow(AppSettings.KEY_DISABLE_NSFW) { isNsfwContentDisabled }, ) { filters, skipNsfw -> if (skipNsfw) { filters + ListFilterOption.SFW } else { filters } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedAdapter.kt ================================================ package org.koitharu.kotatsu.tracker.ui.feed.adapter import android.content.Context 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.ListItemType import org.koitharu.kotatsu.list.ui.adapter.MangaListListener import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD import org.koitharu.kotatsu.list.ui.adapter.errorFooterAD 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.adapter.quickFilterAD import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver import org.koitharu.kotatsu.tracker.ui.feed.model.FeedItem class FeedAdapter( listener: MangaListListener, sizeResolver: ItemSizeResolver, feedClickListener: OnListItemClickListener, ) : BaseListAdapter(), FastScroller.SectionIndexer { init { addDelegate(ListItemType.FEED, feedItemAD(feedClickListener)) addDelegate( ListItemType.MANGA_NESTED_GROUP, updatedMangaAD( sizeResolver = sizeResolver, listener = listener, headerClickListener = listener, ), ) addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD()) addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) addDelegate(ListItemType.FOOTER_ERROR, errorFooterAD(listener)) addDelegate(ListItemType.STATE_ERROR, errorStateListAD(listener)) addDelegate(ListItemType.HEADER, listHeaderAD(listener)) addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(listener)) addDelegate(ListItemType.QUICK_FILTER, quickFilterAD(listener)) } override fun getSectionText(context: Context, position: Int): CharSequence? { return findHeader(position)?.getText(context) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedItemAD.kt ================================================ package org.koitharu.kotatsu.tracker.ui.feed.adapter import androidx.core.content.ContextCompat import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.drawableStart import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.databinding.ItemFeedBinding import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.tracker.ui.feed.model.FeedItem fun feedItemAD( clickListener: OnListItemClickListener, ) = adapterDelegateViewBinding( { inflater, parent -> ItemFeedBinding.inflate(inflater, parent, false) }, ) { val indicatorNew = ContextCompat.getDrawable(context, R.drawable.ic_new) itemView.setOnClickListener { clickListener.onItemClick(item, it) } bind { binding.imageViewCover.setImageAsync(item.imageUrl, item.manga.source) binding.textViewTitle.text = item.title binding.textViewSummary.text = context.resources.getQuantityStringSafe( R.plurals.new_chapters, item.count, item.count, ) binding.textViewSummary.drawableStart = if (item.isNew) { indicatorNew } else { null } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/UpdatedMangaAD.kt ================================================ package org.koitharu.kotatsu.tracker.ui.feed.adapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.databinding.ItemListGroupBinding import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver import org.koitharu.kotatsu.tracker.ui.feed.model.UpdatedMangaHeader fun updatedMangaAD( sizeResolver: ItemSizeResolver, listener: OnListItemClickListener, headerClickListener: ListHeaderClickListener, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemListGroupBinding.inflate(layoutInflater, parent, false) }, ) { val adapter = BaseListAdapter() .addDelegate(ListItemType.MANGA_GRID, mangaGridItemAD(sizeResolver, listener)) binding.recyclerView.adapter = adapter binding.buttonMore.setOnClickListener { v -> headerClickListener.onListHeaderClick(ListHeader(0, payload = item), v) } binding.textViewTitle.setText(R.string.updates) binding.buttonMore.setText(R.string.more) bind { adapter.items = item.list } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/model/FeedItem.kt ================================================ package org.koitharu.kotatsu.tracker.ui.feed.model import org.koitharu.kotatsu.core.model.withOverride import org.koitharu.kotatsu.core.ui.model.MangaOverride import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty data class FeedItem( val id: Long, private val override: MangaOverride?, val manga: Manga, val count: Int, val isNew: Boolean, ) : ListModel { val imageUrl: String? get() = override?.coverUrl.ifNullOrEmpty { manga.coverUrl } val title: String get() = override?.title.ifNullOrEmpty { manga.title } fun toMangaWithOverride() = manga.withOverride(override) override fun areItemsTheSame(other: ListModel): Boolean { return other is FeedItem && other.id == id } override fun getChangePayload(previousState: ListModel): Any? = when { previousState !is FeedItem -> null isNew != previousState.isNew -> ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED else -> super.getChangePayload(previousState) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/model/UpdatedMangaHeader.kt ================================================ package org.koitharu.kotatsu.tracker.ui.feed.model import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaListModel data class UpdatedMangaHeader( val list: List, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is UpdatedMangaHeader } override fun getChangePayload(previousState: ListModel): Any { return ListModelDiffCallback.PAYLOAD_NESTED_LIST_CHANGED } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesActivity.kt ================================================ package org.koitharu.kotatsu.tracker.ui.updates import org.koitharu.kotatsu.core.ui.FragmentContainerActivity class UpdatesActivity : FragmentContainerActivity(UpdatesFragment::class.java) ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesFragment.kt ================================================ package org.koitharu.kotatsu.tracker.ui.updates import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.appcompat.view.ActionMode import androidx.fragment.app.viewModels import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.list.ui.MangaListFragment @AndroidEntryPoint class UpdatesFragment : MangaListFragment() { override val viewModel by viewModels() override val isSwipeRefreshEnabled = false override fun onScrolledToEnd() = Unit override fun onCreateActionMode( controller: ListSelectionController, menuInflater: MenuInflater, menu: Menu ): Boolean { menuInflater.inflate(R.menu.mode_updates, menu) return super.onCreateActionMode(controller, menuInflater, menu) } override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean { return when (item.itemId) { R.id.action_remove -> { viewModel.remove(controller.snapshot()) mode?.finish() true } else -> super.onActionItemClicked(controller, mode, item) } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesViewModel.kt ================================================ package org.koitharu.kotatsu.tracker.ui.updates import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.ui.model.DateTimeAgo import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo import org.koitharu.kotatsu.core.util.ext.onFirst import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.list.domain.QuickFilterListener import org.koitharu.kotatsu.list.ui.MangaListViewModel 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.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.UpdatesListQuickFilter import org.koitharu.kotatsu.tracker.domain.model.MangaTracking import javax.inject.Inject import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.domain.model.LocalManga import kotlinx.coroutines.flow.SharedFlow @HiltViewModel class UpdatesViewModel @Inject constructor( private val repository: TrackingRepository, settings: AppSettings, private val mangaListMapper: MangaListMapper, private val quickFilter: UpdatesListQuickFilter, mangaDataRepository: MangaDataRepository, @LocalStorageChanges localStorageChanges: SharedFlow, ) : MangaListViewModel(settings, mangaDataRepository, localStorageChanges), QuickFilterListener by quickFilter { override val content = combine( quickFilter.appliedOptions.flatMapLatest { filterOptions -> repository.observeUpdatedManga( limit = 0, filterOptions = filterOptions, ) }, quickFilter.appliedOptions, settings.observeAsFlow(AppSettings.KEY_UPDATED_GROUPING) { isUpdatedGroupingEnabled }, observeListModeWithTriggers(), ) { mangaList, filters, grouping, mode -> when { mangaList.isEmpty() -> listOfNotNull( quickFilter.filterItem(filters), EmptyState( icon = R.drawable.ic_empty_history, textPrimary = R.string.text_history_holder_primary, textSecondary = R.string.text_history_holder_secondary, actionStringRes = 0, ), ) else -> mangaList.toUi(mode, filters, grouping) } }.onStart { loadingCounter.increment() }.onFirst { loadingCounter.decrement() }.catch { emit(listOf(it.toErrorState(canRetry = false))) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) init { launchJob(Dispatchers.Default) { repository.gc() } } override fun onRefresh() = Unit override fun onRetry() = Unit fun remove(ids: Set) { launchJob(Dispatchers.Default) { repository.clearUpdates(ids) } } private suspend fun List.toUi( mode: ListMode, filters: Set, grouped: Boolean, ): List { val result = ArrayList(if (grouped) (size * 1.4).toInt() else size + 1) quickFilter.filterItem(filters)?.let(result::add) var prevHeader: DateTimeAgo? = null for (item in this) { if (grouped) { val header = item.lastChapterDate?.let { calculateTimeAgo(it) } if (header != prevHeader) { if (header != null) { result += ListHeader(header) } prevHeader = header } } result += mangaListMapper.toListModel(item.manga, mode) } return result } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt ================================================ package org.koitharu.kotatsu.tracker.work import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo import android.os.Build import android.provider.Settings import androidx.annotation.CheckResult import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.PendingIntentCompat import androidx.hilt.work.HiltWorker import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.CoroutineWorker import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ForegroundInfo import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OutOfQuotaPolicy import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.WorkQuery import androidx.work.WorkerParameters import androidx.work.await import dagger.Lazy import dagger.Reusable import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.CancellationException import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.exceptions.CloudFlareException import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler import org.koitharu.kotatsu.core.model.ids import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.TrackerDownloadStrategy import org.koitharu.kotatsu.core.prefs.TriStateOption import org.koitharu.kotatsu.core.util.ext.awaitUniqueWorkInfoByName import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.core.util.ext.onEachIndexed import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.trySetForeground import org.koitharu.kotatsu.download.ui.worker.DownloadTask import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.toIntUp import org.koitharu.kotatsu.settings.work.PeriodicWorkScheduler import org.koitharu.kotatsu.tracker.domain.CheckNewChaptersUseCase import org.koitharu.kotatsu.tracker.domain.GetTracksUseCase import org.koitharu.kotatsu.tracker.domain.model.MangaTracking import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates import org.koitharu.kotatsu.tracker.work.TrackerNotificationHelper.NotificationInfo import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Provider import kotlin.math.roundToInt import androidx.appcompat.R as appcompatR @HiltWorker class TrackWorker @AssistedInject constructor( @Assisted context: Context, @Assisted workerParams: WorkerParameters, private val captchaHandler: CaptchaHandler, private val notificationHelper: TrackerNotificationHelper, private val settings: AppSettings, private val getTracksUseCase: GetTracksUseCase, private val checkNewChaptersUseCase: CheckNewChaptersUseCase, private val workManager: WorkManager, private val localRepositoryLazy: Lazy, private val downloadSchedulerLazy: Lazy, ) : CoroutineWorker(context, workerParams) { private val notificationManager by lazy { NotificationManagerCompat.from(applicationContext) } override suspend fun doWork(): Result { notificationHelper.updateChannels() val isForeground = trySetForeground() return try { doWorkImpl(isFullRun = isForeground && TAG_ONESHOT in tags) } catch (e: CancellationException) { throw e } catch (e: Throwable) { e.printStackTraceDebug() Result.failure() } finally { withContext(NonCancellable) { notificationManager.cancel(WORKER_NOTIFICATION_ID) } } } private suspend fun doWorkImpl(isFullRun: Boolean): Result { if (!settings.isTrackerEnabled) { return Result.success() } val tracks = getTracksUseCase(if (isFullRun) Int.MAX_VALUE else BATCH_SIZE) if (tracks.isEmpty()) { return Result.success() } val notifications = checkUpdatesAsync(tracks) if (notifications.isNotEmpty() && applicationContext.checkNotificationPermission(null)) { val groupNotification = notificationHelper.createGroupNotification(notifications) notifications.forEach { notificationManager.notify(it.tag, it.id, it.notification) } if (groupNotification != null) { notificationManager.notify(TAG, TrackerNotificationHelper.GROUP_NOTIFICATION_ID, groupNotification) } } return Result.success() } @CheckResult private suspend fun checkUpdatesAsync(tracks: List): List { val semaphore = Semaphore(MAX_PARALLELISM) return channelFlow { for (track in tracks) { launch { semaphore.withPermit { send( runCatchingCancellable { checkNewChaptersUseCase.invoke(track) }.getOrElse { error -> MangaUpdates.Failure( manga = track.manga, error = error, ) }, ) } } } }.onEachIndexed { index, it -> if (applicationContext.checkNotificationPermission(WORKER_CHANNEL_ID)) { notificationManager.notify(WORKER_NOTIFICATION_ID, createWorkerNotification(tracks.size, index + 1)) } when (it) { is MangaUpdates.Failure -> { val e = it.error if (e is CloudFlareException) { captchaHandler.handle(e) } } is MangaUpdates.Success -> processDownload(it) } }.mapNotNull { when (it) { is MangaUpdates.Failure -> null is MangaUpdates.Success -> if (it.isValid && it.isNotEmpty()) { notificationHelper.createNotification( manga = it.manga, newChapters = it.newChapters, ) } else { null } } }.toList() } override suspend fun getForegroundInfo(): ForegroundInfo { val channel = NotificationChannelCompat.Builder( WORKER_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW, ) .setName(applicationContext.getString(R.string.check_for_new_chapters)) .setShowBadge(false) .setVibrationEnabled(false) .setSound(null, null) .setLightsEnabled(false) .build() notificationManager.createNotificationChannel(channel) val notification = createWorkerNotification(0, 0) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { ForegroundInfo(WORKER_NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) } else { ForegroundInfo(WORKER_NOTIFICATION_ID, notification) } } private fun createWorkerNotification(max: Int, progress: Int) = NotificationCompat.Builder( applicationContext, WORKER_CHANNEL_ID, ).apply { setContentTitle(applicationContext.getString(R.string.check_for_new_chapters)) setPriority(NotificationCompat.PRIORITY_MIN) setCategory(NotificationCompat.CATEGORY_SERVICE) setDefaults(0) setOngoing(false) setOnlyAlertOnce(true) setSilent(true) setContentIntent( PendingIntentCompat.getActivity( applicationContext, 0, AppRouter.trackerSettingsIntent(applicationContext), 0, false, ), ) addAction( appcompatR.drawable.abc_ic_clear_material, applicationContext.getString(android.R.string.cancel), workManager.createCancelPendingIntent(id), ) setProgress(max, progress, max == 0) setSmallIcon(android.R.drawable.stat_notify_sync) setForegroundServiceBehavior( if (TAG_ONESHOT in tags) { NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE } else { NotificationCompat.FOREGROUND_SERVICE_DEFERRED }, ) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val actionIntent = PendingIntentCompat.getActivity( applicationContext, SETTINGS_ACTION_CODE, Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) .putExtra(Settings.EXTRA_APP_PACKAGE, applicationContext.packageName) .putExtra(Settings.EXTRA_CHANNEL_ID, WORKER_CHANNEL_ID), 0, false, ) addAction( R.drawable.ic_settings, applicationContext.getString(R.string.notifications_settings), actionIntent, ) } }.build() private suspend fun processDownload(mangaUpdates: MangaUpdates.Success) { if (!mangaUpdates.isValid || mangaUpdates.newChapters.isEmpty()) { return } when (settings.trackerDownloadStrategy) { TrackerDownloadStrategy.DISABLED -> Unit TrackerDownloadStrategy.DOWNLOADED -> { val localManga = localRepositoryLazy.get().findSavedManga(mangaUpdates.manga) if (localManga != null) { val task = DownloadTask( mangaId = mangaUpdates.manga.id, isPaused = false, isSilent = false, chaptersIds = mangaUpdates.newChapters.ids().toLongArray(), destination = null, format = null, allowMeteredNetwork = settings.allowDownloadOnMeteredNetwork != TriStateOption.DISABLED, ) downloadSchedulerLazy.get().schedule(setOf(mangaUpdates.manga to task)) } } } } @Reusable class Scheduler @Inject constructor( private val workManager: WorkManager, private val settings: AppSettings, private val dbProvider: Provider, ) : PeriodicWorkScheduler { override suspend fun schedule() { val frequency = settings.trackerFrequencyFactor if (frequency <= 0f) { return unschedule() } val constraints = createConstraints() val runCount = dbProvider.get().getTracksDao().getTracksCount() val runsPerFullCheck = (runCount / BATCH_SIZE.toFloat()).toIntUp().coerceAtLeast(1) val interval = (18 / runsPerFullCheck / frequency).roundToInt().coerceAtLeast(2) val request = PeriodicWorkRequestBuilder(interval.toLong(), TimeUnit.HOURS) .setConstraints(constraints) .addTag(TAG) .setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.MINUTES) .build() workManager .enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, request) .await() } override suspend fun unschedule() { workManager .cancelUniqueWork(TAG) .await() } override suspend fun isScheduled(): Boolean { return workManager .awaitUniqueWorkInfoByName(TAG) .any { !it.state.isFinished } } fun startNow() { val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() val request = OneTimeWorkRequestBuilder() .setConstraints(constraints) .addTag(TAG_ONESHOT) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build() workManager.enqueue(request) } fun observeIsRunning(): Flow { val query = WorkQuery.Builder.fromTags(listOf(TAG, TAG_ONESHOT)).build() return workManager.getWorkInfosFlow(query) .map { works -> works.any { x -> x.state == WorkInfo.State.RUNNING } } } private fun createConstraints() = Constraints.Builder() .setRequiredNetworkType(if (settings.isTrackerWifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) .build() } private companion object { const val WORKER_CHANNEL_ID = "track_worker" const val WORKER_NOTIFICATION_ID = 35 const val TAG = "tracking" const val TAG_ONESHOT = "tracking_oneshot" const val MAX_PARALLELISM = 6 val BATCH_SIZE = if (BuildConfig.DEBUG) 20 else 46 const val SETTINGS_ACTION_CODE = 5 } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackerNotificationHelper.kt ================================================ package org.koitharu.kotatsu.tracker.work import android.app.Notification import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.os.Build import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat.VISIBILITY_PRIVATE import androidx.core.app.NotificationCompat.VISIBILITY_SECRET import androidx.core.app.NotificationManagerCompat import androidx.core.app.PendingIntentCompat import androidx.core.content.ContextCompat import coil3.ImageLoader import coil3.request.ImageRequest import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.LocalizedAppContext import org.koitharu.kotatsu.core.model.getLocalizedTitle import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import javax.inject.Inject class TrackerNotificationHelper @Inject constructor( @LocalizedAppContext private val applicationContext: Context, private val settings: AppSettings, private val coil: ImageLoader, ) { fun getAreNotificationsEnabled(): Boolean { val manager = NotificationManagerCompat.from(applicationContext) if (!manager.areNotificationsEnabled()) { return false } return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = manager.getNotificationChannel(CHANNEL_ID) channel != null && channel.importance != NotificationManager.IMPORTANCE_NONE } else { // fallback settings.isTrackerNotificationsEnabled } } suspend fun createNotification(manga: Manga, newChapters: List): NotificationInfo? { if (newChapters.isEmpty() || !applicationContext.checkNotificationPermission(CHANNEL_ID)) { return null } if (manga.isNsfw() && (settings.isTrackerNsfwDisabled || settings.isNsfwContentDisabled)) { return null } val id = manga.url.hashCode() val builder = NotificationCompat.Builder(applicationContext, CHANNEL_ID) val summary = applicationContext.resources.getQuantityStringSafe( R.plurals.new_chapters, newChapters.size, newChapters.size, ) with(builder) { setContentText(summary) setContentTitle(manga.title) setNumber(newChapters.size) setLargeIcon( coil.execute( ImageRequest.Builder(applicationContext) .data(manga.coverUrl) .mangaSourceExtra(manga.source) .build(), ).toBitmapOrNull(), ) setSmallIcon(R.drawable.ic_stat_book_plus) setGroup(GROUP_NEW_CHAPTERS) val style = NotificationCompat.InboxStyle(this) for (chapter in newChapters) { style.addLine(chapter.getLocalizedTitle(applicationContext.resources)) } style.setSummaryText(manga.title) style.setBigContentTitle(summary) setStyle(style) val intent = AppRouter.detailsIntent(applicationContext, manga) setContentIntent( PendingIntentCompat.getActivity( applicationContext, id, intent, PendingIntent.FLAG_UPDATE_CURRENT, false, ), ) setVisibility(if (manga.isNsfw()) VISIBILITY_SECRET else VISIBILITY_PRIVATE) setShortcutId(manga.id.toString()) applyCommonSettings(this) } return NotificationInfo(id, TAG, builder.build(), manga, newChapters.size) } fun createGroupNotification( notifications: List ): Notification? { if (notifications.size <= 1) { return null } val newChaptersCount = notifications.sumOf { it.newChapters } val builder = NotificationCompat.Builder(applicationContext, CHANNEL_ID) with(builder) { val title = applicationContext.resources.getQuantityStringSafe( R.plurals.new_chapters, newChaptersCount, newChaptersCount, ) setContentTitle(title) setContentText(notifications.joinToString { it.manga.title }) setSmallIcon(R.drawable.ic_stat_book_plus) val style = NotificationCompat.InboxStyle(this) for (item in notifications) { style.addLine( applicationContext.getString(R.string.new_chapters_pattern, item.manga.title, item.newChapters), ) } style.setBigContentTitle(title) setStyle(style) setNumber(newChaptersCount) setGroup(GROUP_NEW_CHAPTERS) setGroupSummary(true) setVisibility( if (notifications.any { it.manga.isNsfw() }) { VISIBILITY_SECRET } else { VISIBILITY_PRIVATE }, ) val intent = AppRouter.mangaUpdatesIntent(applicationContext) setContentIntent( PendingIntentCompat.getActivity( applicationContext, GROUP_NOTIFICATION_ID, intent, PendingIntent.FLAG_UPDATE_CURRENT, false, ), ) applyCommonSettings(this) } return builder.build() } fun updateChannels() { val manager = NotificationManagerCompat.from(applicationContext) manager.deleteNotificationChannel(LEGACY_CHANNEL_ID) manager.deleteNotificationChannel(LEGACY_CHANNEL_ID_HISTORY) manager.deleteNotificationChannelGroup(LEGACY_CHANNELS_GROUP_ID) val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT) .setName(applicationContext.getString(R.string.new_chapters)) .setDescription(applicationContext.getString(R.string.show_notification_new_chapters_on)) .setShowBadge(true) .setLightColor(ContextCompat.getColor(applicationContext, R.color.blue_primary)) .build() manager.createNotificationChannel(channel) } private fun applyCommonSettings(builder: NotificationCompat.Builder) { builder.setAutoCancel(true) builder.setCategory(NotificationCompat.CATEGORY_SOCIAL) builder.priority = NotificationCompat.PRIORITY_DEFAULT if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { builder.setSound(settings.notificationSound) var defaults = if (settings.notificationLight) { builder.setLights(ContextCompat.getColor(applicationContext, R.color.blue_primary), 1000, 5000) NotificationCompat.DEFAULT_LIGHTS } else 0 if (settings.notificationVibrate) { builder.setVibrate(longArrayOf(500, 500, 500, 500)) defaults = defaults or NotificationCompat.DEFAULT_VIBRATE } builder.setDefaults(defaults) } } class NotificationInfo( val id: Int, val tag: String, val notification: Notification, val manga: Manga, val newChapters: Int, ) companion object { const val CHANNEL_ID = "tracker_chapters" const val GROUP_NOTIFICATION_ID = 0 const val GROUP_NEW_CHAPTERS = "org.koitharu.kotatsu.NEW_CHAPTERS" const val TAG = "tracker" private const val LEGACY_CHANNELS_GROUP_ID = "trackers" private const val LEGACY_CHANNEL_ID_HISTORY = "track_history" private const val LEGACY_CHANNEL_ID = "tracking" } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/widget/WidgetUpdater.kt ================================================ package org.koitharu.kotatsu.widget import android.appwidget.AppWidgetManager import android.content.ComponentName import android.content.Context import android.content.Intent 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_HISTORY import org.koitharu.kotatsu.widget.recent.RecentWidgetProvider import org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider import javax.inject.Inject import javax.inject.Singleton @Singleton class WidgetUpdater @Inject constructor( @ApplicationContext private val context: Context, ) : InvalidationTracker.Observer(TABLE_HISTORY, TABLE_FAVOURITES) { override fun onInvalidated(tables: Set) { if (TABLE_HISTORY in tables) { updateWidgets(RecentWidgetProvider::class.java) } if (TABLE_FAVOURITES in tables) { updateWidgets(ShelfWidgetProvider::class.java) } } private fun updateWidgets(cls: Class<*>) { val intent = Intent(context, cls) intent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE val ids = (AppWidgetManager.getInstance(context) ?: return) .getAppWidgetIds(ComponentName(context, cls)) intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) context.sendBroadcast(intent) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentListFactory.kt ================================================ package org.koitharu.kotatsu.widget.recent import android.content.Context import android.content.Intent import android.widget.RemoteViews import android.widget.RemoteViewsService import androidx.core.graphics.drawable.toBitmap import coil3.ImageLoader import coil3.executeBlocking import coil3.request.ImageRequest import coil3.request.transformations import coil3.size.Size import coil3.transform.RoundedCornersTransformation import dagger.Lazy import kotlinx.coroutines.runBlocking import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow import org.koitharu.kotatsu.core.util.ext.mangaExtra import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.replaceWith import org.koitharu.kotatsu.parsers.util.runCatchingCancellable class RecentListFactory( private val context: Context, private val historyRepository: HistoryRepository, private val coilLazy: Lazy, private val settings: AppSettings, ) : RemoteViewsService.RemoteViewsFactory { private val dataSet = ArrayList() private val transformation = RoundedCornersTransformation( context.resources.getDimension(R.dimen.appwidget_corner_radius_inner), ) private val coverSize = Size( context.resources.getDimensionPixelSize(R.dimen.widget_cover_width), context.resources.getDimensionPixelSize(R.dimen.widget_cover_height), ) override fun onCreate() = Unit override fun getLoadingView() = null override fun getItemId(position: Int) = dataSet.getOrNull(position)?.id ?: 0L override fun onDataSetChanged() { val data = if (settings.appPassword.isNullOrEmpty()) { runBlocking { historyRepository.getList(0, 10) } } else { emptyList() } dataSet.replaceWith(data) } override fun hasStableIds() = true override fun getViewAt(position: Int): RemoteViews { val views = RemoteViews(context.packageName, R.layout.item_recent) val item = dataSet.getOrNull(position) ?: return views runCatchingCancellable { coilLazy.get().executeBlocking( ImageRequest.Builder(context) .data(item.coverUrl) .size(coverSize) .mangaExtra(item) .transformations(transformation) .build(), ).getDrawableOrThrow().toBitmap() }.onSuccess { cover -> views.setImageViewBitmap(R.id.imageView_cover, cover) }.onFailure { views.setImageViewResource(R.id.imageView_cover, R.drawable.ic_placeholder) } val intent = Intent() intent.putExtra(AppRouter.KEY_ID, item.id) views.setOnClickFillInIntent(R.id.imageView_cover, intent) return views } override fun getCount() = dataSet.size override fun getViewTypeCount() = 1 override fun onDestroy() = Unit } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetConfigActivity.kt ================================================ package org.koitharu.kotatsu.widget.recent import android.appwidget.AppWidgetManager import android.content.Intent import android.os.Bundle import android.view.View import androidx.core.view.WindowInsetsCompat import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppWidgetConfig import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.databinding.ActivityAppwidgetRecentBinding @AndroidEntryPoint class RecentWidgetConfigActivity : BaseActivity(), View.OnClickListener { private lateinit var config: AppWidgetConfig override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityAppwidgetRecentBinding.inflate(layoutInflater)) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true) viewBinding.buttonDone.setOnClickListener(this) val appWidgetId = intent?.getIntExtra( AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID, ) ?: AppWidgetManager.INVALID_APPWIDGET_ID if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { finishAfterTransition() return } config = AppWidgetConfig(this, RecentWidgetProvider::class.java, appWidgetId) viewBinding.switchBackground.isChecked = config.hasBackground } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val barsInsets = insets.systemBarsInsets viewBinding.root.setPadding( barsInsets.left, barsInsets.top, barsInsets.right, barsInsets.bottom, ) return insets.consumeAllSystemBarsInsets() } override fun onClick(v: View) { when (v.id) { R.id.button_done -> { config.hasBackground = viewBinding.switchBackground.isChecked updateWidget() setResult( RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, config.widgetId), ) finish() } } } private fun updateWidget() { val intent = Intent(this, RecentWidgetProvider::class.java) intent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE val ids = intArrayOf(config.widgetId) intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) sendBroadcast(intent) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetProvider.kt ================================================ package org.koitharu.kotatsu.widget.recent import android.app.PendingIntent import android.appwidget.AppWidgetManager import android.content.Context import android.content.Intent import android.graphics.Color import android.widget.RemoteViews import androidx.core.app.PendingIntentCompat import androidx.core.net.toUri import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.ReaderIntent import org.koitharu.kotatsu.core.prefs.AppWidgetConfig import org.koitharu.kotatsu.core.ui.BaseAppWidgetProvider import org.koitharu.kotatsu.reader.ui.ReaderActivity class RecentWidgetProvider : BaseAppWidgetProvider() { override fun onUpdate( context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray ) { super.onUpdate(context, appWidgetManager, appWidgetIds) appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.stackView) } override fun onUpdateWidget(context: Context, config: AppWidgetConfig): RemoteViews { val views = RemoteViews(context.packageName, R.layout.widget_recent) if (!config.hasBackground) { views.setInt(R.id.widget_root, "setBackgroundColor", Color.TRANSPARENT) } else { views.setInt(R.id.widget_root, "setBackgroundResource", R.drawable.bg_appwidget_root) } val adapter = Intent(context, RecentWidgetService::class.java) adapter.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, config.widgetId) adapter.data = adapter.toUri(Intent.URI_INTENT_SCHEME).toUri() views.setRemoteAdapter(R.id.stackView, adapter) val intent = Intent(context, ReaderActivity::class.java) intent.action = ReaderIntent.ACTION_MANGA_READ views.setPendingIntentTemplate( R.id.stackView, PendingIntentCompat.getActivity( context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT, true, ), ) views.setEmptyView(R.id.stackView, R.id.textView_holder) return views } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetService.kt ================================================ package org.koitharu.kotatsu.widget.recent import android.content.Intent import android.widget.RemoteViewsService import coil3.ImageLoader import dagger.Lazy import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.history.data.HistoryRepository import javax.inject.Inject @AndroidEntryPoint class RecentWidgetService : RemoteViewsService() { @Inject lateinit var historyRepository: HistoryRepository @Inject lateinit var settings: AppSettings @Inject lateinit var coilLazy: Lazy override fun onGetViewFactory(intent: Intent): RemoteViewsFactory { return RecentListFactory(applicationContext, historyRepository, coilLazy, settings) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfConfigViewModel.kt ================================================ package org.koitharu.kotatsu.widget.shelf import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers 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.core.ui.BaseViewModel import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.widget.shelf.model.CategoryItem import javax.inject.Inject @HiltViewModel class ShelfConfigViewModel @Inject constructor( favouritesRepository: FavouritesRepository, ) : BaseViewModel() { private val selectedCategoryId = MutableStateFlow(0L) val content: StateFlow> = combine( favouritesRepository.observeCategories(), selectedCategoryId, ) { categories, selectedId -> val list = ArrayList(categories.size + 1) list += CategoryItem(0L, null, selectedId == 0L) categories.mapTo(list) { CategoryItem(it.id, it.title, selectedId == it.id) } list }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) var checkedId: Long get() = selectedCategoryId.value set(value) { selectedCategoryId.value = value } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfListFactory.kt ================================================ package org.koitharu.kotatsu.widget.shelf import android.content.Context import android.content.Intent import android.widget.RemoteViews import android.widget.RemoteViewsService import androidx.core.graphics.drawable.toBitmap import coil3.ImageLoader import coil3.executeBlocking import coil3.request.ImageRequest import coil3.request.transformations import coil3.size.Size import coil3.transform.RoundedCornersTransformation import dagger.Lazy import kotlinx.coroutines.runBlocking import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppWidgetConfig import org.koitharu.kotatsu.core.ui.image.TrimTransformation import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow import org.koitharu.kotatsu.core.util.ext.mangaExtra import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.replaceWith class ShelfListFactory( private val context: Context, private val favouritesRepository: FavouritesRepository, private val coilLazy: Lazy, private val settings: AppSettings, widgetId: Int, ) : RemoteViewsService.RemoteViewsFactory { private val dataSet = ArrayList() private val config = AppWidgetConfig(context, ShelfWidgetProvider::class.java, widgetId) private val transformation = RoundedCornersTransformation( context.resources.getDimension(R.dimen.appwidget_corner_radius_inner), ) private val coverSize = Size( context.resources.getDimensionPixelSize(R.dimen.widget_cover_width), context.resources.getDimensionPixelSize(R.dimen.widget_cover_height), ) override fun onCreate() = Unit override fun getLoadingView() = null override fun getItemId(position: Int) = dataSet.getOrNull(position)?.id ?: 0L override fun onDataSetChanged() { val data = if (settings.appPassword.isNullOrEmpty()) { runBlocking { val category = config.categoryId if (category == 0L) { favouritesRepository.getAllManga() } else { favouritesRepository.getManga(category) } } } else { emptyList() } dataSet.replaceWith(data) } override fun hasStableIds() = true override fun getViewAt(position: Int): RemoteViews { val views = RemoteViews(context.packageName, R.layout.item_shelf) val item = dataSet.getOrNull(position) ?: return views views.setTextViewText(R.id.textView_title, item.title) runCatching { coilLazy.get().executeBlocking( ImageRequest.Builder(context) .data(item.coverUrl) .size(coverSize) .mangaExtra(item) .transformations(transformation, TrimTransformation()) .build(), ).getDrawableOrThrow().toBitmap() }.onSuccess { cover -> views.setImageViewBitmap(R.id.imageView_cover, cover) }.onFailure { views.setImageViewResource(R.id.imageView_cover, R.drawable.ic_placeholder) } val intent = Intent() intent.putExtra(AppRouter.KEY_ID, item.id) views.setOnClickFillInIntent(R.id.rootLayout, intent) return views } override fun getCount() = dataSet.size override fun getViewTypeCount() = 1 override fun onDestroy() = Unit } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetConfigActivity.kt ================================================ package org.koitharu.kotatsu.widget.shelf import android.appwidget.AppWidgetManager import android.content.Intent import android.os.Bundle import android.view.View import androidx.activity.viewModels import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.prefs.AppWidgetConfig import org.koitharu.kotatsu.core.ui.BaseActivity 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.ActivityAppwidgetShelfBinding import org.koitharu.kotatsu.widget.shelf.adapter.CategorySelectAdapter import org.koitharu.kotatsu.widget.shelf.model.CategoryItem @AndroidEntryPoint class ShelfWidgetConfigActivity : BaseActivity(), OnListItemClickListener, View.OnClickListener { private val viewModel by viewModels() private lateinit var adapter: CategorySelectAdapter private lateinit var config: AppWidgetConfig override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityAppwidgetShelfBinding.inflate(layoutInflater)) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true) adapter = CategorySelectAdapter(this) viewBinding.recyclerView.adapter = adapter viewBinding.buttonDone.setOnClickListener(this) val appWidgetId = intent?.getIntExtra( AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID, ) ?: AppWidgetManager.INVALID_APPWIDGET_ID if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { finishAfterTransition() return } config = AppWidgetConfig(this, ShelfWidgetProvider::class.java, appWidgetId) viewModel.checkedId = config.categoryId viewBinding.switchBackground.isChecked = config.hasBackground viewModel.content.observe(this, adapter) viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) } 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 onClick(v: View) { when (v.id) { R.id.button_done -> { config.categoryId = viewModel.checkedId config.hasBackground = viewBinding.switchBackground.isChecked updateWidget() setResult( RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, config.widgetId), ) finish() } } } override fun onItemClick(item: CategoryItem, view: View) { viewModel.checkedId = item.id } private fun updateWidget() { val intent = Intent(this, ShelfWidgetProvider::class.java) intent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE val ids = intArrayOf(config.widgetId) intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) sendBroadcast(intent) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetProvider.kt ================================================ package org.koitharu.kotatsu.widget.shelf import android.app.PendingIntent import android.appwidget.AppWidgetManager import android.content.Context import android.content.Intent import android.graphics.Color import android.widget.RemoteViews import androidx.core.app.PendingIntentCompat import androidx.core.net.toUri import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.ReaderIntent import org.koitharu.kotatsu.core.prefs.AppWidgetConfig import org.koitharu.kotatsu.core.ui.BaseAppWidgetProvider import org.koitharu.kotatsu.reader.ui.ReaderActivity class ShelfWidgetProvider : BaseAppWidgetProvider() { override fun onUpdate( context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray ) { super.onUpdate(context, appWidgetManager, appWidgetIds) appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.gridView) } override fun onUpdateWidget(context: Context, config: AppWidgetConfig): RemoteViews { val views = RemoteViews(context.packageName, R.layout.widget_shelf) if (!config.hasBackground) { views.setInt(R.id.widget_root, "setBackgroundColor", Color.TRANSPARENT) } else { views.setInt(R.id.widget_root, "setBackgroundResource", R.drawable.bg_appwidget_root) } val adapter = Intent(context, ShelfWidgetService::class.java) adapter.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, config.widgetId) adapter.data = adapter.toUri(Intent.URI_INTENT_SCHEME).toUri() views.setRemoteAdapter(R.id.gridView, adapter) val intent = Intent(context, ReaderActivity::class.java) intent.action = ReaderIntent.ACTION_MANGA_READ views.setPendingIntentTemplate( R.id.gridView, PendingIntentCompat.getActivity( context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT, true, ), ) views.setEmptyView(R.id.gridView, R.id.textView_holder) return views } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetService.kt ================================================ package org.koitharu.kotatsu.widget.shelf import android.appwidget.AppWidgetManager import android.content.Intent import android.widget.RemoteViewsService import coil3.ImageLoader import dagger.Lazy import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import javax.inject.Inject @AndroidEntryPoint class ShelfWidgetService : RemoteViewsService() { @Inject lateinit var favouritesRepository: FavouritesRepository @Inject lateinit var settings: AppSettings @Inject lateinit var coilLazy: Lazy override fun onGetViewFactory(intent: Intent): RemoteViewsFactory { val widgetId = intent.getIntExtra( AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID, ) return ShelfListFactory(applicationContext, favouritesRepository, coilLazy, settings, widgetId) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/adapter/CategorySelectAdapter.kt ================================================ package org.koitharu.kotatsu.widget.shelf.adapter import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.widget.shelf.model.CategoryItem class CategorySelectAdapter( clickListener: OnListItemClickListener ) : BaseListAdapter() { init { delegatesManager.addDelegate(categorySelectItemAD(clickListener)) } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/adapter/CategorySelectItemAD.kt ================================================ package org.koitharu.kotatsu.widget.shelf.adapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.databinding.ItemCategoryCheckableSingleBinding import org.koitharu.kotatsu.widget.shelf.model.CategoryItem fun categorySelectItemAD( clickListener: OnListItemClickListener ) = adapterDelegateViewBinding( { inflater, parent -> ItemCategoryCheckableSingleBinding.inflate(inflater, parent, false) }, ) { itemView.setOnClickListener { clickListener.onItemClick(item, it) } bind { with(binding.checkedTextView) { text = item.name ?: getString(R.string.all_favourites) isChecked = item.isSelected } } } ================================================ FILE: app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/model/CategoryItem.kt ================================================ package org.koitharu.kotatsu.widget.shelf.model import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.model.ListModel data class CategoryItem( val id: Long, val name: String?, val isSelected: Boolean ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is CategoryItem && other.id == id } override fun getChangePayload(previousState: ListModel): Any? { return if (previousState is CategoryItem && previousState.isSelected != isSelected) { ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED } else { null } } } ================================================ FILE: app/src/main/res/anim/bottom_sheet_slide_in.xml ================================================ ================================================ FILE: app/src/main/res/anim/bottom_sheet_slide_out.xml ================================================ ================================================ FILE: app/src/main/res/color/bg_background_transparency.xml ================================================ ================================================ FILE: app/src/main/res/color/bg_floating_button.xml ================================================ ================================================ FILE: app/src/main/res/color/bottom_menu_active_indicator.xml ================================================ ================================================ FILE: app/src/main/res/color/bottom_menu_active_item.xml ================================================ ================================================ FILE: app/src/main/res/color/colored_button.xml ================================================ ================================================ FILE: app/src/main/res/color/list_item_background_color.xml ================================================ ================================================ FILE: app/src/main/res/color/list_item_text_color.xml ================================================ ================================================ FILE: app/src/main/res/color/selector_overlay.xml ================================================ ================================================ FILE: app/src/main/res/drawable/avd_explore_enter.xml ================================================ ================================================ FILE: app/src/main/res/drawable/avd_explore_leave.xml ================================================ ================================================ FILE: app/src/main/res/drawable/avd_favourites_enter.xml ================================================ ================================================ FILE: app/src/main/res/drawable/avd_favourites_leave.xml ================================================ ================================================ FILE: app/src/main/res/drawable/avd_feed_enter.xml ================================================ ================================================ FILE: app/src/main/res/drawable/avd_history_enter.xml ================================================ ================================================ FILE: app/src/main/res/drawable/avd_splash.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_appwidget_card.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_appwidget_root.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_badge_accent.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_badge_default.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_badge_empty.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_card.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_chip.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_circle_button.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_list_icons.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_reader_indicator.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_rounded_square.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_rounded_transparency.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_search_suggestion.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_tab_pill.xml ================================================ ================================================ FILE: app/src/main/res/drawable/custom_selectable_item_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/divider_horizontal.xml ================================================ ================================================ FILE: app/src/main/res/drawable/divider_transparent.xml ================================================ ================================================ FILE: app/src/main/res/drawable/fastscroll_bubble.xml ================================================ ================================================ FILE: app/src/main/res/drawable/fastscroll_bubble_small.xml ================================================ ================================================ FILE: app/src/main/res/drawable/fastscroll_handle.xml ================================================ ================================================ FILE: app/src/main/res/drawable/fastscroll_track.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_action_pause.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_action_resume.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_action_skip.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_add.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_alert_outline.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_anilist.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_app_update.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_appearance.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_arrow_forward.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_auth_key_large.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_auto_fix.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_backup_restore.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_battery_outline.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_book_page.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bookmark.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bookmark_added.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bookmark_checked.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bookmark_selector.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bot_large.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_cancel_multiple.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_check.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_clear_all.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_current_chapter.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_data_privacy.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_delete.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_delete_all.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_denied_large.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_dice.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_disable.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_discord.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_download.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_drawer_menu.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_drawer_menu_open.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_edit.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_empty_common.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_empty_favourites.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_empty_feed.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_empty_history.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_empty_local.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_error_large.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_error_small.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_expand.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_expand_more.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_explore_checked.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_explore_normal.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_explore_selector.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_eye.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_eye_check.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_eye_off.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_favourites_selector.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_feed.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_feed_selector.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_file_zip.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_filter_menu.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_folder_file.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_gesture_vertical.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_grid.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_heart.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_heart_off.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_heart_outline.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_history.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_history_selector.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_images.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_incognito.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_info_outline.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_interaction_large.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_kitsu.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_language.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_list.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_list_detailed.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_list_group.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_lock.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_mal.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_manga_source.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_move_horizontal.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_network_cellular.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_new.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_next.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_notification.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_nsfw.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_off_small.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_offline.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_open_external.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_pin.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_pin_small.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_placeholder.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_play.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_plug_large.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_prev.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_read.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_reader_ltr.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_reader_rtl.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_reader_vertical.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_reorder_handle.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_replace.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_retry.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_revert.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_save.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_save_ok.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_screen_rotation.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_screen_rotation_lock.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_script.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_select_group.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_select_range.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_services.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_settings.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_sfw.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_shikimori.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_shortcut.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_size_large.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_sort_asc.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_sort_desc.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_split_horizontal.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_star_small.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_state_abandoned.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_state_finished.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_state_ongoing.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_storage.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_storage_checked.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_storage_selector.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_suggestion.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_suggestion_checked.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_suggestion_selector.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_sync.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_tag.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_tap.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_tap_reorder.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_timelapse.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_timer.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_timer_run.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_unpin.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_updated.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_updated_checked.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_updated_selector.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_usage.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_user.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_voice_input.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_web.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_welcome.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_zoom_in.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_zoom_out.xml ================================================ ================================================ FILE: app/src/main/res/drawable/list_selector.xml ================================================ ================================================ FILE: app/src/main/res/drawable/m3_popup_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/m3_spinner_popup_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/search_bar_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/tabs_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable-anydpi-v24/ic_bot.xml ================================================ ================================================ FILE: app/src/main/res/drawable-anydpi-v24/ic_stat_auto_fix.xml ================================================ ================================================ FILE: app/src/main/res/drawable-anydpi-v24/ic_stat_book_plus.xml ================================================ ================================================ FILE: app/src/main/res/drawable-anydpi-v24/ic_stat_done.xml ================================================ ================================================ FILE: app/src/main/res/drawable-anydpi-v24/ic_stat_paused.xml ================================================ ================================================ FILE: app/src/main/res/drawable-anydpi-v24/ic_stat_suggestion.xml ================================================ ================================================ FILE: app/src/main/res/drawable-night/avd_splash.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_alternatives.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_app_update.xml ================================================